release · · 7 min

XRef 0.4.0: from serialized keys to the code that writes the fields

Ultra Blazing Fast XRef 0.4.0 adds xref field: give it the keys a stripped binary serializes, get field offsets with instruction evidence and ranked candidate writers. A recorded walkthrough on a synthetic test program, and the limits.

On this page · 4 sections
  1. The walkthrough
  2. How fast
  3. Limits
  4. Reproduce

Say a program serializes records under the keys c, m and e. You have its binary, without symbols, and one question: which code changes the values that end up under m and e?

Nothing in a stripped binary says “struct with fields c, m, e”. What is there is the key strings, the code that passes them to a serializer, and the address of each value passed alongside. If that address is “argument plus 8”, the field sits at offset 8, and the next step is to look for code that stores to the same offsets through one pointer. XRef 0.4.0 does that chain in one command, xref field, and shows the instruction behind every step.

The walkthrough

The xref repository has a recorded walkthrough of xref field. The program is synthetic: it was generated by a test fixture in the xref repository, not taken from a real application, and its source is published, so every answer can be checked against it:

struct record { uint64_t c, m, e; };

void serializer(struct record *p) {
    emit("c", 1, &p->c); emit("m", 1, &p->m); emit("e", 1, &p->e);
}
void writer(struct record *p, uint64_t value) {
    p->m += value; p->e ^= value;
}

emit does not produce JSON or call any serialization library: it only folds its arguments into a volatile variable. The fixture reproduces the pattern a serializer leaves in machine code (a key, its length and the address of a value passed to one function), not a real serializer, and it is C, not a Rust or Serde program.

main calls writer and then serializer. Next to them sit 2,000 decoy functions that store to the same two offsets, +0x8 and +0x10, through their first argument and are never called. The binary was built with GCC 13.3 at -O2 and stripped with objcopy --strip-all; xref stats reports 2,007 functions and 0 symbols. The tool is the published 0.4.0 Linux release archive, not a build from the main branch.

The expected answer comes from the source and the symbols of the unstripped build, and the disassembly below checks it against the compiled code: serializer (0x11c0) emits the keys, the offsets are c +0x0, m +0x8, e +0x10, and writer (0x1210) is the code that writes m and e.

Keys to offsets

$ xref -b target-stripped field --keys c,m,e --layout-only
layout 0: 0x11c0..0x1204 base=rdi input_relative_key_value_candidate addressed-span>=0x18 exact-size=unknown
  matched keys: ["c", "m", "e"]; missing keys: []
  "c" -> 0x0 instruction-read-widths=[8]
    key 0x13f004 len=1 via rdi value=rdx (input_address_argument)
      key_value_call 0x11c0 0x11d7 -> call 0x11a0
  "m" -> 0x8 instruction-read-widths=[8]
    key 0x13f006 len=1 via rdi value=rdx (input_address_argument)
      key_value_call 0x11c0 0x11ec -> call 0x11a0
  "e" -> 0x10 instruction-read-widths=[8]
    key 0x13f008 len=1 via rdi value=rdx (input_address_argument)
      key_value_call 0x11c0 0x1202 -> jmp short 0x11a0

(Abridged: the full output also prints key diagnostics, caveats, coverage and limitations.)

One layout, in the function at 0x11c0, which the unstripped build names serializer. For each key XRef shows where the key string is, that it goes to the callee in rdi with its length, that the value’s address goes in rdx, and the call instruction where this happens; the third call is a tail jump. Offsets are relative to rdi on entry, the p of the source.

The disassembly confirms it: mov rdx, rdi, then lea rdx, [rbx+8], then lea rdx, [rbx+0x10] before the three calls, which is &p->c, &p->m, &p->e.

Offsets to writers

$ xref -b target-stripped field --keys c,m,e --write --writes-all-of m,e --limit 5
accessor: 0x1210..0x121d base=rdi layout=0 matching-offsets=[8, 16] same_root_offset_constellation_not_type_proof
  rank: score=50 colocated-writes=2 callgraph-distance=Some(2)
  0x1214 +0x8 read_write width=8 add [rdi+8], rsi
  0x1218 +0x10 read_write width=8 xor [rdi+0x10], rsi
