Step 215. Ghidra In Depth: Function Analysis and Struct Recovery — Turning Machine-Made Names into Human Language

Step 215. Ghidra In Depth: Function Analysis and Struct Recovery — Turning Machine-Made Names into Human Language

Level 3 — Reversing Track | Difficulty ★★★☆☆ | Estimated time: 5 hours

Prerequisites: you’ve finished Step 178 (CTF Sampler: Reversing). You’ve solved a crackme with strings, objdump, and gdb, and you know what kind of tool Ghidra is.

⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. The analysis targets are practice binaries you compile yourself.

  • What you need: WSL Ubuntu (measured: Ubuntu 24.04, gcc 13.3.0, gdb 15.1). Ghidra is not installed in this environment, so it’s shown as Screen example, while the same principles are measured with objdump and gdb. If you have Ghidra installed, you can follow the screen examples exactly.
  • Caution: stripped binaries (no symbols) are covered too. Addresses may differ from your environment; the procedure being the same is what counts.

In Step 178 you opened Ghidra for the first time and "read" decompiler output. Real reversing isn’t reading — it’s organizing. Renaming machine names like FUN_00101169, param_1, local_48 into meaningful ones, fixing variable types, and recovering a struct from a list of byte offsets — once this workflow is second nature, your analysis speed doubles. Today is the day the decompiler goes from "a viewing tool" to "a thinking desk."


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Rename functions (L) and edit signatures (Edit Function Signature) in Ghidra
  • Infer struct fields from offset-access patterns like param_1 + 0x14
  • Build the inferred struct as a Ghidra Data Type and apply it to variables
  • Return to the assembly view to check the truth when decompilation looks wrong
  • Cross-check the decompiler’s output against objdump/gdb on the same binary

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C (for building analysis targets) + WSL Ubuntu bash (measured: gcc 13.3.0, gdb 15.1); Ghidra via Screen example
Today’s commands/keys objdump -d, strip, nm, gdb disassemble / Ghidra: L (rename), Ctrl+Shift+E or right-click (Edit Function Signature), Data Type Manager
Concepts needed Symbols and stripping, calling convention (rdi = first argument), struct field offsets, decompilation limits
Today’s deliverables A "recovery note" for the struct practice binary — function name tags + a struct blueprint

2-1. The Decompiler Is a Guessing Machine

A decompiler "recovers" C code from machine code, but this is not translation — it’s guessing. Variable types, function argument counts, the array-vs-pointer distinction — none of that information exists in the original; it vanished during compilation. So the decompiler attaches neutral names like undefined4 and param_1.

The analyst’s job is correcting those guesses. "This variable is always used as a 4-byte integer," "this pointer is always accessed with +0x14" — fixing observations into names and types is the body of reversing.

2-2. Symbols and Stripping — A World with Names and a World Without

A symbol is the name tag of a function or variable. It survives when a developer compiles, but shipped binaries are mostly stripped — the name tags torn off. In a stripped binary, Ghidra calls functions by address: FUN_00101169.

In today’s practice we build two editions of the same binary — "with symbols / without" — and compare. The latter is what you’ll meet in real work.

2-3. The Clue to Struct Recovery — Fixed Offsets

C struct fields are accessed as base address + fixed offset. p->level compiles into machine code like 0x14(%rdi). Conversely, collecting "the offsets that appear repeatedly against the same pointer" in assembly gives you the struct’s blueprint.

  • 4-byte addition at 0x14(%rdi) → a 4-byte integer field at offset 0x14
  • 8-byte subtraction at 0x18(%rdi) → an 8-byte integer field at offset 0x18
  • 0x4(%rdi) passed to a string function → a char array at offset 0x4

That is all there is to struct recovery. Whatever the tool (objdump or Ghidra), what you read is the same offsets.

2-4. Ghidra’s Three Workbenches

A preview via Screen example. ① Symbol Tree (left) — the function/label list. ② Listing (center) — assembly. ③ Decompile (right) — pseudo-C code. Renaming works from any window: select the target and press L. Signature editing: right-click the function name in the Decompile window → Edit Function Signature. Structs: right-click in the Data Type Manager window → New → Structure.

A vital habit: always view Listing and Decompile together. When the pseudo-code looks odd, the truth is in the assembly.

2-5. Why Spend Time Organizing

Renaming isn’t cosmetics. The moment FUN_00101169 becomes level_up, the pseudo-code at every place that calls it starts reading sensibly. Organizing compounds like interest — the bigger the binary, the more the first hour of organizing saves ten later.


3. Follow Along

3-1. The Lab — A Program That Uses a Struct

Build the analysis target yourself. Make it with the author’s eye knowing the structure, then train the analyst’s eye by forgetting it.

Input (structlab.c)

