Post

Finding a 15-Year-Old Zero-Day in strongSwan with AI-Assisted Code Analysis

How I discovered CVE-2026-25075 — a vulnerability that had been quietly sitting in strongSwan's codebase since 2011 — using a structured, multi-pass AI analysis workflow.

Finding a 15-Year-Old Zero-Day in strongSwan with AI-Assisted Code Analysis

Introduction

This post documents CVE-2026-25075, a vulnerability I discovered in strongSwan, a widely used open-source IPsec VPN implementation. The bug had been present in the codebase for over 15 years before being patched in March 2026.

Two things are covered here:

  1. What the vulnerability is — a technical walkthrough with explanations of the relevant concepts. This corresponds to Parts 1 and 2.
  2. How I found it — the structured, multi-pass AI analysis workflow I built (a “pass” here just means one round of analysis — the workflow uses several rounds in sequence), including the full specification document and session prompts I used. This corresponds to Parts 3 and 4.

The methodology is the main focus. The vulnerability is the output; the process is what I think is worth sharing in detail.

A non-destructive detection tool for this issue — meaning a tool that probes servers to check whether they are vulnerable without actually crashing them — was published separately; see here for reference.


Part 1: Background

What is a VPN?

A VPN (Virtual Private Network) creates an encrypted tunnel between two endpoints — for example, a remote worker’s laptop and their company’s office network — over the internet. Data sent through the tunnel is protected from interception in transit (meaning that even if someone is quietly watching the network in between, they only see unreadable ciphertext). Businesses commonly use VPNs to give remote employees secure access to internal systems.

What is IPsec?

IPsec (Internet Protocol Security) is a protocol suite — a collection of related protocols designed to work together — that handles encryption and authentication at the network layer. The network layer is the level of the system responsible for routing packets between machines, sitting below the application level where programs like web browsers and email clients operate. By working at this layer, IPsec protects individual IP packets themselves, so any application’s traffic is covered without each application having to implement its own encryption. strongSwan is one of the most widely deployed open-source IPsec implementations, used in enterprises, cloud environments, and embedded devices such as routers.

What is IKEv2?

Before IPsec can protect any traffic, the two parties must negotiate which algorithms to use and derive shared secret keys. IKEv2 (Internet Key Exchange version 2) is the protocol that handles this negotiation. Think of it like starting a phone call: first you establish the line and agree on a language, then you prove who you are. IKEv2 follows the same logic in two exchanges:

  • IKE_SA_INIT — Algorithm negotiation and a Diffie-Hellman exchange. Diffie-Hellman is a technique that lets two parties each contribute a piece of public information, from which both can independently compute the same shared secret key — without that key ever being sent over the network. After this exchange completes, the two sides have a secure channel, but no authentication of either party has happened yet — neither side knows who is at the other end. This point will matter later.
  • IKE_AUTH — Identity verification and tunnel completion. Each side proves its identity, and this is the phase where EAP authentication happens if the server requires it.

What is EAP?

EAP (Extensible Authentication Protocol) is a common structure for plugging in many different authentication methods — passwords, certificates, tokens, and others — so that the protocol layer above it does not need to know the details of each one. During IKEv2, the server can ask the client to authenticate via EAP. The server specifies which EAP method to use; the client responds accordingly.

What is TLS?

TLS (Transport Layer Security) is the cryptographic protocol behind HTTPS — the same mechanism that encrypts a browser’s connection to a bank or email service. A TLS handshake does two things at the start of a connection. First, the server presents a certificate — a signed document from a trusted authority that binds the server’s identity to a cryptographic key, letting the client verify it is really talking to the intended server. Second, the two sides negotiate fresh session keys so that everything sent after the handshake is encrypted. TLS matters here because EAP-TTLS reuses it as an inner tunnel, and the details of that reuse are exactly what makes this vulnerability reachable without credentials.

What is EAP-TTLS?

