The Bug With No Source, No Symbols, No Debug Info
Research / / 6 min read

The Bug With No Source, No Symbols, No Debug Info

Strip a binary of source, symbols, and DWARF and it is supposed to be opaque to automated analysis. We stripped an OSS-Fuzz target of all three and our engine still found the bug. A stripped binary is an information problem, not an impossibility.

Binary analysis has a comfortable assumption: strip a program of its source, its symbol names, and its debug info, and you have made it opaque. WriteCLUT becomes FUN_08223ed0. The path from input to bug becomes a forest of anonymous subroutines. Nobody is supposed to find anything in there fast, and no tool either.

We wanted to test that. We took a real, previously-fuzzed vulnerability and stripped away every crutch: no source, no symbols, no DWARF, no ground-truth hint. Then we asked our engine to find the bug cold. Here’s where the hard part of automated analysis actually lives.

One note on how we measured, because it shapes the story. The target ships as a sanitizer-instrumented binary for fuzzing. We measured locating the bug on that sanitizer build, stripped. The full end-to-end confirm ran on the plain optimized build, stripped, which is the realistic shape of a shipped, stripped binary. Both with everything withheld.

First, the engine found the bug, then couldn’t trigger it

The target was an OSS-Fuzz crash in a widely-used color-management library, built with sanitizers, then fully stripped. We ran the engine against it with everything withheld. Two walls appeared. They are the whole story.

Wall 1: finding the right function. The engine’s decompile queue prioritizes functions by name: sinks like memcpy, suspicious tokens. Stripped, every function is fcn.0x… and the names are gone. The ground-truth bug sank to rank 4,839 out of 5,494 and was never decompiled. The fix was to stop trusting names. Anchor on the addresses of copy-sink stubs that survive stripping: the imported memcpy@plt, the sanitizer-interposed __asan_* symbols. Take cross-references to them. The bug jumped to rank 180, inside the decompile budget. Wall 1 closed. The engine can now locate a bug in a stripped binary with no names at all.

Wall 2: actually triggering it. The engine could see the sink. Could it drive input into it? Here it hit 0 out of 3 attempts. The reason matters. In the stripped decompile, the reverse call-graph to the sink was empty. The bug is reached through indirect dispatch, a table of function pointers, so there is no visible caller. With no visible path from input to sink, the model had nothing to reason about and produced zero trigger candidates. The bug was visible and unreachable at the same time.

A confession: we were looking at the wrong function

Before we could fix Wall 2, we found we had mismeasured it. The library has two functions named WriteCLUT, in two different source files. The real crashing bug is in one. The indirectly-dispatched one we had been staring at was the other. When we re-read the actual fix diff, it patched the first: a direct-call chain, never indirectly dispatched. Its “empty caller set” was not a dispatch mystery. The function had simply never been decompiled, because the budget ran out 400 functions into 4,577.

That is the kind of correction we build for. The wall we had diagnosed as “indirect call-graph incompleteness” was, for this bug, partly just a decompile-budget problem. Getting the ground truth right changed the fix. An oracle you trust is only as good as the measurement it points at.

The fix, and the payoff

Stripped Callgraph Reconstruction

Two changes came out of it. First, seed the decompile queue from coverage: decompile the functions a generic seed actually executed, so the real bug and its direct path get decompiled instead of truncated. Second, complete the call graph: recover indirect edges (pointer tables, code-installed handlers, edges observed under execution) and feed the completed graph into the analysis, so the reverse path to a sink is not empty.

Then we re-ran, crutch-free. The before/after is the payoff:

  • Before: zero trigger seeds. The model had nothing to work with.
  • After: the model reverse-engineered the input format from the stripped decompile alone. It reconstructed a minimal, valid ICC color profile from scratch, and the run confirmed a real bug: a heap out-of-bounds read in WriteCLUT, at exactly the crash site, verified by a differential oracle (vuln crashes, patched build clean). The winning input was a 3.7 KB file the engine produced itself, not the original proof-of-concept.

Trigger recall on the target went from 0 to 1, fully crutch-free. The wall was never “a stripped binary is impenetrable.” It was LOCATE and reachability, and those are solvable. To be precise: this is one target, and it was already public. It shows the engine can work without names or debug info, not that it was blind to a known bug. The next test is a bug discovered after the model’s training cutoff.

What it means

A stripped binary is an information problem, not a wall. Stripping deletes names and edges, and the tools that lean on those crumble. The structure is all still there: the dispatch tables, the cross-references, the copy operations, the shape of the parser. Recover structure instead of trusting names and the “opaque” binary opens up.

This matters beyond one library. The same machinery, symbol-free locating plus indirect-call-graph recovery, is what turns it toward the targets that are only ever available stripped: closed-source Windows kernel drivers, firmware, legacy binaries. Those are full of long-standing bugs that no source-based fuzzer can even look at, because there is no source to fuzz. That is where this engine goes next. It is where the “bugs nobody’s tools can see” actually live.


Analysis by 0sec’s automated security-research tooling (https://0.security). The vulnerable and fixed builds used are public OSS-Fuzz artifacts; the bug is a previously-reported, already-patched issue used here only to measure the engine.