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.
# A Dangerous Loophole in Popular File-Sharing Software
PsiTransfer is a tool that lets organizations run their own private file-sharing service, similar to Dropbox but hosted on your own servers. Think of it like running your own mailroom instead of using a commercial shipping company. Recently, researchers discovered a serious security flaw that lets attackers break in without needing any password or login credentials.
Here's how the trick works. When you upload a file, the system checks the file path twice using different methods. The first check looks at an encoded version of the path (imagine a secret code), while the actual upload uses the decoded version (the real path). An attacker can exploit this mismatch to sneak malicious code past the security checks, like using a hidden passageway that the guard doesn't know about.
Once inside, the attacker can write fake configuration files containing malicious code. When the software restarts, it automatically loads these files, giving the attacker complete control over the server. This is particularly dangerous because the attacker doesn't need credentials — anyone on the internet can attempt this.
Who should worry? Organizations using PsiTransfer for sensitive file sharing face the biggest risk, including healthcare providers, law firms, and companies handling confidential data. If your organization uses this software, attackers could steal everything you've uploaded or use your servers to launch attacks elsewhere.
What should you do? First, update PsiTransfer to version 2.4.3 or newer immediately. Second, if you can't update right away, temporarily restrict access to your PsiTransfer server to trusted networks only. Third, check your server logs for unusual activity or suspicious file uploads from the past week.
Want the full technical analysis? Click "Technical" above.
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.
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.