This is a master key hidden inside a library book that half your estate forgot it was carrying
CVE-2021-44228, *Log4Shell*, is a JNDI lookup remote code execution bug in log4j-core, not just log4j-api. The vulnerable upstream ranges span 2.0-beta9 through 2.14.1 for the original bug, and the broader operationally unsafe set now includes 2.15.0 because Apache's first fix was incomplete; Apache's current security guidance drives defenders to safe targets 2.17.0 (Java 8+), 2.12.3 (Java 7), or 2.3.1 (Java 6). The attacker only needs one reachable code path that logs attacker-controlled data such as headers, usernames, URLs, API fields, or chat/game text.
The vendor's 10.0 CRITICAL label matches reality. The usual reasons to downgrade a network bug—required auth, narrow exposure, brittle exploit chain, or limited blast radius—do not hold here: exploitation is unauthenticated, scanning was immediate and global, the dependency sat invisibly inside third-party products, and KEV plus ransomware usage prove this was not a lab-only edge case.
4 steps from start to impact.
Reach a logging sink with attacker-controlled input
curl, Burp, or a scanner such as fullhunt/log4j-scan, the attacker sends ${jndi:ldap://...} in a field likely to be logged: User-Agent, X-Forwarded-For, URI, form data, auth failure text, or app-specific parameters. This is initial access, not post-compromise exploitation, which is why the severity stays pinned at the top.- A reachable service or appliance processes attacker input
- The application logs some attacker-controlled field
- A vulnerable
log4j-coreversion is present somewhere in the request path
- Not every endpoint logs attacker data
- Some products sanitize or truncate headers before logging
- Internal-only Java services reduce attacker population, but do not remove risk after foothold
${jndi: strings, but coverage is incomplete because payloads can be obfuscated, nested, encoded, or delivered through uncommon headers and parameters.Trigger JNDI resolution and outbound lookup
log4j-core instance resolves the attacker string and attempts a JNDI lookup over LDAP, LDAPS, RMI, DNS, or related protocols. In real attacks this was commonly paired with marshalsec, JNDIExploit, or simple LDAP referral servers to hand back a second-stage location.log4j-coreis in a vulnerable range- The vulnerable logging code path executes on the supplied input
- The target can perform outbound name resolution or callback traffic
- Egress filtering can break LDAP/RMI/HTTP retrieval
- Newer JDK behavior and environment hardening can reduce clean RCE outcomes
- Some paths degrade to information disclosure or failed lookup instead of code execution
Deliver second-stage bytecode or payload
- Target egress path reaches attacker infrastructure
- Runtime/JDK behavior permits the code-loading path used by the attacker
- The Java process has enough filesystem and process permissions to stage follow-on actions
- Container sandboxes and non-root service accounts reduce post-exploitation options
- Egress proxies and TLS inspection can break simple off-the-shelf exploit kits
- Some modern JDK combinations require attacker adaptation
Monetize access with commodity follow-on tooling
- Successful code execution in the target process
- Some privilege or lateral movement path exists beyond the app container
- Command-and-control or payload staging is not blocked
- Least-privilege service accounts constrain blast radius
- EDR/NGAV may kill commodity payloads quickly
- Segmentation can trap the compromise in one tier
The supporting signals.
| In-the-wild status | Yes, emphatically. CISA says exploitation began around 2021-12-01, listed it in KEV on 2021-12-10, and later warned of continued exploitation for initial access on unpatched VMware Horizon/UAG. |
|---|---|
| KEV / ransomware signal | KEV-listed with Known To Be Used in Ransomware Campaigns: Known. That alone removes any argument that this is a theoretical or niche bug. |
| Proof-of-concept availability | Commodity-level. Public exploit, scanner, and validation tooling appeared immediately, including fullhunt/log4j-scan; operational exploit kits using JNDI/LDAP referral infrastructure were widespread within days. |
| EPSS | 0.94358 from the user-supplied intel. That's an outlier-high exploitation probability signal; secondary mirrors place it around the 94th percentile range. |
| CVSS vector meaning | AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H means unauthenticated remote exploitation with no user click and full triad impact. In practice, this is the exact kind of bug defenders cannot afford to normalize. |
| Affected component / versions | Only log4j-core is affected. Apache lists upstream vulnerable ranges as [2.0-beta9, 2.3.1) ∪ [2.4, 2.12.2) ∪ [2.13.0, 2.15.0) for CVE-2021-44228, but operationally you should also treat 2.15.0 as unsafe because the first fix was incomplete. |
| Fixed versions | Practical safe targets: 2.17.0 for Java 8+, 2.12.3 for Java 7, 2.3.1 for Java 6. Distros also shipped backports; for example Ubuntu fixed affected packages with release-specific package versions such as 2.10.0-2ubuntu0.1 on 18.04. |
| Exposure / scanning data | Massive internet pressure. GreyNoise documented sharply increasing opportunistic scanning, and Censys measured 85,328 potentially vulnerable UniFi Network services alone during the response window. |
| Disclosure timeline | Public disclosure landed on 2021-12-10. Apache credits Chen Zhaojun of Alibaba Cloud Security Team with discovery. |
| Detection reality | Authenticated file/SBOM scanning beats network-only checks. The hard part was never writing signatures; it was finding shaded, nested, and appliance-bundled log4j-core copies across sprawling estates. |
noisgate verdict.
The decisive factor is that this bug delivers unauthenticated remote initial access on widely deployed Java software with almost no preconditions, and it has a long, proven exploitation record in KEV and ransomware activity. The one meaningful friction point—outbound callback and runtime behavior—was not enough to stop real attackers at scale, and hidden dependency sprawl actually amplifies exposure rather than narrowing it.
Why this verdict
- Baseline stays at 10.0: vendor scoring is fair here because the chain starts with unauthenticated remote input and no user interaction; there is no auth, role, or internal-network prerequisite to shave points off.
- Friction audit does not rescue you: outbound LDAP/RMI/HTTP and JDK hardening can break some exploit paths, but that is only one mid-chain choke point; in the wild, attackers had abundant working combinations and moved to alternative callbacks and payload forms quickly.
- Exposure uncertainty is an amplifier, not a discount: Log4j lived as a buried dependency inside apps, appliances, and shaded JARs, so many enterprises could not even enumerate where it was. KEV listing, ransomware association, and continued post-2021 exploitation remove any case for downgrading.
Why not higher?
There is nowhere higher than 10.0, and even this bug still depends on a reachable logging path and a workable runtime/egress combination. Some deployments only exposed internal services, and some hardened JDK or egress controls degraded clean RCE outcomes.
Why not lower?
A downgrade would require meaningful population narrowing—authentication, tenant isolation, local access, rare feature enablement, or a brittle exploit chain. Log4Shell had none of those advantages for defenders, and the KEV/ransomware history proves the real-world path was broad enough to matter at enterprise scale.
What to do — in priority order.
- Block risky egress now — Deny outbound LDAP, LDAPS, RMI, and other unnecessary callback paths from application tiers and appliances immediately, within hours. This is the fastest choke point against second-stage retrieval when you cannot patch every buried copy at once.
- Hunt every
log4j-corecopy — Run authenticated JAR/SBOM scans on servers, containers, golden images, and third-party product directories immediately, within hours, because shaded and nested archives are where vulnerable copies hide. Treat vendor appliances and Java fat JARs as first-class search targets. - Fence off unpatchable systems — Isolate internet-facing or high-value unpatchable assets behind tight ACLs, reverse proxies, or temporary withdrawal from service immediately, within hours. If a vendor cannot provide a clean fix quickly, exposure reduction is your only honest control.
- Deploy detection on both ingress and egress — Push WAF/IDS/EDR detections for
${jndi:patterns, Java child-process spawning, and outbound LDAP/RMI/DNS anomalies immediately, within hours. Use these as tripwires, not as your primary mitigation story.
- Setting only
log4j2.formatMsgNoLookups=trueis not enough; it did not hold up across the later Log4j vulnerability chain and does not equal a safe end state. - Relying on a WAF alone is not enough because payloads can be obfuscated, encoded, or delivered via nonstandard headers and non-HTTP paths.
- Assuming 'not internet-facing' means low risk is wrong; once an attacker gets any foothold, internal Java services become easy post-initial-access targets.
Crowdsourced verification payload.
Run this on the target host, container image filesystem, mounted VM disk, or extracted application directory where Java archives live. Invoke it as python3 log4j_check.py /opt or python3 log4j_check.py C:\apps; no admin rights are required beyond read access to the directories and archives being scanned.
#!/usr/bin/env python3
# log4j_check.py
# Scan a directory tree for log4j-core versions inside JAR/WAR/EAR/ZIP archives.
# This script uses the *practical safe target* logic for Log4Shell-era risk:
# PATCHED = 2.17.0+ (Java 8+), 2.12.3+ on the 2.12.x line, or 2.3.1+ on the 2.3.x line
# VULNERABLE = versions in the Log4Shell / incomplete-fix window, including 2.15.0 and 2.12.2
# Output: VULNERABLE / PATCHED / UNKNOWN
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
import io
import os
import re
import sys
import zipfile
from typing import List, Tuple, Optional
ARCHIVE_EXTS = {'.jar', '.war', '.ear', '.zip'}
POM_PATH = 'META-INF/maven/org.apache.logging.log4j/log4j-core/pom.properties'
MANIFEST_PATH = 'META-INF/MANIFEST.MF'
VERSION_RE = re.compile(r'^version\s*=\s*(.+)$', re.MULTILINE)
IMPL_VERSION_RE = re.compile(r'^Implementation-Version:\s*(.+)$', re.MULTILINE)
class Version:
def __init__(self, raw: str):
self.raw = raw.strip()
self.main, self.stage_rank, self.stage_num = self._parse(self.raw)
def _parse(self, s: str):
s = s.strip().lower()
m = re.match(r'^(\d+)\.(\d+)(?:\.(\d+))?(?:[.-]?(alpha|beta|rc)(\d+)?)?$', s)
if not m:
nums = [int(x) for x in re.findall(r'\d+', s)]
while len(nums) < 3:
nums.append(0)
return tuple(nums[:3]), 3, 0
major = int(m.group(1))
minor = int(m.group(2))
patch = int(m.group(3) or 0)
stage = m.group(4)
stage_num = int(m.group(5) or 0)
rank_map = {'alpha': 0, 'beta': 1, 'rc': 2, None: 3}
return (major, minor, patch), rank_map[stage], stage_num
def _cmp_tuple(self):
return self.main + (self.stage_rank, self.stage_num)
def __lt__(self, other):
return self._cmp_tuple() < other._cmp_tuple()
def __le__(self, other):
return self._cmp_tuple() <= other._cmp_tuple()
def __ge__(self, other):
return self._cmp_tuple() >= other._cmp_tuple()
def __gt__(self, other):
return self._cmp_tuple() > other._cmp_tuple()
def __eq__(self, other):
return self._cmp_tuple() == other._cmp_tuple()
def in_range(v: Version, low: str, high: str) -> bool:
return Version(low) <= v < Version(high)
def is_practically_safe(vs: str) -> bool:
v = Version(vs)
# Safe targets for real-world Log4j remediation, not just the first 44228 fix.
if v.main[0] != 2:
return False
if v.main[1] == 3:
return v >= Version('2.3.1')
if v.main[1] == 12:
return v >= Version('2.12.3')
return v >= Version('2.17.0')
def is_vulnerable(vs: str) -> bool:
v = Version(vs)
# Practical vulnerable set for Log4Shell-era risk:
# 2.0-beta9 <= v < 2.3.1
# 2.4 <= v < 2.12.3
# 2.13.0 <= v < 2.17.0
return (
in_range(v, '2.0-beta9', '2.3.1') or
in_range(v, '2.4.0', '2.12.3') or
in_range(v, '2.13.0', '2.17.0')
)
def read_version_from_zip(zf: zipfile.ZipFile) -> Optional[str]:
# Preferred source: pom.properties
try:
with zf.open(POM_PATH) as f:
text = f.read().decode('utf-8', errors='ignore')
m = VERSION_RE.search(text)
if m:
return m.group(1).strip()
except KeyError:
pass
except Exception:
pass
# Fallback: manifest
try:
with zf.open(MANIFEST_PATH) as f:
text = f.read().decode('utf-8', errors='ignore')
if 'log4j' in text.lower():
m = IMPL_VERSION_RE.search(text)
if m:
return m.group(1).strip()
except KeyError:
pass
except Exception:
pass
return None
def scan_zip_bytes(data: bytes, logical_name: str, findings: List[Tuple[str, str]], errors: List[str]):
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
ver = read_version_from_zip(zf)
if ver:
findings.append((logical_name, ver))
for name in zf.namelist():
lower = name.lower()
_, ext = os.path.splitext(lower)
if ext in ARCHIVE_EXTS:
try:
nested = zf.read(name)
scan_zip_bytes(nested, logical_name + '!' + name, findings, errors)
except Exception as e:
errors.append(f'nested read failed: {logical_name}!{name}: {e}')
except Exception as e:
errors.append(f'zip open failed: {logical_name}: {e}')
def scan_path(root: str):
findings: List[Tuple[str, str]] = []
errors: List[str] = []
for base, _, files in os.walk(root):
for fn in files:
full = os.path.join(base, fn)
_, ext = os.path.splitext(fn.lower())
if ext not in ARCHIVE_EXTS:
continue
try:
with open(full, 'rb') as f:
scan_zip_bytes(f.read(), full, findings, errors)
except Exception as e:
errors.append(f'file read failed: {full}: {e}')
return findings, errors
def main():
if len(sys.argv) != 2:
print('UNKNOWN - usage: python3 log4j_check.py <path>')
sys.exit(2)
root = sys.argv[1]
if not os.path.exists(root):
print(f'UNKNOWN - path does not exist: {root}')
sys.exit(2)
findings, errors = scan_path(root)
if not findings and errors:
print('UNKNOWN - scan errors prevented reliable assessment')
for e in errors[:20]:
print(e)
sys.exit(2)
if not findings:
print('UNKNOWN - no log4j-core artifacts found')
sys.exit(2)
vulnerable = []
patched = []
unknown_versions = []
for path, ver in findings:
try:
if is_vulnerable(ver):
vulnerable.append((path, ver))
elif is_practically_safe(ver):
patched.append((path, ver))
else:
unknown_versions.append((path, ver))
except Exception:
unknown_versions.append((path, ver))
if vulnerable:
print('VULNERABLE')
for path, ver in vulnerable[:100]:
print(f'{path} :: {ver}')
if patched:
print(f'INFO - also found {len(patched)} patched artifact(s)')
if unknown_versions:
print(f'INFO - also found {len(unknown_versions)} artifact(s) with unclassified version strings')
sys.exit(1)
if patched and not unknown_versions:
print('PATCHED')
for path, ver in patched[:100]:
print(f'{path} :: {ver}')
sys.exit(0)
print('UNKNOWN - only unclassified versions found or mixed non-vulnerable artifacts without clear safe status')
for path, ver in (unknown_versions or patched)[:100]:
print(f'{path} :: {ver}')
sys.exit(2)
if __name__ == '__main__':
main()
If you remember one thing.
log4j-core, block risky outbound callback paths, and isolate unpatchable appliances immediately, within hours because KEV and active exploitation override the normal noisgate mitigation SLA. Then drive all affected assets to safe targets (2.17.0, 2.12.3, or 2.3.1, or validated vendor backports) within the noisgate remediation SLA of 90 days, with internet-facing and externally managed products first, followed by internal services, base images, and dormant-but-deployable artifacts.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.