Skip to content
Anti-Disassemblyintermediate

Jump-to-Same-Target Opaque Predicate

A conditional jump whose taken and fall-through paths reach the same address, planting a junk byte in the gap that derails a linear-sweep disassembler.

This technique combines a degenerate conditional branch with a single garbage byte. The conditional jump is arranged so that both the taken and the fall-through edges converge on the same destination — making the branch semantically a no-op for control flow — but the bytes physically placed in the gap between the jcc and its target are never executed. The obfuscator drops a carefully chosen junk byte into that dead gap.

To a human reading the listing the construct looks like a normal conditional, but a disassembler that decodes the fall-through path linearly will consume the junk byte as the start of an instruction, desynchronising from there. Because neither real path ever reaches the junk byte, the program runs correctly while the static listing shows garbage. The "predicate" part is incidental — even an unconditional pair of jumps to the same spot achieves it; the conditional form just looks more natural and survives some recursive analysis.

How it works

asm
; Bytes:  74 03 75 01 E8  <real code at the merge point>
00:  74 03           jz   short 0x05     ; if ZF=1 -> target 0x05
02:  75 01           jnz  short 0x05     ; if ZF=0 -> target 0x05 (same address!)
04:  E8              ; <- JUNK byte. 0xE8 is the opcode for "call rel32"
05:  ...             ; real code — both branches land here

jz/jnz are exhaustive: exactly one of them is taken for any value of ZF, and both target 0x05, so execution always resumes at 0x05. The byte at 0x04 (0xE8) is unreachable. A recursive disassembler that follows both edges to 0x05 may recover correctly, but a linear sweep decodes straight past the second jump and hits 0x04: it reads 0xE8 as call rel32 and swallows the next four bytes — the first real instructions — as a phantom call displacement.

A common refinement uses a single conditional that is provably always-taken so even a recursive engine that prunes the false edge still falls into the trap:

asm
; Bytes:  33 C0 74 01 E9  ...
00:  33 C0           xor  eax, eax       ; sets ZF = 1, deterministically
02:  74 01           jz   short 0x05     ; ALWAYS taken (ZF is 1)
04:  E9              ; <- junk: 0xE9 is "jmp rel32", consumes 4 real bytes
05:  ...             ; real code

Here xor eax,eax guarantees ZF=1, so jz is always taken and the byte at 0x04 is dead — but the linear sweep cannot prove the predicate and decodes the 0xE9 jmp, eating four legitimate bytes.

Detection & analysis

Static analysis:

  • Look for adjacent jz/jnz (or any jcc/inverse pair) with identical targets — a structurally impossible "both-ways" branch that real compilers never emit.
  • For the always-taken single form, trace the flag-setting instruction immediately above the jcc (xor reg,reg, cmp/test of equal operands, sub reg,reg): if the flag is constant the branch is opaque and the byte after the jcc is junk.
  • In IDA/Ghidra, undefine the byte right after the conditional jump and re-decode starting at the real merge target to clear the desync.

Dynamic analysis:

  • Single-step the conditional in a debugger; you will see control reach the merge address every time, and the junk byte is never the start of an executed instruction — confirming it as filler.

Detection rule hint:

Flag any jcc whose fall-through byte is not the start of any recursively-reachable instruction, especially when (a) a complementary jcc to the same target precedes it, or (b) the flags consumed by the jcc were set to a constant by the immediately preceding instruction. This pairing of a degenerate/always-taken branch with an unreachable opcode-shaped byte is a strong anti-disassembly signature.

Votes

Comments(0)