Guetzli is Google’s JPEG compressor. When its parser meets a metadata segment, it reads the segment’s declared length and, before copying anything, checks that the file actually holds that many bytes. Delete that one check and run the project’s test suite: 10 of 10 tests pass. Nothing in the repository notices. Now hand the modified binary a 504-byte JPEG whose length field says 65,535, and AddressSanitizer reports a heap-buffer-overflow in ProcessAPP. That is a security weakness in the sense that matters: invisible to the tests, real under a crafted input.

Software-engineering agents improved by double digits once thousands of real repositories were packaged with reproducible builds and tests. Security agents never got that corpus, because a functional bug is easy to verify (a test fails) while a security weakness has to do the opposite: stay latent under the existing tests and surface only under adversarial input. Existing runnable vulnerability datasets are mined from disclosed CVEs, so they grow at the rate humans find and publish bugs. CyberForge is our attempt to manufacture that data instead. I worked on it with Amine Lbath and colleagues during a NIST PREP fellowship in spring 2026, with Dinesh Manocha at UMD; Amine and I share first authorship. This post traces a single guetzli instance from the released corpus, guetzli/vulnerability_FZ_24, through every stage of the pipeline.

The idea in one picture

The SWE-Smith family of bug-injection pipelines accepts an edit once a unit test fails. CyberForge flips the criterion: an edit is accepted only if every unit test still passes and a proof-of-vulnerability input (PoV) crashes the injected build but not the clean one. Neither half means anything alone, so the oracle checks the injection and the PoV jointly.

Functional bug (SWE-Smith style) Latent weakness (CyberForge) one edit to the project a unit test fails bug accepted the test suite is the oracle one edit to the project every unit test still passes AND PoV crashes injected build, not clean build weakness accepted tests must stay green; a crafted input is the oracle
Left: a functional-bug oracle uses the test suite as its judge. Right: CyberForge requires the tests to stay green and uses a crafted input, run differentially against the clean and injected builds, as its judge.
Key ideaInstead of mining vulnerabilities from disclosure, create them: let an agent weaken a real check in a real project, then admit the instance only by execution, never by reading the diff. Corpus growth then depends on compute, not on CVE publication.

Walkthrough: one guetzli bug, from injection to training signal

Everything below is the actual instance guetzli/vulnerability_FZ_24, produced by the fuzzer-guided pipeline ("producer": "fuzz_poc_guided") and labeled CWE-125, an out-of-bounds read.

Walkthrough: guetzli/vulnerability_FZ_24, end to end

1. The project qualifies

guetzli (OSS-Fuzz) Google's JPEG compressor, C++ Dockerfile + build.sh ASAN / UBSAN instrumented libFuzzer harnesses unit tests (10) no project-specific build logic written 5 unattended test runs on the unmodified code 100% pass, identical results, 5 / 5 any flake → project rejected qualified pool 100 projects qualify 80 end up contributing at least one instance 73 C++ projects, 27 C projects Step 1 · admission of the project, before any edit is made

We start from C and C++ projects enrolled in OSS-Fuzz, because each already ships a Docker image, a build.sh, sanitizer configuration, and libFuzzer harnesses; we write no project-specific build logic. A project enters the pool only if its own tests build, run unattended, and pass at 100% with identical results across five runs on the unmodified code. Guetzli’s 10 unit tests do, so it joins the 100 qualified projects (80 of which eventually contribute at least one validated instance).

2. The pipeline picks a site

reachable from a harness OSS-Fuzz coverage metadata other function other function ProcessAPP other function other function function score structural role (parser entry, buffer writer, decoder…), call depth, fanout, coverage runtime vs static reachability triggerability score parser proximity, path signal, guard-to-sink distance, nearby blockers can a harness input reach it? selected site jpeg_data_reader.cc ProcessAPP guard: VERIFY_LEN sink: std::string copy harness input format: JPEG ranked by a weighted combination, then diversified across harness, role and category buckets

The fuzzer-guided pipeline parses OSS-Fuzz metadata (Fuzz Introspector reports, harness definitions, coverage) into a map of functions reachable from at least one harness. Each is ranked by a weighted combination of two scores: a function score (structural role such as parser entry point or buffer writer, call depth, fanout, file coverage) and a triggerability score (parser proximity, path signal strength, guard-to-sink distance, nearby blockers). ProcessAPP in jpeg_data_reader.cc is a parser entry point where a guard sits directly before the copy it protects, and the harness feeds it JPEGs. Candidates are then diversified across harness, role, and category buckets so one file does not dominate.

3. The agent deletes the length check

The agent receives the function, the site, the inferred weakness type, the harness input format, and category-specific edit rules: no new branches, single-file edit, preserve the downstream operation. It makes one minimal change that weakens the existing check.

VERIFY_LEN(2)marker_len = ReadUint16(data, pos)VERIFY_INPUT(marker_len, 2, 65535)VERIFY_LEN(marker_len - 2)std::string app_str(…, marker_len + 1)
@@ -398,7 +398,7 @@ bool ProcessAPP(const uint8_t* data, size_t* pos, ...)
   VERIFY_LEN(2);
   size_t marker_len = ReadUint16(data, pos);
   VERIFY_INPUT(marker_len, 2, 65535, MARKER_LEN);
