Files
SandTools/walker/recover_key.py
DownloadPizza a44e4db1c3 refactor: group scripts into walker/ wikigen/ bundle/
Organize the 16 loose scripts by concern:
  walker/  -- .wbt save tooling (sand, build_wbt, walker_hashes,
              harvest_hashes, recover_key)
  wikigen/ -- MediaWiki page generators (make_*_wiki, render_wiki)
  bundle/  -- Unity/Odin asset extraction (unitybundle, odin_read,
              extract_*, loot_probe, dump_loot_bytes)

The only cross-script imports (build_wbt->walker_hashes,
extract_loot->odin_read) live within the same folder, so each
script's dir on sys.path[0] keeps them resolving with no code
changes. All data paths are absolute, so the moves don't affect
I/O. Named the code dir wikigen/ to avoid colliding with the
generated wiki/ output dir; ignore the regenerable wiki_site/ render.
2026-06-11 14:49:33 +02:00

69 lines
2.7 KiB
Python

#!/usr/bin/env python3
"""Recover the SAND .wbt XOR key after a game update changes it — no RE needed.
Known-plaintext crib: the walker icon (textureRawData) is RGBA8888 and its empty
space is the solid background pixel BG = 02 05 0D 00 (RGBA 2,5,13,0). The BSON
header before the pixel data is a CONSTANT size, so pixel byte 0 always lands at
decoded offset 0x2A:
4 (int32 BSON doc length)
+ 17 (textureSize field: 0x10 + "textureSize\0"(12) + int32(4))
+ 21 (textureRawData header: 0x05 + "textureRawData\0"(15) + int32 len(4) + subtype(1))
= 42 = 0x2A
Pixel (0,0) is the bottom-left of the image (Unity bottom-up), which is background
on a normal walker, so a long run of BG sits right at offset 0x2A — inside the
first 0xA000 cipher chunk, where the keystream residue is just i % keylen.
Recovery: for each i in [0x2A, 0xA000),
key[i % keylen] = enc[i] XOR BG[(i - 0x2A) % 4]
Majority-vote each residue (non-background pixels are the minority and wash out),
try keylen 6 first (current), then 1..16, and accept the key whose full decode
parses as BSON. Independent of whatever the new key bytes are.
Usage:
recover_key.py <wbt> [<wbt> ...] # print recovered key(s)
"""
import sys, gzip, struct
from collections import Counter
BG = bytes((0x02, 0x05, 0x0D, 0x00)) # background pixel, RGBA (2,5,13,0)
PIXOFF = 0x2A # fixed decoded offset of pixel byte 0
CHUNK = 0xA000
def recover(path, keylens=(6,) + tuple(range(1, 17))):
raw = gzip.decompress(open(path, 'rb').read())
end = min(CHUNK, len(raw))
tried = set()
for L in keylens:
if L in tried:
continue
tried.add(L)
votes = [Counter() for _ in range(L)]
for i in range(PIXOFF, end):
kb = raw[i] ^ BG[(i - PIXOFF) % 4]
votes[i % L][kb] += 1
if any(not v for v in votes):
continue
key = bytes(v.most_common(1)[0][0] for v in votes)
if _verifies(raw, key):
return key, L
return None, None
def _verifies(raw, key):
L = len(key)
dec = bytes(raw[i] ^ key[(i % CHUNK) % L] for i in range(len(raw)))
# cheap structural check: BSON doc length == total, and known field names present
if len(dec) < 8:
return False
if struct.unpack_from('<i', dec, 0)[0] != len(dec):
return False
return dec[5:16] == b'textureSize' and b'textureRawData' in dec[:64] and b'walker' in dec
if __name__ == '__main__':
files = sys.argv[1:] or sys.exit("usage: recover_key.py <wbt> ...")
for f in files:
key, L = recover(f)
if key:
print(f"{f.split('/')[-1][:8]} KEY = {key.hex().upper()} (keylen {L})")
else:
print(f"{f.split('/')[-1][:8]} FAILED (no BG run / unexpected layout)")