#!/usr/bin/env python3 """Answer the VT1200 ``dwt_prf`` TCP challenge (port 771) offline. The firmware sends 16 bytes with message flag 0x7F, reads 16 bytes back, and compares them. It does **not** compare our reply with the challenge directly: recv(session, frame+0x10, 16) ; our reply ntohl() each of the four words ; +0x10..+0x1f becomes swap4(reply) transform(frame+0x10, 16) ; 0x3F5A09C0, in place memcmp(frame+0x20, frame+0x10, 16) ; frame+0x20 = host copy of challenge so the reply the host must send is ``swap4(inverse_T(swap4(challenge)))``. ``transform`` is a bijection: a 16-round Feistel over each independent 8-byte record, so the inverse exists and is the same round function run backwards. (The firmware itself contains the inverse at 0x3F5AADA0; its output matched this module on every test vector.) Evidence and disassembly: dwt/notes/CHALLENGE_REVERSE.md. Pure stdlib; key and SP table are the firmware's own fixed values, captured from the firmware's initialiser (they are identical on every boot and on a live challenge - see the note). python3 dwt/challenge.py --challenge f32cec8d0000ead00263a8c0f324ec8e python3 dwt/challenge.py --selftest """ from __future__ import annotations import argparse import sys # --- Firmware constants --------------------------------------------------- # 8 round keys of 8 bytes used by the 16-round Feistel (key[15] first) and the # 8 x 64-entry SP tables (256 bytes each), both produced by the firmware's own # initialiser 0x3F5A9620(1) + 0x3F5AA2C0(0x1BE810). They are load-independent: # two fresh-NVRAM boots and a live TCP challenge gave identical bytes. KEY = bytes.fromhex("2C3E1F020130080D2D23281F080019120B333920171A00063227333C09041308370E1D18020B0107112D3C2B292C12203A3B250702003D030A1E3B0B272C00103B110B1C2010212E052D2B2C11092A10262725320410113D2B063D2D0239220030371C0D0006143512193F310A302204351F232B3004061323082E3E2D1A2400") SP = bytes.fromhex("0082800000000000008000000282800002808000028200000200000000800000000200000082800002828000000200000202800002808000000080000200000002020000000280000002800000820000008200000080800000808000020280000280000002008000020080000280000000000000020200000282000000008000008000000282800002000000008080000082800000008000000080000002000002808000008000000082000002008000000200000200000002028000028200000282800002800000008080000202800002008000020200000282000000828000020200000002800000028000000000000280000000820000000000000280800010400840004000400040000010400800000008001000000010000840104000401000004010400840004008400000004000400040000008001000000010000840004008001000080010400040000000000000004000400000104008000000084010000800100000400000000000400800104000000040084000000840104000000000000010400800100008400000080010400040000008400040084000400000000008400040004010000000104008401040080010000000004000000000004010400000004008400000080010000040100008001040004010000040100008000040080000000000004000401040000000000040100008401040084000400800040100000001010400000000040001040001000400000000040101000001000404000100040000040400000400000100040101040400010000000104040100000000000404000000000101040001000000010100000001040400010404010100040100040001010000000100040100040400000004010104000100000000000400010104000000040400010004010000000001000001010400010004000000000001000004000100040101040001000404000004000100000000000004000104040100040000010000000004040101040400000004010100000101000400000400000104040100040401000000000104040101000400000004000104000101000010408040100080401000804000000040104000400040800000408000100080000000000010400000104000401040804000008000000000400040000000408000000080001000000000400000104080400000000000400000100080401000004000408000000080401000004000400000100000401040004010408040000080400040000000408000104000401040804000008000000000000000000010400040100000400040004000408000000080001040804010008040100080400000004010408040000080000000800010000000004080001000804010400040004080001000804010000000004000001040804000000000004000001000004010400080000000800004010000040180000021000004008000000000000020000004018000042000000400800000018000042080000021000004218000040000000020000000010000042000000420000000008000002080000421800004218000000100000421800000200000000000000021800004010000000100000021800004000000040080000021800000000000000100000020000004018000002180000420800000010000002000000421800004018000042080000000000000010000042180000421800004000000002180000421000004010000000000000420000000218000040080000001800000200000040000000000000004208000040180000020080000100000201000200000082020100000201008000000082020100000200000200010082020000000200008000010080020000020001000000010082000000000000008002000082000100020000000202000082000100800000008002010080020100000000008202000002020100820000000202000002020100000001000200010080000000800201000202000082020100000200008200000080000100000200000200010000000100820000008000010082020100020200000002010082020000020201000000000080020100800000000200000000020100820200000200000080020000820001000000000002020100000001008002000082000100000100001001002010400020000000000040000010400020104100000041002010410020000100000000000010000020100000000000002010010020104000000040002010410000100100000040002010000020000100200041002010010000000100200040000010400000104100200041000010000000000000200041000000000020004100000001000010400020104000201001002010010020100000001001000000000020004000200001000000410020104000001041000000410020104000001000002010410020000100200041000000000000100000001041002000000000104100000001002000400000100000200040002000400000100100020080008000800000000020020080208000000082008000820000000000000082000020000000208200802080008020000080208200802000008000020000000000002082000000800080008200800000008020020000200200002080008020820080000000000000000000020000208200000080008000820080200000002002008020000000200000802080008000020000000200002080008000020080200000800082000000020000008000002082000020800000008000002002008000800000000200802082000020020000008000002080008000820080008000000002008020800080200000802002008000020080000200002000000000800080208") assert len(KEY) == 128 and len(SP) == 8 * 256 def _round(x: int, k: bytes) -> int: """F(x, k): the firmware's round function 0x3F5ACEC0 (Ghidra 0x07EB59D8). Eight 6-bit table indices formed from the 32-bit input and the 8-byte round key; each selects a 32-bit word from its own 64-entry table; the eight words are OR'd together. Indices, from the disassembly (a2 = rol32(x, 1)): i0 = key[7] ^ rol1(x) i4 = key[3] ^ (x >> 15) i1 = key[6] ^ (x >> 3) i5 = key[2] ^ (x >> 19) i2 = key[5] ^ (x >> 7) i6 = key[1] ^ (x >> 23) i3 = key[4] ^ (x >> 11) i7 = key[0] ^ (x >> 27 | (x & 1) << 5) Table i8-i uses SP[(7-i)*256 + index*4 .. +4] (little-endian 32-bit word). """ x &= 0xFFFFFFFF a2 = ((x << 1) | (x >> 31)) & 0xFFFFFFFF idx = ( (k[7] ^ a2) & 0x3F, (k[6] ^ (x >> 3)) & 0x3F, (k[5] ^ (x >> 7)) & 0x3F, (k[4] ^ (x >> 11)) & 0x3F, (k[3] ^ (x >> 15)) & 0x3F, (k[2] ^ (x >> 19)) & 0x3F, (k[1] ^ (x >> 23)) & 0x3F, (k[0] ^ ((x >> 27) | ((x & 1) << 5))) & 0x3F, ) out = 0 for i, ix in enumerate(idx): o = (7 - i) * 0x100 + ix * 4 out |= int.from_bytes(SP[o:o + 4], "little") return out def _forward_record(rec: bytes) -> bytes: """One 8-byte record: 0x3F5ABA40 (Ghidra 0x07EB5748). The two 32-bit halves are read as big-endian values (the firmware's ``bswap`` helper 0x3F5AE670 after a little-endian load), then 16 Feistel rounds run with keys key[15], key[14], ..., key[0]: round 0: L ^= F(R, k15) round 1: R ^= F(L, k14) ... The result is written back big-endian as (R_final, L_final), because the firmware stores the a9 half first. """ if len(rec) != 8: raise ValueError("record must be 8 bytes") left = int.from_bytes(rec[0:4], "big") right = int.from_bytes(rec[4:8], "big") for n in range(16): k = KEY[(15 - n) * 8:(16 - n) * 8] if n % 2 == 0: left ^= _round(right, k) else: right ^= _round(left, k) return right.to_bytes(4, "big") + left.to_bytes(4, "big") def _inverse_record(rec: bytes) -> bytes: """Inverse of _forward_record; matches firmware 0x3F5AADA0 exactly.""" if len(rec) != 8: raise ValueError("record must be 8 bytes") left = int.from_bytes(rec[4:8], "big") right = int.from_bytes(rec[0:4], "big") for n in range(15, -1, -1): k = KEY[(15 - n) * 8:(16 - n) * 8] if n % 2 == 0: left ^= _round(right, k) else: right ^= _round(left, k) return left.to_bytes(4, "big") + right.to_bytes(4, "big") def transform(data: bytes) -> bytes: """Apply the firmware transform to a 16-byte memory buffer (in place there). Records are independent: bytes 0..7 and 8..15 are transformed separately. """ if len(data) != 16: raise ValueError("transform takes exactly 16 bytes") return _forward_record(data[0:8]) + _forward_record(data[8:16]) def invert(data: bytes) -> bytes: """Inverse of transform() on a 16-byte buffer.""" if len(data) != 16: raise ValueError("invert takes exactly 16 bytes") return _inverse_record(data[0:8]) + _inverse_record(data[8:16]) def swap4(data: bytes) -> bytes: """Reverse the bytes of each 32-bit word (the firmware's ntohl on LE).""" if len(data) % 4: raise ValueError("length must be a multiple of 4") return b"".join(data[i:i + 4][::-1] for i in range(0, len(data), 4)) def respond(challenge: bytes) -> bytes: """Return the 16 bytes to put on the wire for ``challenge`` (wire order). The firmware compares ``transform(swap4(reply))`` with ``swap4(challenge)``, so the required reply is ``swap4(invert(swap4(challenge)))``. """ if len(challenge) != 16: raise ValueError("challenge is 16 bytes") return swap4(invert(swap4(challenge))) def check_reply(challenge: bytes, reply: bytes) -> bool: """Emulate the firmware side: True iff this reply passes the challenge.""" return transform(swap4(reply)) == swap4(challenge) # Known answers: (16-byte input to transform, 16-byte output), produced by # calling 0x3F5A09C0 itself in MAME. See dwt/evidence/oracle-*.log. _KATS = ( ("00" * 16, "90DE09B472855817" "90DE09B472855817"), ("FF" * 16, "6A81040BF2294479" "6A81040BF2294479"), ) def selftest() -> int: for inp, want in _KATS: got = transform(bytes.fromhex(inp)).hex().upper() if got != want: print(f"FAIL transform({inp}) = {got}, want {want}") return 1 back = invert(bytes.fromhex(want)).hex().upper() if back != inp: print(f"FAIL invert({want}) = {back}, want {inp}") return 1 # round-trip on a spread of values for v in range(256): rec = v.to_bytes(4, "big") * 2 if _inverse_record(_forward_record(rec)) != rec: print(f"FAIL round-trip {v}") return 1 # a random challenge: the reply must satisfy the firmware's comparison import os for _ in range(1000): c = os.urandom(16) if not check_reply(c, respond(c)): print("FAIL respond/check_reply") return 1 print("selftest OK: KATs, 256 round-trips, 1000 challenge/reply pairs") return 0 def main(argv=None) -> int: ap = argparse.ArgumentParser(description="VT1200 dwt_prf challenge helper") g = ap.add_mutually_exclusive_group() g.add_argument("--challenge", metavar="HEX", help="16-byte wire challenge; prints the reply to send") g.add_argument("--transform", metavar="HEX", help="forward transform of 16 bytes") g.add_argument("--invert", metavar="HEX", help="inverse transform of 16 bytes") g.add_argument("--check", nargs=2, metavar=("CHALLENGE", "REPLY"), help="emulate the firmware comparison") g.add_argument("--selftest", action="store_true") a = ap.parse_args(argv) if a.selftest: return selftest() if not any((a.challenge, a.transform, a.invert, a.check)): ap.print_help() return 2 try: if a.challenge: print(respond(bytes.fromhex(a.challenge)).hex()) elif a.transform: print(transform(bytes.fromhex(a.transform)).hex()) elif a.invert: print(invert(bytes.fromhex(a.invert)).hex()) elif a.check: ok = check_reply(bytes.fromhex(a.check[0]), bytes.fromhex(a.check[1])) print("PASS" if ok else "FAIL") return 0 if ok else 1 except ValueError as e: print(f"error: {e}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main())