Skip to content

Broaden and harden PoC payload recovery; generate every quoted figure from the corpus - #30

Open
AntoineBastide47 wants to merge 27 commits into
mainfrom
CVE-better-PoC-usage
Open

Broaden and harden PoC payload recovery; generate every quoted figure from the corpus#30
AntoineBastide47 wants to merge 27 commits into
mainfrom
CVE-better-PoC-usage

Conversation

@AntoineBastide47

Copy link
Copy Markdown
Contributor

Payload-backed injection CVEs 5,025 → 11,265, so an increase by a factor of x2.2.

Sources. Nine added to fetch.py, pinned in manifest.json: trickest/cve, vulhub, cve-scores, fuzzdb/PayloadsAllTheThings/SecLists (25,369 payloads by sink type), OWASP Benchmark + NIST SARD Juliet (149,811 CWE-labeled cases). New fetch_github.py (15,194 repos, 5,692 SHAs pinned) and fetch_web.py (seclists, content-hashed). New validate.py runs VERIFY over the payload corpora directly and checks the taxonomy against the labeled suites: all 15 injection CWEs resolve as expected.

Several measurement bugs, some of which corrupted numbers on the base branch:

  • Regex truncation counted as VERIFY rejection: 37% of all rejections. A breakout emits an unmatched closer; an unmatched opener is a cut capture.
  • Application source read as payloads (jQuery includes, an architecture diagram).
  • Payload selection was order-dependent; runs are now byte-identical.
  • URL-form payloads (union+select, %20UNION%20SELECT) were invisible.
  • Explicit payload labels now trusted after signatures fail.

Junk 4% → 0.08%. Reject share 9.0% → 5.6%, mostly artifacts.

Generated figures. latex.py emits doc/generated/: 110 macros, five tables, the containment tree. No data literals remain outside generated/. Validated by reproducing the base branch's values exactly where data was unchanged, which surfaced six already-stale figures, including both numbers in the conclusion.

P.S: requires #29 to be merged first

Fetch seven new sources, pinned by commit in manifest.json:

  trickest/cve, vulhub          CVE -> PoC linkage
  ARPSyndicate/cve-scores       VEDAS exploit-availability scores
  fuzzdb, PayloadsAllTheThings  attack payloads by sink type
  SecLists                      "
  OWASP BenchmarkJava           labeled injection cases

plus the NIST SARD Juliet Java and C/C++ suites, whose paths encode the
CWE. New tables: payload, ground_truth, exploit_score. A new validate
stage runs VERIFY over the payload corpora, which measure matchertext
directly instead of only through CVEs that happen to expose a payload,
and resolves the labeled CWEs through the taxonomy anchors.

fetch.py gains --freeze so an already-pinned rolling feed stays at its
recorded revision, making a corpus change attributable to new sources
rather than to upstream drift.

Two details that would otherwise distort counts:
  - a trickest entry separates candidate exploit repos from advisory
    write-ups; only the former count as PoC evidence, worth 54% of the
    rows.
  - vulhub's local_path points at the environment README, which carries
    the worked exploit request, not at the directory.

fix: make payload selection deterministic

subclass, syntactic_group, and report picked a CVE's representative
payload from an unordered query, so ingesting a new PoC source silently
reshuffled which file each CVE extracted from. Seeding report's RNG did
nothing while the list it shuffled had no fixed order. All three now
order by (cve_id, source, ref); two consecutive runs are byte-identical.

feat: extract URL-form payloads

