cd /news/ai-agents/reverse-engineering-with-ai-your-dec… · home › topics › ai-agents › article
[ARTICLE · art-165622] src=prod.codes ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Reverse Engineering with AI: Your Decompiler Can Lie to You

XRef 0.4.0, released by developer alex09x, adds a `xref field` command that traces serialized field names to the code that writes their offsets, after an earlier query returned `not_found_under_limits` with no matching layouts (recorded in GitHub issue 76). In a published synthetic C and assembly walkthrough, the tool ranks a real writer above 2,000 decoy functions that write the same offsets: the real function and the decoys score the same 40 points for their field writes, but the real function gains ten more because it shares a caller, `main`, with the serializer, while the decoys have no callers. The walkthrough, recorded November 5, 2026, demonstrates a method rather than a benchmark and stresses checking every conclusion against machine instructions, since a wrong pseudo-C branch turned an answer of `4` into `99`.

by read22 min views1 publishedNov 6, 2026
Reverse Engineering with AI: Your Decompiler Can Lie to You
Image: Prod (auto-discovered)

A field name, 2,000 look-alike functions and a wrong pseudo-C branch: an XRef walkthrough in which every conclusion is checked against machine instructions.

TL;DR. Serialized field names can lead you to their offsets. The harder question is which code changes those fields: in a public example, 2,000 decoys write the same offsets as the real function. The real function and the displayed decoys earn the same 40 points for their writes to those fields. The real function gets ten more because it shares a caller, main, with the serializer; the decoys have no callers at all. The candidate is then checked against its instructions and call sites. The walkthrough uses XRef 0.4.0 and a synthetic C and assembly program; it demonstrates a method and is not a benchmark.

Before the 0.4.0 release, one xref field query came back with not_found_under_limits: no matching layouts. The response flagged incomplete discovery under its analysis limits, but it gave no way to tell which keys the tool had seen or where it had lost the connection to the fields. That case is recorded in issue 76.

An empty list makes the next wrong move easy: widen the search at random, conclude that the structure is not there, or start taking the whole binary apart by hand. What I needed was an answer to a much narrower question: which observations get you from a field name to the code that changes it?

When an AI agent does this work, it needs the same checkable chain a human does. In this setup, XRef is a native analyzer: the agent asks it questions through the CLI or MCP and works with the data that comes back. Finding a plausible function, understanding its role and reading its pseudo-C correctly are three separate steps. Later on there is an example of a mistake in that last step turning an answer of 4 into 99.

For an executable without symbols, this is a surprisingly awkward question. You can know a field’s name because it shows up in serialized output and know almost nothing about how it is laid out in memory. The source type names are gone. The function names are gone. A single small displacement can appear in thousands of completely unrelated instructions. A plausible decompilation can make an early guess look convincing long before the facts back it up.

While I was working on XRef, this question gradually got sharper: first find code from the name of a serialized field, then match a set of neighboring keys and find the functions that write those fields. Short keys exposed the next gap: even a negative result has to explain what the tool actually managed to check. You can follow that path through issues 58, 74 and 76.

The xref field command connects key uses to offsets relative to an incoming pointer, then finds and ranks candidate writers. A string reference, an offset and a matching function are different leads. To turn them into an answer, you have to follow the whole chain and keep the instructions each conclusion rests on.

This article works through one question step by step: which code changes the values associated with these keys? Along the way it shows the false leads that keep the problem from being a simple search for memory stores.

What the public example actually is #

To follow this chain to the end, I use a small, published test program with a known answer, so every conclusion can be checked against the source and the machine instructions. Real-world tasks shaped what the tool needed to do; the walkthrough itself runs on the open example.

The existing recording uses the published XRef 0.4.0 Linux executable. It was made on November 5, 2026; I reuse its commands and outputs here rather than claiming a new run. The exact build revision and file hashes are in the recording’s environment record (linked at the end).

The target program is generated C and assembly. The part of the C that matters fits on one screen:

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;
}

This is an excerpt from the full generated source. Function attributes and build flags preserve the call pattern the walkthrough relies on. main calls writer first, then serializer.

