forked from arti/vt1200
195 lines
8.4 KiB
Markdown
195 lines
8.4 KiB
Markdown
# VT1200 LAT client — static memory-safety audit (receive path)
|
||
|
||
Image: `vt1200_v22.bin` (address-ordered, file offset == physical byte; ROM base
|
||
`0x07E40000`). All addresses below are **file/byte offsets**. The cached
|
||
disassembly (`reference/dis34-a-v22.txt.gz`) and `unidasm` both print **bit**
|
||
addresses, where `bit = byte * 8` (verified: reset vector target `0x3F200040`
|
||
-> byte 8 -> file `0x07E40008`).
|
||
|
||
Helper scripts used are in this directory:
|
||
|
||
| script | purpose |
|
||
|---|---|
|
||
| `findrefs.py` | find 32-bit bit-address constants for a string target |
|
||
| `near_refs.py` | find any 32-bit constant whose `>>3` lands in a byte range (catches leading-space strings) |
|
||
| `scan_str.py` | regex over ROM strings + refs |
|
||
| `ua.py` | dump `unidasm` lines for a byte range (address = byte*8) |
|
||
| `dd.py` | same for the `dis34` output |
|
||
| `mapfuncs.py` | map every string ref to its enclosing function |
|
||
|
||
## Wire layout (confirmed)
|
||
|
||
LAT ethertype `0x6004`. Message = 8-byte header + slots:
|
||
|
||
```
|
||
byte 0 bits: [0]=RRF [1]=master [6..7..2]=msg type
|
||
byte 1 nbr_slots
|
||
byte 2-3 dst_cir_id
|
||
byte 4-5 src_cir_id
|
||
byte 6 msg_seq
|
||
byte 7 msg_ack
|
||
slots: byte0=dst_slot, byte1=src_slot, byte2=byte_count, byte3=hi-nibble type,
|
||
byte4.. = data ; next slot at +(byte_count+4), padded to even
|
||
```
|
||
|
||
Confirmed three ways: the wireshark dissector (`lat/code/wireshark-lat/packet-lat.c`),
|
||
the captured session (`lat/local/vt-session.pcap`, e.g. RUN message
|
||
`0103 6054 0100 0201 01 01 16 9f...` = 3 slots), and the firmware's own message
|
||
builders (e.g. byte `0x44A0`, which writes bit0/bit1 then a 6-bit type field at
|
||
bit 2: `MOVE A9,*A11`; `MOVE A9,*A11(1h)`; `SETF 6h`; `MOVE A9,*A11(2h)`).
|
||
|
||
Slot types: 0=Data_A, 9=Start, 10(0xA)=Data_B, 11(0xB)=Attention,
|
||
12(0xC)=Reject, 13(0xD)=Stop.
|
||
|
||
## Key functions
|
||
|
||
| function | byte offset | bit addr (physical) |
|
||
|---|---|---|
|
||
| R_run (RUN handler) | `0xFEEA` | `0x3F27F750` |
|
||
| message dispatcher (AST) | `0x11634` | `0x3F28B1A0` |
|
||
| LAT demultiplexer | `0x16C44` | `0x3F2B6220` |
|
||
| Data_a delivery (to X/VTE) | `0xA1A2A` | `0x3F70D150` |
|
||
| fragment flattener | `0xFC1BA` | `0x3F9E0DD0` |
|
||
| insert_latjob | `0xF316` | `0x3F2798B0` |
|
||
|
||
Demux is called from R_run at byte `0x101A8` (`CALLA 3F2B6220`); its argument
|
||
`count` is the wire `nbr_slots`, read in R_run at byte `0x10140`
|
||
(`MOVE *A10(8h),A11,0`), i.e. bit offset 8 = header byte 1.
|
||
|
||
## FINDING 1 — demux walks `nbr_slots` slots with no length bound
|
||
|
||
Demux entry `0x16C44`. Per-slot dispatch (byte `0x17318`) reads the 4-bit type
|
||
from the high nibble of header byte 3:
|
||
|
||
```
|
||
0b98c0: MOVE *A10(1Ch),A8,0 ; 4-bit field at bit 28 -> type
|
||
0b98e0: SUBK 9h,A8 ; JREQ -> Start
|
||
0b9910: DEC ; JREQ -> Data_B
|
||
... Attn, Reject, Stop, else -> BAD RCV
|
||
```
|
||
|
||
Loop tail (bytes `0x1733E`–`0x1736E`):
|
||
|
||
```
|
||
0b99f0: MOVE *A10(10h),A7,0 ; A7 = slot byte_count (wire byte 2)
|
||
0b9a10: ADDK 4h,A7
|
||
0b9a20: SLL 3h,A7 ; A7 = (byte_count+4) * 8 bits
|
||
0b9a30: MOVE *A13,A5,1
|
||
0b9a40: ADD A5,A7 ; current slot ptr += (byte_count+4) bytes
|
||
0b9a50: MOVE A7,*A13,1
|
||
... ; round up for odd byte_count
|
||
0b9ad0: INC A0 ; slot index++
|
||
0b9b00: CMP A5,A0 ; A5 = count argument (nbr_slots)
|
||
0b9b10: JRNC B9B70h ; only exits when index >= nbr_slots
|
||
```
|
||
|
||
There is **no check** that the accumulated slot bytes stay inside the received
|
||
message, and the demux receives no length argument at all (its three args are
|
||
the slot buffer, `nbr_slots`, and an interface/session-table pointer).
|
||
`nbr_slots` is a full byte (0..255) and
|
||
`byte_count` is a full byte (0..255), so the walk can run up to ~66 KB past the
|
||
buffer.
|
||
|
||
Effect per pushed "slot": Data_a slots (`0x16D32` -> `3F70D150`) deliver
|
||
`byte_count` bytes from an out-of-bounds source into the X/VTE socket, so the
|
||
out-of-bounds bytes are handed to the LAT client's own X session; Start/Reject/
|
||
Stop slots mutate SCB fields from out-of-bounds bytes. This is an
|
||
out-of-bounds **read** (info leak + session-state corruption), not a write.
|
||
|
||
Confidence: **MEDIUM**. The missing self-bound is certain; exploitability
|
||
depends on whether the layer that copies the received frame into the demux
|
||
buffer already validates `nbr_slots`/`byte_count` (see Blockers).
|
||
|
||
## FINDING 2 — `host_credits < 0` checks are present as strings but dead
|
||
|
||
Data_a path stores a single byte at SCB bit-offset `0x1E0`:
|
||
|
||
```
|
||
0b6660: MOVE *A11(1E0h),A5,0 ; host_credits
|
||
0b6690: MOVE *A10(18h),A3,0 ; wire byte 3
|
||
0b66b0: ADD A3,A5
|
||
0b66c0: MOVB A5,*A11(1E0h) ; byte store — no sign handling
|
||
```
|
||
|
||
The guard messages exist but no 32-bit constant references them:
|
||
|
||
* `DMUX Data_A scb->host_credits=%d scb=0x%x < 0` @ `0x175CF` — no ref
|
||
* `dmux Data_B scb->host_credits = %d < 0` @ `0x17626` — no ref
|
||
* `Illegal slot received by remote node.` @ `0x17600` — no ref
|
||
|
||
(checked with `near_refs.py`, which would also catch a leading-space pointer.)
|
||
The same pattern in Data_b at bytes `0x16EE2`–`0x16F2C` and the multiplex/flow
|
||
control code read `*A11(1E0h)` as an unsigned byte elsewhere, so this is at
|
||
minimum a flow-control desync and a possible signedness/length bug downstream.
|
||
|
||
Confidence: **LOW–MEDIUM** (dead-check fact is solid; memory-safety impact not
|
||
proven).
|
||
|
||
## FINDING 3 — service-announcement / LAD parsing (not fully audited)
|
||
|
||
Dispatcher byte `0x11634` branches on message type; type `0xA` (service
|
||
announcement) goes to byte `0x118C0` and calls the LAD update (func `0x10660` /
|
||
`0x103CA`). That code `memcpy`s (callee `3FB44400`) with lengths derived from
|
||
the announcement and has a zero-length check (`Node=%s has zero for service
|
||
class len` @ `0x121D8`, ref byte `0x1088E`) but no confirmed upper bound. The
|
||
host controls announcements, so this is a real attack surface. Confidence:
|
||
**LOW**; needs a dedicated pass.
|
||
|
||
## Ruled out / checks that ARE present
|
||
|
||
* **Data_a delivery copy is clamped.** `3F70D150` (byte `0xA1A2A`) computes
|
||
`min(len, scb->avail)` (bytes `0xA1A4A`–`0xA1A54`) before the `memcpy` at byte
|
||
`0xA1A94`. The write into the X/VTE ring buffer cannot overflow.
|
||
* **Data_b / Attention** are ignored unless the SCB service class is 1:
|
||
`DSJ`/`DSJS` against SCB bit-offset `0x218` (bytes `0x16EF4`, `0x16FF6`), with
|
||
messages `DATA_B ignored on service class 3 or 4` (`0x1764F`) and
|
||
`ATTN_SLOT ignored on service class 3 or 4` (`0x17687`).
|
||
* **Start slot** validates the service class is 1, 3 or 4 (bytes `0x16E48`–
|
||
`0x16E58`) before accepting.
|
||
* **Unknown slot types** fall to the default branch -> `DMUL : BAD RCV`
|
||
(`0x172A2` / string `0x1773A`) and the session is torn down.
|
||
* **Frame size** is bounded in the network layer: `MAXSIZE_RCVPKT` = 1518
|
||
(`packet_size = %d exceeds MAXSIZE_RCVPKT` @ `0x81872`) and `SIZE_OF_RCV_BUFS`
|
||
= 550 (`0x8189B`).
|
||
* **insert_latjob ring** has an overflow guard ("insert_latjob : lat job ring
|
||
overflow", string `0xFC30`, ref byte `0xF364`) and wraps the index at 500
|
||
(`CMPI 0x1F3`).
|
||
|
||
## Reproduce
|
||
|
||
```sh
|
||
cd /home/arti/code/k-space/vt1200
|
||
|
||
# 1. clean linear disassembly (bit addr = byte*8)
|
||
unidasm vt1200_v22.bin -arch tms34010 -basepc 0 > /tmp/opencode/v22.asm
|
||
|
||
# 2. locate the LAT strings / their code refs
|
||
python3 lat/notes/scan_str.py vt1200_v22.bin "DMUL|DMUX|demultiplex|host_credits|insert_latjob"
|
||
python3 lat/notes/near_refs.py vt1200_v22.bin 0x175C0 0x17650 # dead "<0" checks
|
||
|
||
# 3. read the demultiplexer
|
||
python3 lat/notes/ua.py 0x16C44 0x177D2 > /tmp/opencode/demux.asm
|
||
|
||
# 4. read R_run and the Data_a delivery copy
|
||
python3 lat/notes/ua.py 0xFEEA 0x103CA > /tmp/opencode/run.asm
|
||
python3 lat/notes/ua.py 0xA19F0 0xA1B80
|
||
|
||
# 5. cross-check wire format
|
||
tshark -r lat/local/vt-session.pcap -Y 'eth.type==0x6004'
|
||
```
|
||
|
||
## Blockers / needs dynamic confirmation
|
||
|
||
1. The **frame-receive -> demux buffer** copy and the buffer size were not
|
||
located. Whether `nbr_slots` is validated against the received length
|
||
determines if Finding 1 is exploitable. The received message arrives as a
|
||
fragment chain (R_run arg4; counted by `3F9E0B80`, flattened by `3F9E0DD0`
|
||
into a small stack buffer), suggesting the demux walks a stack/message
|
||
buffer; the 3-fragment * 550-byte bound does not constrain `nbr_slots`.
|
||
2. Register/argument mapping was inferred from stack offsets (`A13(FFE0)` =
|
||
last pushed arg, spacing 4 bytes); a MAME TMS34010 run or live trace would
|
||
confirm the buffer base and size.
|
||
3. Finding 2 needs a downstream data-flow check of the signed `host_credits`
|
||
consumers (multiplex flow control, SCB bit-offset `0x1E0`).
|
||
4. Finding 3 (LAD) was only spot-checked.
|