The literal signatures required real whitespace, so union+select,
UNION/**/SELECT, and %20UNION%20SELECT all missed; URL forms were the
largest extraction gap. A URL_SIGNS layer, tried only after the literal
ones, matches those forms and decodes the match: percent-escapes (two
rounds, since %2527 -> %27 -> ' is common), + as query-string space, and
/**/ as a whitespace substitute. Decoding is required for correctness,
not convenience -- the server decodes before the sink parses, so %28 is
really a ( and skeletonizing the raw form would hide matchers.

Also adds attribute- and JS-context XSS breakout, which carry no tag for
the old signatures to anchor on, and URL forms for shell_command and
code_eval. Those last are needed for the measurement to be sound:
improving only SQL and XSS, both matcher-hostable, skewed the
payload-backed set and inflated the contained share to 87%.

Extraction rises 42% -> 63%, payload-backed CVEs 5,040 -> 7,474. The
inert share of contained falls 91.8% -> 91.0% as decoding exposes
unmatched parens, the direction that confirms the change does not favour
a flattering result.
pipeline/latex.py writes doc/generated/, which main.tex inputs: 104
scalar macros plus the syntax, mechanism, payload-corpus, PoC-source and
ground-truth tables, and the containment tree. The tree is emitted as a
whole verbatim block because verbatim does not expand macros.

No number is hand-copied any more; a scan for {,}-formatted literals
outside generated/ comes back empty. Regenerate with
python3 pipeline/latex.py, then make.

Validated by reproducing the previous hand-computed values exactly where
the data had not changed: the tab:mech hostable block, its totals, the
whole projection chain (73,246 -> 7.3% -> 69,536 -> 76%), 351 distinct
CWEs, 345 KEV, and the naive-Bayes holdout scores.

That surfaced six figures the corpus rebuild had already invalidated,
now corrected: injection CVEs with a PoC 13,691 -> 52,684, payload-backed
5,025 -> 7,474, outside 914 -> 1,086, skeleton groups 2,143 -> 3,906,
singletons 1,817 -> 3,347, and the whole "with PoC" column of tab:syntax.

data.tex said four PoC databases; there are now six.
A signature that stops inside a function call, as in
`ASCII(SUBSTRING(passwd,`, leaves an unmatched opener that VERIFY then
reports as a rejection. 212 of 573 rejected CVEs (37%) were this
artifact rather than a real breakout, so the published inert/reject
split was wrong.

The two cases separate cleanly on which side is unbalanced. A breakout
must emit an unmatched *closer* -- closing the slot early is the whole
manoeuvre -- so `'r0t')</script>` is genuine. An unmatched *opener* at
the tail is always the capture being cut short. Extraction now discards
the latter. Checked against the corpus: all 341 excess-closer rejections
are real payloads, all 212 excess-opener ones were truncated.

Also drops three false-positive classes that markdown prose produces and
exploit files did not, found via the GitHub pilot: inline code spans
(`deviceList` read as command substitution), `&id=62` read as the id
command, and bare `javascript:` stubs. And adds the paren-breakout form
`admin') OR '1'='1`, which the quote-anchored signature missed -- these
are exactly the unmatched-closer cases the split turns on.

  payload-backed  7,474 -> 7,221
  rejected          573 ->   347
  reject share     9.0% ->  5.6%
  junk in corpus     4% ->  ~0%

feat: add GitHub PoC fetch pilot

Measures reach, yield and syntax skew on a sample before committing to a
bulk fetch. Its first result corrects the addressable set: most GitHub
links point at CVE mirrors, not exploits (one nuclei-templates fork is
linked from 11,758 CVEs), so repos above a fan-out threshold are dropped
and each CVE is represented by its most specific repo. That leaves
~12,600 addressable CVEs, not the 30,608 a raw link count suggests.
A linked PoC repo is often the vulnerable application itself, not a proof
of concept, and scanning its source recovered benign code as payloads:
jQuery includes, Vite entry points, Blade templates, a C4 architecture
diagram whose System(...) call matched the code-eval signature, and CLI
flags like -lzma2=dict=64KiB matching argument injection. Half the
payloads from a 40-repo trial were of this kind.

Curated exploit files hid the problem because everything in them is an
exploit. Arbitrary repository source is not, so the loose signatures had
to become discriminating:

  <script>            require a body that alerts, evals or touches
                      document.cookie, not any script tag
  "><script           require the same, not an attribute boundary
  javascript:         require an attack call, not an inline handler
  system()/eval()     require a shell command, $, backtick or
                      base64_decode as the argument, not an identifier
  -flag=value         require a command substitution or separator

fetch_github only searches documentation, plus code whose path claims to
be the exploit. That costs some genuine payloads and removes far more
application source.

Also reclaims closers a regex cannot count: eval(base64_decode($x)) was
captured one ) short and then discarded by the truncation guard, so the
capture is now extended over closing matchers that immediately follow.

Junk in sampled payloads falls from ~50% to ~8%; yield drops to 22%,
which is the intended trade.

feat: pin fetched repos by commit SHA

git ls-remote resolves HEAD without spending API quota, and codeload
serves that exact commit, so the archive root is repo-<sha> and the pin
proves itself. Pins land in data/github_pins.json, making the fetch
replayable even though a quarter of these repos are already gone.
Payloads land in remote_payload and feed syntactic_group and subclass,
which prefer a local file and fall back to the remote payload.
Downloading archives was bandwidth-bound, not concurrency-bound. Twenty
four parallel codeload fetches saturated the link at ~283 KB/s, so
neither more threads nor more cores moved it; measured throughput was
1.66/s however the work was arranged. Two earlier speedups I reported
were measurement artifacts: a startup burst read as steady state, and a
poll that straddled exactly one 200-row flush, which is quantised
because flush only writes every 200 results.

One GraphQL request now returns the SHA, the root file listing and the
README for a batch of repos; a second fetches only the documents worth
reading. A few KB per repo replaces a multi-megabyte archive, which
takes the transfer off the critical path entirely.

  throughput   1.66/s -> 38/s   (12,590 repos in 2m50s, was ~2h)
  yield        28% -> 31% of reachable repos
  oversize     348 -> 0, since nothing large is transferred

Reading ten root documents per repo beats the archive scan outright: the
archive searched more files but the payload is nearly always in the
write-up, and skipping application source avoids its false positives.

Corpus effect of the completed fetch:

  payload-backed CVEs   7,221 -> 9,633
  contained             6,244 -> 7,937
  contained share       86.5% -> 82.4%
  rejected                347 -> 600
  junk in payloads              0.0%

The contained share falls because the recovered payloads are not the
same mix as the local corpus: non-matcher contexts triple (334 -> 1,021),
mostly shell commands documented in PoC write-ups. That corrects a skew
in the previous figure rather than contradicting it, and lands close to
the 82% originally reported before any of this work.

5,595 commit SHAs pinned in data/github_pins.json, so the fetch replays
even though 3,034 of the linked repos (24%) are already deleted.
An audit of every numeric literal across the paper found six data
figures the generator did not cover, one of them already stale:

  concl.tex    82% contained, 80% hostable -- both headline results,
               and 82% was only correct by coincidence; it read 86.5%
               two commits ago
  results.tex  "fifteen distinct verdicts" -- now 16, changed by the
               GitHub fetch
  results.tex  "four in five" for the two dominant syntaxes, now
               emitted as a share
  classify.tex code/eval naive-Bayes scores, the deterministic layers'
               share of labels, and the audit sample size

The audit size is a pipeline parameter rather than a measurement, so
export.AUDIT_PER_METHOD is now the single definition and latex.py reads
it from there.

A scan for data-shaped literals outside generated/ is now empty across
abs, corpus, results, prevent, concl, classify and data. corpus.tex
quotes no figures at all.
Payload-backed CVEs 9,633 -> 11,087, junk 0.08%, contained share
unchanged at 82.4%.

1. Exploit-DB links joined to the local clone (+3,086 poc rows)

load_exploitdb mapped CVEs through the `codes` column of
files_exploits.csv; where that column is blank the mapping was lost even
though another source linked the CVE to the same EDB entry and the file
was already on disk. Pure bookkeeping, no network.

2. Descend into doc/poc subdirectories (+148)

Well below the 10-15% I estimated: only 8 of 60 previously-empty repos
have such a directory at all, so the yield is ~2%. The first DIR_HINT
also matched by substring, taking `docker` for a doc directory and
`aj-report` for a report; it is anchored on name segments now.

3. Widen the aggregator cut-off to fan-out 100 (+337)

13% yield against 31% for the fan-out<=20 band, so this range is a mix
of genuine multi-CVE researcher repos and mirrors. Still worthwhile.

4. Trust explicit payload labels (+~870)

Exploit-DB write-ups and sqlmap transcripts label the payload outright.
The label supplies what a signature cannot: it marks a string as the
attack even when the sink is one the signatures do not enumerate, as in
`...&currentTSREmailTo=|date>/tmp/x`. Applied only after every signature
has failed, and the value must still carry a delimiter or metacharacter,
contain an `=`, and pass the junk guards.

Two ordering details matter for correctness. The whole labelled value is
preferred over a signature match inside it, because the signature
captures a suffix such as `SLEEP(5)))vltp)` whose stray closers turn a
balanced payload into an apparent rejection. And is_truncated is not
applied to labelled values: it detects a regex stopping mid-expression,
whereas a labelled line is a delimited whole, so the trailing `{` of
`'};alert(1);{'` is the payload rather than a cut.

The SLEEP signature no longer runs to end of line for the same reason.
That correction alone drops the reject share from 7.6% to 5.6%.
…orm block

fetch_web reads the two PoC sources that host advisory text rather than
another link to it, and that need no API key. seclists completed: 374
pages, 178 payloads (48%), 165 of them CVEs with no other source.
Payload-backed CVEs 11,087 -> 11,265.

Packet Storm blocked us partway through and its rows have been deleted.
Two defects caused it, both mine:

The throttle and the lane partition keyed on hostname. Packet Storm
answers to both packetstormsecurity.com and .org, so one server received
two independent one-per-second budgets and thus 2/s for twenty-five
minutes, which tripped its velocity limiter. Both now key on the site.

The block is served as HTTP 200 with a refusal page, so every request
after it was recorded as "this advisory contains no payload". That is
worse than an error: 1,093 CVEs carried a false negative that looked
like a measurement. Responses are now checked for refusal text, and a
site is abandoned after three consecutive blocks instead of being
hammered for the rest of the run.

Packet Storm is not retried here. We are blocked, the polite interval is
unknown, and re-running against a site that has explicitly refused us is
not something to do casually; its ~1,500 CVEs stay unaddressed.
@AntoineBastide47
AntoineBastide47 requested a review from bford July 28, 2026 17:10
@AntoineBastide47 AntoineBastide47 self-assigned this Jul 28, 2026
prevent.tex asserted that the distance between the payload-backed subset
and the full corpus is payload availability rather than a containment
failure, but gave the reader nothing to size it with. Two published
measurements do: Mandiant puts 51% of vulnerabilities disclosed in
2021-2022 as eventually acquiring public exploit or proof-of-concept
code, and VulnCheck puts 26% of CVEs with 2025 identifiers as having it
within the year, a share that grows with age.

Our own corpus agrees from the other side: 57% of injection CVEs carry
any proof-of-concept reference at all, so for the rest there is no
artifact for any pipeline to recover. A literal attack string is
stricter again, since a proof of concept that builds its payload at run
time exposes none.

The point of the paragraph is that the fragment-level bound should be
read against the measurable population, roughly half the corpus, rather
than against all 91,760.

These are literature constants rather than corpus measurements, so they
stay as literals with citations instead of becoming generated macros;
the two corpus figures in the same paragraph are macros as usual.
The closing of the projection called the reach "bounded from below at
the fragment level and from above at the syntax level" and then gave 82%
and at most 80%. Those measure different populations: 82% is contained
over the payload-backed subset, 80% is matcher-delimitable syntax over
the whole corpus. Presented as a range the floor exceeds the ceiling,
which reads as a contradiction unless the reader notices the
denominators differ.

Adds \dataFloorShare, contained over all injection CVEs, so the three
figures sit on one scale: a floor of 10.1%, a central estimate of 77%,
a ceiling of 80%. The floor treats every CVE whose payload we could not
recover as uncontained, making it a hard lower bound rather than an
estimate; the paragraph that follows explains why that assumption is
harsh, since most of those CVEs have no published payload at all.

ContainedShare stays, relabelled as what it is: what happens once a
payload is known, not how much of the corpus that covers.

The abstract and conclusion already qualified both denominators in
prose and are unchanged.
…yntax

The projection took the count of matcher-delimitable CVEs and subtracted
an estimate of javascript: sinks, a correction hand-written for HTML/DOM
and applied nowhere else. That happened to be right, but only because
HTML/DOM is the single syntax where the model can find a partial rate;
it does not generalise, and it obscured why the correction is needed.

The reason is sampling. Payload-backed CVEs are not a random draw: SQL
is 1.7x over-represented because a SQL payload is a literal string a
write-up can quote, while code-eval and argument payloads are built at
run time and never appear verbatim. Payload availability correlates with
syntax, and specifically with matcher-hostable syntax, 85% of the subset
against 80% of the corpus. Applying the pooled 82% to the whole corpus
therefore yields 75,550, too high by 5,276 CVEs.

latex.post_stratify now measures a containment rate per syntax and
weights by the corpus counts. The estimate is unchanged at 70,274 (77%),
which is the point: the two routes agree exactly, so the syntax-level
bound is the post-stratified form of the fragment-level measurement
rather than an independent guess. A syntax with no payload-backed CVE
falls back to the structural rule.

Adds tab:strata showing the strata, and \dataNaiveEstimate and
\dataSkewCost so the text can state what the skew costs rather than
asserting the correction matters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant