Back to course

LEB128

24 minadvancedModule 1: The ContainerContinues from wasm-sections
24 min · Module 1: The Container · Algorithms

Why This Matters

Here is the statistic that should make this concept worth a page. In a WebAssembly module, there are exactly two fixed-width integers in the container framing: the four magic bytes and the four version bytes. Every other number — every section size, every name length, every count of entries, every index, every memory limit, every instruction operand — uses the same variable-width encoding. The header and the framing are the exception, not the rule.

That encoding is LEB128, and it is the single detail that a reader of this format must get exactly right, because the failure is silent in a way that almost nothing else in the format is. A decoder that reads a two-byte LEB128 correctly and a one-byte LEB128 correctly will still be wrong on every negative number, and it will be wrong by an amount that varies with the value.

The reason is worth stating before the mechanism, because it explains the design. Almost every number in a WebAssembly module is small. Section sizes, entry counts, type indices, local indices — the overwhelming majority fit in one byte, so a variable-width encoding that spends one byte on the common case and only more when necessary is close to free. A u32 field costs four bytes per number forever; a LEB128 costs one. In a module with a thousand instructions, that difference is thousands of bytes.

And the cost of the optimisation is this: the encoding no longer has a fixed width, so the reader cannot tell where the next number starts without decoding it. A fixed-width field is a stride you can jump over. LEB128 is a length you must walk. That is the trade, it is a good trade, and every decoder in the format has to implement it correctly before it can do anything else.

A Simple Model

The mechanism, in one picture. A LEB128 is little-endian base 128: seven bits of value per byte, and the high bit says whether more bytes follow.

  byte:   7 6 5 4 3 2 1 0
          +-+-+-+-+-+-+-+-+
          | |  value   0 |
          +-+-+-+-+-+-+-+-+
            ^             ^
            |             +-- bit 0
            +---------------- the high bit: 1 means "another byte follows"

value = (byte0 and 0x7f)
      + (byte1 and 0x7f) << 7
      + (byte2 and 0x7f) << 14
      + ...

That is the whole unsigned encoding. Two properties are worth holding onto: the bytes are consumed least-significant group first, and a single byte with the high bit clear is a complete value with no lookahead. The second is why the format has no length prefix for its numbers — the encoding is self-delimiting, and a reader knows where it ended by looking at the last byte it read.

And then there is the signed version

Signed LEB128 uses the same seven-bits-per-byte layout and differs in exactly one rule: the sign of the value comes from the high bit of the last byte, not the low bit. The decoder takes the value it has assembled and, if that final byte's bit 6 is set, subtracts one more group unit.

the final byte is  b6 b5 b4 b3 b2 b1 b0 | 0
                     |                      |       |
                     |                      |       +-- continuation
                     |                      +---------- bit 6: the SIGN
                     +--------------------------------- the value bits

one-byte signed values, exhaustively:
    00 ->   0      40 ->  -64
    01 ->   1      41 ->  -63
    3f ->  63      7f ->  -1
    40 -> -64      80 -> (continues, so it is not a one-byte value)

Read that table and the trap is visible. 0x3f is 63 and 0x40 is −64, with no gap between them — the range is continuous because 63 is followed immediately by its negation. And 0x7f is −1, not 127.

The same two bytes, three answers.0x7f is −1 read as signed LEB128, 127 read as unsigned LEB128, and — once it has been widened to a 32-bit i32 and printed without a sign — 4294967295. All three are correct readings of two bytes, and the file contains nothing that distinguishes them. What tells you which is intended is the field: a i32.const operand is signed, a section size is unsigned. There is no marker in the encoding itself. This is the one place in the WebAssembly format where the same bytes are genuinely ambiguous, and it is why the format uses two different functions for the two cases rather than one function with a flag.

The Actual Detail

Four real operands, three decoders

Take a module with four constants, one of them positive, and read the four immediates out of the code section. The source is:

(func (export "neg") (param i32) (result i32)
  local.get 0
  i32.const -1
  i32.add)
(func (export "big") (result i32)  i32.const 1000000)
(func (export "minusbig") (result i32)  i32.const -1000000)
(func (export "i64neg") (result i64)  i64.const -1)

Read the immediates with the decoder shipped with this course, and compare all three decodings side by side:

SourceBytesSigned LEB128 (correct)Unsigned LEB128 (wrong)Widened unsigned (what wabt prints)
i32.const -17f−11274294967295
i32.const 1000000c0 84 3d100000010000001000000
i32.const -1000000c0 fb 42−100000010971524293967296
i64.const -17f−112718446744073709551615

