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
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
fieldrun takes longer than 250 ms on an input of at least 16 MiB (the default thresholds;XREF_NO_DAEMON=1disables 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=1bypasses 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,candcallers. - 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-seedswidens the search at the cost of time. - Input.
fieldhas 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 forfield. - Synthetic. The program was built to show the mechanism, with an answer known in advance.
It shows how
fieldreaches 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.gzSHA-25615a32d4bbe59307709c058e6c6379d98814fcafe106d8edc0379165454edf0a2, build revision39997783594f5e8da910b1cc0e923b16c91738e8; - generator
tests/fixtures/field_perf/generate.py --functions 2000 --writers, SHA-2563a56e0afe43e74ccc93b80ce4a050c53b2f74fccc1a8fa7ed02d490d1f3d1b04; cc13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1),-O2 -g0 -fno-inline -fno-ipa-icf, thenobjcopy --strip-all; stripped binary SHA-2568eb4454ef7714df295c984225653249de68315631b3c00b63d2cdb5dd5037119;- 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
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/