DEV Community

Cover image for How x86 Conditional Jumps Really Work — EFLAGS, Not Operands
Harrison Guo
Harrison Guo

Posted on Originally published at harrisonsec.com

How x86 Conditional Jumps Really Work — EFLAGS, Not Operands

Read enough x86 and you start to narrate it wrong in your head: "ja .target — jump if the first operand is above the second." It's a convenient lie. ja has no operands and no idea what you compared. It reads two bits of EFLAGS, and those bits were written by whatever instruction last touched them. Usually that's the cmp right above it. Sometimes it isn't, and that gap is where a whole class of reverse-engineering and crash-triage confusion lives.

This post pins down what conditional jumps actually read, with real code where the flag-setter is not a cmp, and how to watch the whole thing happen one instruction at a time in GDB.

The model in one sentence

x86 splits a comparison into two instructions that communicate through a hidden register:

  1. An instruction that writes flagscmp, test, sub, add, and, cmpxchg, most of the ALU.
  2. A conditional jump that reads flagsja, jb, je, jl, js, and the rest.

EFLAGS is the channel between them. cmp a, b is just sub a, b that throws away the result and keeps the flags. test a, b is and a, b doing the same. The jump then inspects the bits. Nothing carries the operands forward — only the flags survive.

Which jump reads which bit

Every conditional jump is a named test over a fixed combination of flag bits. The common ones, after a cmp a, b:

Jump Reads True when (cmp a, b)
je / jz ZF=1 a == b
jne / jnz ZF=0 a != b
ja / jnbe CF=0 and ZF=0 a > b (unsigned)
jae / jnc CF=0 a >= b (unsigned)
jb / jc CF=1 a < b (unsigned)
jbe CF=1 or ZF=1 a <= b (unsigned)
jg ZF=0 and SF=OF a > b (signed)
jge SF=OF a >= b (signed)
jl SF ≠ OF a < b (signed)
jle ZF=1 or SF ≠ OF a <= b (signed)
js / jns SF result negative / not

Two things fall out of this table immediately. First, signed and unsigned comparisons are different instructionsja (unsigned, carry) versus jg (signed, sign vs overflow). Pick the wrong one and the branch is correct on small numbers and wrong the moment a value crosses the sign boundary. That is a real bug pattern, not a curiosity. Second, none of these read a or b. They read CF, ZF, SF, OF. Whoever set those last decides the branch.

When the flag-setter isn't the cmp

Here is the part the "ja means greater-than" mental model hides. This is a lock-free stack push, in real assembly:

push_retry:
    mov QWORD PTR [rsi], rax        ; new_node->next = current head
    lock cmpxchg QWORD PTR [rdi], rsi  ; if head==rax, head=rsi
    jne push_retry                   ; retry if it changed
Enter fullscreen mode Exit fullscreen mode

There is no cmp here at all. The jne is reading ZF — and ZF was set by cmpxchg, which sets it to 1 when the compare-and-swap succeeded and 0 when it failed. So jne ("jump if ZF=0") loops back on a failed swap. The branch condition is entirely defined by an instruction most people don't think of as a "comparison."

The same shape shows up constantly once you look for it:

    test al, al     ; sets ZF from al & al, i.e. is al zero?
    jz  .done       ; jump if al == 0
Enter fullscreen mode Exit fullscreen mode

test al, al is the idiomatic "is this register zero" — cheaper than cmp al, 0 and it sets ZF the same way. The jz reads that. No operand comparison in the source sense; just a flag set and a flag read.

The rule that actually keeps you out of trouble: the branch reflects EFLAGS at the moment of the jump, not the state at the last cmp you happened to notice. Anything between them that writes flags — an arithmetic instruction, a test, sometimes the tail of a called function — changes the decision. "The values look right but the branch went the wrong way" is almost always a flag clobbered in that gap.

Watching it in GDB

You do not have to trust any of this. Step it. With GDB and pwndbg (or GEF/peda), pwndbg decodes EFLAGS into named bits on every stop, so you can watch a flag-writer set them and the jump read them:

pwndbg> starti
pwndbg> nexti            # advance to the cmp / test / cmpxchg
pwndbg> info registers eflags
# eflags 0x...  [ CF PF ZF SF ... ]   <- decoded bit names
pwndbg> nexti            # the conditional jump
# pwndbg shows whether the branch is TAKEN based on those bits
Enter fullscreen mode Exit fullscreen mode

The habit worth building: stop on the flag-setting instruction, read the decoded flags, then confirm the jump's decision against the table above rather than against your memory of the operands. In a malware sample full of obfuscated arithmetic and junk instructions between the compare and the branch, that is the difference between reconstructing the real control flow and guessing at it. The video does this live on a small program if you want to see the bits move rather than take my word for the mapping.

Why this reaches code you actually ship

You don't hand-write jumps, but you read their consequences:

  • Crash-dump and coredump triage. When you're staring at a disassembly around the faulting instruction, knowing that the branch above it depends on flags set several instructions earlier — not on the registers you can see right there — is what lets you reconstruct which path was actually taken.
  • Reading compiler output. A source-level if (x < y) becomes jb or jl depending on whether the compiler decided the values are unsigned or signed. That single letter tells you how the compiler typed your variables, which occasionally reveals a bug the source hid.
  • Constant-time / security-sensitive code. Comparisons that must not branch on secret data (crypto equality, timing-safe checks) live and die by exactly which instruction sets the flags and whether a branch consumes them. Auditing that requires reading the flag flow, not the operands.

The general lesson under all of it: CPU flags are shared, mutable, global state. Instructions you don't think of as comparisons write them; a branch far below reads them. Knowing which instructions have that side effect — and reading EFLAGS at the branch, not at the cmp — is most of what separates "I can follow assembly" from "I can read assembly."

Related

Top comments (0)