# 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--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. ```sh # 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 -nvram_directory \ -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 (`" "`). 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).