switch / case in x86_64In this post I want to talk about how to correctly rewrite a disassembled C++ switch / case in assembly so that it can later be assembled back into machine code. I know the task sounds odd, but bear with me β I’ll explain why this is needed and what the actual problem is.
A bit of context
So… sometimes, when fuzzing a parser inside a binary with no source code available, the following approach turns out to be justified:
- Find the entrypoint in the disassembled code of the library or executable.
- Study the code that runs once control reaches the entrypoint, and determine:
- the prior state required for the code to execute correctly: register values, global variables, allocated memory, open sockets, and so on
- all possible code execution paths, including calls to other functions, including system calls (in the case of parsers the picture is often not that scary: there are few system calls, and the work is mostly memory manipulation)
- the data the function’s code operates on
- Rewrite all of this in assembly, in a separate
.asm/.sfile (obviously, nobody rewrites it completely from scratch β usually it boils down to copy-pasting the disassembled code into an IDE and fixing whatever syntax is incompatible with the specific assembler that will compile this file) - Wrap it in a C/C++ harness and compile it for fuzzing, for example in QEMU mode under AFL++
Sounds like a lot of work at the preparation stage. And as a rule, it is π€·ββοΈ Even though plugins and AI handle most of the tedious parts, this approach still demands a lot of attention and patience.
But once it’s done, we end up with source code. Yes, partly in assembly, but it’s still source code! Which, on top of that, builds into a small, fast executable, often with almost no dependencies.
This opens up a lot of possibilities β for example, painless parallel fuzzing across all free cores with 100% stable persistence in AFL++. Or emulating reads from a socket β we simply replace read (or whatever the equivalent is in your code) with a stub that reads from a specified memory region instead of a socket.
The problem with a decompiled switch / case
Now that we have some general context, let’s get back to the task. In 64-bit x86_64 code, compilers often produce the following machine instruction for a C++ switch / case table jump:
; rax: switch argument (case number)
; switch_table: address of the switch/case table,
; an array of qwords holding case addresses
jmp qword [rip + switch_table + rax * 8]
The problem is that if we manually reconstruct the switch / case table as something like
; C++ switch/case table
table_ee5370:
dq loc_44cd30, 9 dup (loc_44cd04) ; switch table used at 0x44ccc6 containing 42 entries
dq loc_44cd30, 2 dup (loc_44cd04)
dq loc_44cd30, 26 dup (loc_44cd04)
dq loc_44cccd, loc_44cd00
and then copy the instruction
jmp qword [rip + switch_table + rax * 8]
as-is into a .asm or .s file, your assembler won’t be able to assemble it back into machine code. Even though the instruction is valid, assemblers can’t compile it. I tried both GAS and NASM β no luck either way. I tried different syntax variations, for example using rel in NASM:
jmp qword [rel switch_table + rax * 8]
That didn’t work either β the assemblers stubbornly refused to compile this instruction into machine code. Turns out this isn’t some quirky assembler limitation, it’s baked into the x86_64 instruction encoding itself. RIP-relative addressing is encoded via a specific ModRM form that means “no base register, no index register, just a 32-bit displacement added to RIP.” A SIB byte (the thing that lets you write + rax * 8), on the other hand, requires a base register field β and when that field is encoded as “no base,” it means an absolute 32-bit displacement, not RIP-relative. In other words, “RIP-relative base + an index register” simply isn’t a thing the CPU knows how to decode. There’s no opcode encoding for it. So the assemblers weren’t being stubborn β they were correctly telling me that the instruction, as written, doesn’t exist.
At that point I gave up and just wrote the instruction into the .asm file as raw bytes:
; jmp qword [rip + switch_table + rax * 8]
db 0xFF, 0x24, 0xC5
dq switch_table - ($ + 4)
Surprisingly, this ugly hack partially worked β NASM managed to assemble the code, and at runtime it even started jumping… somewhere. Usually, not to the switch/case table at all. The reason turned out to be that the switch / case table in the .asm file ended up in .data. So during the harness build:
- The compiler produced a
.ofile from the.asmfile, resolvingswitch_tableto some offset relative to thejmpinstruction within that.ofile’sdata. - It then linked an executable from this
.ofile and others (!), merging thedatasections from the different.ofiles into one bigdatasection.
Naturally, this gave no guarantee that the offset of switch_table relative to the jmp instruction would stay the same in this new data section. And as a rule, it didn’t.
My solution
This offset issue could probably have been solved by moving the switch / case table into .text within the .asm file, but that would have made my ugly hack even uglier. I asked both ChatGPT and Claude how to solve this elegantly. But this time around, the usually-pretty-smart models didn’t come up with anything coherent. I had to solve it myself, so I came up with replacing the instruction
jmp qword [rip + switch_table + rax * 8]
with the following sequence of instructions:
push rsi ; save rsi on the stack
lea rsi, [rel switch_table] ; load the address of switch_table into rsi
lea rsi, [rsi + rax*8] ; load the address of the target case into rsi
xchg rsi, [rsp] ; restore rsi from the stack, push the case address onto the stack
ret ; jump to the case
This sequence of instructions doesn’t clobber any registers and, functionally, does exactly the same thing as the original jmp.
Is this a good solution? Well, it gets the job done, and (in my subjective opinion) it looks considerably more elegant than
db 0xFF, 0x24, 0xC5
dq switch_table - ($ + 4)
Could something even better have been done? Probably. See the next section.
The community solution
Funny enough, after I’d already settled on my little push/lea/xchg/ret trick and written most of this blog post, I went digging again and found that the NASM manual itself describes essentially the same problem β in the context of writing position-independent Win64 DLL code β and even offers its own fix:
lea rbx, [rel dsptch]
add rbx, QWORD [rbx + rax*8]
jmp rbx
...
dsptch:
dq case0 - dsptch
dq case1 - dsptch
The trick here is that the table doesn’t store absolute case addresses β it stores offsets relative to the table itself (case0 - dsptch). That way lea gives you a RIP-relative pointer to the table, add pulls in the relative offset for the right case, and the whole thing stays position-independent without ever touching the stack. Three instructions instead of my five, and arguably cleaner.
So why didn’t I just use that? Mostly because by the time I found it, my hack was already working, and switching over would mean rebuilding every jump table I’d extracted so that it stores caseN - table_start instead of the raw absolute addresses IDA/Ghidra dump out. Doing that by hand for one table is annoying but doable; parsers tend to come with a lot of switch / case blocks (think format dispatchers, opcode handlers, state machines), so doing it by hand for all of them stops being “annoying” and starts being “a great way to waste an evening.” The honest fix would be a small script to rewrite the dumped tables into relative form automatically β I don’t have one, and, frankly, I’m not in a hurry to write it π€·ββοΈ
So it goes.
ALTV!ST