#include <stdio.h>
#include <string.h>

struct player {
    int  id;            /* +0x00 */
    char name[16];      /* +0x04 */
    int  level;         /* +0x14 */
    long gold;          /* +0x18 */
};

void level_up(struct player *p) {
    p->level += 1;
    p->gold -= 100;
}

void print_player(struct player *p) {
    printf("id=%d name=%s level=%d gold=%ld\n",
           p->id, p->name, p->level, p->gold);
}

int main(void) {
    struct player p = {7, "daimon", 3, 5000};
    level_up(&p);
    print_player(&p);
    return 0;
}

Compile — make two editions:

cd ~/lab214_218
gcc -O1 -o structlab structlab.c
strip -o structlab_stripped structlab
./structlab
id=7 name=daimon level=4 gold=4900

(Measured 2026-09-09.)

strip makes a copy with the symbols torn off. Check:

nm structlab_stripped
nm: structlab_stripped: no symbols

(Measured 2026-09-09.) Every name tag is gone. This is what a real-world binary looks like when you receive it.

3-2. The Analyst’s Eye — Reading a Struct from Offsets (objdump measurement)

Now forget the source and look at the stripped one. With no function names, you navigate by address — and practice reading while cross-checking against the unstripped binary.

objdump -d structlab | sed -n "/<level_up>:/,/ret/p"
0000000000001169 <level_up>:
    1169:	f3 0f 1e fa          	endbr64
    116d:	83 47 14 01          	addl   $0x1,0x14(%rdi)
    1171:	48 83 6f 18 64       	subq   $0x64,0x18(%rdi)
    1176:	c3                   	ret

(Measured 2026-09-09.)

How to read the output — without symbols, this function would be FUN_00101169. Read its contents:

  • addl $0x1, 0x14(%rdi) — add 1 to the 4-byte (addl = long, 32-bit) integer at +0x14 of wherever the first argument (rdi) points.
  • subq $0x64, 0x18(%rdi) — subtract 100 (0x64) from the 8-byte (subq = quad, 64-bit) integer at +0x18.

This one function alone reveals two struct fields. Let’s name it: "a function that adds 1 at +0x14" — looks like a level-up.

3-3. The Second Function — Completing the Field Map (objdump measurement)

objdump -d structlab | sed -n "/<print_player>:/,/ret/p"
0000000000001177 <print_player>:
    117f:	48 8d 4f 04          	lea    0x4(%rdi),%rcx
    1183:	8b 17                	mov    (%rdi),%edx
    1185:	4c 8b 4f 18          	mov    0x18(%rdi),%r9
    1189:	44 8b 47 14          	mov    0x14(%rdi),%r8d
    118d:	48 8d 35 74 0e 00 00 	lea    0xe74(%rip),%rsi        # 2008
    119e:	e8 cd fe ff ff       	call   1070 <__printf_chk@plt>

(Measured 2026-09-09.)

How to read the output: it reads four fields from the same pointer (rdi) and lines them up for printf.

Offset Access Inference
+0x00 mov (%rdi),%edx — 4 bytes int field (id?)
+0x04 lea — the address is passed char array (name? since a pointer is passed)
+0x14 4 bytes int (the same spot that went up by 1 in 3-2 — level)
+0x18 8 bytes long (the spot that lost 100 — gold)

The gap from +0x04 to +0x14 is 16 bytes — room for name[16]. Combine the observations from both functions and the struct blueprint is complete. It matches the original source exactly — you’ve just recovered a struct by hand.

3-4. The Same Work in Ghidra — Screen Example

Ghidra isn’t installed in this environment, so this is a Screen example. Import structlab_stripped and run analysis (Analyze), and the Symbol Tree’s Functions list fills with address-based names. Double-click FUN_00101169 and the Decompile window shows pseudo-code roughly like this:

# Screen example — Ghidra Decompile window (FUN_00101169)

void FUN_00101169(long *param_1)
{
  *(int *)(param_1 + 2) = *(int *)(param_1 + 2) + 1;   /* +0x14 = long[2] */
  param_1[3] = param_1[3] - 100;                        /* +0x18 = long[3] */
  return;
}

How to read it: the decompiler guessed param_1 as long *. It rendered +0x14 as param_1 + 2 (in long-based pointer arithmetic, 2×8=16=0x10 — well, precisely, the result of converting the byte offset) and +0x18 as param_1[3]. Awkward to read — because the type guess is wrong. The truth lives in the Listing window’s assembly (addl $0x1,0x14(%rdi)), and in 3-2 we already know the truth.

3-5. The Organizing Workflow — Screen Example

Now the four moves that correct the guesses:

# Screen example — the Ghidra organizing procedure
1. Function names: select FUN_00101169 → L key → rename to level_up
   FUN_00101177 → rename to print_player
