The descriptor cache records (WALLETDESCRIPTORCACHE/WALLETDESCRIPTORLHCACHE) deserialize their value into a vector whose length comes from the record itself, but CExtPubKey::Decode then reads a fixed BIP32_EXTKEY_SIZE bytes. A record encoding a shorter xpub makes Decode read past the vector (caught as a container-overflow under ASAN). Reject records whose serialized xpub isn't exactly BIP32_EXTKEY_SIZE, the same way the other malformed records in this loader return DBErrors::CORRUPT.
wallet: check descriptor cache xpub length before decoding #35440
pull alhudz wants to merge 1 commits into bitcoin:master from alhudz:walletdb-xpub-size-check changing 2 files +53 −0-
alhudz commented at 9:40 AM on June 2, 2026: none
- DrahtBot added the label Wallet on Jun 2, 2026
-
DrahtBot commented at 9:41 AM on June 2, 2026: contributor
<!--e57a25ab6845829454e8d69fc972939a-->
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.
<!--006a51241073e994b41acfe9ec718e94-->
Code Coverage & Benchmarks
For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35440.
<!--021abf342d371248e50ceaed478a90ca-->
Reviews
See the guideline and AI policy for information on the review process. A summary of reviews will appear here.
<!--174a7506f384e20aa4161008e828411d-->
Conflicts
No conflicts as of last run.
<!--5faf32d7da4f0f540f40219e4f7537a3-->
LLM Linter (✨ experimental)
Possible places where named args for integral literals may be used (e.g.
func(x, /*named_arg=*/0)in C++, andfunc(x, named_arg=0)in Python):- [WalletDescriptor(descriptor, 0, 0, 0, 0)] in
src/wallet/test/walletload_tests.cpp
<sup>2026-07-20 14:03:37</sup>
- [WalletDescriptor(descriptor, 0, 0, 0, 0)] in
-
winterrdog commented at 1:56 PM on June 2, 2026: contributor
is there a reason as to why no tests were needed for these changes ?
-
maflcko commented at 3:24 PM on June 2, 2026: member
Was this LLM generated? What are the steps to test this? What is the output before and after the changes here?
ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86
-
alhudz commented at 5:28 PM on June 2, 2026: none
No, it's not LLM-generated. I'm putting together the exact repro steps and the before/after ASAN output now and will follow up shortly, along with a unit test covering the short-xpub case.
- DrahtBot added the label CI failed on Jun 5, 2026
- maflcko closed this on Jun 9, 2026
- maflcko reopened this on Jun 9, 2026
- DrahtBot removed the label CI failed on Jun 10, 2026
- alhudz force-pushed on Jun 13, 2026
-
alhudz commented at 7:01 AM on June 29, 2026: none
Steps and before/after, sorry for the delay.
Build with the sanitisers the ASan CI job uses and run the new case:
cmake -B build -DSANITIZERS=address,undefined cmake --build build --target test_bitcoin build/bin/test_bitcoin --run_test=walletload_tests/wallet_load_descriptor_cache_invalid_xpub_sizeThe test writes a descriptor plus a single cache record whose serialised xpub is one byte short of
BIP32_EXTKEY_SIZE(73 bytes), for bothwalletdescriptorcacheandwalletdescriptorlhcache.expected: the loader rejects the record and returnsDBErrors::CORRUPT.before: the record deserialises fine (ser_xpub.size() == 73), thenCExtPubKey::Decode(ser_xpub.data())reads a fixed 74 bytes, one past the end of the vector. ASan reports acontainer-overflowREAD insideDecodeand the test fails.after: theser_xpub.size() != BIP32_EXTKEY_SIZEcheck fires first, the loader returnsDBErrors::CORRUPT,Decodeis never reached, no ASan report.
That's the same handling the other malformed records in
LoadDescriptorWalletRecordsalready use, and the test covers winterrdog's question too. -
alhudz commented at 10:31 AM on July 9, 2026: none
any update?
-
in src/wallet/test/walletload_tests.cpp:98 in 6b3e07dbfe
92 | @@ -91,5 +93,70 @@ BOOST_FIXTURE_TEST_CASE(wallet_load_descriptors, TestingSetup) 93 | } 94 | } 95 | 96 | +BOOST_FIXTURE_TEST_CASE(wallet_load_descriptor_cache_invalid_xpub_size, TestingSetup) 97 | +{ 98 | + // A descriptor cache record stores a serialized extended public key whose length is
achow101 commented at 5:04 PM on July 9, 2026:Tests do not need this much commentary.
alhudz commented at 2:03 PM on July 20, 2026:Good point, trimmed the comments back to a two-line summary of the intent.
achow101 commented at 5:04 PM on July 9, 2026: memberany update?
Review will happen when it happens. Do not repeatedly comment in a pr asking for more review.
wallet: check descriptor cache xpub length before decoding c6076d9100in src/wallet/test/walletload_tests.cpp:154 in 6b3e07dbfe
149 | + std::unique_ptr<WalletDatabase> database = make_db_with_short_cache_xpub("walletdescriptorlhcache"); 150 | + bool found = false; 151 | + DebugLogHelper log_helper("descriptor last hardened cache xpub has invalid size", [&](const std::string* s) { 152 | + found = true; 153 | + return false; 154 | + });
maflcko commented at 2:09 PM on July 14, 2026:This should just use
ASSERT_DEBUG_LOG("descriptor last hardened cache xpub has invalid size");?
alhudz commented at 2:03 PM on July 20, 2026:Makes sense, switched both blocks to
ASSERT_DEBUG_LOGand dropped the manualfoundflag.
winterrdog commented at 9:27 PM on July 20, 2026:should just use
ASSERT_DEBUG_LOGminor follow-up: now that
ASSERT_DEBUG_LOGis being used here, it looks likewallet_load_descriptors, just above, could also be simplified in the same way. it currently uses aDebugLogHelperplus a manualfoundflag just to assert that the expected message was logged.refactoring it to
ASSERT_DEBUG_LOG(...)would make the test a bit cleaner and consistent with the newer cases (like this one)<details><summary>a diff that shows how it can be done whenever that time rolls around</summary>
diff --git a/src/wallet/test/walletload_tests.cpp b/src/wallet/test/walletload_tests.cpp index 905b1b1b13..4ee0561e4c 100644 --- a/src/wallet/test/walletload_tests.cpp +++ b/src/wallet/test/walletload_tests.cpp @@ -70,13 +70,6 @@ BOOST_FIXTURE_TEST_CASE(wallet_load_descriptors, TestingSetup) // As the software produces another ID for the descriptor, the loading process must be aborted. database = CreateMockableWalletDatabase(); - // Verify the error - bool found = false; - DebugLogHelper logHelper("The descriptor ID calculated by the wallet differs from the one in DB", [&](const std::string* s) { - found = true; - return false; - }); - { // Write valid descriptor with invalid ID WalletBatch batch(*database); @@ -88,8 +81,8 @@ BOOST_FIXTURE_TEST_CASE(wallet_load_descriptors, TestingSetup) { // Now try to load the wallet and verify the error. const std::shared_ptr<CWallet> wallet(new CWallet(m_node.chain.get(), "", std::move(database))); + ASSERT_DEBUG_LOG("The descriptor ID calculated by the wallet differs from the one in DB"); BOOST_CHECK_EQUAL(wallet->PopulateWalletFromDB(_error, _warnings), DBErrors::CORRUPT); - BOOST_CHECK(found); // The error must be logged } }</details>
alhudz commented at 9:23 AM on July 23, 2026:Good idea, that older case could take the same
ASSERT_DEBUG_LOGtreatment. I'd keep this PR to the cache fix though and pick that cleanup up separately so the diff stays focused.alhudz force-pushed on Jul 20, 2026winterrdog commented at 9:33 PM on July 20, 2026: contributorwhile reviewing this PR's changes and the surrounding code,
CExtPubKey'sEncode/Decode(and by extensionEncodeWithVersion/DecodeWithVersion) stood out to me.also from reading this comment:
ASan reports a container-overflow READ inside Decode
so, since we know that a serialised
CExtPubKeyis always exactlyBIP32_EXTKEY_SIZEbytes, would it make sense for those interfaces to takestd::span<const unsigned char, BIP32_EXTKEY_SIZE>instead of raw pointers (preferably in a follow-up PR)? the runtime size check after deserialisation (as added in this PR) would still be needed, but the API would:- encode the fixed-size contract in the type,
- make the expected buffer size explicit at every call site, and
- make accidental misuse harder in the future
has something like that been considered before, or is there a reason to keep these interfaces pointer-based (like exposing it through a C API interface for FFI) ?
:thinking: ..
achow101 commented at 8:14 PM on July 22, 2026: memberIt would probably be better to change
CExtKeyandCExtPubKeyde/serialization to match the pattern that we do in the rest of the codebase. These are the only de/serialization functions that take an output parameter like this.alhudz commented at 9:23 AM on July 23, 2026: noneAgreed,
Encode/Decodewriting into a caller buffer is the odd one out here. I'll addSerialize/UnserializetoCExtKey/CExtPubKeyand route the cache reads and writes through them.One constraint so existing wallets keep loading: the cache value is stored on disk as a length-prefixed
std::vector<unsigned char>(WriteIC(..., ser_xpub)andvalue >> ser_xpub), not a bare 74-byte record. I'll keep that wrapper and (de)serialise the fixed-size key into and out of it rather than writing the extkey straight to the record, so the on-disk format doesn't change and the short read this PR guards against gets caught by the deserialise itself. Shout if you'd sooner change the record format.The
std::span<const unsigned char, BIP32_EXTKEY_SIZE>idea @winterrdog raised is subsumed by this, since the stream methods pin the length at every call site.winterrdog commented at 12:05 AM on July 25, 2026: contributorIt would probably be better to change
CExtKeyandCExtPubKeyde/serialization to match the pattern that we do in the rest of the codebase.yes, even better! my initial idea was just around having some sort of bounds & strict checks to proactively avoid similar mistakes coming up again in the future
The
std::span<const unsigned char, BIP32_EXTKEY_SIZE>idea@winterrdograised is subsumed by thiscorrect!
SERIALIZE_METHODSgets us most of the way there -- a short record will fail the bounded read instead of silently overrunning a raw pointer. it will still need atry/catcharound the cache loads (same pattern as the existingWALLETDESCRIPTORdeserialise) to turn that into a cleanDBErrors::CORRUPTrather than an uncaught exceptionOne constraint so existing wallets keep loading: the cache value is stored on disk as a length-prefixed
std::vector<unsigned char>(WriteIC(..., ser_xpub)andvalue >> ser_xpub), not a bare 74-byte record. I'll keep that wrapper and (de)serialise the fixed-size key into and out of it rather than writing the extkey straight to the record, so the on-disk format doesn't changeagreed!
I'll add
Serialize/UnserializetoCExtKey/CExtPubKeyand route the cache reads and writes through themsounds ok. i would guess all of this happens in a follow-up PR given the scope
achow101 commented at 1:14 AM on July 27, 2026: membersounds ok. i would guess all of this happens in a follow-up PR given the scope
It should happen in this PR, or an alternative, but this PR as is now is unlikely to be merged.
This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-08-01 07:50 UTC
More mirrored repositories can be found on mirror.b10c.me