EAP-TTLS (EAP Tunneled TLS) is an EAP method that protects the authentication exchange by first establishing an inner TLS tunnel. You might wonder why another tunnel is needed when IKEv2 already set up encryption. The reason is that IKEv2 encryption protects the data in transit from outside observers, but it does not protect the data from the VPN gateway itself. In a typical enterprise deployment, the VPN gateway and the authentication server (often a RADIUS server — a centralized server that handles credential verification) may be separate machines. In such setups, the VPN gateway decrypts the IKEv2 layer and forwards authentication data onward, meaning the client’s password could be visible at the gateway if no additional protection existed. EAP-TTLS adds a second, independent TLS tunnel specifically so that the client’s password or token is encrypted end-to-end between the client and the authentication server — never visible even to intermediate components.

The design rationale: client credentials should only be transmitted after a secure channel exists. With EAP-TTLS, the server authenticates itself to the client via a TLS certificate (the signed identity document explained above), the TLS tunnel is established, and then the client’s credentials are transmitted inside that encrypted tunnel. The client has not proved anything yet when the tunnel is being set up — that is precisely the feature that this vulnerability exploits.

EAP-TTLS is defined in RFC 5281 and is common in enterprise VPN and Wi-Fi deployments.

What are AVPs?

Inside the EAP-TTLS tunnel, data is structured as AVPs (Attribute-Value Pairs), a data format widely used in network authentication protocols. The format is derived from the Diameter protocol and consists of a fixed-size header — always 8 bytes when no vendor extension is present — followed by a variable-length data payload (the actual value, whose size differs from AVP to AVP). The header contains three fields: an AVP Code (4 bytes, identifying the attribute type), a Flags byte (1 byte), and an AVP Length field (3 bytes, specifying the total size of the entire AVP including the header):

AVP Structure

The AVP Length field encodes the total size of the AVP, including the 8-byte header. If Length is 12, that means 8 bytes of header plus 4 bytes of data. But what if Length is less than 8 — say, 1? The header alone is 8 bytes, so a total length of 1 would imply negative 7 bytes of data. Keep that question in mind — it is the detail at the center of the vulnerability.

(A note on the Length field size: although the AVP Length is only 3 bytes on the wire, strongSwan reads these 3 bytes into a uint32_t — a 32-bit variable — for processing. The underflow math described below operates on this 32-bit variable, not on the 3-byte wire value.)


Now that the protocol stack is in place — VPN, IPsec, IKEv2, EAP, TLS, EAP-TTLS, and AVPs — here is the vulnerability itself.

Part 2: The Vulnerability

Root Cause

The bug is in src/libcharon/plugins/eap_ttls/eap_ttls_avp.c, line 133 (version 6.0.4). avp_len is declared as uint32_t — an unsigned (non-negative) 32-bit integer, a type that will be explained fully in the next section. When the plugin reads an incoming AVP, it computes the payload length like so:

C
this->data_len = avp_len - 8;
this->input = chunk_alloc(this->data_len + (4 - avp_len) % 4);

A note on C syntax: this->field is how C accesses a named field inside a structure through a pointer — roughly equivalent to this.field or object.field in many other languages.

The subtraction is correct for any well-formed AVP: total length minus header size gives payload size. The (4 - avp_len) % 4 on the second line is a small padding calculation so the allocation ends on a 4-byte boundary (a minor alignment detail that ensures the memory address is evenly divisible by 4, which helps CPUs access it efficiently) — an implementation detail unrelated to the bug, so I won’t dwell on it further. The problem is on the first line: there is no check that avp_len >= 8 before the subtraction.

What is an Integer Underflow?

avp_len is declared as uint32_t — an unsigned 32-bit integer. Because it uses 32 bits and each bit has two possible states, it has 2³² = 4,294,967,296 distinct patterns available, which uint32_t uses to represent the values 0 through 4,294,967,295. It cannot represent negative numbers at all.

When you subtract 8 from a small uint32_t value, the result does not become negative — since negatives are not representable — but instead wraps around to a very large positive value. Think of a uint32_t as a circular number line: the values go from 0, 1, 2, all the way up to 4,294,967,295, and then the next value after the maximum loops back to 0. Moving forward along this circle is addition; moving backward is subtraction. If you start at 1 and subtract 8, you move backward 8 steps — past 0 and continuing from the top — and land on 4,294,967,289. The number just below 0 is the maximum value, 4,294,967,295, and you keep counting down from there.

