Skip to content

Lesson 4.5 · Disassembly & Code Analysis· 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.

Objectives

  • 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?".

KindExampleGhidra reference typeIDA suffix
Code → code, callcall 0x1400014b0UNCONDITIONAL_CALLp (procedure)
Code → code, jumpjne 0x1400015c0CONDITIONAL_JUMP, UNCONDITIONAL_JUMPj
Code → code, indirectcall qword ptr [rip+0x6db5] through the IATCOMPUTED_CALLp
Code → data, readcmp dword ptr [rip+0x1234], 0READr
Code → data, writemov qword ptr [rip+0x1234], raxWRITEw
Code → data, address takenlea rdx, [rip+0x2bb1]DATAo (offset)
Data → dataan IAT slot pointing at an imported function, a table of string pointersDATAo
Data → codea vtable, a callback table, an exception-unwind entryDATAo

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] at 0x140001490 (7 bytes long) refers to 0x140001497 + 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:

  1. Pick an anchor: a string, an import, a constant, a byte pattern from a YARA hit, a function capa flagged.
  2. Use xrefs to jump to the code that uses it.
  3. Understand just enough of that function to name it and its interesting variables.
  4. 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 stringXrefs to the stringThe C2, download or logging routine
A registry path such as …\CurrentVersion\RunXrefs to the string, then to RegSetValueEx near itPersistence code
An interesting import (CreateRemoteThread, CryptEncrypt)Xrefs to the importThe capability's implementation and its arguments
GetProcAddress / LoadLibrary importsXrefs to them, then the name argumentDynamically resolved APIs (see below)
A constant such as 0x5A4D (MZ), 0xEDB88320 (CRC-32) or an API hashSearch for the scalar, then xrefs to the enclosing functionPE parsing, checksums, hash-based API lookup
A global variable written once and read in many placesWrite xref first, then read xrefsConfiguration or state set at start-up
A function with many callersXrefs to the functionUtility routines: string decryption, logging, allocation
A function with no callersXrefs to its address in dataCallbacks, thread start routines, vtable methods
A blob of high-entropy dataXrefs to the blob, then the loop that reads itThe 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:

text
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

  1. Find the resolver. Xrefs to GetProcAddress/LoadLibrary, or the function that walks PEB → loader data → export directory in the hashing case. It usually has many callers or fills a table.
  2. 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).
  3. 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 a struct whose fields are typed function pointers and apply it to the table: every (*api->VirtualAlloc)(...) becomes readable at once.
  4. 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's ida_xref.add_cref. For large samples, script it.
  5. 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.

  1. 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.c
    text
    [xrefdemo] starting measurement
    [xrefdemo] result 49
  2. Pivot from the string. Import xrefdemo.exe into a Ghidra project and analyse it with the defaults. Open Search > For Strings, filter for xrefdemo, 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 is log_start and which is log_result; rename both (L on the function name).

  3. 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 GetTickCount under Imports at all.

  4. 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 FARPROC is a generic "returns INT_PTR" pointer, so the return values are mistyped and cast.

  5. Restore the type. Right-click pFVar2, choose Retype Variable (Ctrl+L), and enter GetTickCount *. The prototype comes from Ghidra's windows_vs12_64 archive; if the type chooser does not offer it, open that archive in the Data Type Manager first. Then rename it pGetTickCount and the function measure_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.

  6. Restore the xrefs. The pseudocode is fixed, but references to GetTickCount still do not exist. In the Listing, click the CALL RAX at 0x1400014db, press R (References > Add/Edit), and add an external reference to KERNEL32.DLL / GetTickCount with type COMPUTED_CALL. Repeat for CALL RBX at 0x1400014ea. 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
  7. 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 GetTickCount
    text
    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 table

    The string result matches Ghidra. The import result has an extra hit: a jmp stub at 0x140002aa8 from 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. GetTickCount has no IAT slot and therefore no xrefs; its only trace is the string, which --string GetTickCount finds.

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.