The emit function does nothing but mix the key, the length and the value at the given address into a volatile variable. That reproduces the machine-level pattern “key + value address → shared function” without a real JSON encoder or Serde. It is a synthetic C and assembly example for studying the mechanism. Assessing accuracy on real Rust applications requires a separate evaluation.

The assembly part adds 2,000 decoy functions. Each one writes at offsets +8 and +16 from its first argument. None of them is ever called. That is what keeps the question from being trivial: an analysis that just looks for matching offsets will produce plenty of convincing answers.

The program was built with GCC 13.3.0 using -O2 -g0 -fno-inline -fno-ipa-icf, then run through objcopy --strip-all. XRef reports 2,007 functions and zero symbols; in the recorded example, the boundaries of those functions come from .eh_frame, the stack-unwinding data. The analysis runs on the stripped file; the symbols of a separate reference build are used only to check the answer.

A key string is a starting point, not a field #

Searching for m does not immediately tell you which memory location holds the value named m. A short string is an especially weak lead: its bytes can be part of a shared literal or be used in some other context. Those were exactly the cases that had to be handled after the request in issue 76. PR 77 added shared-literal recognition for short keys, per-key diagnostics and a way to inspect partial matches.

What I need is the relationship between two arguments at the use site: the key being passed and the value passed along with it. Only then does the name acquire a useful local meaning.

The first query in the recording looks for layout evidence only:

xref -b target-stripped field --keys c,m,e --layout-only

The report finds one candidate function, 0x11c0..0x1204, with offsets relative to its incoming rdi. For the three keys it reports c -> 0x0, m -> 0x8 and e -> 0x10; the corresponding instructions read eight bytes.

These values are useful because the report also lists the call sites where each key and value are passed. In the recorded image they are 0x11d7, 0x11ec and 0x1202; the last one is a tail jump to the same callee. You can inspect the function that passes the keys instead of accepting the layout as an unexplained result.

Here is a shortened disassembly excerpt from the recording. The omitted instructions load the keys and their lengths:

0x11c5  mov rdx, rdi
0x11c8  mov rbx, rdi
0x11d7  call 0x11a0
0x11dc  lea rdx, [rbx+8]
0x11ec  call 0x11a0
0x11f1  lea rdx, [rbx+0x10]
0x1202  jmp short 0x11a0

On entry, rdi holds the pointer p. The copy saved in rbx survives the first call. So the values passed in rdx are p, p + 8 and p + 16. In the full listing, the matching key string addresses and lengths are passed right next to them. That is how key names get tied to offsets.

A field’s address and the value stored there are different things #

One distinction shaped the problem more than any other: following a pointer and following a value loaded through that pointer are not the same thing.

Consider two schematic instructions. They illustrate the distinction and are not an additional result from the recorded test program:

lea rax, [rdi+8]   ; address: incoming pointer + 8
mov rax, [rdi+8]   ; value: eight bytes loaded there

After lea, rax still holds an address relative to the original input. After mov, it holds whatever was stored at that address: a number, a pointer to a different object, or a value the current analysis cannot classify. A later write through [rax+16] does not automatically become a write to rdi+24.

That is why it matters where a base pointer came from: whether it arrived as an argument, was returned from a call or was loaded from memory. Each leads to a different conclusion about which object you are looking at. Lose track of a pointer’s origin, and later offsets can be wrongly attributed to the earlier object.

There is another practical trap here: searching only for lea can miss a field that is accessed through a direct load or store. An empty result from such a search does not prove the field was never used. It means the chosen instruction pattern was not found under those search conditions.

An access range is not a struct declaration #

The three offsets and read widths in this example give an addressed range of at least 0x18 bytes. It is tempting to write sizeof(record) = 24 next to it and move on.

Here, the source lets us look up the real declaration. The stripped binary on its own supports a narrower claim: those positions were accessed with those widths. It does not follow that there are no fields beyond them, that no field was missed, or that the allocated memory ends there. Alignment, tail padding and how the memory was allocated are separate questions.

The report expresses this as addressed-span>=0x18 and exact-size=unknown. It also keeps the lexical order of call sites separate from the source declaration order or any universal execution order. These caveats are a useful part of the result, not an apology tacked on after the analysis.

