7.8 KiB
Attacker-chosen text in the Terminal Manager Messages box (rdebug opcode 8)
Status: DONE — opcode 8 (rdebug_display_message) puts attacker-chosen text
in the Terminal Manager's Messages box. No payload, no code injection and no
debugger breakpoint are involved. The visible result is the same pane the OS
uses for System defaults loaded., TCP/IP is now up. and
Debug connection from IP host ….
ROM untouched (md5 b46a3e442ccb86134d2356bd70d22fd2). No driver edits.
VT1200_SKIP_LEVEL1=1 (documented divergence), private -nvram_directory and
BOOTP/TFTP as in the other OS-path runs.
1. The wire request
request = u32be 8 | u32be level | u32be len | len raw bytes
len counts the raw bytes that follow and must be 1..1024. The NUL
terminator is the sender's job. The handler malloc(len + 2) and copies
len bytes, but it never writes a terminator. A message sent without one is
followed on screen by stale heap bytes (first experiment: the 29-byte
RDEBUG MSG ONE - LEVEL ZERO rendered as RDEBUG MSG ONE - LEVEL ZEROUUUUUU@).
Sending text + NUL with len = len(text)+1 renders cleanly. So the largest
clean text is 1023 bytes, not 1024.
Exact requests used (dry run, dwt/exploit.py --dry-run):
# level 0, "ATTACKER MESSAGE ONE" + NUL (len 0x15)
00 00 00 08 00 00 00 00 00 00 00 15
41 54 54 41 43 4b 45 52 20 4d 45 53 53 41 47 45 20 4f 4e 45 00
# level 3, "LEVEL THREE TERTIARY" + NUL (len 0x15)
00 00 00 08 00 00 00 03 00 00 00 15
4c 45 56 45 4c 20 54 48 52 45 45 20 54 45 52 54 49 41 52 59 00
The reply is the usual "Done\n" frame. dwt/exploit.py now has
RDebug.display_message(level, text, terminate=True) and a --message TEXT
option; terminate=True appends the NUL (and therefore counts it in len).
2. What level does
level is a syslog-style message class, not an offset or a length. The append
routine is 0x07E6126A (bit 0x3F309350) — the same routine accept_debug_conn
calls for Debug connection from IP host %s. On entry it checks per-class
enable bits in RAM:
| level | check (bit address) | on this boot | visible in Messages |
|---|---|---|---|
| 0 | @0xAECE2 (byte 0xAECE0 bit 2) |
1 | yes |
| 1 | @0xAECE3 (bit 3) |
0 | no |
| 2 | @0xAECE4 (bit 4) |
0 | no |
| 3 | no check — always accepted | — | yes |
Measured byte at 0xAECE0 on the live OS (opcode 2 peek): 05 =
bit 2 set, bits 3 and 4 clear. That matches the observed result exactly:
levels 0 and 3 render; 1 and 2 are silently dropped. Levels 0 and 3 write to
the same Messages list — the level chooses whether the line is admitted, not
where it goes.
Disassembly of the filter: 3F3093B0 (bit 0xAECE2 / jreq exit when the
class is 0), 3F309430 (0xAECE3), 3F3094C0 (0xAECE4, subk 2 /
jreq exit). Level 3 falls through all of them.
3. Where it appears, and what the pane does
- The Terminal Manager Messages box, below the
Messagesheading and above the Ethernet/IP/LAT block. Nothing else on the screen changes: no dialog, no popup, no status line. - It is not modal. The TM keeps running, the rdebug session stays usable
(
opcode 7answers after the messages) and the message is just another line in the list. - The pane is a 5-line viewport. The append routine computes the top line
as
count - 5(3F30A940/3F30AA40), which is why sending eightSCROLL LINE 01…08messages leavesSCROLL LINE 04…08visible. Older lines go out of view (the OS's ownSystem defaults loaded./Using local fonts.scroll off the same way); the scrollbar is present. A hard cap on retained lines was not established; the only verified per-message bound is the 1024-bytelencheck. - Persistence: after the three demo messages the frame at 944 and the frame
at 1938 (≈1000 frames later) are byte-identical
(
4bf8e2299d4ee704848a0b107bf2a31d). The line persists and survives the OS's repaints. - Repaint timing (honest caveat): in the controlled run the text appeared in the next snapshots (5–14 frames after the send). In one earlier run the very first message was buffered but not drawn for more than 130 frames, until the next message/connection caused a pane redraw. So the list update itself is immediate; when it becomes visible depends on the TM redrawing the pane.
4. Why no payload is needed
The firmware already ships the primitive. There is no reason to call the
append routine from a jump payload: dwt/visual/msg.asm was not built,
because opcode 8 reaches the same routine with an attacker-controlled string and
returns Done. It composes trivially with the visual work — the same rdebug
connection can poke/jump a payload and then send opcode 8; no hook, no
resident code, no breakpoint.
5. Reproduce
The host tap tap-mess-<uid>-0 is fixed by MAME's taptun provider, so a second
MAME cannot share it. This run used a private network namespace with its own
tap-mess-1000-0, bridge and veth to the host, while another MAME held the
default tap. Nothing about the result depends on that choice; on a free tap the
ordinary harness command works.
# terminal (private NVRAM; 192.168.100.2 comes from harness/serve.py --bootp)
VT1200_SKIP_LEVEL1=1 \
/home/arti/code/k-space/mame0289/vt1200 vt1200 -rompath harness/mame/roms \
-sound none -skip_gameinfo -networkprovider taptun -video none -nothrottle \
-cfg_directory <cfg> -nvram_directory <nvram> \
-debug -debugger none -autoboot_script dwt/visual/msg_snap.lua \
-autoboot_delay 0
# host: one connection, challenge answered by dwt/challenge.respond
python3 dwt/visual/msg_demo.py --host 192.168.100.2 --wait 40 \
--message '0:ATTACKER MESSAGE ONE' \
--message '0:RDEBUG TEXT TWO' \
--message '3:LEVEL THREE TERTIARY'
msg_snap.lua is read-only with respect to the emulated machine: it reads the
frame counter and takes screen:snapshot() PNGs when the host writes a trigger
file ("<prefix> <count> <stride>"). No breakpoints, no firmware writes.
6. Evidence
| file | what it shows |
|---|---|
dwt/evidence/visual/msg_before_frame00824.png |
before: five OS lines, no attacker text |
dwt/evidence/visual/msg_after_frame00944.png |
after: ATTACKER MESSAGE ONE, RDEBUG TEXT TWO, LEVEL THREE TERTIARY in the Messages box |
dwt/evidence/visual/msg_persist_frame01938.png |
≈1000 frames later: identical frame (md5 4bf8e229…) |
dwt/evidence/visual/msg_scroll_frame07979.png |
eight lines sent; viewport shows the last five (SCROLL LINE 04…08) |
dwt/evidence/visual/msg_levels_frame15265.png |
level 1 and 2 sent just before: invisible; level 3 visible |
dwt/evidence/visual/msg_*_crop.png |
the Messages box crops of the above |
dwt/evidence/visual/msg_client.log |
challenge, reply and every opcode-8 body on the wire |
The log was captured as msg2_client.log; only the PNG prefix was renamed
(msg2_* → msg_*) when the duplicate run-1 frames were dropped.
Also dwt/visual/msg_demo.py (new host driver) and dwt/visual/msg_snap.lua
(new snapshot trigger). dwt/exploit.py gained only additive support
(display_message, --message, --message-level); existing behaviour is
unchanged.
7. Caveats
- The challenge (
dwt_prf) still has to be answered;msg_demo.pyusesdwt/challenge.respond, the established offline answer. This note is about what the service does once a session is open, not about bypassing the challenge. - Level 1/2 are filtered on this boot because their enable bits are clear.
Writing
0xAECE0to set bits 3/4 would presumably enable them, but that is a runtime config change and was not needed; 0 and 3 are both usable. - The 5-line viewport is measured and matches the
count-5code. Scrollback beyond the viewport and any ring cap were not exercised (no pointer-input run for the scrollbar).