HackBlue

Tools · Ghidra · 14 / 22

Ghidra: switches and calling conventions

A switch is a selection among cases, compiled either as a chain of compares or as a jump through a table. A calling convention is the agreement about where arguments and the return value sit. Both are concepts you set in the database so the decompiler stops guessing.

18 min read · practice only with authorization

A switch is a selection, compiled one of two ways

In source, status_text picks a word from a code. A compiler can implement that pick as a chain of compares and branches, each branch loading the address of ok, missing, bad-config, or unknown. It can also implement the pick as a jump table: check that the code is inside the range, scale it to a pointer width, and jump through a table of addresses that point at those loads. For three cases either shape is plausible. Your build decides. The decompiler’s job is to print the selection. A chain becomes a switch or a nest of ifs. A table becomes a switch once the table is understood as data.

Read the Listing before you argue with the printout. A compare against 0, then 1, then 2, with a string address on each path, is the chain. A bounds check, a scaled index, and an indirect jump is the table. Both are compiler output. The concept to keep is that the table’s entries are addresses of code, stored as data. They are not instructions.

A jump table has to be typed as data

If the disassembler ran into the table, each pointer was decoded as an instruction, the indirect jump lost its targets, and the decompiler fell back to an opaque computed branch. The repair is a database edit. Clear the code units that cover the table. Define the range as an array of pointers whose width matches the machine, eight bytes on this lab. Reference analysis can then see an edge from the jump to each case, and the decompiler can reprint a switch. Nothing in that repair writes to the executable. You are telling the database how to group bytes it already had.

You can force the same lesson by adding several dense cases to status_text and rebuilding. A longer dense range makes a table more likely. Keep that rebuild as its own program in the project if you try it, so the earlier -O0 notes stay attached to the earlier bytes.

A calling convention is an agreement

The convention answers a short list of questions. Which registers hold the first arguments? When do arguments start appearing on the stack? Which register holds the return value? Which registers may the callee overwrite, and which must it save? Who pops stack arguments? On the 64-bit Linux lab the answers are the System V AMD64 rules: RDI, RSI, RDX, RCX, R8, R9, then the stack, with the return in RAX. On 64-bit Windows the first four integer or pointer arguments are RCX, RDX, R8, and R9, and the caller reserves 32 bytes of shadow space. On 32-bit x86, cdecl puts arguments on the stack and the caller cleans up; stdcall puts them on the stack and the callee cleans up. A C++ this pointer has its own variants, often ECX on 32-bit Windows.

Ghidra stores the agreement on the function. Edit Function Signature and set the calling convention to the one the call sites actually use. You check a call site in the Listing: the instruction just before the call should place the first pointer argument in the register the convention names. If the decompiler’s parameters look swapped, or it invents stack arguments the caller never writes, the convention on the callee is the first thing to fix. Set it, reread the call, and only then invent a type.

  • System V AMD64: RDI, RSI, RDX, RCX, R8, R9, return in RAX.
  • Microsoft x64: RCX, RDX, R8, R9, plus 32 bytes of shadow space, return in RAX.
  • cdecl and stdcall are 32-bit stack conventions that differ on who cleans the arguments.
  • The Listing at the call site is the check. The signature editor is where the agreement is stored.

Lab: the status selection and the path register

Open the function that returns the status words. Record which shape your compiler chose, compare chain or jump table, and quote one Listing line that proves it. If it is a table, record the table’s address and the block it sits in, and confirm the block is data. If the decompiler already shows a switch, the recovery succeeded. Leave it.

Then find the call to the function that opens the file. In the Listing, name the register that holds the path pointer at the call. On this Linux build it should be RDI. Open the signature editor for the callee and set the System V convention if it is not already set, with the first parameter typed char *. The decompiler at the call should now read as a path being passed, in agreement with the register you just named.

Snippet
Where the first arguments live, conceptually

x86-64 System V (typical Linux ELF, including this lab):
  integer/pointer args: RDI, RSI, RDX, RCX, R8, R9, then stack
  return value: RAX

Microsoft x64:
  integer/pointer args: RCX, RDX, R8, R9, then stack
  the caller also leaves 32 bytes of shadow space
  return value: RAX

32-bit cdecl: arguments on the stack, caller cleans up
32-bit stdcall: arguments on the stack, callee cleans up

Diagram

How a small switch can appear

  1. Selection

    The source selects a string from a status code. The compiler emits compares or a table. Both implement the same selection.

  2. Table

    A jump table is data: addresses of the case blocks. It belongs in a read-only block, typed as pointers, not disassembled as instructions.

  3. Agreement

    A calling convention names the registers, the stack slots, and who cleans the stack. Ghidra stores it on the function.

  4. Signature

    Edit Function Signature, pick the convention, and set the types. The decompiler reprints the calls from that agreement.

Three honest readings of status_text

  • Compares

    A short chain of compare-and-branch against 0, 1, and 2. Each branch loads the address of a different word. Common for a tiny switch, and ordinary compiler output.

  • Jump table

    A bounds check, an index, and an indirect jump through a table of code addresses in read-only data. Also ordinary, especially as the case list grows.

  • Recovered switch

    The decompiler prints switch (code) with cases. That print is successful recovery. If the table was decoded as code, the recovery fails until you mark the table as data.

Where the first arguments live, conceptually
x86-64 System V (typical Linux ELF, including this lab):
  integer/pointer args: RDI, RSI, RDX, RCX, R8, R9, then stack
  return value: RAX

Microsoft x64:
  integer/pointer args: RCX, RDX, R8, R9, then stack
  the caller also leaves 32 bytes of shadow space
  return value: RAX

32-bit cdecl: arguments on the stack, caller cleans up
32-bit stdcall: arguments on the stack, callee cleans up

Check yourself

1. status_text compares a value with 0, 1, and 2 and loads a different string address on each path. The decompiler shows a chain of ifs. What are you seeing?

2. An indirect jump uses an indexed table, but the table’s bytes were disassembled as instructions, and the decompiler will not show a switch. What repair matches the bytes?

3. In the unoptimized Linux build, the call to load_config puts the path pointer in RDI. Which convention is the compiler following?