The same goes for names. We observed the byte strings c, m and e. We know the type name struct record from the source and the reference build’s symbols, not from the query result.

Explicit boundaries make the result more useful. You can search for writes at the observed offsets without first declaring an invented complete type.

Two thousand functions that look like the answer #

Now let’s ask for functions that write both named fields through the same pointer:

xref -b target-stripped field --keys c,m,e \
  --write --writes-all-of m,e --limit 5

The key words here are through the same pointer. Two writes at offsets eight and sixteen from unrelated bases do not belong to the same object just because the numbers match. The query looks for writes at the m and e offsets relative to a common root. The root is the pointer the offsets are measured from; what matters next is whether it was an argument, a call result or a load from memory.

The first candidate starts at 0x1210. In the recording, its instructions are:

0x1210  endbr64
0x1214  add [rdi+8], rsi
0x1218  xor [rdi+0x10], rsi
0x121c  ret

This matches the two updates in the source. Both instructions read the field’s previous value and write back the result. The report marks them as read_write with a width of eight bytes. In the reference build’s symbol table, the function is called writer. The read_write label by itself gives no ranking advantage: in the decoys, the xor instruction also reads and writes the field.

The next candidate looks plausible too. It writes [rdi+8] with mov and updates [rdi+0x10] with xor. The rest of the displayed decoys are built the same way. Their offsets really do match. These are not imaginary coincidences, and the instructions were not misread.

That is the core difficulty. A matching combination of offsets establishes a local access pattern. It does not prove that the pointer has the source type whose serializer we looked at, or that the function takes part in the operation we care about. The report calls this same_root_offset_constellation_not_type_proof.

If you present the answer as a list of recovered types, the ambiguity disappears from the formatting but stays in the machine code. I would rather see the candidates and state the next question explicitly.

What ranking gives you, and what it doesn’t #

In the recording, the real function scores 50 points and the displayed decoys score 40. Start with the shared part, which comes from score calculation in XRef 0.4.0:

  • 16 points: two writes to the target fields in the same block;
  • another 16: both writes start exactly at the target field and are no wider than the observed read of that field;
  • another 8: two fields of the layout are covered by such precise writes.

That makes 40 for the real function and for the decoys alike. None of them compares a field before writing it, which would earn an extra bonus. Pointer origin is the same too: they all write through the first argument.

The ten-point difference comes from the direct call graph alone. main calls both serializer and writer, so the two are two edges apart through a shared caller. At distance two, the call-graph bonus is (7 - 2) * 2 = 10. The decoys have no callers and get no bonus.

That advantage is built into the test program: the decoys were deliberately left without callers. If they also sat next to the serializer in the graph, this signal would separate them less well. Here it shows why the known function came out on top. The path serializer ← main → writer describes relationships in the program; it is not a call to writer from the serializer, and it is not a trace of a particular execution.

Development, however, also taught the opposite lesson: call-graph proximity alone is not enough. PR 118 describes a case where the layout was already being found, but copy and initialization code sitting next to the serializer ranked above the right function. It also wrote at matching offsets and got the proximity advantage. The question was no longer “who writes nearby” but “which writes actually look like updates to these fields.”

So PR 118 introduced field-precision signals: the write starts at the field’s offset and is no wider than the observed read; wide copies and dereferenced pointer slots earn no precision points. A separate signal is comparing the field before writing it. PR 120 also excluded constant stores from precise writes: zero initialization should not score the same as writing a computed value.

That is the difference between the demonstration and the engineering problem it came from. In the open example, writer and the displayed decoys earn the same 40 points for their writes, and the call graph makes the difference. In the harder case, field updates first had to be told apart from copying, initialization and pointer handling. A position in the list suggests which function to check next; it does not replace the check.

Ask where the base pointer came from #

Version 0.4.0 provides --writer-root to distinguish bases that come from an argument, a call result or a memory load. That is useful when the investigation already has a reason to prefer one origin.

For example, if the object you care about comes from a lookup call, a candidate whose base is the returned pointer may be more relevant than an unrelated write relative to an argument. That is a hypothesis about the program’s data flow, not a license to pick whichever filter produces the nicest first row.

