Files
vt1200/dwt/challenge.py
SDD 1e5c095b72 dwt: reverse the dwt_prf challenge; remote poke/jump now works
The firmware transforms the reply (16-round Feistel per 8-byte record) and
compares it with the host-order copy of the challenge, so the answer is
swap4(inverse_transform(swap4(challenge))).  The font stages are memcpy in
this path because dwt_prf initialises only mode 1.

challenge.py implements the transform and its inverse using the firmware's
fixed key/SP table; solve.py answers the challenge over TCP using the
unmodified exploit client.  320 forward vectors, the firmware's own inverse
and three live sessions validate it.  A live run with no breakpoints poked
the 82-byte hello-world payload and ran it: MAGIC DEAD/BEEF/0400, VRAM
1024/1024.  Notes and evidence under dwt/notes and dwt/evidence.
2026-10-18 02:36:23 +03:00

226 lines
13 KiB
Python

#!/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())