2. Signature: right-click level_up in the Decompile window → Edit Function Signature
   → prepare to change the argument type to struct player *
3. Create the struct: right-click in Data Type Manager → New → Structure
   → add fields: +0x00 int id / +0x04 char name[16] / +0x14 int level / +0x18 long gold
   (offsets identical to the objdump measurements in 3-2 and 3-3)
4. Apply the type: set param_1's type to player * in Edit Function Signature

The moment you apply the type, the Decompile window changes to this:

# Screen example — after applying the struct

void level_up(player *p)
{
  p->level = p->level + 1;
  p->gold = p->gold - 100;
  return;
}

Not one machine-code byte changed, yet the code became human language. That is the power of organizing. And the fact that this result equals 3-1’s original source — proof the recovery was exact.

3-6. When Decompilation Is Wrong — Assembly Is the Truth

As 3-4 showed, the decompiler gets types wrong. The judgment criterion is one: if changing a type makes the code stranger, that type is wrong. Conversely, if code everywhere turns clean the moment you change it, it’s right.

The confirmation procedure when in doubt: click a suspicious line in the Decompile window and the cursor moves to the matching assembly in the Listing window. The assembly’s opcode (addl vs addq, mov vs lea) tells the truth about types. l is 4 bytes, q is 8 bytes, lea means "compute an address" — with just these three hints you read today’s entire struct.

3-7. The Recovery Note — Organizing the Deliverable

Address Machine name Assigned name Basis
0x1169 FUN_00101169 level_up +0x14 increment, +0x18 decrement
0x1177 FUN_00101177 print_player prints 4 fields via printf
Offset Size Field name Basis
+0x00 4 id mov (%rdi) — 4-byte read
+0x04 16 name address passed via lea — char array
+0x14 4 level addl $0x1 — increment by 1
+0x18 8 gold subq $0x64 — decrement by 100

These two tables are today’s deliverable, the "recovery note." In real work it grows to dozens of functions and dozens of fields.


4. Missions & Exercises

Mission — Recover One Unknown Function to C Level

  1. Add a function to structlab.c — e.g., void buy_item(struct player *p, int price) { if (p->gold >= price) p->gold -= price; } (don’t change the field layout)
  2. Recompile and strip
  3. With no symbols, find the new function, read it with objdump, and recover what it does as one paragraph of C code — you must read the conditional branch (jge/jl family) too
  4. If you have Ghidra, open the same function, apply names and types, and compare your hand recovery with the decompile result

Exercises

Problem 1. In 3-2’s addl and subq, what do the final letters l and q mean, and why do they matter for struct recovery?

Problem 2. In 3-3, the +0x04 field was accessed with lea, not mov. How does this difference feed the inference "an array, not an integer field"?

Problem 3. Explain, from the symbol perspective, why Ghidra calls a function FUN_00101169 in a stripped binary.

Problem 4. We said "the decompiler is a guessing machine." What is a practical criterion for telling a guess is wrong, and where is the final truth confirmed?


5. Model Answers & Completion Criteria

Mission Model Answer

Example recovery process after adding buy_item, recompiling, and stripping:

[Discovery] spot an unfamiliar call address in main's disassembly → read the function at that address with objdump

[Observation — output example; addresses vary by environment]
  mov    0x18(%rdi),%rax        ; read +0x18 (8 bytes)
  cmp    %rsi,%rax              ; compare with the second argument (rsi)
  jl     return                 ; if smaller, do nothing
  movsxd %rsi,%rsi
  sub    %rsi,0x18(%rdi)        ; if greater or equal, subtract rsi from +0x18
  ret

[Recovered C]
void buy_item(player *p, long price) {
    if (p->gold >= price) p->gold -= price;
}

How to verify: ① do the recovered C’s field accesses (+0x18, 8 bytes) match 3-3’s struct map. ② is the branch direction right — jl is "jump if less," so it’s the branch that goes to not subtracting. Get the direction backwards and you have inverted code that "charges when you’re short on money." ③ does it mean the same as the original (different syntax is fine — same behavior is a successful recovery).

Exercise Answers

Problem 1 answer. They’re AT&T-syntax size suffixes — l (long) is a 4-byte operation, q (quad) is 8 bytes. Since the suffix determines a field’s size even at the same offset, it’s direct evidence for distinguishing int from long in a struct. Without sizes you can’t draw a struct’s boundaries.

Problem 2 answer. mov fetches the value at that spot; lea computes its address. Passing a string to the printf family requires an address, not a value, so lea 0x4(%rdi) means "there’s a contiguous space (an array) starting at +0x04." Not reading a value but passing an address — this single opcode difference separates "integer field" from "char array."

