Zip Slip with Jwt Tokens
How Zip Slip Manifests in Jwt Tokens
Zip Slip is a path‑traversal vulnerability that occurs when an application extracts entries from an archive without validating that the resulting file paths stay inside a intended directory. In the context of JWT tokens, the vulnerability appears when a token claim (for example, attachment or file) is trusted and used directly to construct a path for extracting a zip archive that was uploaded alongside the token.
A typical vulnerable flow looks like this:
- The client sends a request containing a JWT in the
Authorizationheader and a multipart‑form file fieldarchivethat holds a zip. - The server verifies the token’s signature, then reads a claim such as
token.payload.target_path. - The claim value is concatenated with a base directory (
./uploads/) and passed to an extraction library. - If the claim contains directory‑traversal sequences (
../../), the extracted file can be written outside./uploads/, overwriting sensitive files or planting a web‑shell.
Real‑world analogues include CVE‑2020-28052 (Spring Boot) and CVE‑2022-24715 (WordPress), where attacker‑controlled metadata dictated extraction paths. When that metadata originates from a JWT claim, the same Zip Slip logic applies, but the attack surface is widened because the token can be replayed or forwarded to other services that trust its signature.
Example vulnerable Node.js code using jsonwebtoken and adm-zip:
const jwt = require('jsonwebtoken'); const AdmZip = require('adm-zip'); const fs = require('fs'); function handleUpload(req, res) { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) { return res.status(401).send('Missing token'); } const token = auth.slice(7); let payload; try { payload = jwt.verify(token, process.env.JWT_SECRET); // signature verified } catch (e) { return res.status(401).send('Invalid token'); } // Assume the client sent a zip file in req.files.archive const zipPath = req.files.archive.tempFilePath; const zip = new AdmZip(zipPath); // Vulnerable: using a claim directly as a sub‑folder name const target = payload.target_path || '.'; // e.g. '../../etc' const extractTo = path.join(__dirname, 'uploads', target); // <-- traversal possible zip.extractAllTo(extractTo, true); res.send('Extracted to ' + extractTo); }If
payload.target_pathis../../../tmp, the zip contents are written to./uploads/../../../tmp→/tmp, bypassing the intended upload directory.
Jwt Tokens-Specific Detection
Detecting Zip Slip that stems from JWT claims requires the scanner to:
- Identify endpoints that accept a JWT (usually via
Authorization: Bearer) and a file upload. - Trace how claims from the verified token influence file‑system operations, especially archive extraction.
- Check whether the claim‑derived path is sanitized or confined to a safe base directory.
middleBrick’s unauthenticated black‑box scan performs these steps automatically:
- It sends a request with a valid‑looking JWT (generated using a dummy secret or extracted from a public endpoint) together with a crafted zip containing an entry named
../../evil.php. - If the server extracts the zip and the malicious file appears outside the intended directory, middleBrick flags the finding under the Input Validation check, assigning a severity based on the impact (e.g., ability to write to web‑root).
- The report includes the exact claim name that was abused, the raw token used, and the path traversal sequence observed.
Because middleBrick does not need credentials or agents, it can test the exact same flow an attacker would use: obtain a token from a login or public endpoint, replay it with a malicious claim, and upload the zip. The scanner’s parallel checks also verify other related issues (e.g., missing signature verification, excessive claims) that could exacerbate the Zip Slip risk.
Sample middleBrick CLI invocation that would trigger the detection:
middlebrick scan https://api.example.com/upload \ --header "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0YXJnZXRfcGF0aCI6Ii4uLy4uLy90bXAvIn0.tX_YZ..." \ --file archive=@test.zipThe resulting JSON report will contain a finding such as:
{ "check": "Input Validation", "severity": "high", "description": "Zip Slip via JWT claim 'target_path' allows writing outside intended upload directory.", "remediation": "Validate and sanitize the claim before using it in file‑system paths." }
Jwt Tokens-Specific Remediation
The fix centers on treating any JWT claim that influences file‑system operations as untrusted input, regardless of the token’s signature. A valid signature only guarantees the claim was issued by the expected party; it does not guarantee the claim’s content is safe for a particular use case.
Remediation steps:
- Define an allow‑list of permissible values for claims that are used in file paths (e.g., only alphanumeric IDs, UUIDs, or predefined folder names).
- If a relative path is required, normalize it with
path.resolve()(Node) oros.path.normpath()(Python) and then verify that the resolved path starts with the intended base directory. - Prefer using indirect mapping: store a random identifier in the claim and look up the real storage location from a server‑side database or cache.
- When extracting archives, use libraries that provide built‑in protection against Zip Slip (e.g.,
adm-zipwith theextractAllTooption set tofalseand manual entry validation, orunzipperstream with entry‑level path checks).
Corrected Node.js example:
const jwt = require('jsonwebtoken'); const AdmZip = require('adm-zip'); const path = require('path'); const { promisify } = require('util'); const fs = require('fs'); const BASE_DIR = path.resolve(__dirname, 'uploads'); function isSafeTarget(target) { // Allow only alphanumeric, hyphens, underscores, and a single dot for extension return /^[a-zA-Z0-9_-]+$/.test(target); } async function handleUpload(req, res) { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) { return res.status(401).send('Missing token'); } const token = auth.slice(7); let payload; try { payload = jwt.verify(token, process.env.JWT_SECRET); } catch (e) { return res.status(401).send('Invalid token'); } const rawTarget = payload.target_path; if (!isSafeTarget(rawTarget)) { return res.status(400).send('Invalid target_path claim'); } const zipPath = req.files.archive.tempFilePath; const zip = new AdmZip(zipPath); // Resolve the final extraction directory and ensure it stays inside BASE_DIR const extractTo = path.resolve(BASE_DIR, rawTarget); if (!extractTo.startsWith(BASE_DIR)) { return res.status(400).send('Path traversal attempt blocked'); } // Optional: validate each entry before extraction zip.getEntries().forEach(entry => { const entryPath = path.resolve(extractTo, entry.entryName); if (!entryPath.startsWith(extractTo)) { throw new Error(`Zip Slip detected: ${entry.entryName}`); } }); zip.extractAllTo(extractTo, true); res.send(`Extracted safely to ${extractTo}`); }Equivalent Python fix using
PyJWTandzipfile:import jwt, zipfile, os from pathlib import Path SECRET = os.getenv('JWT_SECRET') BASE = Path('/app/uploads').resolve() def safe_target(claim): # Allow only simple identifiers return bool(re.fullmatch(r'[A-Za-z0-9_-]+', claim)) def handle_request(auth_header, uploaded_file): token = auth_header.split()[1] try: payload = jwt.decode(token, SECRET, algorithms=['HS256']) except jwt.PyJWTError: raise ValueError('Invalid token') target = payload.get('target_path', '.') if not safe_target(target): raise ValueError('Unsafe target_path claim') dest = (BASE / target).resolve() if not str(dest).startswith(str(BASE)): raise ValueError('Path traversal blocked') with zipfile.ZipFile(uploaded_file) as zf: for info in zf.infolist(): member_path = (dest / info.filename).resolve() if not str(member_path).startswith(str(dest)): raise ValueError(f'Zip Slip: {info.filename}') zf.extractall(dest) return f'Extracted to {dest}'