-  VERIFY_LEN(marker_len - 2);
+
   // Save the marker type together with the app data.
   std::string app_str(reinterpret_cast<const char*>(
       &data[*pos - 3]), marker_len + 1);

The range check (VERIFY_INPUT, 2 to 65,535) stays. The check that the buffer really contains marker_len - 2 more bytes is gone. The copy into app_str now trusts a length the attacker controls.

4. The unit tests still pass (Condition 1)

injected build guetzli minus one line compiled with build.sh ASAN + UBSAN on project's own unit tests 10 / 10 pass (expected_passing_count: 10) conforming JPEGs declare lengths that match their data, so the missing check is never exercised Condition 1 weakness is latent a test failure → retry Step 4 · a failing test would admit this edit under a SWE-Smith oracle; here it sends the agent back to retry

The modified project is compiled and run against guetzli’s own tests: 10 of 10 pass ("expected_passing_count": 10 in the instance metadata). JPEGs produced by conforming encoders carry segment lengths that match their data, so the deleted check never fires on them. Had a test failed, the agent would have been prompted to retry; a failing test means a functional bug, which is exactly what we do not want.

5. A crafted JPEG separates the two builds (Condition 2)

The agent then writes a deterministic PoV, guided by the harness input model and seed extensions. Here it is a 504-byte file that opens with:

FF D8SOIFF E0APP0FF FFlength = 65,5354A 46 49 46"JFIF"… 504 bytes total
PoV: a 504-byte JPEG with a lying length field 504 bytes actually present …65,035 bytes past the end of the file bytes 0-1 FF D8 = SOI · bytes 2-3 FF E0 = APP0 · bytes 4-5 FF FF = declared length 65,535 (largest VERIFY_INPUT allows) clean build VERIFY_LEN(marker_len - 2) buffer holds fewer than 65,533 bytes → input rejected, parsing stops no crash injected build std::string app_str(…, marker_len + 1) copies from the declared length → reads beyond the input buffer ASAN: heap-buffer-overflow Condition 2 · crash on injected only → accepted

65,535 is the largest value VERIFY_INPUT accepts, so the surviving check lets it through. On the clean build, VERIFY_LEN rejects the file and nothing crashes. On the injected build, the std::string constructor copies from the declared length and reads 65,035 bytes past the end of the input. The verifier also checks that the sanitizer reports the expected error type at the expected location:

==432==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 65531 at 0x6fc93620b1f8 thread T0
    #0 ProcessAPP jpeg_data_reader.cc:403:15
SUMMARY: AddressSanitizer: heap-buffer-overflow
    jpeg_data_reader.cc:403:15 in guetzli::ProcessAPP

Heap-buffer-overflow read, in ProcessAPP, at the copy: this matches the CWE-125 target, so the instance is accepted and saved as the diff, the PoV, the metadata, and this report.

6. From instance to training signal

repair task SEC-bench format injected guetzli repo PoV at /testcase sanitizer report reference patch withheld teacher agent Mini-SWE-Agent scaffold GPT-5.4-mini or Gemma 4 31B runs in the OSS-Fuzz container, no network access same oracle decides success, not the agent patched build: tests pass, PoV no longer crashes failed trajectories discarded student SFT LoRA r=32, α=64 Gemma 4 E4B / 12B / 31B 1,194 GPT trajectories, 880 Gemma trajectories Step 6 · the guetzli instance is now one of 1,034 tasks; the corpus of successful teacher runs is the training set

The accepted pair becomes a SEC-bench-style repair task: the injected repository, the PoV, and the sanitizer report, with the reference patch withheld. A teacher agent (GPT-5.4-mini or Gemma 4 31B on Mini-SWE-Agent) works inside the OSS-Fuzz container with no network access. Success is decided by the same differential oracle, not by the agent’s own claim, so a trajectory that announces a fix without passing validation is discarded. Only successful trajectories are used to fine-tune Gemma 4 students at E4B, 12B, and 31B.

Under the hood

Symbol Meaning
\(P, T\) a qualified OSS-Fuzz project and its unit test suite
\(B_0\) the clean build of \(P\)
\(\delta\) an injected edit (the diff)
\(B_\delta\) the build of \(P\) with \(\delta\) applied
\(x\) a proof-of-vulnerability input
\(\mathrm{pass}(B, t)\) build \(B\) passes unit test \(t\)
\(\mathrm{crash}(B, x)\) running \(B\) on \(x\) yields a sanitizer report of the expected type at the expected location
\(F_n, G_m\) empirical distributions of an edit statistic over injected and real patches
\(D_{n,m}\) two-sample Kolmogorov-Smirnov distance

The admission predicate. Both pipelines end at the same criterion. Written out, an instance \((\delta, x)\) is admitted when

\[\mathrm{accept}(\delta, x) \iff \underbrace{\forall t \in T:\ \mathrm{pass}(B_\delta, t)}_{\text{Condition 1: latent}} \;\wedge\; \underbrace{\mathrm{crash}(B_\delta, x) \wedge \neg\,\mathrm{crash}(B_0, x)}_{\text{Condition 2: differential PoV}}\]