Problem 3 answer. A symbol is the name-to-address table for functions, and strip removes that table. Ghidra’s analysis can find where code starts (function boundaries) but can’t recover names, so it attaches the temporary name FUN_<address> with the address embedded. Only the name tags vanished — the functions themselves are intact, which is why an analyst can read the behavior and re-name them.

Problem 4 answer. The practical criterion: "when you apply the type, does all related code become easier to read?" — if one spot turns pretty while others break, that type is probably wrong. The final truth is confirmed in the assembly (Listing). Decompilation is a translation for convenience; what the CPU actually executes is the assembly, so when the two disagree, the assembly is right.

Completion Criteria Checklist

  • [ ] I can explain that decompiler output is "guesses"
  • [ ] I confirmed the difference between pre/post-strip binaries (nm results)
  • [ ] I inferred four struct fields from offset-access patterns in objdump output
  • [ ] I can distinguish field sizes and kinds using opcode suffixes (l/q) and the mov/lea difference
  • [ ] I can describe Ghidra’s rename (L), signature editing, and struct creation procedures
  • [ ] I know the habit of returning to assembly when decompilation looks off
  • [ ] Mission: I recovered a new function to C from a stripped state

6. Common Pitfalls & Fixes

Wall 1. Ghidra analysis (Analyze) never finishes

Symptom: analysis has been running for minutes after Import.
Cause: normal when the file is large or many analysis options are on.
Fix: just wait. Small practice binaries finish in seconds. If it takes too long, turn off heavy options like Decompiler Switch Analysis and try again.

Wall 2. The pseudo-code shows weird offsets like param_1 + 2

Symptom: the +0x14 we know appears as param_1 + 2.
Cause: when the decompiler guesses the argument as long *, byte offsets get expressed as pointer arithmetic (8-byte units). The guess is wrong (3-4, 3-6).
Fix: check the real byte offset (0x14) in the Listing’s assembly, then build and apply a struct type. If it still looks wrong afterward, that struct is wrong.

Wall 3. I renamed something but other windows still show the old name

Symptom: you renamed in Listing, but the call site in Decompile shows the old name.
Cause: you renamed with the cursor on the wrong target (an address literal instead of a label), or you renamed a label inside the function rather than the function itself.
Fix: the surest way is selecting the function in Symbol Tree’s Functions and pressing L. The Decompile window refreshes automatically after the rename.

Wall 4. I can’t find main in a stripped binary

Symptom: nm says no symbols, and you don’t know where main is.
Cause: missing name tags is the normal state.
Fix: start from the entry point — an ELF’s entry is _start, and the argument it passes to __libc_start_main is main. In objdump measurements, the lea ...(%rip),%rdi in the entry disassembly points to main’s address. Ghidra analyzes the entry automatically, so follow that call’s argument.

Wall 5. I don’t know where to draw a struct field’s boundary

Symptom: you know +0x04 is an array, but not its length.
Cause: array-length information often doesn’t exist in machine code.
Fix: the next field’s offset is the boundary. The field after +0x04 is accessed at +0x14, so the array is at most 16 bytes. Drawing boundaries from "the gaps between observed offsets" is the basic skill of struct recovery. Remember that alignment padding (invisible gaps) can exist too — between +0x14 and +0x18 in today’s struct, for example.


7. Summary

Today’s Concepts

Concept One-line description
Symbol / strip Function name tags / the shipped state with those tags torn off
FUN_0010xxxx Ghidra’s address-based temporary name for a symbol-less function
Struct recovery The work of regaining a field blueprint by collecting fixed-offset access patterns
Opcode suffix l=4 bytes, q=8 bytes — evidence of field size
mov vs lea Reading a value vs computing an address — the dividing line between integers and arrays
Signature editing Correcting a function’s argument types to turn all pseudo-code into human language

Today’s Commands & Keys

Command/key What it does
strip -o output input Make a copy with symbols torn off
nm binary Check for symbols (no symbols = stripped)
objdump -d binary Collect offset-access patterns — the raw material of struct recovery
Ghidra L Rename functions/variables
Ghidra right-click → Edit Function Signature Correct argument types
Data Type Manager → New → Structure Build the inferred struct

The Sense That Matters More Than Commands

Reversing skill splits not on "reading speed" but on "organizing habits." When you meet a suspicious function, do three things right away — name it, fix its argument types, and group repeated offsets into a struct. What you’ve organized once never needs reading again, anywhere in that binary.

And trust the decompiler, but doubt it. Awkward pseudo-code is a signal to the analyst — "my guess is wrong; fix it." The material for fixing always lives in the assembly. Today’s hand recovery with objdump is the muscle that keeps you steady tomorrow, when Ghidra gets it wrong.


Once every box is checked, Step 215 is complete. Click the checkbox in the sidebar to save your progress.