The public recording includes a query rooted at the argument:

xref -b target-stripped field --keys c,m,e \
  --write --writes-all-of m,e \
  --writer-root argument --limit 3 --json

In this example it does not remove the decoys. All of these functions, the real one included, work through the first argument. The candidate order stays the same. That is an important negative result: a useful filter does not have to be useful on every input.

So I first find out where the object comes from on the path I care about, and only then choose a filter. Swapping flags in until the first row looks right is an easy way to fit the answer to a guess.

One function, different call sites, different questions #

The function we found answers part of the question: where the matching writes are. The next step is to work out from where, and under what condition, it gets called. That is a continuation of the investigation after field, not a decision the command makes on the researcher’s behalf.

In this teaching example, one function is called with different arguments:

if (condition_a) {
    update(object, 0);
} else {
    update(object, 1);
}

There is one callee, but the branch condition and the argument differ. Pick the first call from the cross-reference list, and you have discarded the alternative before explaining it. You need to look at the specific call site and where the argument comes from.

These are separate questions, and callers, args and slice serve them: call sites, the values passed, and an instruction slice leading up to the point of interest. Choosing between branches stays with the researcher. That sequence is more useful than a single function found with no explanation of its role.

Look at the candidate from another angle #

The investigation does not end once 0x1210 comes out on top. I look at the instructions, read the pseudo-C and check the callers:

xref -b target-stripped asm 0x1210
xref -b target-stripped c 0x1210
xref -b target-stripped callers 0x1210

The recorded pseudo-C contains these operations:

*(u64 *)(arg_0 + 8) = (*(u64 *)(arg_0 + 8) + arg_1);
*(u64 *)(arg_0 + 16) = (*(u64 *)(arg_0 + 16) ^ arg_1);

This is XRef’s pseudo-C notation, not portable C source recovered verbatim. It conveniently shows the addition and the XOR that are visible in the instructions. The callers output finds a direct call at 0x1085 inside 0x1050; in the reference build, that function is main.

Each view here answers its own question: pseudo-C helps you read the operations, the disassembly shows the instructions, and callers shows the link to calling code. All of them come from the same tool; the final check for this test comes from the open source and the reference build’s symbols.

That closes the loop on the example: the expected offsets, operations and caller all match, and the right function is ranked first.

One of these practical checks is exactly what exposed an earlier XRef bug: when reconstructing a branch after an SBB that wrote to a stack slot, the analyzer used the wrong value for the carry. The public issue 117 contains a self-contained assembly and C reproducer. On input (10,6,1) the executable returns 4, while the pseudo-C XRef produced at the time would return 99 if interpreted literally: the carry check used the already-modified left operand.

PR 119 fixed the flag calculation by keeping the operand’s value from before the write. The bug was recorded in XRef 0.3.0 and a pre-release build; the fix landed before the 0.4.0 release used in this walkthrough. This is a historical reproducer that I did not rerun for this article. It shows why, after you find a function, you check the instruction that carries the semantics: what exactly was read, when the value changed, and which value the flag was computed from.

An empty answer should explain itself #

Back to the empty answer this article started with. The not_found_under_limits flag describes the outcome of the query but does not tell you what to do next. Look for other strings? Raise the budget? Take the unsupported region apart by hand?

That is why PR 77 added diagnostics: which keys were seen, where references and value passes were found, which functions were analyzed, and what limited the search. Alongside them came --max-functions, --max-instructions and partial matching via --min-keys. That turns a negative answer into grounds for the next action.

The recording also contains a separate cold JSON query, run with an empty cache:

xref -b target-stripped field --keys c,m,e \
  --write --writes-all-of m,e --limit 1 --json

This cold JSON query with --limit 1 reports 2,007 scanned functions. Of those, 2,003 were checked for candidate writes, and four were excluded by the image-scan prefilter. The three selected functions were analyzed with no exclusions for unsupported control flow or the instruction budget.

But the output is still limited. In the same response, the accessors_truncated field is true. That means there are more candidate writers than were printed. The rest have not been judged irrelevant. A limit on how the result is presented and a limit on analysis coverage must not be confused.

