8.4 KiB
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 refdmux Data_B scb->host_credits = %d < 0@0x17626— no refIllegal 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(byte0xA1A2A) computesmin(len, scb->avail)(bytes0xA1A4A–0xA1A54) before thememcpyat byte0xA1A94. The write into the X/VTE ring buffer cannot overflow. - Data_b / Attention are ignored unless the SCB service class is 1:
DSJ/DSJSagainst SCB bit-offset0x218(bytes0x16EF4,0x16FF6), with messagesDATA_B ignored on service class 3 or 4(0x1764F) andATTN_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/ string0x1773A) and the session is torn down. - Frame size is bounded in the network layer:
MAXSIZE_RCVPKT= 1518 (packet_size = %d exceeds MAXSIZE_RCVPKT@0x81872) andSIZE_OF_RCV_BUFS= 550 (0x8189B). - insert_latjob ring has an overflow guard ("insert_latjob : lat job ring
overflow", string
0xFC30, ref byte0xF364) 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
- The frame-receive -> demux buffer copy and the buffer size were not
located. Whether
nbr_slotsis validated against the received length determines if Finding 1 is exploitable. The received message arrives as a fragment chain (R_run arg4; counted by3F9E0B80, flattened by3F9E0DD0into a small stack buffer), suggesting the demux walks a stack/message buffer; the 3-fragment * 550-byte bound does not constrainnbr_slots. - 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. - Finding 2 needs a downstream data-flow check of the signed
host_creditsconsumers (multiplex flow control, SCB bit-offset0x1E0). - Finding 3 (LAD) was only spot-checked.