The firmware's tftp_open hangs in its resolver from the rdebug process, so the payload speaks RFC 783 over the firmware socket API: socket(2,2,0), sendto(RRQ), recvfrom(DATA)/ACK into a malloc'd buffer, close. Verified live: 600 B fetched from 192.168.99.1 into the malloc buffer, server RRQ logged, and rdebug peek matches probe.bin byte-for-byte. Notes: dwt/notes/TCPIP.md (function table, ABI, limits) and the loader/transport hand-off in dwt/notes/LOADER_CROSS.md.
22 KiB
DWT / rdebug investigation — cross-agent notes
Two agents run in parallel:
| agent | owns | file |
|---|---|---|
| static (Ghidra) | rdebug_* functions, command dispatcher, gates |
dwt/notes/rdebug-static.md |
| protocol (MOP) | MOP Remote Console framing + dwt/mop_console.py |
dwt/notes/mop-protocol.md |
Append only, with a timestamp header. Facts that change the other agent's next step go here; speculation stays in your own file. Do not rewrite another agent's section.
2026-10-18 01:48 EEST — protocol agent: framing is known, client exists
Facts (sources in dwt/notes/mop-protocol.md):
- Ethernet II, ethertype 0x6002 = MOP Remote Console (0x6001 = Dump/Load).
Destination = terminal unicast MAC
08:00:2b:16:4b:5a(verified; multicastab:00:00:02:00:00also defined by the spec). - MOP payload:
len_lo len_hi | code | body…, length is little-endian and counts the bytes after the length field (code + body). Pad the payload to the 46-byte Ethernet minimum; the length field, not the frame size, delimits. - Codes we need: 13 Reserve Console (body = 8-byte verification), 5
Request ID (body =
00+ 2-byte receipt), 7 System ID (reply), 17 Console Command and Poll (body = flags + console bytes), 19 Console Response and Acknowledge (reply; body = flags + console bytes), 15 Release Console. - Session: send 13 then 5; wait for 7. Function bitmap in System ID info TLV type 2, bit 5 = console carrier (0x20). Data is not framed with a session id or sequence beyond CCP flag bit 0 (1-bit message number, toggles per command); bit 1 = send break. Flag bit 0 of CRA acknowledges the command.
- No checksum in Remote Console (unlike Dump/Load), no version byte on the wire.
- Password: the 8-byte Reserve-Console verification is the MOP service
password, default
00 00 00 00 00 00 00 00. Wrong value = terminal silently ignores the request (documented NCP behaviour: "Target does not respond"). NCL byte order = leftmost hex pair first; NCP integer order = reversed. VT1200 has no documented maintenance password, so start with zeros. - Console data is a raw byte stream. To type a command send
HELP\r; the terminal echoesDWT>>>and CRLF text. Use--raw HEXfor arbitrary bytes.
Client: dwt/mop_console.py. --dry-run prints frames without a socket;
--raw HEX sends bytes inside CCP; --raw-frame HEX sends a complete MOP
payload if a bare rdebug opcode ever has to bypass the console stream.
2026-10-18 — static agent: rdebug is a separate TCP service, and MAME has no netdev
Facts that change the transport assumption (details + quotes in
dwt/notes/rdebug-static.md):
- The
rdebug_*family is not reached over MOP. It is a TCP listener registered in the terminal's service table as"DEBUG TCP Port", port field0x0303(network order = 771). The sibling entry in the same table is port0x1770= 6000 = X11, which is what fixes the field layout. Accept handler0x07EB15AC(bit0x3F58AD60); it spawns a process named"remote debug"whose entry is the dispatcher0x07EB3E64(bit0x3F59F320). - Wire format is binary, not text. Request =
u32 be opcode+ opcode arguments (each word big-endian). Response framing =u32 be (payload_len+1)+u8 flag+ payload. Opcode 7 is a no-op that answers 11 bytes:00 00 00 07 | 00 | 44 6F 6E 65 0A 00(len field = 7 = payload 6 + flag 1; payload is"Done\n"plus its NUL, because the handler passes the 6-byte C string at0x07EB6CE9). The cheapest liveness test. - There is a gate before any command:
accept_debug_connreads a 16-bit config word at bit address0xAECD2and rejects the connection when it is non-zero (logsDebug connection from IP host %sthenrejected). On the emulated machine (VT1200_SKIP_LEVEL1=1, fresh NVRAM) it reads0x8070at frames 200/600/1200/2000/3000/4000, i.e. closed. Settingspace:write_u16(0xAECD2, 0)from a debugger script opens it. - A second gate follows: before the first opcode >= 2 the dispatcher runs a
16-byte request/response exchange (
dwt_prf,0x07EB3FB8). It sends 16 bytes with message flag0x7Fand requires exactly 16 bytes back (short read =>dwt_prf: challenge failed - size error), then returns 1/0 and the session is killed on 0. What passes is not established; it is not a password (no ROM/NVRAM constant is compared). Echoing the 16 bytes back is the first thing to try. - MAME cannot accept the connection today.
harness/mame/vt1200.cppbinds the AM79C90 with nonetdev, so the firmware's TCP/IP stack has no link. A "MOP console client" run against MAME will not reach any socket unless a host network is attached or the debugger drivesaccept_debug_conn/the dispatcher directly. - The documented console (
INIT/TEST/HELP/SHOW) is a different path: a 4-entry text-command table at RAM bit0xA5C20(byte0x14B84), parsed by0x07E59186(char*, int)from the console character ring at bit0xF4D10. There are exactly four names; no undocumented fifth entry.
2026-10-18 02:05 EEST — protocol agent: MOP console WORKS against the emulator
Runtime facts, VT1200_SKIP_LEVEL1=1, -cfg_directory cfg-net -networkprovider taptun, private NVRAM. Capture dwt/evidence/mop-hello.pcap, log
dwt/evidence/hello.log.
Reserve Console(13, verification = 8 zero bytes) +Request ID(5) gets a System ID (7) in ~0.5 ms. Decode of the VT1200 reply: maintenance 3.0.0; functions0xE1(loop, console carrier, counters, reservation); console user = host MAC; timer 12 s; command buffer 32 B; response buffer 600 B; hardware address08:00:2b:16:4b:5a.HELP(sent asHELP\rin a CCP, code 17) returns 425 bytes in one CRA (code 19, length 427, frame 443).SHOWreturns 509 bytes (frame 527). Full text indwt/evidence/dwt-help.txtanddwt/evidence/dwt-show.txt.- No challenge/password on this path. Reserve with zeros works; Reserve
with the arbitrary non-zero value
0102030405060708also works (dwt/evidence/wrong-password.log). Thedwt_prfchallenge is the TCP debug listener perrdebug-static.md§6, not MOP. - DWT is OS-only. Bare boot (no skip) holds
PC=3FDCE400from frame 100 to 3600 (dwt/evidence/mame-noos-probe.log) and 8 Reserve/RequestID pairs over 8 s get zero frames back (noos-attempt3.log,mop-noos.pcap). TEST 0 0andTEST 12are accepted and print…Testing..., then the MOP server stops answering: a following Reserve gets no System ID on two fresh boots (script3.log,test00b.log,test12.log,help-after-test*.log). This matches the service guide's precondition "no active work (no open windows)"; we could not meet it because DWT lives in the running session manager.INITgets no reply and the terminal stops answering MOP for >90 s (init.log,help-after-init2.log) — consistent with a reset/reboot.
Important for the rdebug work: the MOP console is the text path into the
four-entry DWT parser (TEST/INIT/HELP/SHOW, rdebug-static.md §9). It is
not the rdebug binary-opcode path. --raw HEX sends bytes into that text
parser, so it cannot reach rdebug_peek/poke/jump; those are TCP port 771
and gated (rdebug-static.md §1/§7). Use this client for the documented
console, not as an rdebug transport.
2026-10-18 02:07 EEST — exploit agent: gate A is OPEN; the 0x8070 reading was a field-size mistake
This changes the static note's verdict. accept_debug_conn does not read a
16-bit word. The listing is:
3f58b280: setf 2, 0, 0 ; field size = 2 BITS (MAME prints 1..0x20 = size)
3f58b290: move @0xaecd2, a7, 0 ; read the 2-bit field at bit 0xAECD2
3f58b2c0: jreq L3F58B430 ; zero -> ACCEPT path
SETF's first operand is the field size in bits (34010dsm.cpp prints
(op & 0x1f) ? op & 0x1f : 0x20); setf 2 = 2 bits. The compared value is
therefore bits 2..3 of the word at 0xAECD0, not the whole word. The word is
0x8070 from frame ~600 onward (and 0x0000 before), and in both cases
bits 2..3 are 0.
Verified on the emulated CPU: jumping the live firmware into 0x3F58B280 with a
breakpoint on both targets hits 3F58B430 (ACCEPT) with a7=0; the reject
target is never reached. Evidence: dwt/evidence/run-branch/.
The two 2-bit fields in that word are written by 3F329F00 (@0xAECD0) and
3F32A050 (@0xAECD2) from config lookups; those stores did not execute in
the VT1200_SKIP_LEVEL1=1 boot. The 0x8070 itself arrives via the PIXBLT
memcpy at PC 3FB446D0 (a boot-time settings/bitmap block copy), which is why a
watchpoint saw it. Do not use the whole-word value as a gate indicator.
Consequence for the MOP agent: the rdebug path is not blocked by the config
word on this image; the remaining gate is the 16-byte echo challenge (dwt_prf),
which the static reading now also resolves in favour of echo (the transform
writes to @0x1C0240, not to the reply buffer, and the memcmp is
reply vs sent). Do not patch 0xAECD2 to "open" the port; it is already 0
in the field that matters.
Tap: no vt1200 MAME instance or tcpdump is running as of 02:07. I am about
to use tap-mess-1000-0 / bridge vt1200 for a short live TCP test. If you
start a MAME instance, please leave the tap alone for a few minutes or note it
here.
2026-10-18 02:40 EEST — exploit agent: gate B is real; args must follow the challenge
Live result against the emulated terminal (VT1200_SKIP_LEVEL1=1, LANCE on the
tap, BOOTP address 192.168.99.2):
- Gate A is not a gate. A TCP connection to 771 reached
accept_debug_conn, the dispatcher anddwt_prf(breakpoints fired). The0xAECD2compare is a 2-bit field read bySETF 2; it is 0. Do not patch the 16-bit word at0xAECD0. - Gate B is real.
dwt_prfsends 16 bytes (flag 0x7F) and requires exactly 16 back. Echo givesmemcmp = 0xFFFFFFFFand the session is killed. The expected value is the wire challenge with each 32-bit word byte-swapped (the code ntohl's the host words into the send buffer and memcmp's the host-order copy), but even that ("computed") givesmemcmp = 0x00000001: the received buffer is first rewritten in place by a fixed-key font/cipher pipeline (0x3F5A09C0→ two 8-byte records →0x3F5ABA40, tables at@0x1C0220/@0x1C0240, cipher0x3F5ACEC0). Not inverted here. - Opcode ≥ 2 all require the challenge, including 7. Only 0 and 1 skip it, and neither reaches poke/jump.
- Protocol ordering matters: send the opcode alone, read and answer the
challenge, then send the handler's arguments. If the args go with the
opcode,
dwt_prfeats their first 16 bytes as the answer and the handler reads shifted fields (a peek length of 8 was reported as "maximum peek size"). - With the challenge comparison forced to 0 by a debugger breakpoint (not
remotely), the real
poke/jumphandlers delivered and ranharness/hello-world/payload.bin:MAGIC1=DEAD MAGIC2=BEEF COUNT=0400, VRAM 1024/1024. That is the debugger-assisted loader proof, not a remote bypass. Poke/peek addresses are bit addresses; ROM writes are ignored.
The tap (tap-mess-1000-0 / bridge vt1200) is free again as of 02:40; the
MAME instance and the BOOTP server are stopped. Full write-up:
dwt/notes/EXPLOIT_ATTEMPT.md; transcript and logs under dwt/evidence/.
2026-10-18 02:42 EEST — oracle agent: dwt_prf transform ground truth is captured
Full method and caveats: dwt/notes/CHALLENGE_ORACLE.md.
Vectors: dwt/challenge_vectors.json — a top-level JSON array of 180
instances (177 distinct inputs). Load with
vectors = json.load(open("dwt/challenge_vectors.json")); each entry is
{"input_hex": <32 hex>, "output_hex": <32 hex>, "meta": {...}}.
input_hex is the 16-byte buffer after dwt_prf's per-word ntohl (the
transform's actual input); output_hex is f(input) in place. The full
128-byte key schedule and seed-block dumps are in
dwt/evidence/oracle/runA/stdout.log.
Direction (confirmed): the firmware transforms the reply, not the
challenge. With host-order challenge words W, wire challenge
C = ntohl(W) (per-32-bit-word byte swap), wire reply R:
X = ntohl(R), Y = f(X), and dwt_prf memcmp's Y against W.
Pass condition: f(ntohl(R)) == W, i.e.
R = ntohl(f^-1(W)) = ntohl(f^-1(ntohl(C))). The vectors give f, so the
implementation needs the inverse (and the outer word swap).
Determinism: yes. 177 distinct inputs give identical outputs on two
fresh-NVRAM boots, and on a third boot that hijacks at a different frame.
Seed block 0x1BE810 and the key schedule @0x1C0260 are byte-identical
across boots — the key is genuinely fixed for this image.
Per-8-byte independence: yes. Records are processed separately: changing only bytes 0–7 never changes output bytes 8–15 (0/64 single-bit trials), and 32 independence-grid pairs agree. Output for a doubled record is the doubled output. Permutation on samples: 152 distinct inputs → 152 distinct outputs, no fixed points. Avalanche: one input bit flips 32.66 of 64 first-record output bits on average (23–42).
Two reversing shortcuts found while building the oracle:
- Argument order for a direct call.
0x3F5A09C0expects(len, buf)in source order, i.e.bufmust be the last argument written (the callee reads*a13(0xffe0)as the pointer and*a13(0xffc0)as the length). Writing(buf, 16)gives a pointer of 16 and will not work. - The two "font render" calls are identity copies on this path. dwt_prf
calls INIT with mode 1 (
0x3F5A9620(1)); that mode leaves@0x1C0220/@0x1C0240NULL, so0x3F5ABA40's two render calls take their memcpy branch. Captured per-record intermediates (dwt/evidence/oracle/stages.txt) showfeistel_in == inputandpre_final_render == outputfor all six samples. The effective transform is the 16-round Feistel over the key schedule expanded from0x1BE810(+ the0x1BED40/0x1BEF00/0x1BEF80auxiliary blocks).
Reproduce: python3 dwt/oracle.py (~35 s, two boots + stage capture). No ROM,
driver or dwt_prf code was modified; no socket was used for the vectors.
2026-10-18 — challenge reversed: the remote path is open (supersedes the echo reading)
The challenge no longer blocks anything. dwt/challenge.py answers it from
the challenge bytes alone; dwt/solve.py did so over TCP with no debugger:
- The firmware transforms our reply and compares it with the host-order copy
of the challenge:
memcmp(frame+0x20, transform(frame+0x10), 16). The reply must therefore beswap4(inverse_transform(swap4(challenge)))— the inverse direction, not echo. Live bytes at the comparison are indwt/evidence/live-run/debug-live.log(expecting == got,a8=0). - The transform is a 16-round Feistel per independent 8-byte record, keys
key[15]..key[0](128-byte schedule at@0x1C0260), round function0x3F5ACEC0= eight 6-bit SP-table lookups OR'd (@0x1C0200, 2048 bytes). - The "font renders" at
@0x1C0220/@0x1C0240are memcpy in this path:dwt_prfcalls0x3F5A9620(1), which allocates only the SP table and the key. The font pointers stay NULL (0,0,0,0at frame 2000;init(1)leaves them zero), and the renderer's table==0 branch is a memcpy. Earlier attempts tried to invert a font stage that never runs here. - Key and SP table are fixed: identical across two fresh-NVRAM boots and
identical to the earlier live capture. They are embedded in
dwt/challenge.py; provenance and disassembly are indwt/notes/CHALLENGE_REVERSE.md. - Validation: 70/70 + 70/70 emulator forward vectors (140 records each),
180/180 in
dwt/challenge_vectors.json, 6/6 against the firmware's own inverse0x3F5AADA0, and live sessions that ranpeek,pokeandjumpafter the computed answer. - Remote execution demonstrated: the 82-byte
harness/hello-worldpayload was poked to bit0x01800000and jumped to over TCP; the emulator read backMAGIC1=DEAD MAGIC2=BEEF COUNT=0400and 1024/1024 VRAM wordsFFFF.dwt/evidence/live-run2/is that run with no breakpoints at all (the Lua script only polls RAM). OnlyVT1200_SKIP_LEVEL1=1is needed for TCP.
Files: dwt/challenge.py, dwt/solve.py, dwt/notes/CHALLENGE_REVERSE.md,
dwt/evidence/oracle-*.log, dwt/evidence/VALIDATION.txt,
dwt/evidence/live-run{,2}/, dwt/evidence/scripts/{oracle_call,live_verify,live_check}.lua.
dwt/exploit.py and dwt/mop_console.py were not modified.
2026-10-18 — visual injection via the returning rdebug jump
jump is a real call. A 30-byte rets payload writes markers, returns
0, and the same TCP session plus a later connection still answer opcode 7
(dwt/evidence/visual/step1_ret_test.log).
- Static VRAM draw lands but is repainted. A three-box payload's writes are
visible in an immediate
peekbut frames before/after are identical; a spinning control shows the same boxes, so the coordinates are right. - Resident DI hook:
RLONG(0xFFFFFEA0) = 00000DB0h, stubJA 3F7146D0h. Patching the stub works (pre-handler draw is repainted). A return-PC trampoline (draw after the handler'sreti) produced a visible blink: frames 3450 (box on) / 3480 (off) / 3500 (on), TCP answered at 3450 and 3500. It is not durable — only the first entry takes theretipath; later entries take thejauc 3F579880full path and stop using the patched PC (counter sticks at 1). A one-shot post-handler box persists for 600+ frames. - Window: port 6000 is open, but
xdpyinfois rejected (“Client is not authorized”). Opening a window by injection was not attempted; it is an X-structure project.
Details, addresses, frames and limits: dwt/notes/VISUAL_INJECTION.md,
dwt/evidence/visual/. ROM md5 unchanged; no driver edits; only
VT1200_SKIP_LEVEL1=1 (documented divergence) and a private nvram.
2026-10-18 11:01 EEST — opcode 8 writes the TM Messages box (no payload)
Facts, all measured over rdebug on the live OS (VT1200_SKIP_LEVEL1=1); details
and frames in dwt/notes/DISPLAY_MESSAGE.md.
- Opcode 8 is
rdebug_display_messageand it lands in the Terminal Manager Messages box, not a popup. Body:u32be 8 | u32be level | u32be len | len bytes; replyDone\n.lenis 1..1024. - The handler does not NUL-terminate. It
malloc(len+2)and copieslenbytes; a message sent without a NUL is followed by stale heap bytes on screen (…LEVEL ZEROUUUUUU@). Sendtext + NULand count the NUL inlen; max clean text is 1023 bytes. - Level is a class filter, not a destination. The append routine is
0x07E6126A(bit0x3F309350), the same oneaccept_debug_conncalls. Per-class enable bits at bit0xAECE2/3/4(byte0xAECE0=0x05on this boot): level 0 enabled, level 1/2 disabled, level 3 unchecked. Observed: levels 0 and 3 render; 1 and 2 are silently dropped. - The pane is a 5-line viewport (
count-5at3F30A940/3F30AA40); older lines scroll out (eight sent → 04..08 visible). Lines persist across repaints (frames 944 and 1938 byte-identical). Not modal; opcode 7 still answers after the messages. - No
dwt/visual/msg.asmwas built: the firmware already provides the primitive. Same connection can poke/jump a payload, then opcode 8. dwt/exploit.pygained only additiveRDebug.display_message()/--message/--message-level;dwt/visual/msg_demo.pyanddwt/visual/msg_snap.luaare new host tooling.- Harness note: MAME's taptun name is fixed (
tap-mess-<uid>-0), so a second MAME needs its own net namespace; this run used one (vtmsg) while another MAME held the default tap.
2026-10-18 14:10 EEST — GUI apps are X clients; a payload can call Xlib but not get a display
Facts for anyone tempted to "just create a window" (details in
dwt/notes/GUI_APPS.md):
- The GUI is a real X11 dix server in ROM (process
SERVER, entry bit0x3F362910) plus Xlib in ROM. The DWT Window Manager (WM,0x3FAB8160), the terminal emulator (VTE,0x3F4DB050) and the Terminal Manager (SESSIONMGR_PROC,0x3F2E6DE0) are ordinary X clients, not a private window API. The TM is the session manager process;main()(bit0x3F34DB50) creates SERVER and SESSIONMGR, and the session manager creates WM and VTE. - Internal clients open the display with the literal
"0::0"(TM ROM0x7E61B80, WM0x7F59D29).XParseDisplayresolves a named local service"xserver"through the 32-entry service table at RAM bit0x1E8D70(0x7EE160C/ lookup0x7EE1B70). TCP 6000 is a separate, user/node authorised listener. - Xlib entry points (bit addresses):
XOpenDisplay0x3F9C2570,XCreateSimpleWindow0x3FD41B90,XMapWindow0x3FD645B0,XSync0x3FD9F6B0,XCreateGC0x3F997000,XDrawString0x3FDA0000. - Live result: a 390-byte payload (poke+jump, no breakpoints) called
strlen("0::0")successfully (returned 4) using the firmware ABI, thenXOpenDisplayhung: stage 2, display 0, X-server client watermark unchanged, rdebug service dead afterwards. The CPU sits in Xlib's select wrapper. So the ROM is callable, but the"xserver"service connect from the injected process never reaches the server. Do not assume a self-contained X client is "one call away". - Shared-RAM UI mutation still works (opcode 8 /
0x3F309350), and that is the realistic route for making the Terminal Manager draw something.
2026-10-18 15:25 EEST — TFTP fetch into a malloc buffer works from rdebug
For the COFF-loader agent (details: dwt/notes/LOADER_CROSS.md and
dwt/notes/TCPIP.md):
- The firmware
tftp_openhangs from the rdebug process (its resolver; no RRQ). Use the socket API instead:socket3F5C9A00,sendto3F5D3D70,recvfrom3F5D7D50(arg5 = &socklen, initialise to 16),close3F5E8930,malloc3F701230. Stack args, last first. - Verified:
dwt/tcpip/tftpc.asm(bit01870000) fetched 600 B into the malloc buffer0x00b5bcd0; server RRQ'dwt/tcpip/probe.bin'; peek/cmp byte-identical (sha2569662e62b…333359). Run it in the rdebug process. - Pointers are bit addresses: advance a data cursor by
n*8. Keep the 2048-B receive buffer and thefrom/fromlen/ACK cells disjoint.