I look at coverage and limits first, then interpret the result. An empty list after incomplete analysis is a reason to refine the query. A single printed row with accessors_truncated: true is a reason to look at the other candidates, not to declare the winner the only one.

Fast repeats: what can be cached and what can’t #

Coverage affects more than how you read an answer. It also decides whether the index that was built can be saved. During development, repeated queries ran into exactly this boundary: the key scan hit the upper limit on the number of functions and never finished, so the index was never written and the next query started from scratch. PR 118 covers that root cause.

Saving half an index as if it were complete would be worse: a fast answer could rest on an unfinished picture and still look complete. So analysis limits and safe reuse are tied together. Speeding up this path has to preserve what the result means and report coverage explicitly.

For a researcher, repeats come in different kinds too. A new set of keys can reuse a ready index of references, calls and writes. A long-lived session keeps the image and its state in memory. An identical question can be served from a saved report. All three modes are useful, but “answer a new question” and “return the previous answer” are different jobs.

The recording explicitly disables ready-result replay and the background daemon. Every cold command uses an empty cache directory; one separate query reuses the index built by the previous cold writer search. Its JSON reports one cache hit each for the scan index and the writer index. For the cold runs, those values are zero.

Those hits show that reuse worked. A single run with a different filter and limit cannot measure how much it saves: a comparison would mean running the same work many times. But the engineering boundary is already clear: you need to know whether you are reusing a complete index, session state or a finished report.

A workflow you can hand to an agent #

The useful unit of work is a short sequence of questions, not an instruction to keep searching until something plausible turns up:

  1. Pin down the exact input file and tool revision. For every address, record which file it came from.
  2. Find where the keys are passed and look at the witness instructions. Check the base and the address-versus-value distinction.
  3. Record the observed offsets and widths without inventing a complete source declaration.
  4. Ask for writers at the target offsets through a single root. Keep the candidate and coverage details.
  5. Justify the ranking or the root filter with a specific hypothesis about the program.
  6. Look at the selected candidate’s instructions and callers. Use pseudo-C to read, not as a substitute for instruction evidence.
  7. State the remaining uncertainty and the observation that would help resolve it.

JSON makes it easier to pass these steps between tools. It does not relieve the agent of following them in order. Evidence for a field lives in candidates[].fields[].emissions[].evidence_steps[], and candidate writers in accessor_candidates[]. The caller can keep these anchors without collapsing everything into a sentence about a recovered type.

If you later need to show which path actually executes, a static report is not that observation. Examining a running process is a separate step with its own platform, access and process-impact constraints. This recording involves no attaching to a live process and no observing writes at run time.

What I took away from this #

The tempting shortcut is to jump from a name to a type, from an offset to an object, or from the first candidate to a proven writer. Each of those jumps erases exactly the distinction that made the original question hard.

The public example checks a useful result: three keys lead to three offsets, the offsets lead to candidate writers, and the known writer comes out first for reasons the report spells out. The two thousand decoys are an essential part of the explanation. They show why finding the writes was not enough.

This is the direction I want to take XRef: make a narrow question about a binary cheap enough to ask again and again, and return enough evidence that the next question becomes clearer. The goal is not to make uncertainty disappear, but to stop hiding it inside an attractive answer.

You can repeat the walkthrough on the same public example: the same binary, the same commands, the same checkable answer, with links in the sources below. XRef is an open project: source code, with installation and documentation at xrefcode.com.

Sources and reproduction #

The full recording contains stdout, stderr, exit codes, environment details and a reproduction script. The shortened listings above keep the recording’s addresses; they belong to that exact image and may change on a rebuild.

The test program was generated with --functions 2000 --writers; its source, generator and input file hashes are published in the recording. This article does not report a new benchmark, a comparative accuracy study or production measurements.

Cite this article #

Alexander Panasenko (2026-11-06). Reverse Engineering with AI: Your Decompiler Can Lie to You. https://prod.codes/blog/reverse-engineering-with-ai-your-decompiler-can-lie-to-you/
── more in #ai-agents 4 stories · sorted by recency
── more on @xref 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/reverse-engineering-…] indexed:0 read:22min 2026-11-06 · —