Skip to content
Anti-Disassemblyintermediate

Obfuscated Jump Tables

Encoded or computed switch tables hide branch targets behind arithmetic, defeating automatic control-flow recovery and leaving dead-end indirect jumps.

Compilers turn dense switch statements into jump tables: an index scales into an array of code pointers, and a single jmp [table + idx*4] dispatches to the selected case. Disassemblers lean on the recognisable shape of this idiom — a bounds check, a scaled memory operand, a contiguous table of in-section addresses — to recover every branch target and reconnect the control-flow graph. Obfuscated jump tables break each of those cues so the recovery silently fails.

The trick is to make the table entries not be plain addresses. Malware stores deltas, XOR-encoded pointers, or base-relative offsets that are only resolved at runtime by arithmetic the disassembler will not execute. The final jmp reg then targets a value the static analyser cannot compute, so it gives up: the indirect branch becomes a dead end, every case body is left as unreferenced bytes, and the function appears to terminate at the dispatch. Packers like VMProtect and droppers such as Emotet use this to fragment whole functions into islands the auto-analysis never links.

How it works

A clean jump table dispatches through raw pointers; the obfuscated version stores encoded entries and decodes the chosen one before jumping, so the static table holds no usable address:

asm
; --- Recognisable form a disassembler can resolve ---
00: 3C 04            cmp  al, 4              ; bounds check on the index
02: 77 13            ja   default
04: FF 24 85 40 70 40 00  jmp [tbl + eax*4]  ; tbl holds plain code addresses
; tbl: dd off_401000, off_401040, ...        ; <- recovered as 5 targets

; --- Obfuscated form: entries are XOR-encoded, decoded at runtime ---
00: 8B 04 85 40 70 40 00  mov eax, [tbl + eax*4]  ; eax = ENCODED entry
07: 35 5A A5 5A A5         xor eax, 0xA55AA55A     ; runtime-only decode key
0C: FF E0                  jmp eax                 ; target unknown statically
; tbl: dd 0xA55AD55A, 0xA55A957A, ...             ; <- looks like data, not addrs

The disassembler sees jmp eax with eax derived from a memory load and an xor it does not constant-fold, so it cannot enumerate the destinations. The table bytes XOR-decode to real addresses (0xA55AD55A ^ 0xA55AA55A = 0x401000) only when the CPU runs the stub, leaving the case bodies as orphaned code.

A base-relative variant stores 32-bit deltas and adds an image base recovered at load time, so the same table relocates anywhere and still defeats analysis:

asm
; Bytes:  8B 1C 81  03 DF  FF E3
00: 8B 1C 81         mov  ebx, [ecx + eax*4]  ; ebx = signed delta from table
03: 03 DF            add  ebx, edi            ; edi = runtime base of code section
05: FF E3            jmp  ebx                  ; absolute target = base + delta
; tbl: dd 0x00000000, 0x00000040, ...          ; deltas, not addresses

Because the table contains offsets relative to a register (edi) the disassembler never resolves, the jmp ebx is unbounded and the switch arms stay disconnected from the function.

Detection & analysis

Static analysis:

  • Look for an indirect jmp reg/jmp [mem] preceded by a scaled load ([base + idx*4]) plus an xor, add, or lea against the loaded value — the arithmetic between load and jump is the decode step.
  • Treat a contiguous array of section-relative or high-entropy 32-bit words sat just before the dispatch as a candidate encoded table; apply the inline decode key to each entry to materialise the real case addresses.
  • In IDA/Ghidra, manually define the resolved targets as code and add the missing xrefs so the orphaned case bodies reattach to the function graph.

Dynamic analysis:

  • Set a breakpoint on the indirect jmp and log the target register across many inputs; each observed value is one real case address to feed back into the static listing.
  • Emulators (Unicorn) or a coverage trace (Intel PT, DynamoRIO) execute the decode arithmetic and reveal every reached arm, reconstructing the full table.

Detection rule hint:

Flag a scaled-index memory load whose result flows through an xor/add/lea into an indirect jmp within the same basic block, especially when the referenced table is a run of equal-stride 32-bit words that do not point into any executable section as-is. Compilers emit jump tables of direct in-section addresses; an intervening runtime decode of each entry is a high-confidence obfuscation marker.

Votes

Comments(0)