Files
vt1200/lat/notes/lat-receive-audit.md
T

8.4 KiB
Raw Blame History

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 memcpys (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

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.