Three things to read out of that table, and the third is the important one.

First, the positive value is identical in all three columns.1000000 has the high bit of its final byte clear, so signed and unsigned decoding agree and so does the widening. A decoder with no signed LEB128 passes every positive value in the module. That is why the bug survives testing.

Second, the negative values are wrong by wildly different amounts in the unsigned column. −1 becomes 127, a small error. −1000000 becomes 1097152, an error of 2097152. The unsigned decoder is not off by a constant, so no single correction factor will rescue it, and the size of the error grows with the magnitude of the value.

Third, look at the last column and compare it with the disassembler's own output:

$ wasm-objdump -d neg.wasm | grep const
   000049: 41 7f        | i32.const 4294967295
   00004f: 41 c0 84 3d  | i32.const 1000000
   000056: 41 c0 fb 42  | i32.const 4293967296
   00005d: 42 7f        | i64.const 18446744073709551615

Every one of those matches the "widened unsigned" column exactly, including the 64-bit case at 18446744073709551615. So wasm-objdump decodes i32.const and i64.const operands and prints them as unsigned.

That is not a bug in the disassembler, and the distinction matters. The encoding is signed LEB128 — the signed column above proves it, because it recovers the source constants exactly. What the disassembler does is take the decoded signed value and display it in the unsigned form of its own type, which is a presentation choice and arguably a defensible one: a 32-bit value printed unsigned is unambiguous, where −1 could be mistaken for a label. But it means a reader who takes the disassembler's number as the constant will compute the wrong answer, and will compute it wrong for exactly the constants most likely to appear in real code: −1, and small negative offsets.

Decoding a multi-byte value by hand

The negative one-million case, since it has all three features at once — several bytes, a final byte with bit 6 set, and a value that needs the sign step:

bytes:  c0    fb    42
        |     |     |
        |     |     +-- 0x42, high bit clear: this is the LAST byte
        |     +-------- 0xfb, high bit set: continue
        +-------------- 0xc0, high bit set: continue

groups, least significant first:
    0xc0 and 0x7f = 0x40 = 64
    0xfb and 0x7f = 0x7b = 123
    0x42 and 0x7f = 0x42 = 66

value  = 64 + (123 << 7) + (66 << 14)
       = 64 + 15744 + 1081344
       = 1097152

now the sign: the LAST byte is 0x42, whose bit 6 (0x40) IS set,
so this is negative. Subtract one more group:
       1097152 - (1 << 21)
       = 1097152 - 2097152
       = -1000000

The step that matters is the last one. Notice that 0x42 has its high bit clear — so it is the last byte — and its bit 6 is set, which is what says "negative". Those are two different bits of the same byte doing two different jobs, and confusing them is the classic LEB128 bug. The high bit answers "is there another byte"; bit 6 answers "is the value negative".

Also worth noticing from the arithmetic: the intermediate value 1097152 is the same number the unsigned decoder produces. Signed and unsigned LEB128 assemble the same bits; the only difference is the final adjustment. So a decoder that gets the sign step wrong and a decoder that uses the wrong function produce identical bytes and identical intermediate values — which is why neither can be caught by comparing intermediate state.

Where each one is used, and why the split exists

The rule is not arbitrary, and it follows from what each number means:

FieldEncodingWhy
section size, name length, entry countsunsignedA count is never negative, and the unsigned encoding of a small positive is one byte
indices: type, function, local, label, globalunsignedSame. An index into anything in a module is a position, and positions are non-negative
limits: memory and table minimum and maximumunsignedPage counts and element counts
i32.const, i64.constsignedThey are constants, and constants are negative roughly half the time
br_table targets, and other relative offsetsunsignedRelative in the instruction stream, so the sign is implicit in the arithmetic rather than stored

The row that is easy to get wrong is the last one, and it is worth pausing on. A branch to an earlier label is a negative distance, and you would expect it to be signed. It is not: br_table depths are unsigned, because the label indices are unsigned and the target is looked up rather than computed. Meanwhile i32.const is signed, because it is a value. So "is this number a distance or a position" is the question, not "is it a distance". A reader that assumed signed-wherever-there-is-offset would be wrong here, and wrong in a way that produced an out-of-range index rather than a wrong constant.

A Real Example

Writing the decoder, because the two-function split is the design and the design is what prevents the bug. This is the version the course ships:

def read_uleb128(b, p): result = 0 shift = 0 while True: byte = b[p]; p += 1 result |= (byte and 0x7f) << shift if not (byte and 0x80): # high bit clear: last byte return result, p shift += 7 def read_sleb128(b, p): result = 0 shift = 0 while True: byte = b[p]; p += 1 result |= (byte and 0x7f) << shift shift += 7 if not (byte and 0x80): # high bit clear: last byte if byte and 0x40: # bit 6 set: negative result -= (1 << shift) return result, p

