Contents
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:
- What the vulnerability is — a technical walkthrough with explanations of the relevant concepts. This corresponds to Parts 1 and 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):
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:
this->data_len = avp_len - 8;
this->input = chunk_alloc(this->data_len + (4 - avp_len) % 4);
A note on C syntax:
this->fieldis how C accesses a named field inside a structure through a pointer — roughly equivalent tothis.fieldorobject.fieldin 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 accounts for the padding bytes that follow each AVP on the wire (the AVP format requires each attribute to be padded to a 4-byte boundary, meaning if the AVP is 10 bytes long, 2 padding bytes follow it before the next AVP starts) — 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.
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. For any non-zero size it 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, because the two lines run at two different widths and that decides everything below. avp_len is a uint32_t, but data_len is declared size_t. So the subtraction on the first line is 32-bit arithmetic, and that is the underflow. The addition on the second line happens at the width of size_t, which is 64 bits on a 64-bit build, so nothing wraps there. The padding expression (4 - avp_len) % 4 is 32-bit and does wrap, giving 0, 3, 2, 1, 0, 3, 2, 1 for avp_len 0 through 7. Put both together and the size actually handed to chunk_alloc() is:)
| AVP Length | 64-bit build | 32-bit build |
|---|---|---|
| 0 | 0xFFFFFFF8 | 0xFFFFFFF8 |
| 1 to 4 | 0xFFFFFFFC | 0xFFFFFFFC |
| 5 to 7 | 0x100000000 | 0x0 |
On a 64-bit build every length from 0 to 7 asks for roughly 4 GiB. Only on a 32-bit build, where size_t is 32 bits as well, does the sum wrap to zero. strongSwan’s own advisory prints the same two columns.
The Crash
Correction, 12 September 2026. An earlier version of this section named AVP lengths 5, 6 and 7 as a trigger that works regardless of how much memory the target has, on the reasoning that the padding term wraps the allocation size to zero. That reasoning holds only on a 32-bit build, and a zero-size allocation does not crash anything, for the reason given at the end of this section. On a 64-bit build all eight lengths ask for roughly 4 GiB, and the crash depends on that request failing. The text below is the corrected analysis, and it agrees with the vendor advisory.
Everything after the allocation turns on one question: does the roughly 4 GiB request succeed?
If it fails, the daemon crashes. chunk_alloc() is a macro, and it fills in the length field of the chunk it returns from the requested size whether or not the allocation worked:
#define chunk_alloc(bytes) \
({size_t x = (bytes); \
chunk_create(x ? malloc(x) : NULL, x);})
A failed allocation therefore produces a chunk whose pointer is NULL — a special pointer value meaning “allocation failed,” represented as memory address zero — and whose length still claims roughly 4 GiB. Neither chunk_alloc() nor the code that called it checks the pointer. The parser then copies whatever data arrived into that chunk:
len = min(reader->remaining(reader),
this->input.len - this->inpos);
memcpy(this->input.ptr + this->inpos, buf.ptr, len);
len is the smaller of two values: how many bytes are left in this record, and how much room the chunk claims to have. The claimed room is roughly 4 GiB, so len comes out as however many bytes actually arrived — a few kilobytes at most. memcpy() is the C standard function that copies a given number of bytes from a source address to a destination address, and here the destination is 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, 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.
If it succeeds, there is no crash and no overflow. On Linux with memory overcommit enabled, which is the default on most distributions, the kernel reserves 4 GiB of virtual address space without backing it with physical RAM, so the request appears to succeed. The chunk then holds a valid pointer and 4 GiB of room, the few kilobytes that arrived are copied into the start of it with space to spare, and this->inpos is left far below this->input.len — so the parser reports that it needs more data and waits. The connection sits there holding a 4 GiB reservation. Nothing is written out of bounds, physical memory is never exhausted, and the out-of-memory killer is never involved. The vendor advisory describes this outcome as resource exhaustion and states plainly that it is not a buffer overflow.
A zero-size allocation is not a crash either. On a 32-bit build, avp_len of 5, 6 or 7 makes the sum wrap to zero. chunk_alloc(0) returns a NULL pointer with a length of zero — the macro above does not even call malloc() for a zero size. len is then the smaller of the bytes remaining and zero, which is zero, so memcpy() copies nothing and the NULL is never written through. The parser still returns a chunk whose pointer is NULL and whose length claims roughly 4 GiB, but copying that chunk produces nothing: chunk_clone() hands it to chunk_create_clone(), which returns an empty chunk whenever the source pointer is NULL. That holds whether or not the copy’s own allocation succeeded — and on a 32-bit build it could not have succeeded anyway. What reaches the EAP layer is an empty message, and it is rejected for being shorter than an EAP header. The exchange fails and the daemon keeps running. This is the detail that makes the whole branch harmless, and it is easy to get backwards: the copy is bounded by the same zero that looked like the dangerous value.
So which outcome an attacker gets is a property of the target machine, not of the length they choose. What decides it is whether a single 4 GiB request is refused: the kernel’s overcommit setting, how much RAM plus swap the machine has, and any address-space limit applied to the daemon. A small VPN gateway whose RAM plus swap comes to less than 4 GiB has the request refused by that default heuristic, and it crashes on the first malformed AVP. A large server accepts it, and an attacker who wants the crash there has to open connections until a later allocation is the one that fails. That is the same defect either way, and both ends of it are a denial of service.
Impact: Unauthenticated Denial of Service
An attacker can reach this code with no valid credentials. The required steps are:
- Send an IKE_SA_INIT to the target server (no authentication required — this is just the initial handshake)
- Begin IKE_AUTH — the server proposes EAP-TTLS as the authentication method, and the EAP exchange starts within this phase
- 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
- Send an AVP whose Length field is anywhere from 0 to 7, with at least one byte of data behind it, inside the tunnel
At step 4 the parser computes the underflowed length and asks for roughly 4 GiB. Any of the eight values reaches the defect, and none of them is more dependable than the others, because what happens next is decided by the target’s memory situation rather than by the length. The data behind the header does matter: with a bare 8-byte header and nothing after it, the copy length is zero and the NULL is never touched, which is exactly how a non-destructive detector for this issue tests a server without crashing it. The proof of concept fits in roughly 100 lines of Python.
Two configurations are not affected at all, and the vendor names both. A client or server that does not use EAP-TTLS authentication never reaches the plugin. A server that terminates EAP-TTLS on a RADIUS server rather than in charon does not parse these AVPs itself, so it is not vulnerable either.
Remote code execution is not possible here. When the allocation fails, the write destination is address zero, which the Memory Management Unit unconditionally blocks — the process is killed before any data reaches memory. When it succeeds, the write destination is a page chosen by the allocator, not by the attacker, the copied length is whatever arrived on the wire rather than a length the attacker can aim, and the destination has 4 GiB of room, so the copy stays inside it. In neither case does the attacker gain control over the program’s execution.
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. That commit was made on 16 August 2010 and shipped in strongSwan 4.5.0 on 29 October 2010; the file does not exist in 4.4.1.
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.
A malformed packet often does nothing visible. As set out above, a machine that accepts the 4 GiB reservation does not crash at the time. The connection simply stops making progress while holding the reservation, and the failure surfaces later, when some other allocation is the one that cannot be satisfied. A symptom that appears disconnected from its cause is much harder to trace during testing, and on a machine with plenty of memory a single test packet produces no symptom at all.
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. The ... line stands for four unchanged lines the patch does not touch:
-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:
-
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.
-
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
[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
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
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)
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)
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.
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:
- 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?
- 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.
- 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.
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 later, or apply the official patch for older releases.
- If EAP-TTLS is not used in your deployment, disabling the
eap-ttlsplugin 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.cis 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.
Kazuma Matsumoto. “Finding a 15-Year-Old Zero-Day in strongSwan with AI-Assisted Code Analysis”. 637th Research Lab, 2026-04-18. https://y637f9qq2x.com/posts/cve-2026-25075/