Condition 1 says the weakness survives normal execution, which is what real weaknesses that pass code review and production testing do. Condition 2 is a differential test under identical input, and it is what makes the pair meaningful: a crash on both builds is a pre-existing bug, a crash on neither is an injection nobody can reach. The verifier’s check on sanitizer type and location, folded into \(\mathrm{crash}\) above, rules out spurious failures.

OSS-Fuzz 100 projects containers, tests, harnesses, sanitizers P1 fuzzer-guided harness-reachable site, one minimal edit, PoV, 90 s libFuzzer replay P2 agentic in-context PrimeVul CVE retrieval or specialist exploration, CodeQL, taint analysis, retry loops differential oracle all unit tests pass PoV: injected crashes, clean does not sanitizer type + location must match the target 1,034 instances 643 from P1, 391 from P2 80 projects, 63 CWEs median edit: 2 lines tasks → SFT teacher trajectories, oracle-verified only Gemma 4 E4B / 12B / 31B 16,172 attempts largest failure: PoV never triggers (44.6% P1, 33.1% P2)
The two pipelines differ in how they find sites and build PoVs; they share the oracle, the task format, and the training recipe. Both run Gemma 4 31B through Mini-SWE-Agent, capped at 200 iterations per invocation.

Yield. CyberForge made 16,172 injection attempts and 1,034 passed validation: 643 from the fuzzer-guided pipeline and 391 from the agentic one. Writing a plausible injection is easy; producing one the oracle accepts is the hard part. In the Pipeline 2 ablation, a naive single-pass agent compiles and passes unit tests on 68.2% of attempts and passes validation on 0%; taint analysis alone reaches 2.8% validated, retry loops alone 3.5%, and the full workflow lifts plausible injections to 77.6% and validated yield to 7.5%. Pipeline 1 shows the same from the other side: a post-hoc 90-second libFuzzer replay over a seeded corpus raises yield with the injection stage untouched. The pipelines fail at different stages, Pipeline 1 losing 64.2% of candidates at validation and Pipeline 2 58.8% at injection, but a PoV that never triggers is the largest single cause in both (44.6% and 33.1%).

Realism. For an edit statistic (functions modified, files touched, hunks, lines changed), let \(F_n\) and \(G_m\) be the empirical distributions over the injected corpus and over the 300 real SEC-bench instances. The two-sample Kolmogorov-Smirnov statistic is

\[D_{n,m} = \sup_x \,\bigl| F_n(x) - G_m(x) \bigr|\]

A KS distance has no universal threshold, so the floor is measured between two real corpora, the SEC-bench cve and oss splits. For functions modified, injected-to-real is 0.165 against a real-to-real floor of 0.190; files touched and hunk count give 0.123 and 0.243 against 0.065 and 0.205. The edits are small and local in the way CVE patches are: 1,025 of 1,034 instances change one file, 944 confine the change to one hunk, and the median edit is 2 lines. The guetzli instance, one deleted line, is typical.

Training. Over the 1,034 instances the teachers yielded 1,194 accepted trajectories from GPT-5.4-mini and 880 from Gemma 4 31B. Raw trajectories are recorded in the teacher’s environment, so before fine-tuning they are aligned to the SEC-bench harness (secb repro / secb build, PoV paths remapped to /testcase, rg rewritten to grep), linearized to one command per assistant turn, and compressed with a sliding window over old observations so the turn that writes the patch is never truncated. Every student uses the same recipe: LoRA adapters (\(r = 32\), \(\alpha = 64\), dropout 0.05) on the attention and MLP projections with the base frozen, learning rate \(1 \times 10^{-4}\) with a cosine schedule, batch size 1 with gradient accumulation 2, three epochs, BFloat16, one H200. Holding the 12B student and Gemma teacher fixed, SEC-bench rises from 3.6% to 12.1% to 16.0% as the trajectory corpus doubles twice; at 220 trajectories the student scores below its own base, so a corpus too small to teach the workflow is worse than none.

What the numbers say

Student Teacher SEC-bench (%) PatchEval strict (%)
Gemma 4 E4B base 6.0 2.6
CyberForge-E4B GPT-5.4-mini 9.3 (+3.3) 9.1 (+6.5)
Gemma 4 12B base 8.7 3.9
CyberForge-12B GPT-5.4-mini 16.7 (+8.0) 12.8 (+8.9)
Gemma 4 31B base 58.0 12.2
CyberForge-31B GPT-5.4-mini 72.7 (+14.7) 14.8 (+2.6)
GPT-5.4-mini (teacher) 74.0 13.0

All six student-teacher configurations improve on SEC-bench, by 3.3 to 14.7 points; the self-distilled students taught by Gemma 4 31B gain too (31B: 64.7%). The corpus is entirely C/C++, yet every configuration also improves on PatchEval’s Go, JavaScript, and Python CVEs, and the 31B student passes its teacher there. What changed inside the agent explains the scores: the 12B base completes an edit-then-verify cycle on 20.7% of instances, and after fine-tuning on 82.7%.

Try it