accessor: 0x121d..0x14a6 base=rdi layout=0 matching-offsets=[8, 16] same_root_offset_constellation_not_type_proof
  rank: score=40 colocated-writes=2 callgraph-distance=None
  0x121d +0x8 write width=8 mov [rdi+8], rsi
  0x1221 +0x10 read_write width=8 xor [rdi+0x10], rdx
...

(Abridged: the layout is printed again first, and three more decoys follow with score 40. --limit 5 prints five candidates; the other decoys are not shown because of the limit, not because XRef dropped them, and every printed decoy is a real candidate by the same rule.)

--writes-all-of m,e keeps only functions that store to both offsets through one pointer. The first candidate, 0x1210, is writer: add [rdi+8], rsi and xor [rdi+0x10], rsi are p->m += value and p->e ^= value. It scores 50 and every decoy shown scores 40. All of them write both fields in one block (colocated-writes=2); what separates writer is the call graph. It is two direct-call edges from the serializer (serializer ← main → writer), while the decoys have no callers, so no distance. By offsets alone the 2,000 decoys and the real writer look the same, and the output says so on every row: same_root_offset_constellation_not_type_proof.

Then the candidate is checked with the ordinary commands. xref asm 0x1210 shows the two stores and a ret. xref c 0x1210 prints:

void fn_1210(void *arg_0, unk64 arg_1) {
  *(u64 *)(arg_0 + 8) = (*(u64 *)(arg_0 + 8) + arg_1);
  *(u64 *)(arg_0 + 16) = (*(u64 *)(arg_0 + 16) ^ arg_1);
  return; /* callers ignore rax */
}

and xref callers 0x1210 finds one call, from 0x1050, which is main. On this program every expected answer came out: the emitting function, the three offsets, and the real writer ranked first.

JSON and coverage

With --json the same report is structured: the instruction evidence of each field under candidates[].fields[].emissions[].evidence_steps[], and the writers under accessor_candidates[] with ranking_score, colocated_written_fields and callgraph_distance. A coverage block says how much was actually analyzed. From the recorded cold JSON run (--limit 1), verbatim:

"coverage": {
  "scanned_functions": 2007,
  "selected_functions": 3,
  "scanned_code_bytes": 1298287,
  "skipped_functions": 0,
  "analyzed_functions": 3,
  "unsupported_flow_functions": 0,
  "function_byte_budget_exhausted_functions": 0,
  "instruction_budget_exhausted_functions": 0,
  "context_budget_exhaustions": 0,
  "key_seed_filter_excluded": 0,
  "summary_depth_limit_reached": false,
  "candidates_truncated": false,
  "accessors_truncated": true,
  "accessor_scanned_functions": 2003,
  "accessor_prefiltered_functions": 4,
  "scan_index_cache_hits": 0,
  "writer_index_cache_hits": 0,
  "warnings": []
}

Nothing was skipped, no function hit a budget and no key seed was filtered out (analysis_incomplete is false in the key diagnostics). For writers, 2,003 functions were examined and the other 4 were already known from the whole-image scan to contain no memory write: all 2,007 are accounted for. accessors_truncated: true is the --limit 1: more writer candidates existed than the one printed. When these numbers are not clean, the answer is partial and the report says so instead of looking complete.

How fast

XRef is Ultra Blazing Fast, and on this input that means milliseconds. Each command prints its own load and total times on stderr; these are XRef’s self-reported times, not an external wall-clock measurement of the whole process. In the recording, on an AMD Ryzen 9 9950X under Linux 6.8:

Command Cache XRef total (stderr)
field --keys c,m,e --layout-only cold, own empty cache 8.5 ms
field … --write --writes-all-of m,e --limit 5 cold, own empty cache 13.8 ms
field … --write --writes-all-of m,e --limit 1 --json cold, own empty cache 14.3 ms
field … --write --writes-all-of m,e --writer-root argument --limit 3 --json reuses the index of the previous cold --write run 8.2 ms

