The read-only BDB parser reads a btree page by walking its index array and
reading the record each entry points at, keeping a copy of every one
(RecordsPage::Unserialize and InternalPage::Unserialize in migrate.cpp).
The only thing checked about an index is that it does not point before the
running cursor. Nothing stops several entries from pointing at the same record.
The cursor only advances by the two bytes of the index just read, so all of a
page's entries can hold the same offset, and the parser reads that one record
once per entry and keeps every copy. entries and the record length are both
16-bit fields, so a single ~64 KB page can be read into entries * len bytes.
A 256 KB file takes a node from 54 MB to ~800 MB during migratewallet, and it
succeeds rather than erroring. Distinct such pages add up, so a few-MB file
OOMs the process.
This is not caught by #34959 (btree levels and overflow lengths) or #35990
(revisited pages), because it happens within a single page, with no cycle or
repeated page. It is the same hostile-wallet-file case #34959 set out to
harden: migratewallet, loadwallet of a legacy .dat, and bitcoin-wallet.
In a valid page the records do not overlap and so cannot be larger than the page. This tracks their total size while reading and rejects a page whose records do not fit, which bounds what one page can allocate to its own size. Nothing that parses today is affected. The two rejections are:
Data records exceed page size # leaf page
Internal records exceed page size # internal page
Testing
A regression test in db_tests.cpp hand-crafts such a page and checks the parser
rejects it; it fails without this change. Core cannot write a BDB file, so the
page bytes are laid out directly, the same reason #34959 was only fuzz-tested.
The two new error strings are also added to the wallet_bdb_parser fuzz
allow-list, and a page packed with many distinct records still parses, so the
bound does not reject a legitimately full page.