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.
This commit is contained in:
SDD committed 2026-10-18 02:36:23 +03:00
1 parent 041c82cb73
commit 1e5c095b72
22 files changed
+1351

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
-- live_check.lua -- verification with NO breakpoints and no memory writes.
--
-- This script only reads RAM/VRAM once per frame. There is no bpset anywhere,
-- so the exploit cannot be attributed to any debugger action: the only thing
-- the emulator does is report what the firmware and the payload left in memory.
local machine = manager.machine
local cpu = machine.devices[':maincpu']
local dbg = machine.debugger
local space = cpu.spaces['program']
if not dbg then machine:exit(); return end
dbg.visible_cpu = cpu
local MAGIC1 = 0x01810000
local MAGIC2 = 0x01810010
local COUNT = 0x01810020
local VRAM = 0x04019040
local f = 0
local done_at = nil
local last = nil
emu.register_frame_done(function()
f = f + 1
if f % 500 == 0 then
print(string.format("CHECK frame %d MAGIC1=%04X MAGIC2=%04X COUNT=%04X",
f, space:read_u16(MAGIC1), space:read_u16(MAGIC2), space:read_u16(COUNT)))
end
if done_at == nil and space:read_u16(MAGIC1) == 0xDEAD then
done_at = f
local filled, holes = 0, 0
for r = 0, 63 do
for c = 0, 15 do
if space:read_u16(VRAM + r * 1024 + c * 16) == 0xFFFF then
filled = filled + 1
else
holes = holes + 1
end
end
end
print(string.format("CHECK payload-ran: MAGIC1=%04X MAGIC2=%04X COUNT=%04X",
space:read_u16(MAGIC1), space:read_u16(MAGIC2), space:read_u16(COUNT)))
print(string.format("CHECK payload-ran: VRAM block %d/1024 words FFFF, %d holes",
filled, holes))
end
if done_at and f >= done_at + 120 then
print(string.format("CHECK exiting at frame %d (payload seen at %d)", f, done_at))
machine:exit()
end
if f >= 12000 then
print("CHECK timeout: no payload markers")
machine:exit()
end
end, "vt1200-live-check")
dbg.execution_state = 'run'
+72
View File
@@ -0,0 +1,72 @@
-- live_verify.lua -- passive verification of the remote challenge bypass.
--
-- No breakpoint here writes a register or memory: it only prints. In
-- particular the memcmp at 3F5A0920 is *observed*, never forced.
local machine = manager.machine
local cpu = machine.devices[':maincpu']
local dbg = machine.debugger
local space = cpu.spaces['program']
if not dbg then machine:exit(); return end
dbg.visible_cpu = cpu
local D = "/home/arti/code/k-space/vt1200/dwt/evidence/live-run"
local MAGIC1 = 0x01810000
local MAGIC2 = 0x01810010
local COUNT = 0x01810020
local VRAM = 0x04019040
local f = 0
local armed = false
local saw_challenge = false
local saw_memcmp = false
local done_at = nil
local function report_magic(tag)
print(string.format("LIVE2 %s: MAGIC1=%04X MAGIC2=%04X COUNT=%04X",
tag, space:read_u16(MAGIC1), space:read_u16(MAGIC2), space:read_u16(COUNT)))
local filled, holes = 0, 0
for r = 0, 63 do
for c = 0, 15 do
if space:read_u16(VRAM + r * 1024 + c * 16) == 0xFFFF then
filled = filled + 1
else
holes = holes + 1
end
end
end
print(string.format("LIVE2 %s: VRAM block %d/1024 words FFFF, %d holes", tag, filled, holes))
end
emu.register_frame_done(function()
f = f + 1
if f == 1 and not armed then
armed = true
dbg:command('bpset 3F58AD60,,{ printf "LIVE2 accept_debug_conn entry pc=%08X\\n", pc ; g }')
dbg:command('bpset 3F59F320,,{ printf "LIVE2 dispatcher entry pc=%08X\\n", pc ; g }')
dbg:command('bpset 3F59FDC0,,{ printf "LIVE2 dwt_prf challenge entry pc=%08X\\n", pc ; g }')
dbg:command('bpset 3F5A0200,,{ printf "LIVE2 challenge sent a13=%08X wire=%08X.%08X.%08X.%08X host=%08X.%08X.%08X.%08X\\n", a13, d@(a13), d@(a13+0x20), d@(a13+0x40), d@(a13+0x60), d@(a13+0x100), d@(a13+0x120), d@(a13+0x140), d@(a13+0x160) ; g }')
dbg:command('bpset 3F5A0740,,{ printf "LIVE2 pre-transform reply=%08X.%08X.%08X.%08X\\n", d@(a13+0x80), d@(a13+0xa0), d@(a13+0xc0), d@(a13+0xe0) ; g }')
dbg:command('bpset 3F5A08E0,,{ printf "LIVE2 memcmp-args expecting=%08X.%08X.%08X.%08X got=%08X.%08X.%08X.%08X\\n", d@(a9), d@(a9+0x20), d@(a9+0x40), d@(a9+0x60), d@(a11), d@(a11+0x20), d@(a11+0x40), d@(a11+0x60) ; g }')
-- OBSERVE ONLY: print the comparison result; never assign to a8.
dbg:command('bpset 3F5A0920,,{ printf "LIVE2 challenge memcmp a8=%08X (0=equal)\\n", a8 ; g }')
dbg:command('bpset 3F5A1B50,,{ printf "LIVE2 rdebug_poke entry pc=%08X\\n", pc ; g }')
dbg:command('bpset 3F5A3890,,{ printf "LIVE2 rdebug_jump entry pc=%08X\\n", pc ; g }')
print("LIVE2 armed read-only breakpoints")
end
if f % 500 == 0 then
print(string.format("LIVE2 frame %5d word@0xAECD0=%04X field2..3=%d",
f, space:read_u16(0xaecd0), (space:read_u16(0xaecd0) >> 2) & 3))
end
if done_at == nil and space:read_u16(MAGIC1) == 0xDEAD then
done_at = f
report_magic("payload-ran")
end
if done_at and f >= done_at + 120 then
print(string.format("LIVE2 exiting at frame %d (payload seen at %d)", f, done_at))
machine:exit()
end
if f >= 9000 then
print("LIVE2 timeout: no payload markers")
machine:exit()
end
end, "vt1200-live-verify")
dbg.execution_state = 'run'
+197
View File
@@ -0,0 +1,197 @@
-- oracle_call.lua -- drive the firmware's own dwt_prf transform from the
-- debugger, with no TCP connection.
--
-- At frame 1000:
-- 0x3F5A9620(1) dwt_prf's allocator/init (Ghidra 0x07EB52C4)
-- 0x3F5AA2C0(0x1BE810) key schedule from the boot-time block
-- then, for each vector in VECTORS:
-- 0x3F5A09C0(BUF, 16) the transform dwt_prf applies to the reply
-- and prints the 16 output bytes.
--
-- Optionally (INVERSE=1) it calls 0x3F5AADA0 (the per-record companion that
-- looks like the inverse) on the vectors instead.
--
-- Output lines: ORACLE <inhex> <outhex>
local machine = manager.machine
local cpu = machine.devices[':maincpu']
local dbg = machine.debugger
local space = cpu.spaces['program']
if not dbg then machine:exit(); return end
dbg.visible_cpu = cpu
local ARG = 0x01800000
local STACK = 0x01840000
local RET = 0x018c0000
local BUF = 0x01810000
local IE = 0x200000
local INIT = 0x3F5A9620
local SCHED = 0x3F5AA2C0
local XFORM = 0x3F5A09C0
local XFORM1 = 0x3F5AADA0
local INVERSE = (os.getenv("ORACLE_INVERSE") or "") ~= ""
local COUNT = tonumber(os.getenv("ORACLE_COUNT") or "0")
local f = 0
local started = false
local phase = "init" -- init | sched | vectors | dump | done
local saved = {}
local idx = 0
local vectors = {}
local cur = nil
-- deterministic pseudo-random vectors plus the all-zero and ff cases
local function make_vectors()
vectors[1] = string.rep("\0", 16)
vectors[2] = string.rep("\255", 16)
local x = 0x12345678
for i = 3, COUNT do
local t = {}
for j = 1, 16 do
x = (x * 1103515245 + 12345) & 0x7fffffff
t[j] = string.char((x >> 16) & 0xff)
end
vectors[i] = table.concat(t)
end
end
local function state(name)
local r = cpu.state[name]
return r and r.value or nil
end
local function push(v)
local sp = state('SP') - 0x20
space:write_u32(sp, v)
cpu.state['SP'].value = sp
end
local function setup_call(target, args)
saved.pc = state('PC')
saved.sp = state('SP')
saved.a14 = state('A14')
saved.st = state('ST')
cpu.state['SP'].value = STACK
if #args > 0 then
local a = ARG
for i = 1, #args do
space:write_u32(a, args[i])
a = a + 0x20
end
push(saved.a14)
cpu.state['A14'].value = ARG + #args * 0x20
else
push(saved.a14)
cpu.state['A14'].value = ARG
end
push(RET)
cpu.state['PC'].value = target
cpu.state['ST'].value = saved.st & ~IE
dbg:command('bclear')
dbg:command(string.format('bpset %X', RET))
dbg.execution_state = 'run'
end
local function restore()
dbg:command('bclear')
cpu.state['PC'].value = saved.pc
cpu.state['SP'].value = saved.sp
cpu.state['A14'].value = saved.a14
cpu.state['ST'].value = saved.st
end
local function hexbytes(addr, n)
local t = {}
for i = 0, n - 1 do
t[#t + 1] = string.format("%02X", space:read_u8(addr + i * 8))
end
return table.concat(t)
end
local function writebytes(addr, s)
for i = 1, #s do
space:write_u8(addr + (i - 1) * 8, s:byte(i))
end
end
local function next_vector()
idx = idx + 1
if idx > #vectors then
phase = "done"
restore()
machine:exit()
return
end
cur = vectors[idx]
writebytes(BUF, cur)
if INVERSE then
-- per-record inverse companion; call it twice, one 8-byte record each
phase = "vec_inv_lo"
setup_call(XFORM1, { BUF })
else
phase = "vec_fwd"
setup_call(XFORM, { 16, BUF })
end
end
emu.register_frame_done(function()
f = f + 1
if f == 1000 and not started then
started = true
if COUNT < 2 then COUNT = 2 end
make_vectors()
print(string.format("ORACLE start frame=%d p200=%08X p400=%08X",
f, space:read_u32(0x1c0200), space:read_u32(0x1c0400)))
phase = "init"
setup_call(INIT, { 1 })
return
end
if not started then return end
if f % 100 == 0 then
print(string.format("ORACLE trace frame=%d phase=%s state=%s pc=%08X sp=%08X",
f, phase, tostring(dbg.execution_state),
state('PC') or 0, state('SP') or 0))
end
if f > 1600 and phase ~= "done" then
print("ORACLE watchdog: exiting")
phase = "done"
restore()
machine:exit()
return
end
if dbg.execution_state ~= 'stop' then return end
if phase == "init" then
print(string.format("ORACLE init done @1C0200=%08X @1C0220=%08X @1C0240=%08X @1C0260=%08X @1C0400=%08X",
space:read_u32(0x1c0200), space:read_u32(0x1c0220),
space:read_u32(0x1c0240), space:read_u32(0x1c0260), space:read_u32(0x1c0400)))
phase = "sched"
setup_call(SCHED, { 0x1be810 })
elseif phase == "sched" then
local kp = space:read_u32(0x1c0260)
local fp = space:read_u32(0x1c0200)
print("ORACLE KP=" .. string.format("%08X", kp) .. " FP=" .. string.format("%08X", fp))
if kp ~= 0 then print("ORACLE KEY " .. hexbytes(kp, 128)) end
if fp ~= 0 then print("ORACLE FTAB " .. hexbytes(fp, 2048)) end
-- boot-time inputs the schedule reads
print("ORACLE SEED " .. hexbytes(0x1be810, 128))
print("ORACLE R1 " .. hexbytes(0x1bed40, 0x38))
print("ORACLE R2 " .. hexbytes(0x1bef00, 0x80))
print("ORACLE R3 " .. hexbytes(0x1bef80, 0x80))
print("ORACLE R4 " .. hexbytes(0x1c0280, 0xE0))
next_vector()
elseif phase == "vec_fwd" then
print("ORACLE " .. cur:gsub(".", function(c) return string.format("%02X", c:byte()) end)
.. " " .. hexbytes(BUF, 16))
next_vector()
elseif phase == "vec_inv_lo" then
phase = "vec_inv_hi"
setup_call(XFORM1, { BUF + 0x40 })
elseif phase == "vec_inv_hi" then
print("ORACLE_INV " .. cur:gsub(".", function(c) return string.format("%02X", c:byte()) end)
.. " " .. hexbytes(BUF, 16))
next_vector()
end
end, "vt1200-oracle-call")
dbg.execution_state = 'run'