# Reverse Engineering with AI: Your Decompiler Can Lie to You

> Source: <https://prod.codes/blog/reverse-engineering-with-ai-your-decompiler-can-lie-to-you/>
> Published: 2026-11-06 00:00:00+00:00

# Reverse Engineering with AI: Your Decompiler Can Lie to You

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](https://github.com/alex09x/xref/issues/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](https://xrefcode.com/), 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](https://github.com/alex09x/xref/issues/58), [74](https://github.com/alex09x/xref/issues/74) and [76](https://github.com/alex09x/xref/issues/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](https://github.com/alex09x/xref/tree/2f01e7ba8d9ccfab08f055cce3119e95dd1504bb/docs/walkthroughs/field-synthetic) 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](https://github.com/alex09x/xref/blob/2f01e7ba8d9ccfab08f055cce3119e95dd1504bb/docs/walkthroughs/field-synthetic/target.c). 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](https://github.com/alex09x/xref/pull/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](https://github.com/alex09x/xref/blob/v0.4.0/src/serde_fields/writer_rank.rs):

- 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](https://github.com/alex09x/xref/blob/v0.4.0/src/serde_fields/accessors.rs#L305-L310) 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](https://github.com/alex09x/xref/pull/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](https://github.com/alex09x/xref/pull/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](https://github.com/alex09x/xref/issues/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](https://github.com/alex09x/xref/pull/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](https://github.com/alex09x/xref), with installation and documentation at [xrefcode.com](https://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.

- [Public recording and full walkthrough with source-backed checks](https://github.com/alex09x/xref/tree/2f01e7ba8d9ccfab08f055cce3119e95dd1504bb/docs/walkthroughs/field-synthetic) .
- [Verbatim transcript](https://github.com/alex09x/xref/blob/2f01e7ba8d9ccfab08f055cce3119e95dd1504bb/docs/walkthroughs/field-synthetic/transcript.txt) and[environment](https://github.com/alex09x/xref/blob/2f01e7ba8d9ccfab08f055cce3119e95dd1504bb/docs/walkthroughs/field-synthetic/environment.txt) .
- [XRef 0.4.0 release](https://github.com/alex09x/xref/releases/tag/v0.4.0) and[versioned software archive](https://doi.org/10.5281/zenodo.23968336) .
- [Field search documentation, corrected after the release](https://github.com/alex09x/xref/blob/1686ee4b84e63423beed2109b6ac3bf03df0e535/docs/field-search.md) .
- [Earlier short walkthrough](https://prod.codes/blog/xref-0-4-0-from-serialized-keys-to-the-writer/) , based on the same recording rather than an independent experiment.
- [XRef website](https://xrefcode.com/) with installation, documentation and recorded examples.

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/
```