In the numbers below, 0x is a prefix that says “this is written in hexadecimal” — a base-16 number system commonly used in programming because it lines up cleanly with the bit layout of memory. The exact hex value is not essential for following the argument; it is shown alongside the decimal so readers who know hex can cross-check.

Plaintext
0 - 8 = -8  →  wraps to  4,294,967,288  (0xFFFFFFF8)
1 - 8 = -7  →  wraps to  4,294,967,289  (0xFFFFFFF9)

(general rule: N - 8 for any N < 8 wraps to 4,294,967,288 + N)

This is an integer underflow (CWE-191 — a standardized identifier from MITRE’s Common Weakness Enumeration catalog, which classifies this pattern as “Integer Underflow (Wrap or Wraparound)”). The result is not -7 but approximately 4.3 billion. Every AVP Length value in the range 0–7 produces a similarly enormous data_len — the specific values are shown below for completeness:

AVP Length Expected Actual data_len (uint32_t)
0 4,294,967,288
1 4,294,967,289
2 4,294,967,290
3 4,294,967,291
4 4,294,967,292
5 4,294,967,293
6 4,294,967,294
7 4,294,967,295

After computing the underflowed length, the code calls chunk_alloc() — strongSwan’s memory allocation wrapper. Internally, chunk_alloc() calls C’s standard malloc() function, which asks the operating system for a block of memory of the requested size and returns a pointer to it (a pointer is a variable whose value is a memory address — a number identifying a specific location in RAM). With the underflowed data_len, chunk_alloc() ends up requesting roughly 4 gigabytes of memory for a single AVP.

(A technical note on the padding term: when avp_len is small, the padding expression (4 - avp_len) % 4 also operates on unsigned integers, which can add 0–3 bytes to the already-enormous data_len. For most values in the 0–7 range this just makes the allocation slightly larger, but for avp_len = 5 specifically, data_len + padding overflows the 32-bit range and wraps to 0, which means chunk_alloc(0) is called instead of chunk_alloc(~4 GB). The behavior of malloc(0) is implementation-defined — it may return NULL or a minimal valid allocation — but in either case the subsequent memcpy writes more data than the allocated size permits, resulting in a crash. The core issue — an unchecked subtraction on an unsigned integer — is the same regardless of which specific avp_len value triggers it.)

The Crash

On most systems, no realistic server has 4 GB of free memory available for a single allocation. What happens next depends on the operating system’s memory allocation policy.

On Linux with memory overcommit disabled (or any system where the allocator performs strict accounting), malloc() returns NULL — a special pointer value meaning “allocation failed,” represented as memory address zero.

On Linux with memory overcommit enabled — which is the default on most Linux distributions — malloc() may appear to succeed even for a 4 GB request. The kernel reserves virtual address space without immediately backing it with physical RAM. The actual crash then occurs later, when memcpy() tries to write to the allocated region and the kernel cannot provide enough physical memory pages. The kernel’s OOM (Out-Of-Memory) Killer — a last-resort mechanism that forcibly terminates processes to free memory — selects and kills the process.

In the more straightforward non-overcommit case: strongSwan does not check the return value of chunk_alloc(). The code proceeds as if the memory was successfully allocated, storing the enormous underflowed length alongside a NULL pointer.

When the code then attempts to copy received data into this “buffer” (a contiguous block of memory set aside for holding data) using memcpy(), it writes to the address that chunk_alloc() returned:

C
memcpy(this->input.ptr, received_data, received_data_length);

memcpy() is the C standard function that copies a given number of bytes from a source address to a destination address. Here, this->input.ptr is NULL — memory address zero. The operating system does not permit access to address zero; this is a hardware-enforced protection. The CPU’s Memory Management Unit (the same component that isolates processes from each other) refuses the access at the silicon level. The process is killed immediately with a segmentation fault (or “segfault”) — the operating system’s way of saying “this process tried to access memory it is not allowed to touch.”

