mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
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.
226 lines
13 KiB
Python
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())
|