Files
vt1200/dwt/notes/DISPLAY_MESSAGE.md
T

7.8 KiB
Raw Blame History

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 Messages heading 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 7 answers 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 eight SCROLL LINE 01…08 messages leaves SCROLL LINE 04…08 visible. Older lines go out of view (the OS's own System 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-byte len check.
  • 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.py uses dwt/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 0xAECE0 to 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-5 code. Scrollback beyond the viewport and any ring cap were not exercised (no pointer-input run for the scrollbar).