agentggagentgg
Back to all findings
CRITICALconfirmedzip-slipzip-slipc8db72b2d9e2

Zip Slip in handleZipFileUpload via broken containment check

The ZIP extractor writes entries to `uploads/complaints/<entry.path>` after a broken `absolutePath.includes(path.resolve('.'))` containment check, allowing an attacker-controlled archive entry name like `../../ftp/legal.md` to overwrite arbitrary files inside the project tree.

Fileroutes/fileUpload.ts
Lines4049
Confidence
98%
File statusvalidated
Details

In handleZipFileUpload the per-entry handler treats the archive's internal filename as trusted:

.on('entry', function (entry: any) {
  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()
  }
})

Two issues compound into a classic Zip Slip:

  1. The containment check absolutePath.includes(path.resolve('.')) checks whether the resolved path's string contains the current working directory, not whether it startsWith(destAbs + path.sep). Since path.resolve('.') returns the process CWD (typically the parent of uploads/complaints/), any traversal that lands somewhere inside the CWD — e.g. ../../ftp/legal.md, ../../config/secrets.json, ../../package.json — still satisfies the check and is allowed through.
  1. The write target is built by string concatenation 'uploads/complaints/' + fileName, not from absolutePath. With fileName = '../../ftp/legal.md', Node opens uploads/complaints/../../ftp/legal.md, i.e. ftp/legal.md, completely outside the intended uploads/complaints/ directory.

The solveIf line — absolutePath === path.resolve('ftp/legal.md') — explicitly acknowledges this as the intended exploit path (the OWASP Juice Shop fileWriteChallenge). There is no startsWith(destAbs + path.sep) check, no realpath verification, and unzipper does not sanitize entry names by default.

This matches the prompt's true-positive example verbatim (the broken includes(path.resolve('.')) shape).

Proof of concept
  1. Craft a ZIP archive containing an entry whose internal name is ../../ftp/legal.md (or ../../etc/cron.d/payload if the deployment runs as root with a writable cron path). For instance, with Python:
import zipfile
with zipfile.ZipFile('evil.zip', 'w') as z:
    z.writestr('../../ftp/legal.md', b'PWNED')
  1. Upload evil.zip to the file-upload endpoint that routes through handleZipFileUpload (e.g. POST /file-upload with multipart/form-data).
  2. The extractor resolves absolutePath to <cwd>/ftp/legal.md. Because that string contains path.resolve('.') (the CWD), the if passes, and fs.createWriteStream('uploads/complaints/../../ftp/legal.md') overwrites ftp/legal.md with the attacker's content.
  3. Any file reachable via .. from uploads/complaints/ and still under the CWD (or whose absolute path string happens to contain the CWD substring) can be overwritten — including source files, configuration, web roots, or SSH/cron paths depending on deployment.
Impact

Unauthenticated (or low-privileged) attacker who can hit the ZIP upload endpoint achieves arbitrary file write inside the application's working directory and any directory reachable via .. whose absolute path contains the CWD string. In Juice Shop this is the documented fileWriteChallenge; in real deployments it commonly escalates to RCE via overwriting JS source, config files, web roots, cron drop-ins, or ~/.ssh/authorized_keys when the process has the necessary permissions.

Validation
confirmed

The containment check absolutePath.includes(path.resolve('.')) compares against the CWD rather than path.resolve('uploads/complaints'), and the write target 'uploads/complaints/' + fileName is unsanitized string concatenation. An entry name like ../../ftp/legal.md resolves to <cwd>/ftp/legal.md, which still contains the CWD string so the check passes, and fs.createWriteStream then writes outside uploads/complaints/. The solveIf line targeting path.resolve('ftp/legal.md') confirms this is the intended exploit path, and per scope this must be treated as a real production bug.

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Base score: 9.8 · CRITICAL

The ZIP upload endpoint is reachable over HTTP and the handleZipFileUpload function shows no authentication or authorization checks gating the sink, so a remote unauthenticated attacker can trigger extraction (AV:N, PR:N, UI:N, AC:L). The broken absolutePath.includes(path.resolve('.')) check combined with concatenated write target 'uploads/complaints/' + fileName lets an attacker write any path under (or string-containing) the CWD via ../ entries — directly compromising integrity (H), and in a Node.js app this trivially escalates to RCE by overwriting JS source/config/cron/authorized_keys, which then exposes all on-box secrets (C:H) and lets the attacker crash or wipe the service (A:H). Scope remains Unchanged because the resulting code execution runs under the same Node.js process's security authority rather than crossing into a distinct component.

References