Loading the image took 0.39 to 0.44 ms of each. Every row is a fresh process with result replay and the background daemon switched off. The reuse row asks a different question (only writers relative to an argument) and reports scan_index_cache_hits: 1 and writer_index_cache_hits: 1 where the cold runs report 0; it ranks the same writer first, score 50, ahead of the decoys it shows at 40. Because it is a different query and a single run, the gap between it and the cold rows is not a measured speedup from the index. Each row is a single run on a synthetic input with 1,298,287 bytes of code, so it says nothing about typical speed on a real application.

The release has four separate ways to answer a field question, and they are not the same work:

  • A cold run loads the image, scans it and builds a reusable index of references, direct calls and writer facts.
  • A new question on the same binary (another key set or writer filter) reuses that index from disk instead of rebuilding it.
  • A background daemon may start on Unix, best effort, once a field run takes longer than 250 ms on an input of at least 16 MiB (the default thresholds; XREF_NO_DAEMON=1 disables it). While it runs it keeps the image in memory and can answer later runs for the same unchanged binary. The recording disabled it.
  • An identical repeated command on an unchanged input can be answered from a stored report, when one is available, without loading the image. Reports that hit the analysis budget are not stored, and XREF_NO_RESULT_CACHE=1 bypasses the store, as the recording did.

For many questions in a row there is also the MCP server, xref -b ./app mcp, with xref_query and "operation": "field". These modes are described by the field search documentation; this recording measured the first two, not the daemon, the replay or MCP.

Limits

  • Candidates, not proof. A writer is a function whose stores match the offsets through one pointer. It is not proof that it handles the same type or the same object; here the call graph separated the real writer, and in a binary where decoys were reachable too, the scores would be closer. Check a candidate with asm, c and callers.
  • No type name, size or declaration order. XRef reports offsets relative to the serializer’s argument, a minimum extent (addressed-span>=0x18, exact-size=unknown) and the order of the call sites, not the order in which fields were declared.
  • A calling-convention model. The analysis models the System V integer argument registers and the known control flow; key/value evidence is not proof of a particular serialization library.
  • Bounded analysis. Function and instruction budgets and a 30-second analysis budget limit how far it goes. When a fast seed filter or a budget drops something, coverage reports it, and --exhaustive-key-seeds widens the search at the cost of time.
  • Input. field has no format check of its own: it runs on whatever XRef 0.4.0 loads, x86-64 ELF (ET_EXEC, ET_DYN) and thin little-endian x86-64 Mach-O with DWARF __eh_frame. Its tests and this recording cover ELF only; there is no Mach-O test for field.
  • Synthetic. The program was built to show the mechanism, with an answer known in advance. It shows how field reaches an answer, not how often it is right on real applications.

Reproduce

capture.sh in the walkthrough downloads the published archive, checks its SHA-256, generates and builds the program, checks that the generator is byte-identical to the one at tag v0.4.0, and reruns every command, each cold run with its own empty cache and one run reusing the index. The recorded identities:

  • xref 0.4.0, xref-v0.4.0-x86_64-unknown-linux-gnu.tar.gz SHA-256 15a32d4bbe59307709c058e6c6379d98814fcafe106d8edc0379165454edf0a2, build revision 39997783594f5e8da910b1cc0e923b16c91738e8;
  • generator tests/fixtures/field_perf/generate.py --functions 2000 --writers, SHA-256 3a56e0afe43e74ccc93b80ce4a050c53b2f74fccc1a8fa7ed02d490d1f3d1b04;
  • cc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1), -O2 -g0 -fno-inline -fno-ipa-icf, then objcopy --strip-all; stripped binary SHA-256 8eb4454ef7714df295c984225653249de68315631b3c00b63d2cdb5dd5037119;
  • recorded 2026-11-05 UTC on an AMD Ryzen 9 9950X, Linux 6.8.0-111-generic.

XRef 0.4.0 is archived on Zenodo as 10.5281/zenodo.23968336. The project page is XRef.

Cite this article
Citation
Alexander Panasenko (2026-11-05). XRef 0.4.0: from serialized keys to the code that writes the fields. https://prod.codes/blog/xref-0-4-0-from-serialized-keys-to-the-writer/