(For readers familiar with Unix internals: the CPU raises a page fault exception on the unmapped zero page, the kernel translates this into a SIGSEGV signal delivered to the process, and since strongSwan does not install a handler for SIGSEGV, the default action — process termination — takes effect.)

strongSwan runs a daemon process called charon — the long-running background server process (analogous to a Windows service) that handles all IKE negotiations on the host. When charon crashes — whether via segfault from a NULL dereference or via the OOM Killer — all active VPN sessions are terminated.

This is a NULL pointer dereference: the program attempts to use a null pointer as if it were a real memory address, resulting in an immediate, unrecoverable crash.

A note on delayed crashes: Depending on the system’s memory allocator (the component inside malloc() that tracks which memory is in use) and whether memory overcommit is enabled, the 4 GB request does not always fail cleanly with NULL. On some systems the first malformed packet quietly disturbs the allocator’s internal state, and the actual crash happens later, during an unrelated allocation. A crash whose symptom appears disconnected from its cause is much harder to notice during routine testing — which is one of the reasons this bug survived for so long.

Impact: Unauthenticated Denial of Service

An attacker can trigger this crash with no valid credentials. The required steps are:

  1. Send an IKE_SA_INIT to the target server (no authentication required — this is just the initial handshake)
  2. Begin IKE_AUTH — the server proposes EAP-TTLS as the authentication method, and the EAP exchange starts within this phase
  3. Complete the TLS handshake inside the EAP-TTLS tunnel — the attacker simply responds to the server’s TLS messages; no certificate or credential is needed from the attacker’s side, because the TLS tunnel only authenticates the server at this stage
  4. Send a malformed AVP with Length = 1 inside the tunnel

At step 4, the parser computes the underflowed length, requests ~4 GB, and crashes. To demonstrate the severity: the proof of concept fits in roughly 100 lines of Python.

Remote code execution is not possible here. In the most common scenario, the allocation fails and the write destination is address zero, which the CPU’s Memory Management Unit unconditionally blocks — the process is killed before any data reaches memory. Even in the less common case where the allocation appears to succeed (on systems with memory overcommit enabled), the write destination is a heap address chosen by the allocator, not by the attacker, and the process is typically killed by the OOM Killer before any controlled exploitation is possible. In neither case does the attacker gain control over the program’s execution.

Attack path diagram

Why Did This Survive for Over 15 Years?

The bug originates in commit 79f2102cb442 — “implemented server side support for EAP-TTLS” — which introduced the EAP-TTLS plugin in strongSwan 4.5.0.

Three factors contributed to its longevity:

Normal clients never trigger it. A well-implemented EAP-TTLS client always sends AVPs with Length ≥ 8. The subtraction is correct for all valid input. The bug only manifests for deliberately malformed packets.

The attack surface requires navigating multiple layers. Reaching the vulnerable line requires completing IKE_SA_INIT, IKE_AUTH, and a full TLS handshake. This might create the impression that the code is protected by the outer layers — but none of them validate the AVP Length field.

The crash can be delayed. As mentioned above, on some systems the first malformed packet disturbs the memory allocator’s internal state without crashing immediately; the actual crash occurs during a later, unrelated allocation. A crash that appears disconnected from its cause is much harder to trace during testing.

The Fix

Two lines changed in strongSwan 6.0.5. In the diff below, lines prefixed with - (shown in red) were removed and lines prefixed with + (shown in green) were added in their place; unchanged lines have a leading space:

Diff
-if (!success)
+if (!success || avp_len < AVP_HEADER_LEN)
 {
     DBG1(DBG_IKE, "received invalid AVP header");
     return FAILED;
 }
 this->process_header = FALSE;
-this->data_len = avp_len - 8;
+this->data_len = avp_len - AVP_HEADER_LEN;

