ZIP entries extracted to disk using attacker-controlled paths (weak zip-slip guard)
handleZipFileUpload writes each archive entry to `uploads/complaints/<entry.path>` after a containment check that compares `absolutePath.includes(path.resolve('.'))`, which is bypassable and still allows writing anywhere under the project root.
const fileName = entry.path
const absolutePath = path.resolve('uploads/complaints/' + fileName)
challengeUtils.solveIf(challenges.fileWriteChallenge, () => { return absolutePath === path.resolve('ftp/legal.md') })
if (absolutePath.includes(path.resolve('.'))) {
entry.pipe(fs.createWriteStream('uploads/complaints/' + fileName).on('error', function (err) { next(err) }))
} else {
entry.autodrain()
}
Problems:
- The containment check uses
String.prototype.includeson the resolved absolute path.path.resolve('.')returns the current working directory (e.g./app). An entry such as../../../app/ftp/legal.mdresolves to/app/ftp/legal.md, which contains/appand therefore passes the check — confirmed by the explicitfileWriteChallengesolver that detects exactly this overwrite offtp/legal.md. The check is not equivalent toabsolutePath.startsWith(path.resolve('uploads/complaints/') + path.sep). - The original ZIP filename (
file.originalname.toLowerCase()) is also used to name the tmp file inos.tmpdir()without sanitisation — a craftedoriginalnamecontaining path separators can be written outside the temp directory on some platforms. - There is no size limit on the uploaded ZIP, no entry-count limit, no compressed/uncompressed-ratio check — zip-bomb DoS is unmitigated.
- The middleware does not authenticate the caller.
Create archive evil.zip containing one entry whose path is ../../../app/ftp/legal.md (or any sibling directory of uploads/complaints/). POST it to the upload endpoint. The containment check resolves to /app/ftp/legal.md, which still .includes('/app'), so the stream writes attacker content to ftp/legal.md, overwriting the served legal notice file. (This is the very behaviour the fileWriteChallenge solver in this code rewards.)
Unauthenticated attackers can overwrite arbitrary files inside the application root via zip-slip, leading to defacement or, when overwriting a file that is later executed/required, remote code execution. Also enables disk-fill DoS via zip bombs since no size or extraction-ratio limits exist.
The check absolutePath.includes(path.resolve('.')) is a substring test against the cwd, so any path that resolves under the project root (e.g. ../../../app/ftp/legal.md → /app/ftp/legal.md) passes and is then written via fs.createWriteStream('uploads/complaints/' + fileName), escaping the uploads directory. The in-code solver absolutePath === path.resolve('ftp/legal.md') confirms the exact zip-slip target. A proper guard would be absolutePath.startsWith(path.resolve('uploads/complaints/') + path.sep). Exploit chain is reachable from an unauthenticated multipart upload of a crafted zip.
The handleZipFileUpload middleware is reachable over HTTP with no authentication check in this file (PR:N, AV:N, UI:N), and exploitation only requires crafting a ZIP with a ../../../app/... entry — the bypassable absolutePath.includes(path.resolve('.')) guard makes this trivial (AC:L), as confirmed by the built-in fileWriteChallenge solver that rewards overwriting ftp/legal.md. Worst plausible exploitation: writing attacker-controlled content to any file under the project root (e.g., a required JS module, config, or static asset) yields code execution within the same Node.js process, giving full read/write of application data and process disruption (C:H/I:H/A:H); the lack of size, entry-count, and ratio limits independently enables zip-bomb DoS reinforcing A:H. Scope stays Unchanged because the impact remains within the Node.js application's own security authority — there is no sandbox or cross-origin boundary being crossed by the file write itself.
- CWE-22
- CWE-434
- CWE-409