Leçon 4.5 · Désassemblage & analyse de code· 50 min
Cross-References and Data Flow
Use cross-references to pivot from strings and imports to the code that uses them, trace values backwards, and restore xrefs lost to indirect calls.
Cette leçon n’est disponible qu’en anglais pour le moment.
Objectifs
- Distinguish code-to-code, code-to-data and data-to-data cross-references and explain how a disassembler computes them
- Use xrefs to pivot from a string, import or constant to the code that uses it
- Follow a value backwards with use-def reasoning, register and stack tracking, and constant propagation
- Explain why indirect calls and dynamic API resolution break xrefs, and restore them with types and manual references
- Compute RIP-relative xrefs yourself with Capstone and pefile
Most time in a disassembler is spent navigating, not reading top to bottom: from a suspicious string to the function that uses it, from that function to its callers, from an argument back to where it was computed. The edges you travel are cross-references (xrefs); the reasoning you apply along the way is data-flow analysis, mostly done in your head with help from the tool.
This lesson closes Module 4. Decompilers and Their Limits showed how to make a single function readable; here you learn how to find the few functions that matter in a binary of thousands, and what to do when the tool cannot see the connections.
What a cross-reference is
An xref is a recorded relationship "the item at address A refers to address B", with a type. Every serious disassembler keeps them in both directions, so you can ask "what does this instruction reference?" and, more usefully, "what references this address?".
| Kind | Example | Ghidra reference type | IDA suffix |
|---|---|---|---|
| Code → code, call | call 0x1400014b0 | UNCONDITIONAL_CALL | p (procedure) |
| Code → code, jump | jne 0x1400015c0 | CONDITIONAL_JUMP, UNCONDITIONAL_JUMP | j |
| Code → code, indirect | call qword ptr [rip+0x6db5] through the IAT | COMPUTED_CALL | p |
| Code → data, read | cmp dword ptr [rip+0x1234], 0 | READ | r |
| Code → data, write | mov qword ptr [rip+0x1234], rax | WRITE | w |
| Code → data, address taken | lea rdx, [rip+0x2bb1] | DATA | o (offset) |
| Data → data | an IAT slot pointing at an imported function, a table of string pointers | DATA | o |
| Data → code | a vtable, a callback table, an exception-unwind entry | DATA | o |
The "address taken" row matters: a lea that loads a
string's address does not read the string, it passes the pointer on, usually as
an argument. Strings and function pointers are almost always referenced this
way.
How the tool computes xrefs
Xrefs are a by-product of disassembly. As the disassembler decodes each instruction (see Linear vs Recursive Disassembly), it evaluates any operand whose target is a constant:
- Direct branches and calls carry a relative displacement; the target is the address of the next instruction plus the displacement.
- RIP-relative memory operands, the normal way x86-64 code addresses globals
(see addressing modes), work the same way:
lea rdx, [rip+0x2bb1]at0x140001490(7 bytes long) refers to0x140001497 + 0x2bb1 = 0x140004048. - Absolute addresses in 32-bit code (
push offset aHello) are immediate values that happen to fall inside the image. Relocation entries help confirm that a number really is an address and not a coincidence. - Data scanning looks for pointer-sized values in data sections that point into the image: vtables, jump tables, callback arrays.
Anything that is computed at runtime — a register loaded from memory, a sum of two registers, a pointer returned by an API — produces no xref at all. Keep this in mind; it is the root of every problem in the second half of the lesson.
The analyst's main loop
Static code analysis of malware is mostly a loop:
- Pick an anchor: a string, an import, a constant, a byte pattern from a YARA hit, a function capa flagged.
- Use xrefs to jump to the code that uses it.
- Understand just enough of that function to name it and its interesting variables.
- Follow xrefs outward (to callers, via xrefs to the function) or inward (to callees and the data it touches) and repeat.
Each rename shows up everywhere the function is called, so the binary gets easier to read as you go. A practical pivot list:
| I see… | I pivot via… | Looking for… |
|---|---|---|
A URL, domain, user agent or %s format string | Xrefs to the string | The C2, download or logging routine |
A registry path such as …\CurrentVersion\Run | Xrefs to the string, then to RegSetValueEx near it | Persistence code |
An interesting import (CreateRemoteThread, CryptEncrypt) | Xrefs to the import | The capability's implementation and its arguments |
GetProcAddress / LoadLibrary imports | Xrefs to them, then the name argument | Dynamically resolved APIs (see below) |
A constant such as 0x5A4D (MZ), 0xEDB88320 (CRC-32) or an API hash | Search for the scalar, then xrefs to the enclosing function | PE parsing, checksums, hash-based API lookup |
| A global variable written once and read in many places | Write xref first, then read xrefs | Configuration or state set at start-up |
| A function with many callers | Xrefs to the function | Utility routines: string decryption, logging, allocation |
| A function with no callers | Xrefs to its address in data | Callbacks, thread start routines, vtable methods |
| A blob of high-entropy data | Xrefs to the blob, then the loop that reads it | The decryption or decompression routine |
Tip: A function with dozens of callers, each passing a different pointer into the same data blob, is very often a string decryptor. Name it once and every call site becomes a place to recover a plaintext string — scripting that is covered in Module 8.
Following values backwards: data flow
Xrefs get you to the right function. Data flow answers the next question: what is actually in this register at this call?
Use-def and def-use chains
Every instruction uses some values and defines others. For a given use —
say, the rcx argument of a call — the use-def chain lists the definitions
that could have produced it. The def-use chain is the reverse: for one
definition, every place that reads it.
Formally, compilers compute reaching definitions: a definition reaches a point if there is a path from the definition to that point along which the value is not overwritten. You do not need the set equations to use the idea. Walking backwards from the call, you stop at the first write to the register on every path; if paths merge from different blocks, each of them contributes a candidate. Within one basic block there is exactly one answer; across a CFG merge there may be several.
Here is the function from this lesson's lab, as disassembled by objdump:
1400014b0: push rsi
1400014b1: push rbx
1400014b2: sub rsp,0x28
1400014b6: lea rcx,[rip+0x2b51] # 0x14000400e "kernel32.dll"
1400014bd: call QWORD PTR [rip+0x6dbd] # 0x140008280 GetModuleHandleA
1400014c3: lea rdx,[rip+0x2b51] # 0x14000401b "GetTickCount"
1400014ca: mov rcx,rax
1400014cd: call QWORD PTR [rip+0x6db5] # 0x140008288 GetProcAddress
1400014d3: mov rbx,rax
1400014d6: test rax,rax
1400014d9: je 0x1400014f5
1400014db: call rax
1400014dd: mov ecx,0x32
1400014e2: mov esi,eax
1400014e4: call QWORD PTR [rip+0x6dbe] # 0x1400082a8 Sleep
1400014ea: call rbx
1400014ec: sub eax,esi(The string and API names are annotations added for readability.) To identify
call rbx at 0x1400014ea, walk backwards: rbx was last defined at
0x1400014d3 by mov rbx,rax; that rax is the return value of the call at
0x1400014cd, which reads its IAT slot 0x140008288 → GetProcAddress. Its
rdx argument was defined at 0x1400014c3 as a pointer to "GetTickCount".
Both indirect calls therefore invoke GetTickCount.
Registers across calls
The walk above crossed a call to Sleep without worrying. That is safe only
because of the Windows x64 calling convention:
rax, rcx, rdx, r8–r11 are volatile (a callee may destroy them),
while rbx, rbp, rdi, rsi, rsp and r12–r15 are nonvolatile
(the callee must preserve them). The compiler knew this too: it copied the
function pointer into rbx and the first tick count into esi precisely so
they would survive Sleep. When you track a value across a call, trust only
nonvolatile registers and memory — and remember that malware written in
assembly or with custom conventions may not follow the rules.
Stack variables
Values also flow through the stack. The compiler spills registers to fixed
offsets from rsp (or rbp), and both Ghidra and IDA turn those offsets into
named locals (local_38, var_28). Tracking a stack variable means finding
every write to that slot, which the tool can show you: in Ghidra, the locals
at the top of a function in the Listing carry their own XREF lists with R
and W markers. Watch out for the aliasing problem from the previous lesson:
once a local's address is passed to a function (lea rcx,[rsp+0x20] then
call), any write through that pointer is invisible to simple tracking.
Constant propagation
Many arguments are constants set a few instructions before the call. Constant
propagation carries those values forward so that mov ecx,0x32 followed by
call Sleep reads as Sleep(50). It matters most for flags and enumerations:
VirtualAlloc(..., 0x3000, 0x40) means MEM_COMMIT | MEM_RESERVE with
PAGE_EXECUTE_READWRITE, and knowing the numbers by sight is a real skill.
Decompilers do this automatically; in the listing you do it by hand. The same
technique resolves computed addresses when a base and an offset are both
constants, which is how tools recover jump-table targets.
Slicing: "where does this buffer come from?"
A backward slice from a value is the set of instructions that could have
contributed to it; a forward slice is everything the value can influence.
When you ask "where does the buffer passed to send come from?", you are
computing a backward slice by hand: through the call's argument, to the
memcpy that filled it, to the decryption loop before that, to the resource it
was loaded from.
Ghidra's Decompiler can highlight slices for you: right-click a variable and use the Def-use, Forward Slice and Backward Slice highlight actions. They work within one function, and they are only as good as the decompiler's view of aliasing, but they are an excellent way to see at a glance which lines matter. Tracking data dynamically through a whole program (taint analysis) is covered in Module 11.
Warning: Static data flow stops at anything the tool cannot resolve: indirect calls, values read from files or the network, pointers written through aliases. When a slice ends at "unknown", switch to a debugger and read the value at runtime rather than guessing.
When xrefs break
Indirect calls
A call rax or call qword ptr [rbx+0x18] has no constant target, so no code
xref is recorded: the callee loses a caller, the call graph has a hole, and the
decompiler cannot propagate the callee's prototype. At best the tool records an
"address taken" xref where the pointer is loaded, none where it is called.
Callbacks, vtables and dispatch tables in ordinary C and C++ look like this.
Dynamic import resolution and API hashing
Malware goes further on purpose. With
dynamic import resolution, APIs are
obtained through GetProcAddress and never appear in the import table, so
"xrefs to CreateRemoteThread" returns nothing because there is no
CreateRemoteThread symbol to reference. The name strings may themselves be
hidden as stack strings or
encrypted. With
API hashing, even GetProcAddress disappears: the
code walks export tables and compares hashes, leaving only a 32-bit constant
at each lookup.
Either way, the pivot table stops working for exactly the APIs the author wanted to hide. The resolver becomes your anchor instead.
Restoring what was lost
- Find the resolver. Xrefs to
GetProcAddress/LoadLibrary, or the function that walksPEB→ loader data → export directory in the hashing case. It usually has many callers or fills a table. - Recover the names. Read the string arguments, decrypt them, or compute the hash of every export of the usual DLLs and match the constants (a small script; see the API hashing page).
- Type the pointers. Retype the variable or global that stores each
result as a pointer to the real function prototype. Ghidra's Windows
archive contains prototypes for documented APIs, so a type like
GetTickCount *restores the return type and arguments at every call site. When the malware fills a whole table of pointers, define astructwhose fields are typed function pointers and apply it to the table: every(*api->VirtualAlloc)(...)becomes readable at once. - Add the references. Types fix the pseudocode; they do not add xrefs. To
make calls appear in reference lists and call graphs, add them explicitly:
Ghidra's References > Add/Edit (
R) on the call instruction, or IDAPython'sida_xref.add_cref. For large samples, script it. - Or observe it. A debugger breakpoint on
GetProcAddress, or an API trace, gives you the resolved names directly — the Dynamic Analysis module covers both.
The Windows API conventions that make step 2 and 3 easier are covered in the upcoming lesson The Windows API for Analysts (Module 5), and deliberate attacks on disassembly itself in the upcoming Anti-Disassembly lesson (Module 9).
Lab: following xrefs and restoring an indirect call
This lab uses a small, harmless program: one string used by two logging
functions, and GetTickCount called through a pointer obtained with
GetProcAddress. Ghidra output below is from Ghidra 12.1.4 (via its headless
analyser and scripts; the GUI shows the same data) on a MinGW-w64 GCC 15.2
build. Addresses may differ with other compiler versions.
-
Save as
xrefdemo.c:c /* xrefdemo.c: one string used by two functions, one API called through a pointer */ #include <windows.h> #include <stdio.h> static const char LOG_PREFIX[] = "[xrefdemo] "; typedef DWORD (WINAPI *tick_fn)(void); __attribute__((noinline)) static void log_start(const char *what) { printf("%sstarting %s\n", LOG_PREFIX, what); } __attribute__((noinline)) static void log_result(DWORD value) { printf("%sresult %lu\n", LOG_PREFIX, (unsigned long)value); } __attribute__((noinline)) static DWORD measure(void) { HMODULE k32 = GetModuleHandleA("kernel32.dll"); tick_fn get_ticks = (tick_fn)GetProcAddress(k32, "GetTickCount"); if (!get_ticks) return 0; DWORD t0 = get_ticks(); Sleep(50); return get_ticks() - t0; } int main(void) { log_start("measurement"); log_result(measure()); return 0; }Build and run it:
bash x86_64-w64-mingw32-gcc -O2 -s -o xrefdemo.exe xrefdemo.ctext [xrefdemo] starting measurement [xrefdemo] result 49 -
Pivot from the string. Import
xrefdemo.exeinto a Ghidra project and analyse it with the defaults. Open Search > For Strings, filter forxrefdemo, and double-click the hit. In the Listing, right-click the string and choose References > Show References to Address. You get two references:text xrefs to "[xrefdemo] " @ 140004048 140001490 DATA in FUN_140001490 : LEA RDX,[0x140004048] 140001507 DATA in FUN_140001500 : LEA RDX,[0x140004048]Both are address-taken references from a
lea. The format strings next to them tell you which function islog_startand which islog_result; rename both (Lon the function name). -
Pivot from the import. In the Symbol Tree, expand Imports > KERNEL32.DLL and show references to
GetProcAddress:text xrefs to GetProcAddress (Function) @ EXTERNAL:00000005 140008288 DATA in ? : null 1400014cd COMPUTED_CALL in FUN_1400014b0 : CALL qword ptr [0x140008288]The first entry is the IAT slot (a data → data reference); the second is the real call site. Note that there is no
GetTickCountunder Imports at all. -
Read the resolver's output. Open
FUN_1400014b0. The Decompiler shows:c int FUN_1400014b0(void) { int iVar1; HMODULE hModule; FARPROC pFVar2; INT_PTR IVar3; INT_PTR IVar4; hModule = GetModuleHandleA("kernel32.dll"); pFVar2 = GetProcAddress(hModule,"GetTickCount"); if (pFVar2 == (FARPROC)0x0) { iVar1 = 0; } else { IVar3 = (*pFVar2)(); Sleep(0x32); IVar4 = (*pFVar2)(); iVar1 = (int)IVar4 - (int)IVar3; } return iVar1; }The Windows types were applied automatically, but
FARPROCis a generic "returnsINT_PTR" pointer, so the return values are mistyped and cast. -
Restore the type. Right-click
pFVar2, choose Retype Variable (Ctrl+L), and enterGetTickCount *. The prototype comes from Ghidra'swindows_vs12_64archive; if the type chooser does not offer it, open that archive in the Data Type Manager first. Then rename itpGetTickCountand the functionmeasure_ticks:c int measure_ticks(void) { DWORD DVar1; DWORD DVar2; int iVar3; HMODULE hModule; GetTickCount *pGetTickCount; hModule = GetModuleHandleA("kernel32.dll"); pGetTickCount = (GetTickCount *)GetProcAddress(hModule,"GetTickCount"); if (pGetTickCount == (GetTickCount *)0x0) { iVar3 = 0; } else { DVar1 = (*pGetTickCount)(); Sleep(0x32); DVar2 = (*pGetTickCount)(); iVar3 = DVar2 - DVar1; } return iVar3; }The return values are now
DWORD, and the subtraction no longer needs casts. -
Restore the xrefs. The pseudocode is fixed, but references to
GetTickCountstill do not exist. In the Listing, click theCALL RAXat0x1400014db, pressR(References > Add/Edit), and add an external reference toKERNEL32.DLL/GetTickCountwith typeCOMPUTED_CALL. Repeat forCALL RBXat0x1400014ea. Showing references to the new external symbol now lists both call sites:text xrefs to GetTickCount (Label) @ EXTERNAL:0000002c 1400014db COMPUTED_CALL in measure_ticks : CALL RAX 1400014ea COMPUTED_CALL in measure_ticks : CALL RBX -
Compute xrefs yourself. To see that there is no magic involved, this script finds every instruction whose RIP-relative operand points at a target, given as a string, an import name or an RVA. Set up a virtual environment with
pip install capstone pefile(tested with Capstone 5.0.7 and pefile 2024.8.26):python # findrefs.py: list instructions whose RIP-relative operand points at a target # usage: python findrefs.py file.exe (--string TEXT | --import NAME | --rva 0xRVA) import argparse import pefile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 from capstone.x86 import X86_OP_MEM, X86_REG_RIP ap = argparse.ArgumentParser() ap.add_argument("exe") g = ap.add_mutually_exclusive_group(required=True) g.add_argument("--string") g.add_argument("--import", dest="imp") g.add_argument("--rva", type=lambda s: int(s, 0)) args = ap.parse_args() pe = pefile.PE(args.exe) base = pe.OPTIONAL_HEADER.ImageBase # 1. Resolve the target to an RVA if args.string: needle = args.string.encode() + b"\0" for sec in pe.sections: off = sec.get_data().find(needle) if off >= 0: target = sec.VirtualAddress + off break else: raise SystemExit("string not found") elif args.imp: iat = {imp.name.decode(): imp.address - base # IAT slot RVA for d in pe.DIRECTORY_ENTRY_IMPORT for imp in d.imports if imp.name} if args.imp not in iat: raise SystemExit(f"{args.imp} is not in the import table") target = iat[args.imp] else: target = args.rva print(f"target RVA 0x{target:x} (VA 0x{base + target:x})") # 2. Linear sweep over executable sections, computing every RIP-relative address md = Cs(CS_ARCH_X86, CS_MODE_64) md.detail = True md.skipdata = True for sec in pe.sections: if not sec.Characteristics & 0x20000000: # IMAGE_SCN_MEM_EXECUTE continue code = sec.get_data()[: sec.Misc_VirtualSize] for insn in md.disasm(code, base + sec.VirtualAddress): if insn.id == 0: # skipped data byte continue for op in insn.operands: if op.type == X86_OP_MEM and op.mem.base == X86_REG_RIP: ea = insn.address + insn.size + op.mem.disp if ea - base == target: print(f" 0x{insn.address:x} {insn.mnemonic} {insn.op_str}")Run it three ways:
bash python findrefs.py xrefdemo.exe --string "[xrefdemo] " python findrefs.py xrefdemo.exe --import GetProcAddress python findrefs.py xrefdemo.exe --import GetTickCounttext target RVA 0x4048 (VA 0x140004048) 0x140001490 lea rdx, [rip + 0x2bb1] 0x140001507 lea rdx, [rip + 0x2b3a] target RVA 0x8288 (VA 0x140008288) 0x1400014cd call qword ptr [rip + 0x6db5] 0x140002aa8 jmp qword ptr [rip + 0x57da] GetTickCount is not in the import tableThe string result matches Ghidra. The import result has an extra hit: a
jmpstub at0x140002aa8from the MinGW import library that nothing calls. Ghidra's recursive disassembly never reached it and left the bytes undefined; the linear sweep decoded them anyway.GetTickCounthas no IAT slot and therefore no xrefs; its only trace is the string, which--string GetTickCountfinds.
Questions to answer: Why are the string references DATA rather than
READ? Which registers could the compiler have used to keep the function
pointer alive across Sleep, and why not rcx? In a real sample with fifty
APIs resolved into a global table, which of steps 5 and 6 would you script,
and what would the script need as input? What would the linear-sweep script
report if the .text section contained a jump table or embedded data?
Key takeaways
- Xrefs are recorded "A refers to B" relationships: code → code (calls, jumps), code → data (read, write, address taken) and data → data or code (pointer tables, IAT slots, vtables).
- Tools compute them from constant operands: branch displacements, RIP-relative addresses, relocated absolute addresses and pointer scans. Anything computed at runtime produces no xref.
- The analyst's main loop is anchor → xref → understand → rename → pivot. Good anchors are strings, imports, constants and functions with unusual caller counts.
- Follow values backwards with use-def reasoning; trust only nonvolatile registers across calls; watch for stack aliasing; propagate constants to decode flags.
- Indirect calls, dynamic resolution and API hashing remove the edges you navigate by. Restore them by finding the resolver, recovering names, typing the pointers, and adding references explicitly — or by observing the resolution dynamically.