Since AVP_HEADER_LEN is defined as 8, the new bounds check — a small guard that verifies a value falls within an allowed range before the code relies on it — (avp_len < AVP_HEADER_LEN) rejects any AVP whose declared length is less than 8, precisely the range 0–7 shown in the underflow table above. With that range rejected before the subtraction runs, the underflow can no longer occur. The hard-coded literal 8 — a so-called “magic number,” meaning an unexplained numeric constant sitting directly in the source code — is also replaced with the named constant AVP_HEADER_LEN for clarity. (this->process_header is a boolean flag that tracks whether the parser is currently expecting a header or a data payload; setting it to FALSE tells the parser “header processing is done, the next bytes are data.” This line is unchanged in the fix.)


Part 3: The Methodology

This part covers the structured workflow I used to find the vulnerability — starting with why an unstructured approach fails, and building up to the full multi-pass system that produced the result above.

The vulnerability described above sat undetected for over 15 years. It was not obscure — the code path was reachable by an unauthenticated attacker and the bug pattern (an unchecked subtraction on an unsigned integer) is one of the most well-known categories of C vulnerability. So why wasn’t it found sooner? And what made it possible to find it with AI-assisted analysis? The answer is not the AI itself, but the structure around it.

The Problem with Unstructured AI Analysis

The naive approach is: point an AI at a codebase, ask it to find bugs, review what comes out. This produces output, but a substantial fraction of that output — without additional structure — consists of findings that are already patched in an earlier commit, duplicates of existing CVEs, or false positives (findings that look like bugs on the surface but aren’t real — typically because the attack path is blocked by a check somewhere else in the code). An attack path here is simply the sequence of steps an attacker would need to execute — from the network input they send, through each layer of the code that processes it, to the specific line where the damage would happen.

The ratio of genuine findings to false ones, without additional structure, is low enough that you spend more effort disproving AI output than you would have spent reading the code directly.

The Solution: A Two-Part System

The workflow I built consists of two components:

  1. User Instruction Document — A standing specification that defines how the analysis must be conducted: what pre-investigation is required, what to check for, and the formal false-positive elimination flow that all findings must pass. This goes into Claude’s Project instructions (a feature that lets you attach a persistent instruction document that Claude reads automatically at the start of every chat inside that project) — or, if you are working outside Claude, any equivalent persistent system prompt location (the top-level instruction slot that most LLM interfaces expose for setting the model’s role and rules up front). Once placed there, the document applies automatically to every analysis session.

  2. Session Prompts — Short, structured messages that invoke specific steps of the analysis process. Each prompt references the instruction document by name and specifies what the current step should produce.

This separation means the rules only need to be written and refined once. Each individual session prompt stays short and focused on “what to do now,” while the instruction document handles all the “how to do it correctly.”


The User Instruction Document

This is the full specification I used. In Claude, this lives in the Project instructions. The email/disclosure-generation sections are omitted here; only the analysis-relevant portions are shown. These are reference materials — the explanation below is fully self-contained without them, and you do not need to read this document to follow the rest of the post.

Click to expand: Full User Instruction Document
Text
[OSS (C/C++) Vulnerability Analysis Rules]

## Core Principles
- Report only CVE-eligible, demonstrably exploitable vulnerabilities.
  Uncertain findings go into "needs verification" — not reported until confirmed.
- Before reporting, always ask: "Can an attacker actually reach this code path?"
  This check must include protocol state transitions and encryption state.
- Verify the complete attack chain end-to-end, not just a single defect in isolation.
- Before reporting, eliminate two categories:
    (1) duplicates of known CVEs
    (2) issues already fixed in existing commits

## Pre-investigation (Required before any code analysis begins)
- git log — retrieve full commit history; identify commits mentioning:
  security / fix / CVE / bounds / overflow
- CVEdetails, NVD, GitHub Issues — check all known CVEs for this OSS
- If working from a shallow clone, run: git fetch --unshallow

## C/C++ Verification Checklist

