Case study · Reverse engineering
One byte: a signedness bug in arm64, end to end
The bug behind this site’s hero. A 32-bit length read as signed, a b.le that should have been b.ls, and a one-byte patch verified against the real binary.
The scrolling field on the home page is not decoration. It is the disassembly of a real program, compiled for arm64, with a real bug in it. This note walks the same path the page does: read the binary, find the assumption, patch it, prove the patch.
- Binary
- fwupd, Mach-O 64-bit arm64 executable
- Compiler
- Apple clang version 21.0.0 (clang-2100.3.34.2), -arch arm64 -O2 -fno-inline
- Size
- 28 functions, 783 instructions
- SHA-256
- 0715bbdca5b355a9b994abe3f99c2331a222856c40a48692fdb73bc45a75c4f2
- Patch
- 0x100000ce8 (file 0xce8): 6d 00 00 54 → 69 00 00 54
The target
fwupd is a small firmware-update receiver for a fictional sensor board. It reads length-prefixed packets from stdin: open a session, stream chunks into a staging buffer, verify a CRC, commit. The full source is here. A chunk packet looks like this:
/* CHUNK: [op:1][session:4][len:4][payload:len], big-endian */The bug, in source
NOINLINE int fw_write_chunk(struct fw_ctx *ctx, const uint8_t *pkt, size_t n)
{
int32_t len = (int32_t)read_u32_be(pkt + 5);
if (len > FW_CHUNK_MAX)
return ERR_RANGE;
memcpy(ctx->chunk, pkt + 9, len);
...
}The length on the wire is unsigned. Stored in an int32_t, anything with the top bit set becomes negative, and a negative number is never greater than 0x400. The check passes. memcpy takes a size_t, so the same value is converted back, sign-extended to 64 bits. This is CWE-195, signed to unsigned conversion.
The bug, in the binary
Source can mislead. The instruction stream decides. Here is fw_write_chunk exactly as clang emitted it:
******************************************************************************************* FUNCTION******************************************************************************************int fw_write_chunk(struct fw_ctx *ctx, const uint8_t *pkt, size_t n)_fw_write_chunk XREF[1]: handle_packet:10000104c(j)100000cc4 f6 57 bd a9 stp x22, x21, [sp, #-0x30]! ; fw_write_chunk+0x0 spill x22,100000cc8 f4 4f 01 a9 stp x20, x19, [sp, #0x10] ; fw_write_chunk+0x4 spill x20, x100000ccc fd 7b 02 a9 stp x29, x30, [sp, #0x20] ; fw_write_chunk+0x8 save fp/lr,100000cd0 fd 83 00 91 add x29, sp, #0x20 ; fw_write_chunk+0xc set frame po100000cd4 f4 03 01 aa mov x20, x1 ; fw_write_chunk+0x10 x20 = x1100000cd8 f3 03 00 aa mov x19, x0 ; fw_write_chunk+0x14 x19 = x0100000cdc 20 14 00 91 add x0, x1, #0x5 ; fw_write_chunk+0x18 x0 = x1 + 0100000ce0 20 fe ff 97 bl _read_u32_be ; len = be32(pkt+5), tainted100000ce4 1f 00 10 71 cmp w0, #0x400 ; len vs FW_CHUNK_MAX100000ce8 6d 00 00 54 b.le LAB_100000cf4 ; signed: 0xfffffff0 is -16100000cec 60 00 80 12 mov w0, #-0x4 ; fw_write_chunk+0x28 w0 = -0x4100000cf0 14 00 00 14 b LAB_100000d40 ; fw_write_chunk+0x2c jumpLAB_100000cf4 XREF[1]: 100000ce8(j)100000cf4 15 7c 40 93 sxtw x21, w0 ; -16 -> 0xfffffffffffffff0100000cf8 81 26 00 91 add x1, x20, #0x9 ; fw_write_chunk+0x34 x1 = x20 +100000cfc e0 03 13 aa mov x0, x19 ; fw_write_chunk+0x38 x0 = x19100000d00 e2 03 15 aa mov x2, x21 ; fw_write_chunk+0x3c x2 = x21100000d04 23 01 00 94 bl _memcpy ; memcpy(chunk, pkt+9, len)100000d08 74 02 42 f9 ldr x20, [x19, #0x400] ; fw_write_chunk+0x44 load x20100000d0c 80 0a 40 b9 ldr w0, [x20, #0x8] ; fw_write_chunk+0x48 load w0100000d10 e1 03 13 aa mov x1, x19 ; fw_write_chunk+0x4c x1 = x19100000d14 e2 03 15 aa mov x2, x21 ; fw_write_chunk+0x50 x2 = x21100000d18 1a fe ff 97 bl _crc32_update ; fw_write_chunk+0x54 call crc32_100000d1c e8 03 00 aa mov x8, x0 ; fw_write_chunk+0x58 x8 = x0100000d20 00 00 80 52 mov w0, #0x0 ; fw_write_chunk+0x5c w0 = 0x0100000d24 88 0a 00 b9 str w8, [x20, #0x8] ; fw_write_chunk+0x60 store w8100000d28 88 0a 40 f9 ldr x8, [x20, #0x10] ; fw_write_chunk+0x64 load x8100000d2c 08 01 15 8b add x8, x8, x21 ; fw_write_chunk+0x68 x8 = x8 + x100000d30 88 0a 00 f9 str x8, [x20, #0x10] ; fw_write_chunk+0x6c store x8100000d34 68 12 44 b9 ldr w8, [x19, #0x410] ; fw_write_chunk+0x70 load w8100000d38 08 05 00 11 add w8, w8, #0x1 ; fw_write_chunk+0x74 w8 = w8 + 0100000d3c 68 12 04 b9 str w8, [x19, #0x410] ; fw_write_chunk+0x78 store w8LAB_100000d40 XREF[1]: 100000cf0(j)100000d40 fd 7b 42 a9 ldp x29, x30, [sp, #0x20] ; fw_write_chunk+0x7c restore fp/100000d44 f4 4f 41 a9 ldp x20, x19, [sp, #0x10] ; fw_write_chunk+0x80 reload x20,100000d48 f6 57 c3 a8 ldp x22, x21, [sp], #0x30 ; fw_write_chunk+0x84 reload x22,100000d4c c0 03 5f d6 ret ; fw_write_chunk+0x88 return
Three instructions tell the whole story:
cmp w0, #0x400compares the 32-bit length with the limit.b.lebranches to the copy when the result is less than or equal, signed. That is where the C type became machine semantics.sxtw x21, w0sign-extends the length to 64 bits before it reachesmemcpyasx2.
Triggering it
Open a session, then send one chunk with a length of 0xfffffff0:
# BEGIN session 1, size 0x100
00 09 02 00000001 00000100
# CHUNK session 1, len 0xfffffff0, 32 bytes of payload
00 29 03 00000001 fffffff0 4242...420xfffffff0 is −16 as an int32_t. It passes the check, becomes 0xfffffffffffffff0 after sxtw, and memcpy starts writing 18 exabytes into a 1 KiB buffer. The process dies with SIGSEGV (exit 139).
Patching one byte
An arm64 conditional branch, B.cond, is encoded as 0101 0100 | imm19 | 0 | cond. In memory, little-endian, the condition sits in the low nibble of the first byte:
100000ce8 6d 00 00 54 b.le LAB_100000cf4 ; 0x6d = 011 0 1101, cond = LE
100000ce8 69 00 00 54 b.ls LAB_100000cf4 ; 0x69 = 011 0 1001, cond = LS1101 is LE, signed less-or-equal. 1001 is LS, unsigned lower-or-same. The top three bits are the low bits of imm19 (3 words, so the target is still +0xc), and they do not change. One byte at file offset 0xce8 turns the signed check into the unsigned one the protocol needed.
The sxtw stays in the patched binary, and that is fine: once the unsigned check holds, the length is at most 0x400, bit 31 is clear, and sign extension is a no-op.
******************************************************************************************* FUNCTION******************************************************************************************int fw_write_chunk(struct fw_ctx *ctx, const uint8_t *pkt, size_t n)_fw_write_chunk XREF[1]: handle_packet:10000104c(j)100000cc4 f6 57 bd a9 stp x22, x21, [sp, #-0x30]! ; fw_write_chunk+0x0 spill x22,100000cc8 f4 4f 01 a9 stp x20, x19, [sp, #0x10] ; fw_write_chunk+0x4 spill x20, x100000ccc fd 7b 02 a9 stp x29, x30, [sp, #0x20] ; fw_write_chunk+0x8 save fp/lr,100000cd0 fd 83 00 91 add x29, sp, #0x20 ; fw_write_chunk+0xc set frame po100000cd4 f4 03 01 aa mov x20, x1 ; fw_write_chunk+0x10 x20 = x1100000cd8 f3 03 00 aa mov x19, x0 ; fw_write_chunk+0x14 x19 = x0100000cdc 20 14 00 91 add x0, x1, #0x5 ; fw_write_chunk+0x18 x0 = x1 + 0100000ce0 20 fe ff 97 bl _read_u32_be ; len = be32(pkt+5), tainted100000ce4 1f 00 10 71 cmp w0, #0x400 ; len vs FW_CHUNK_MAX100000ce8 69 00 00 54 b.ls LAB_100000cf4 ; signed: 0xfffffff0 is -16100000cec 60 00 80 12 mov w0, #-0x4 ; fw_write_chunk+0x28 w0 = -0x4100000cf0 14 00 00 14 b LAB_100000d40 ; fw_write_chunk+0x2c jumpLAB_100000cf4 XREF[1]: 100000ce8(j)100000cf4 15 7c 40 93 sxtw x21, w0 ; -16 -> 0xfffffffffffffff0100000cf8 81 26 00 91 add x1, x20, #0x9 ; fw_write_chunk+0x34 x1 = x20 +100000cfc e0 03 13 aa mov x0, x19 ; fw_write_chunk+0x38 x0 = x19100000d00 e2 03 15 aa mov x2, x21 ; fw_write_chunk+0x3c x2 = x21100000d04 23 01 00 94 bl _memcpy ; memcpy(chunk, pkt+9, len)100000d08 74 02 42 f9 ldr x20, [x19, #0x400] ; fw_write_chunk+0x44 load x20100000d0c 80 0a 40 b9 ldr w0, [x20, #0x8] ; fw_write_chunk+0x48 load w0100000d10 e1 03 13 aa mov x1, x19 ; fw_write_chunk+0x4c x1 = x19100000d14 e2 03 15 aa mov x2, x21 ; fw_write_chunk+0x50 x2 = x21100000d18 1a fe ff 97 bl _crc32_update ; fw_write_chunk+0x54 call crc32_100000d1c e8 03 00 aa mov x8, x0 ; fw_write_chunk+0x58 x8 = x0100000d20 00 00 80 52 mov w0, #0x0 ; fw_write_chunk+0x5c w0 = 0x0100000d24 88 0a 00 b9 str w8, [x20, #0x8] ; fw_write_chunk+0x60 store w8100000d28 88 0a 40 f9 ldr x8, [x20, #0x10] ; fw_write_chunk+0x64 load x8100000d2c 08 01 15 8b add x8, x8, x21 ; fw_write_chunk+0x68 x8 = x8 + x100000d30 88 0a 00 f9 str x8, [x20, #0x10] ; fw_write_chunk+0x6c store x8100000d34 68 12 44 b9 ldr w8, [x19, #0x410] ; fw_write_chunk+0x70 load w8100000d38 08 05 00 11 add w8, w8, #0x1 ; fw_write_chunk+0x74 w8 = w8 + 0100000d3c 68 12 04 b9 str w8, [x19, #0x410] ; fw_write_chunk+0x78 store w8LAB_100000d40 XREF[1]: 100000cf0(j)100000d40 fd 7b 42 a9 ldp x29, x30, [sp, #0x20] ; fw_write_chunk+0x7c restore fp/100000d44 f4 4f 41 a9 ldp x20, x19, [sp, #0x10] ; fw_write_chunk+0x80 reload x20,100000d48 f6 57 c3 a8 ldp x22, x21, [sp], #0x30 ; fw_write_chunk+0x84 reload x22,100000d4c c0 03 5f d6 ret ; fw_write_chunk+0x88 return
Proving it
All three builds were replayed against the same three chunks: a valid one, one byte over the limit, and the negative length. The table is generated by the same script that produced the listing, on the same machine.
| Chunk | Original | Patched (1 byte) | Rebuilt (source fix) |
|---|---|---|---|
| len = 0x10valid chunk | ERR_OK | ERR_OK | ERR_OK |
| len = 0x401one byte too long | ERR_RANGE | ERR_RANGE | ERR_RANGE |
| len = 0xfffffff0negative as int32 | SIGSEGV (exit 139) | ERR_RANGE | ERR_RANGE |
The original crashes only on the negative length. The patched binary and the source-level fix behave identically on all three.
The real fix
A binary patch is a mitigation. The remedy is in the source: parse the field as what it is.
uint32_t len = read_u32_be(pkt + 5);
if (len > FW_CHUNK_MAX)
return ERR_RANGE;Rebuilt with that change, clang emits b.ls at the same address and replaces sxtw x21, w0 with mov w21, w0, a zero extension. Two instructions differ; only one of them mattered.
What to take from it
- Wire formats are unsigned until proven otherwise. Parse them that way.
- Signedness is decided by the branch condition, not by how the source reads. When in doubt, read the instruction.
- Verify the fix against the exploit and the happy path. A patch that rejects everything also "fixes" the crash.
Reproduce it
$ clang -arch arm64 -O2 -fno-inline -o fwupd fwupd.c
$ clang -arch arm64 -O2 -fno-inline -DHARDENED -o fwupd_fixed fwupd.c
$ otool -tvVj fwupd | sed -n '/_fw_write_chunk:/,/^_fw_verify:/p'
# or regenerate everything this page uses:
$ node lab/build-listing.mjs