40.6. Phase 2 Ends With a Real Architectural Finding¶
Continuation of 40.5. Phase 2's job was three things:
- Stand up a vetting harness that classifies docker-compose targets as
plug_and_play,needs_setup, orrejected. - Run it across Vulhub short-lists, OWASP apps, and Docker Hub tagged versions until we had ~20 ready-to-go targets.
- Smoke the full runtime path: random target → spin up → nmap → corpus query → tear down.
The first two were grind. The third surfaced the entry's actual story.
The smoke's first run was a clean architectural failure¶
Pool state going in:
- 20 plug_and_play targets (Vulhub + OWASP + Docker Hub)
- 13 hand-vetted entries in cve_fingerprints
- 26,946 CVEs in cve_intel
- 33,243 exploit recipes
Phase 2.6 smoke (gemma_forge/harness/tools/pentest/lab_smoke.py)
picks N random targets from the pool, spins each up on the lab
network, runs nmap from inside, queries the corpus for each discovered
service+version, and reports candidates per port.
First run:
Full pipeline succeeded on: 5/5 targets
Targets returning candidates: 0/5
Total CVE candidates returned: 0
Five-for-five on the pipeline (containers up, nmap ran, queries went out, containers torn down) and zero-for-five on the actual answer. Not a wiring bug — every layer worked. The corpus lookup was just returning empty sets for every observed banner.
The cause was obvious in retrospect and embarrassing in real-time:
CorpusClient.lookup_candidates() matched only against
cve_fingerprints, which had 13 entries. For random pool sampling to
work, fingerprints would need to cover thousands of (service, version)
pairs by hand. That doesn't scale. We'd built a Ferrari with a 5-gallon
fuel tank.
The fix: a tiered lookup with a vendor-fallback rank multiplier¶
lookup_candidates(service, version) now tries two tiers:
- Precise fingerprint match (existing path) —
cve_fingerprintsexact-match on service + version pattern. Rank multiplier = 1.0. - Vendor+product fallback — wildcard
vendor/productmatch againstcve_inteldirectly, scoped to entries that have at least one exploit recipe (msf_modules,exploitdb_paths, orvulhub_pathnon-empty). Rank multiplier = 0.5.
The 0.5× multiplier is the load-bearing piece. It keeps precise
fingerprint matches preferred when both tiers return results, but lets
the broader corpus do useful work when fingerprints are silent. Apache
2.4.49 → CVE-2021-41773 (KEV, rank=100, precise fingerprint).
Tomcat 9.0.20 → multiple Tomcat CVEs at rank=50 (vendor-fallback).
The fallback's tightness depends on a small alias map in
_vendor_product_fallback() that maps nmap service names to
vendor/product tokens stored in cve_intel. First version mapped
"tomcat" → ["apache", "tomcat"], which over-matched by pulling in
every Apache product (Struts, ActiveMQ, HTTP Server, Airflow…) via
the vendor token. Sample the data, see the products are stored as
(vendor="Apache", product="Tomcat"), tighten to
"tomcat" → ["tomcat"], and now product-name match is the gate, not
vendor-name match. Clean separation.
What the second smoke showed¶
[1/5] target-struts2-s2-057 (Apache Tomcat 8.5.33)
→ 4 candidate(s): CVE-2025-24813, CVE-2017-12615, CVE-2017-12617 …
[2/5] target-owasp-dvwa (Apache httpd 2.4.25)
→ 4 candidate(s): CVE-2021-41773, CVE-2021-42013 …
[3/5] target-weblogic-cve-2017-10271 (Oracle WebLogic Server)
→ 5 candidate(s): CVE-2020-14882, CVE-2017-10271, CVE-2023-21839 …
[4/5] target-owasp-webgoat (zeus-admin?) ← nmap blind
→ no corpus candidates
[5/5] target-elasticsearch-cve-2014-3120 (wap-wsp?) ← nmap blind
→ no corpus candidates
Three observations worth recording:
The WebLogic case is the ideal match. The target's cve_ids is
exactly ['CVE-2017-10271']. The corpus query — run against the live
nmap banner with no advance knowledge of what the target was — returned
CVE-2017-10271 in the top-3 candidates. End-to-end blind: target
spun up, banner harvested, corpus queried, exact intended CVE
recovered. This is the loop the Worker will run in Phase 3.
Generic "http" service names are still a problem. Nmap reports
service="http" for Tomcat, Jetty, Struts containers (because they
all speak HTTP). The smoke harness now tries the product token parsed
from the version string FIRST ("Apache Tomcat 9.0.20" → tomcat),
then the vendor token, then the raw service name. That correctly
routed the Tomcat target to Tomcat CVEs instead of generic HTTP Server
CVEs. But the corpus client itself still treats the input opaquely —
ideally lookup_candidates() would parse a full banner string and
disambiguate internally. Leaving that for Phase 3.
Nmap goes blind on some services that vet_target.py handles fine.
WebGoat presents as zeus-admin? on 9090; Elasticsearch 1.4.2
presents as wap-wsp? on 9200. The vetting harness already solved
this with an HTTP-probe fallback (curl the port, parse Server header
and body markers). The smoke doesn't use that path yet. The Worker
WILL need it in Phase 3 — same code, reused.
The pattern that's emerging¶
Both the Phase 2 wins and the Phase 2 gaps are about the same thing: dynamic runtime lookup over a 27k-entry corpus is not the same problem as a hand-curated playbook of 50 entries. Every system layer needs to assume incomplete fingerprints, generic service names, vendor-name collisions, and ambiguous banners. The fallback path isn't a hack — it's the mainline. Fingerprints are an optimization on top of it.
ADR-0023 §1 said "shift from static playbook to runtime DB query." Phase 2 proved the second-order consequence: when the DB is large, the query has to be tolerant. Tiered lookups with rank multipliers are the mechanism. The 0.5× number is a magic constant for now — Phase 4's cross-run measurement will tell us if it's the right one (does fallback-tier exploitation succeed less often than fingerprint-tier exploitation, by what ratio?). If we get good telemetry there, the multiplier becomes a learnable parameter.
What's not done yet¶
- Nmap-blind fallback in the Worker. Reuse
vet_target.http_probe_open_ports()andderive_fingerprint_from_http_probe()from inside the Worker's recon loop, not just from inside vetting. - Banner-aware corpus lookup. Push the product-token parsing INTO
CorpusClient.lookup_candidates()so callers don't have to do it. Currently it's inlab_smoke._product_alias()— fine for smoke, wrong place for the Worker. - Webgoat/Shiro/Jetty fingerprints. Either backfill the alias map or solve generically via the HTTP probe path.
These all roll into Phase 3.
Where we stand at end of Phase 2¶
- Intel corpus: 26,946 CVEs, 33,243 recipes, 17s refresh ✓
- Target pool: 20 plug_and_play, 3 rejected, 1 needs_setup ✓
- Vetting harness: vulhub + OWASP + Docker Hub, three classifications ✓
- Lab network: isolated
pentest-labbridge, override-injection works ✓ - End-to-end smoke: 5/5 pipeline succeeds, 3/5 return candidates, WebLogic exact-CVE cross-match recovered blind ✓
- Cross-run learning: Phase 4 ✗
Phase 3 is the skill code: 5 sub-runtimes (WorkQueue, Architect, Worker, Reflector, Auditor), msfrpcd wrapper, banner-aware lookup, nmap-blind HTTP-probe fallback in the Worker's recon. Estimated 3 days. The hard architecture work — corpus shape, fallback strategy, pool curation — is done. Phase 3 is engineering, not design.