Why Patches Fail: The Assumption Problem
Every security patch encodes an assumption about how the vulnerable code is reached. A bounds check assumes every caller passes through the function it guards. A depth counter assumes every entry and exit point increments and decrements it in lockstep. A null check assumes the pointer can only be null at the one call site the developer was looking at when they wrote the fix. These assumptions are usually correct for the specific proof-of-concept that triggered the original bug report — and usually incomplete for the codebase as a whole.
A Fix Is a Claim
Treat every merged patch as an explicit, falsifiable claim rather than a closed question. The claim has a precise shape: "this guard, placed at this location, prevents this unsafe operation from executing under this condition." A claim phrased this precisely is testable. The question incomplete fix detection asks is simple to state and hard to answer by inspection alone: does every code path capable of reaching the unsafe operation actually pass through the guard?
The isCalledFromInsideHandler() Pattern
A representative example: a parser library ships a fix that adds a re-entrancy guard, isCalledFromInsideHandler(), to prevent a set of functions from being called while a callback is executing. The guard reads a counter, m_handlerCallDepth, that is incremented before a handler runs and decremented after. The fix is correct wherever that increment/decrement pair actually wraps the handler invocation. It silently does nothing wherever a handler is invoked without that wrapping — because the counter never leaves zero, and a guard reading zero reports "not inside a handler" even while one is actively running.
Five Functions Fixed, One Function Missed
This is precisely the shape of CVE-2026-56412 in libexpat. The upstream fix in PR #1246 added the isCalledFromInsideHandler() guard to five functions: XML_GetBuffer, XML_Parse, XML_ParseBuffer, XML_ParserFree, and XML_ParserReset. All five were correctly guarded against the originally reported re-entrancy path. The fix was reviewed, merged, and the CVE it addressed was closed. What the fix did not do — because nothing in a five-function patch forces an audit of every call site in the file — is verify that every code path invoking a character-data handler actually incremented the depth counter first. One path, doCdataSection(), called the handler directly without the wrapping. See the full technical breakdown in the CVE-2026-56412 case study.
How to Detect an Incomplete Fix in Source Code
The process is systematic, not exploratory. It replaces "look for bugs" with "test one specific claim exhaustively," which is a much narrower and more tractable search space than general vulnerability hunting.
Map Every Call Site of the Fix's Assumption
Start from the guard itself, not from the original bug report. Identify the exact condition the guard checks — a counter value, a flag, a state enum — and then find every place in the codebase that can influence that condition. For a re-entrancy guard, this means finding every function that invokes a callback, not just the one the original report mentioned. This step alone usually surfaces the gap: a call graph that lists five wrapped invocations and one unwrapped one is a visible discrepancy once it's laid out explicitly, even though it's invisible during a normal patch review that only looks at the diff.
Identify Code Paths Not Protected by the Guard
Cross-reference the call sites found in the previous step against the functions the patch actually modified. Any call site that can reach the unsafe operation but was not touched by the patch is a candidate bypass. This is where the incomplete fix detection differs from a routine code review: a routine review checks that the diff is correct in isolation. This step checks that the diff is complete relative to the full set of paths that share the same precondition.
Build a Proof-of-Concept for the Bypassed Path
A candidate bypass is not a finding until it reproduces. Write the smallest possible input that drives execution through the unprotected call site while the guarded condition is active, and run it against the patched binary compiled with a memory sanitizer. If the sanitizer does not fire, the candidate was wrong — the guard covered more than the static analysis suggested, or a different check elsewhere closes the gap. If it does fire, the incomplete fix is confirmed with the same standard of evidence as a fresh vulnerability: a deterministic, sanitizer-backed crash.
Case Study: CVE-2026-56412 in libexpat
Applying this process to libexpat's PR #1246: the guard condition is m_handlerCallDepth > 0. Mapping every invocation of m_characterDataHandler in xmlparse.c surfaces two call sites — doContent(), correctly wrapped, and doCdataSection(), which invokes the same handler from inside its XML_TOK_DATA_CHARS case with no wrapping at all. A 17-line proof-of-concept — a parser, a character-data handler that frees the parser instance, and a single CDATA input — reproduces a heap use-after-free under AddressSanitizer on the fully patched library. Full technical detail, sanitizer output, and the timeline are in the CVE-2026-56412 writeup.
Incomplete Fix Patterns in the Wild
Incomplete fixes tend to recur in a small number of structural shapes. Recognizing the shape narrows the search dramatically.
Handler Depth Guards
Any fix that tracks "are we currently inside a callback" with a counter or flag is only as complete as the set of call sites that update it. New call sites added after the fix — or call sites the original patch author didn't know invoked the same handler — silently bypass the guard rather than triggering a visible error.
Size Validation Gaps
A fix that adds a bounds check to one function that computes a buffer index is only complete if every other function computing the same index is checked identically. It is common for a library to have two or three code paths that compute a conceptually identical value — one for the streaming API, one for the batch API, one for a legacy compatibility wrapper — and for a patch to fix the one path exercised by the original crash report while leaving the others with the same unchecked arithmetic.
Race Conditions in Multi-threaded Fixes
A fix that adds a lock or an atomic operation around one access to shared state is only complete if every other access to that state is synchronized the same way. Incomplete fixes in concurrent code frequently synchronize the write path that was reported as racy while leaving a read path — added later, or simply overlooked — unsynchronized against it.
Incomplete Fix Detection vs. Full CVE Research
Full vulnerability research starts from an unconstrained attack surface: any function, any input, any code path. Incomplete fix detection starts from a single, already-published claim and asks whether that specific claim holds everywhere it needs to. The search space is smaller, which means it's more tractable to automate exhaustively — but the resulting bugs are just as real, and often more dangerous, because the codebase, the security community, and the CVE database all currently believe the issue is resolved. A CVE that reopens six months after being marked fixed catches maintainers and downstream consumers off guard in a way a fresh CVE does not.
How Vorthix Automates Incomplete Fix Detection
Vorthix runs this process autonomously as a first-class analysis mode, not an occasional manual exercise. Given a target repository, the agent identifies recently merged security patches, extracts the guard condition each patch introduces, builds a call graph of every function capable of influencing that condition, and generates targeted harnesses for any call site the patch did not modify. Each candidate is compiled with AddressSanitizer and executed; a finding is only reported once it reproduces a crash deterministically. This is the same Ingest → Hypothesize → Prove → Report loop used for greenfield vulnerability discovery, applied specifically to the population of recently patched functions across the open-source ecosystem — the highest-signal, lowest-competition hunting ground in security research, because almost no one else is looking there.