Compare the two loops. They are nearly identical, and the difference is two lines: signed increments shiftbefore testing the last byte, and applies the sign adjustment after. Unsigned does not need either, because a count has no sign and there is nothing to subtract.

Now the guardrails, because a decoder without them will be handed something it should refuse:

three checks a LEB128 reader should make, and what each is for 1. LENGTH if more than 10 bytes: reject a 64-bit value needs at most ceil(64 / 7) = 10 bytes. Without the check, a run of 0x80 bytes makes shift grow without bound and a hostile file can ask for gigabytes of arithmetic. This is the check that matters most: it is the difference between a decoder and a denial-of-service. 2. OVERFLOW if shift is already 63 and the byte has bits above what is left: reject "i32.const" with a 10-byte operand that overflows 32 bits is malformed, and a reader that silently truncates produces a constant that is not the one in the file. 3. THE RIGHT FUNCTION know, per field, whether the field is signed this is the one that cannot be checked mechanically

The third check is the one that matters most in practice, and it is worth being honest about why: there is no way to detect it from the bytes. The other two are self-evident properties of the encoding — a value cannot need more than ten bytes, and a 32-bit field cannot hold a 40-bit number. But given 0x7f with no field context, −1 and 127 are equally consistent with the file. A reader cannot be self-checking here. It has to be written correctly, from a table saying which fields are signed, and reviewed by someone checking that table.

That is a general property worth noticing about formats. Some of a format's rules are checkable at runtime and some are not. WebAssembly is unusually well designed in the checkable category — the validator verifies types, arities, indices, and memory bounds before a single instruction runs, which is why an untrusted module is safe to hand to a JIT. But the encoding layer sits below the validator, and a decoder has to be right before any validation can happen. The layer that decides what the bytes mean is the layer that cannot check itself, and that is true of every binary format ever designed.

Try It Yourself

$ python3 courses/wasm/assets/samples/wasm_decode.py --hex neg.wasm
$ wasm-objdump -d neg.wasm | grep const

Check Your Understanding

Without looking back: a i32.const instruction is followed by the bytes c0 fb 42. Read the constant three ways, and say which one the program means.

Apply It

You are writing an interpreter for WebAssembly. Arithmetic works. Comparisons work. Loops work. But one function is subtly wrong: it returns a plausible result, and that result is wrong only for some inputs, and always wrong in the same direction. i32.const, i32.add and local.get are all implemented and all tested against a reference interpreter on a large generated test suite. What is the most likely defect, and why did the test suite not find it?

The general habit: a differential test against your own model proves the implementation matches your understanding. To break a tie you need a case your model will not generate — and for a signedness bug, that means a negative number on purpose.

Connect

LEB128 is not WebAssembly's invention and it is not its only one. The same encoding appears in three formats in this collection, and each use teaches something slightly different about why it exists.

In DWARF the encoding is where a silent bug is easiest to make, because there the field's signedness is decided by the abbreviation table rather than by the format. A reader must consult a table to learn whether a given operand is signed, and getting that wrong is the bug the location-expressions concept warns about with 0x5c being −36 and not 92. In COFF's archive format the size field is decimal text rather than binary, which is a remarkable choice for a format that is otherwise entirely binary — and it is a reminder that a format can mix encodings when humans need to read the file. In ELF the LEB128 fields are comparatively rare, because ELF predates the technique.

WebAssembly adopted it wholesale, and the reason is visible in the numbers this concept opened with: a WebAssembly module is dominated by small integers — indices, counts, offsets, constants — so the one-byte case is the common case, and a variable-width encoding is close to free. A format with a dozen large fields, like ELF, gains little by switching and loses the ability to seek to a field by arithmetic on its offset.

One consequence of the encoding belongs to the container rather than to the numbers, and it is worth carrying forward. Because a LEB128 is self-delimiting, a section's content cannot be indexed without decoding it — there is no stride, so you cannot compute the offset of the third entry without walking past the first two. That is why the Code section has to be read in order to find the third function's body, and it is the reason the next module's first concepts are about how bodies are found rather than about what is in them.

That closes Module 1. The container is decoded end to end: eight fixed bytes, a framing of identifier and length, fourteen section kinds in a required order, and one variable-width encoding governing every number in between. What remains in the format is what those numbers mean — the declaration sections, the code section, and the two sections that place data — and those are the subject of the modules that follow.

Back to the course index.