[Memory Safety]
  [ ] Is the size argument to memcpy / memcmp / memmove attacker-controlled?
  [ ] Is a bounds check absent from BOTH the calling function AND the called function?
    (A check in one location may be sufficient — don't double-report.)
  [ ] Distinguish stack / heap / global buffers; assess impact scope accurately.
  [ ] Prioritize: fixed-size buffers receiving variable-length network input.

[Integer and Length Calculations]
  [ ] Does the function's return value equal the bytes actually consumed?
    Watch for parsers that return consumed + N (off-by-N source).
  [ ] Are signed/unsigned integer types mixed, creating implicit conversions?
  [ ] Can a size_t vs. int comparison cause wraparound?
  [ ] Are protocol-specific types (uint24, uint16, etc.) expanded correctly?

[ASN.1 / TLV / AVP Parsers]
  [ ] When both short-form and long-form lengths are accepted, is consumed-byte
    tracking correct across both branches?
  [ ] In nested structures, can the outer length check detect errors in inner parsing?
  [ ] Even if code is described as DER-only, verify there is active BER-rejection code.

[Protocol State Machine]
  [ ] What state machine state is required to reach the vulnerable function?
  [ ] Has authentication and key exchange completed before that state is reachable?
  [ ] Distinguish pre-handshake (pre-encryption) from post-handshake reachability.
  [ ] Evaluate client-side and server-side reachability separately.
    Server-side reachability is generally more severe.

## False-Positive Elimination Flow
  (Complete ALL steps before marking anything "Confirmed")

  (1) Full Attack Path Trace
     Trace: network input → record layer → handshake layer → target function
     At each intermediate validation layer, verify with CONCRETE VALUES that
     attacker-controlled input actually passes through unrejected.

  (2) Existing Fix Deduplication
     Run: git log -p -S "target_function_name"
     If a similar fix commit exists, inspect the diff LINE BY LINE.
     Confirm whether the fix covers this specific code path.
     "A fix to nearby code" is NOT the same as "this bug was fixed."

  (3) Known CVE Deduplication
     For all known CVEs (including forks):
     check affected function, affected versions, root cause.
     Confirm no existing CVE covers the same root cause in the same function.

  (4) Verdict
     All of (1)(2)(3) pass → "Confirmed" (ready to report)
     Any step ambiguous → "Needs Verification" (do not report until resolved)

## Output Format (Initial Analysis Report)
First report: table only, no explanations:

| # | Type          | File:Line         | CWE     | Attacker        | Reach Condition      | Verdict              |
|---|---------------|-------------------|---------|-----------------|----------------------|----------------------|
| 1 | Int Underflow | eap_ttls_avp.c:133| CWE-191 | Unauthenticated | EAP-TTLS enabled     | Confirmed            |
| 2 | OOB Read      | dtls.c:2355       | CWE-125 | Unauthenticated | ECDSA cert present   | Needs Verification   |

"Confirmed"       = ready to report immediately
"Needs Verification" = additional review required before reporting

Detailed payloads and PoC steps: provided on explicit request only.

## On Conversation Interruption
If the same message is received consecutively:
this is an automatic re-send from conversation context compression.
Do not restart from the beginning — continue from where analysis was interrupted.

The Session Prompts

These are the messages pasted into the chat to invoke each pass. Each one is short precisely because the instruction document handles all the detailed rules. Passes 1 and 2 run in the same chat session; Pass 3 runs in a separate session so the model has no access to its prior reasoning. These are reference materials — the explanation below is fully self-contained without them. They are included so that anyone who wants to replicate the workflow has the exact prompts used.

Click to expand: All Session Prompts Pass 1 — Initial Analysis
Text
Analyze the following OSS project according to the
[OSS (C/C++) Vulnerability Analysis Rules].

Complete all steps in order:
  pre-investigation → analysis → false-positive elimination flow ((1)–(4))

Then output only the specified table. No explanations or comments.

Target: [URL or local path to the repository]

Note: If you receive this same message consecutively, it is an automatic re-send
due to conversation context compression. Do not restart — continue from where
analysis was interrupted.
Pass 2 — False-Positive Elimination
Text
Re-run the false-positive elimination flow ((1)–(4)) from the
[OSS (C/C++) Vulnerability Analysis Rules] on the current detection results.

  (1) Re-trace the full attack path (verify each layer's validation with concrete values)
  (2) git log -p -S "function_name" — eliminate overlaps with existing fixes
  (3) Cross-reference all known CVEs for this OSS and its forks
  (4) Mark only items passing all of (1)(2)(3) as "Confirmed"

  Also re-verify protocol state transitions:
    - pre- vs. post-encryption reachability
    - client-side vs. server-side reachability (evaluated separately)

For any item where the verdict changes, update the table and add a one-line
explanation of the reason for the change.

Note: If you receive this same message consecutively, it is an automatic re-send
due to conversation context compression. Do not restart — continue from where
analysis was interrupted.
Pass 3 — Bias-Free Re-Analysis (separate chat session)
Text
Re-evaluate the following confirmed vulnerability findings
for technical accuracy. You have not seen any prior analysis of this codebase —
assess each claim independently against the source code.

For each finding, verify:
  - Filenames, line numbers, function names, variable names — are they correct?
  - Is the attack path actually reachable as described? Trace from network input.
  - Are all numeric values (buffer sizes, offsets, byte counts) accurate?
  - Does any intermediate validation layer block the attack path?
  - Is the CWE classification appropriate?

Target source code: [repository URL or attachment]
Confirmed findings: [summary of findings from Pass 2]

Note: If you receive this same message consecutively, it is an automatic re-send
due to conversation context compression. Do not restart — continue from where
you left off.
Error Recovery (resume after an interruption)
Text
Read any existing files in the output directory and resume work from
where it was interrupted.

Multi-Pass Review with Bias Breaking

Even with a well-specified instruction document, a single-pass analysis has a structural problem: once the model identifies something it believes is a real vulnerability, it tends to rationalize that conclusion on re-read rather than re-evaluate it from scratch. This is anchoring — a well-studied cognitive bias in which an initial judgment distorts every judgment that follows, making it harder to notice contrary evidence.

In AI-assisted code analysis, this plays out concretely. Suppose the model concludes in Pass 1 that a certain subtraction can underflow. When it reviews the same code in Pass 2, it already “knows” the answer. It skims past a bounds check three lines above the subtraction — a check that might actually prevent the underflow — because the conclusion already feels settled. The model is not lying or hallucinating; it is simply not re-evaluating with the same rigor it applied the first time. The prior conclusion acts as a filter that makes confirming evidence easy to see and disconfirming evidence easy to miss.

As a reminder: a “pass” (or round) means one complete run through the analysis. The workflow uses several rounds in sequence, each with a specific purpose.

The workflow addresses this with a mandatory three-pass structure:

Pass 1 — Initial analysis. Pre-investigation (git log, NVD, CVEdetails, issue trackers) followed by structured analysis per the specification, targeting memory safety, integer handling, ASN.1 parsing, and protocol state issues. Produces a table of candidates.

Pass 2 — Formal false-positive (FP) elimination, running three independent checks in sequence: (1) full attack path trace from network input to the target function, verified with concrete values; (2) commit history dedup via git log -p -S to confirm the specific vulnerable pattern has not already been fixed; (3) cross-referencing all known CVEs for the target project and its forks to ensure no existing CVE covers the same root cause. Only items that pass all three checks are marked “Confirmed.”

Pass 3 — Independent bias-free re-analysis, conducted in a separate chat session. By opening a fresh session, the model has no access to its own Pass 1 and Pass 2 reasoning — only the confirmed findings and the source code. It re-evaluates each finding from scratch: re-tracing the attack scenario, re-checking reachability, and verifying every filename, line number, function name, variable, and numeric value against the real code.

Pass 3 is where the remaining errors tend to surface. Without access to its own previous review, the model has no anchor to prior conclusions — it evaluates the claim purely on the evidence in front of it. The separate session is not a stylistic choice; it is the mechanism that makes bias-breaking work in practice.

Methodology flow diagram

Learning from Mistakes: How False Positives Improve the System

Even with the full multi-pass structure in place, rare false positives occasionally slip through — most commonly caught at the verification stage when a PoC (proof of concept — a minimal runnable demonstration that the bug actually triggers) fails to reproduce, or when a manual code review reveals an inconsistency.

When this happens, the response is not just to discard the finding. It triggers an additional loop:

  1. AI root cause analysis — The model examines its own error and articulates precisely why the false positive passed the elimination checks. What reasoning was flawed? Which validation was skipped or applied incorrectly?
  2. Instruction document update — That diagnosis is translated into a concrete new rule, check, or observation, and appended to the user instruction document. Not as a description of the past mistake, but as actionable guidance that applies to future analyses.
  3. Next iteration runs with the improved spec — The updated document is active from that point forward, and the same class of error becomes systematically less likely.

The instruction document is a living artifact — meaning it is not written once and left alone, but continuously edited as the workflow encounters new failure modes. It starts as a general-purpose specification and becomes increasingly precise through accumulated experience. Each false positive that escapes the main flow contributes a permanent improvement to the system. Over multiple research sessions, these incremental refinements accumulate — each one builds on the ones that came before — producing a specification that has been tested against real failures and refined to address them.

Precision improvement loop


Application to CVE-2026-25075

For this specific finding, the protocol state machine check was the most important step.

Is the parser reachable by an unauthenticated attacker? The EAP-TTLS AVP parser is invoked during the TLS tunnel phase, before any client credential exchange. In EAP-TTLS, the tunnel is server-authenticated only by design — the whole point is that client credentials are sent inside the tunnel. The client proves nothing while the tunnel is being set up. This confirmed that the vulnerable function is reachable with zero client authentication.

Does any intermediate layer reject a malformed AVP length? I traced the code path: incoming IKEv2 record → EAP handler → EAP-TTLS plugin → eap_ttls_avp.c. None of those layers validate the AVP Length field for a minimum value. The malformed AVP arrives at line 133 intact.

Was this already fixed or assigned a CVE? git log -p -S "avp_len" on the eap_ttls directory showed no prior commit addressing the missing lower-bound check. Cross-referencing all known strongSwan CVEs confirmed no existing CVE covered this root cause.

The finding passed all three FP elimination checks and survived the Pass 3 re-analysis unchanged.

Two other candidates from the initial analysis were eliminated:

  • One matched a fix commit from three years earlier. The surrounding code looked similar, but the diff confirmed the specific vulnerable pattern was gone.
  • One was only reachable after successful client authentication, which materially changes the severity and attack model. It was deprioritized.

Part 4: Key Takeaways

On the Vulnerability

  • strongSwan 4.5.0 – 6.0.4 is affected. Upgrade to 6.0.5, or apply the official patch for older releases.
  • If EAP-TTLS is not used in your deployment, disabling the eap-ttls plugin eliminates the attack surface entirely.
  • Servers that offload EAP-TTLS termination to a RADIUS backend are not affected, because in those deployments the AVP parsing happens on the RADIUS server rather than on the strongSwan host — the vulnerable code in strongSwan’s eap_ttls_avp.c is never invoked.

On the Methodology

  • Structure is the primary variable. Unstructured AI analysis produces too much noise to be useful on its own. A well-written instruction document — one that encodes where false positives typically come from and what must be verified before reporting — is what makes the output actionable.

  • Protocol state tracing requires the most care. Code-level defect identification is where AI analysis performs reliably. Correctly evaluating attack reachability across protocol layers — especially authentication state — is where errors accumulate without explicit verification steps.

  • Pass 3 (bias-free re-analysis) catches errors that self-review misses. Running a fresh analysis without reading prior review output is not redundant; it serves a specific purpose: removing the anchoring effect of earlier conclusions.

  • False positive elimination is a prerequisite, not a formality. Commit history review, known CVE cross-referencing, and concrete-value attack path tracing are three separate required checks. Skipping any one of them leads to reporting findings that are not real, or that have already been patched.


A two-line fix closed a bug that had been open for over 15 years. The vulnerability itself is specific to strongSwan’s EAP-TTLS parser — but the pattern behind it, an unchecked subtraction on an unsigned integer derived from untrusted input, is not. The same class of error exists wherever C code computes a payload length by subtracting a header size from a total length without first verifying that the total is large enough. This is a pattern worth looking for systematically, and the methodology described here is one way to do that at scale.

I hope this was useful — more to come.

This post is licensed under CC BY 4.0 by the author.