forked from arti/vt1200
The TFTP font path loads through 3F96B850 and parses with the metadata reader 3F97EF00, which handles only PROPERTIES/ACCELERATORS/METRICS/ ENCODINGS and silently skips BITMAPS. A font with num_chars=0x40000001 therefore passes the loader (LOADER-A-SUCCESS) without the field ever being read, and pcfReadBitmaps (3F984970, multiply 3F985050) is never entered. pcfReadBitmaps has exactly one caller, 3F98BB70, in the X11 font-request reader 3F98AC20, not the TFTP fetch path. The 'refused after 1024 bytes' of evil-real.pcf is the METRICS table: its big-endian compressed count 0x00DF is read little-endian as 57088, so fosAlloc(57088*16) fails at ~906 bytes; BITMAPS (offset 2024) is never reached. Baseline.pcf fails the same way; baseline_le.pcf puts that one 2-byte field in the order the loader reads it, parses cleanly, and still ignores the crafted BITMAPS count. No ROM patch, no check disabled, no serve.py edit. Baselines unchanged: no env samples=198, stub samples=53.
275 lines
13 KiB
Markdown
275 lines
13 KiB
Markdown
# What the PCF loader validates on the TFTP font path — and why it cannot reach `pcfReadBitmaps`
|
||
|
||
Date: 2026-10-17. Firmware `vt1200_v22.bin`, md5
|
||
`b46a3e442ccb86134d2356bd70d22fd2` (**unmodified**). No change to
|
||
`harness/mame/vt1200.cpp`, `build.sh`, `harness/serve.py`, or the MAME tree. No
|
||
ROM patch and no check was disabled. Scratch and instrumentation live in
|
||
`harness/mame/pcfwork/`.
|
||
|
||
## Verdict (the short answer)
|
||
|
||
**The TFTP font path does not use `pcfReadBitmaps` at all.**
|
||
|
||
It loads the file through `3F96B850`, which recognises the PCF magic and calls a
|
||
**metadata reader** (`3F97EF00`, "parser A"). Parser A handles four table types —
|
||
PROPERTIES, ACCELERATORS, METRICS, ENCODINGS — and **silently skips the BITMAPS
|
||
table**. The crafted `num_chars = 0x40000001` is therefore never read on this
|
||
path, let alone multiplied. A font can pass the loader's validation (and it
|
||
does), but passing it does not reach the vulnerable arithmetic.
|
||
|
||
The "refused after 1024 bytes" observation was **not** a `pcfReadBitmaps`
|
||
validation. For `evil-real.pcf` the parser gives up on the **METRICS** table:
|
||
the compressed-metrics count is read little-endian as 57,088 instead of the
|
||
intended 223, so `fosAlloc(57088*16 = 913,408)` fails. It never reaches BITMAPS
|
||
(offset 2024); it consumed about 906 bytes, which is why the transfer looked like
|
||
it stopped at the 1024-byte window.
|
||
|
||
`pcfReadBitmaps` still exists and is still called — but only by a **different**
|
||
parser (`3F98AC20`, "parser B") on the **X11 font-request** path, not by the
|
||
TFTP file loader. That path was not demonstrated from a TFTP-delivered font (see
|
||
"Remaining open question" below).
|
||
|
||
## Address convention (confirmed)
|
||
|
||
The TMS34010 is bit-addressed and the ROM is mapped at bit `0x3F200000`.
|
||
Therefore **bit address = `0x3F200000` + (ROM file offset × 8)**. The earlier
|
||
notes' `0x0F09CE` is byte `0x0F09CE` = bit `0x3F984E70`, which is exactly
|
||
`pcfReadBitmaps`' arithmetic block. All addresses below are bit addresses.
|
||
|
||
## 1. The path a TFTP-delivered font actually takes
|
||
|
||
Live trace of a valid fetched font (trace script: `pcfwork/pcftrace.lua`):
|
||
|
||
```
|
||
FONTLOAD 3F96B850
|
||
FONT-MAGIC magic=70636601
|
||
PCF->parserA 3F96BB80 -> L3F97EF00
|
||
PARSER-A-ENTRY 3F97EF00
|
||
```
|
||
|
||
`3F96B850` reads the first four bytes, stores them in the font object
|
||
(`*a9(0x400)`), and compares them with `0x70636601`
|
||
(`3F96BB20: cmpi 0x70636601,a5`). PCF goes to `L3F97EF00`; anything else falls to
|
||
the DEC-SNF check (`0x1DF5E76`). The name comparison between the "path file"
|
||
name and the font's own name is at `3F96C6B0`, and the success return
|
||
(`movk 1,a8`) is at `3F96C930`.
|
||
|
||
The same loader/parser pair is the only one exercised by the fetch: in a
|
||
150-emulated-second run with a font parser A accepts, parser B and parser C were
|
||
**never** entered.
|
||
|
||
## 2. What parser A validates
|
||
|
||
Parser A (`3F97EF00`) reads `nTables` and up to **eight** TOC entries
|
||
(`label, format, size, start`, 16 bytes each). It then walks those entries in
|
||
**TOC order**, seeks to each `start`, and dispatches on the label
|
||
(`L3F9811B0`):
|
||
|
||
| label | table | handler | reader | what is checked |
|
||
|---|---|---|---|---|
|
||
| 1 | PROPERTIES | `L3F97F8A0` | `L3F9770B0` | `format & 0xFFFFFF00 == 0`; `nProps`; two allocations; reads `name/type/value` then the string table; "Unkown property format", "allocation failure … (props/isstring/tmpbuf)", "short read … (tmpbuf)" |
|
||
| 2 | ACCELERATORS | `L3F980200` | `L3F979C00` | `format & 0xFFFFFF00 == 0`; fixed 100-byte body; "Unknown accelerator format" |
|
||
| 4 | METRICS | `L3F980E40` | `L3F9886B0` | format must be default `0` or compressed `0x100`; **count cross-checked against `pCS->nChars`** ("bad char count"); allocation `nChars*12` / `nChars*16` |
|
||
| 32 | ENCODINGS | `L3F980A20` | `L3F97C750` | `format & 0xFFFFFF00 == 0`; first/last col/row; each index `< nChars` ("Illegal char index") |
|
||
| 8,16,64,128 | BITMAPS, INK_METRICS, SWIDTHS, GLYPH_NAMES | — | — | **skipped** (`L3F9812B0`: `inc a9`, next entry) |
|
||
|
||
The dispatch is unambiguous:
|
||
|
||
```
|
||
3f9811e0: move *a7(0x80),a8,1 ; a8 = label
|
||
3f981200: dec a8
|
||
3f981210: jaeq L3F97F8A0 ; 1 -> properties
|
||
3f981230: dec a8
|
||
3f981240: jaeq L3F980200 ; 2 -> accelerators
|
||
3f981260: subk 2,a8
|
||
3f981270: jreq L3F980E40 ; 4 -> metrics
|
||
3f981280: subk 28,a8
|
||
3f981290: jaeq L3F980A20 ; 32 -> encodings
|
||
3f9812b0: inc a9 ... ; everything else -> next TOC entry
|
||
```
|
||
|
||
The seek before each handler (`3F96F9F0`) returns ≤ 0 on failure, which reaches
|
||
the `Failed to read PCF format <table>` messages (`3F97FA40`, `3F9803A0`,
|
||
`3F981050`, `3F980BB0`).
|
||
|
||
**What is not there:** there is no reader for label 8. Parser A never reads the
|
||
BITMAPS count, offsets or glyph data. Consequently there is no `num_chars`
|
||
validation on this path either — the field is simply not looked at.
|
||
|
||
## 3. Where `pcfReadBitmaps` actually is, and who calls it
|
||
|
||
`pcfReadBitmaps` is `3F984970`; its arithmetic block is `3F984E70`:
|
||
|
||
```
|
||
3f984e70: move *a9(0x270),a11,1 ; nChars
|
||
3f984e90: jrne L3F984EE0
|
||
3f984ea0: move *a13(0x20),*a9(0x270),1 ; nChars == 0 -> nChars = n
|
||
3f984f20: cmp a7,a11 ; else compare n with nChars
|
||
3f984f30: jreq L3F985020
|
||
3f984f80: "bad count in pcfReadBitmaps (expected %d, got %d)"
|
||
3f985030: move *a13(0x20),a11,1 ; n
|
||
3f985050: sll 0x2,a11 ; n*4 <-- unchecked
|
||
3f985090: calla L3F703D90 ; fosAlloc(n*4,1)
|
||
3f985260: (loop) read n CARD32 offsets at pBitOffsets + i*4
|
||
```
|
||
|
||
The ROM string `allocation failure in pcfReadBitmaps (offsets,%d)` is the
|
||
`fosAlloc` failure report at `3F985150` — it names the **size argument**, it is
|
||
not a separate validation of the offsets table. There is no check that `n`
|
||
matches the table size, nor that `n*4` did not wrap.
|
||
|
||
There is **exactly one caller** in the ROM: `3F98BB70`, inside parser B
|
||
(`3F98AC20`). Parser B is not the TFTP loader. It is reached from the X11
|
||
request dispatcher:
|
||
```
|
||
3F960640 (inside the X11 request handler that prints
|
||
"Invalid request code %d received.", string 3F98EA50 at 3F960750)
|
||
-> 3F9643E0 (font resource lookup: id -> font, then the file cache)
|
||
-> 3F964850 (PCF/SNF dispatch over the cached font object, magic at +0x400)
|
||
-> 3F98AC20 (parser B)
|
||
-> 3F98BB70 -> pcfReadBitmaps
|
||
```
|
||
|
||
Parser B's own TOC dispatch (`3F98C320`) handles labels 2, 4, 8 and 32, and
|
||
calls `pcfReadBitmaps` for label 8. It *also* has the count cross-check
|
||
(`3F984F20`/`3F984F80`), so on parser B a mismatched `num_chars` is caught by
|
||
default.
|
||
|
||
So `pcfReadBitmaps` belongs to the X11 font-request reader (font-id lookup over
|
||
the cached font data); it is not the code that parses the file while it is being
|
||
fetched.
|
||
|
||
## 4. Live evidence
|
||
|
||
All runs: `VT1200_SKIP_LEVEL1=1`, the private cfg selecting `taptun`, a private
|
||
`-nvram_directory` seeded from a configured NVRAM (TFTP font path
|
||
`192.168.99.1:69`), and the TFTP server serving the candidate. Trace output is
|
||
`pcfwork/pcftrace.lua`.
|
||
|
||
| candidate | result |
|
||
|---|---|
|
||
| `reference/fonts/baseline.pcf` (680 B) | Parser A: TOC 1,2,4 → **`A-FAIL metrics`**. The compressed-metrics count `00 01` is read little-endian as `0x0100` = 256, not 1; the metrics reader then returns 0. |
|
||
| `pcfwork/baseline_le.pcf` (680 B; metrics count byte-swapped to `01 00`) | Parser A: TOC 1,2,4 (metrics count 1, `A-METR-C done`, `readerret=1`), then 8,16,32,64,128. No failure. |
|
||
| `pcfwork/baseline_le_evil.pcf` (same, **BITMAPS `num_chars=0x40000001`**) | **Identical trace** to the line above: all eight TOC entries, `LOADER-A-SUCCESS` at frame 395. `BITMAPS-ENTRY`, `BITMAPS-ARITH` and `MULTIPLY` never hit; parser B and parser C never entered. The crafted count is ignored, not validated. |
|
||
| `reference/fonts/evil-real.pcf` (25,860 B) | Parser A: TOC 1 (properties, 648 B), 2 (accelerators), 4 (metrics at 900). `fmt=0x10E` (compressed), count bytes `00 DF` read little-endian = `0xDF00` = **57,088**; `fosAlloc(57088*16 = 913,408)` returns 0 → **`A-FAIL metrics`**. BITMAPS is at offset 2024 and is never reached. ≈906 bytes consumed. |
|
||
|
||
The last row is the "1024-byte" stop: the parser bailed at the METRICS
|
||
allocation after ~906 bytes, so only the first two 512-byte TFTP blocks were
|
||
ever consumed. It is not a validation of `num_chars`, and it is not a size limit
|
||
of the transfer path.
|
||
|
||
### Why the byte order matters (and why it is not cheating)
|
||
|
||
`bdftopcf` on this host writes the compressed-metrics count big-endian
|
||
(`00 01`); parser A copies the two bytes with a plain `fosRead` and reads them
|
||
little-endian (`3F9898B0`, `3F9899F0: setf 16,1,0; move *a13,a7,0`). The stock
|
||
font is therefore misread. `baseline_le.pcf` is the **same font** with that one
|
||
2-byte field in the order the loader actually reads it. Nothing is disabled; the
|
||
loader's own checks still run. The generator is
|
||
`pcfwork/make_candidates.py`.
|
||
|
||
## 5. Judgement
|
||
|
||
**Closed, for the TFTP font path.** A font can pass the loader's validation
|
||
(`LOADER-A-SUCCESS`), but that validation is not the one guarding the multiply:
|
||
the loader never reads the BITMAPS table at all, so `num_chars = 0x40000001`
|
||
cannot reach `pcfReadBitmaps`. The "1024-byte refusal" is a METRICS failure
|
||
caused by the loader's little-endian read of a big-endian field, not a
|
||
`pcfReadBitmaps` count/offset check.
|
||
|
||
This is not "the validation happens to be good enough"; it is stronger: **the
|
||
vulnerable function is not on this route**.
|
||
|
||
### Remaining open question (stated, not papered over)
|
||
|
||
`pcfReadBitmaps` is still present and still called by parser B on the X11
|
||
font-request path (`3F960640 → 3F9643E0 → 3F964850 → 3F98AC20`). Parser B reads
|
||
the **cached** font bytes, so in principle a font that parser A accepted and
|
||
that the terminal then *opened* would be parsed by parser B. Two things were not
|
||
demonstrated:
|
||
|
||
1. that a TFTP-registered font is ever opened (the harness has no X11 client, and
|
||
in 150 emulated seconds the terminal fetched the index but never requested the
|
||
served font file); and
|
||
2. whether reordering the TOC so BITMAPS precedes METRICS would leave
|
||
`pCS->nChars == 0` when `pcfReadBitmaps` runs, bypassing parser B's cross-check.
|
||
(Parser A would not object: it skips BITMAPS and reads METRICS whenever it
|
||
occurs.)
|
||
|
||
That is a separate trigger (an X11/font-server `OpenFont` of a crafted font), not
|
||
the "serve the font over TFTP and let the loader parse it" route this work was
|
||
about. It is recorded here so the next session starts from the right place
|
||
rather than from the earlier assumption that the TFTP loader reaches
|
||
`pcfReadBitmaps`.
|
||
|
||
## 6. What I refused to fake
|
||
|
||
* No ROM patch; `md5sum vt1200_v22.bin` = `b46a3e442ccb86134d2356bd70d22fd2`
|
||
before and after.
|
||
* No check disabled, no `harness/serve.py` edit, no invented loader behaviour.
|
||
* I did **not** use `overflow.lua` (jumping straight into `3f984e70`) as evidence
|
||
of reachability. The reachability statement above comes from the real path:
|
||
TFTP fetch → `3F96B850` → parser A.
|
||
* The one constructed input (`baseline_le.pcf`) is the project's valid font with
|
||
a 2-byte field put in the byte order the loader actually reads; the loader's
|
||
checks still run and still pass on their own terms.
|
||
* `evil-real.pcf` is reported as a METRICS failure even though that makes a
|
||
"nicer" BITMAPS story impossible.
|
||
|
||
## 7. Reproduction
|
||
|
||
```sh
|
||
cd /home/arti/code/k-space/vt1200
|
||
python3 harness/mame/pcfwork/make_candidates.py
|
||
|
||
# isolated tap (does not touch the owner's tap-mess-1000-0)
|
||
sudo ip netns add vt1test
|
||
sudo ip netns exec vt1test ip link set lo up
|
||
sudo ip netns exec vt1test ip tuntap add dev tap-mess-0-0 mode tap
|
||
sudo ip netns exec vt1test ip addr add 192.168.99.1/24 dev tap-mess-0-0
|
||
sudo ip netns exec vt1test ip link set tap-mess-0-0 up
|
||
|
||
# a configured NVRAM copy (TFTP font path 192.168.99.1:69)
|
||
mkdir -p harness/mame/pcfwork/nvram-run/vt1200
|
||
cp harness/mame/pcfwork/nvram-pristine.bin harness/mame/pcfwork/nvram-run/vt1200/nvram
|
||
|
||
harness/mame/pcfwork/run_candidate.sh \
|
||
harness/mame/pcfwork/baseline_le_evil.pcf 40
|
||
```
|
||
|
||
`run_candidate.sh` starts `serve_candidate.py` (baseline for the path/index
|
||
names, the candidate for the derived font name), runs MAME with
|
||
`-nvram_directory pcfwork/nvram-run`, and prints the parser trace from
|
||
`debug.log`. Switch the candidate to `reference/fonts/evil-real.pcf` for the
|
||
25 KB case. The NVRAM copy is not committed: it is the owner's settings block
|
||
(font path, IP) copied for the run, not part of the harness.
|
||
|
||
## 8. Regression baselines (unchanged)
|
||
|
||
No source file used by the driver changed, so every configuration is intact; the
|
||
two documented change detectors were re-run on the shipped binary:
|
||
|
||
```
|
||
no env, progress.lua 200 frames : PC=3FDCE400 samples=198 (3 distinct PCs)
|
||
VT1200_IO_PATH_STUB=1, 200 fr : PC=3FDCE400 samples=53 (75 distinct PCs)
|
||
```
|
||
|
||
`VT1200_SKIP_LEVEL1=1` and `VT1200_MOUSE=1` were used by these runs on the same
|
||
unmodified driver; no code path they depend on was touched.
|
||
|
||
## 9. New files
|
||
|
||
```
|
||
harness/mame/pcfwork/
|
||
pcftrace.lua parser A / parser B / pcfReadBitmaps / TFTP breakpoints
|
||
pcfflow.lua coarse call-graph trace of the font path
|
||
make_candidates.py baseline_le.pcf and baseline_le_evil.pcf
|
||
serve_candidate.py path/index -> baseline, font name -> candidate
|
||
serve_index.py a real fonts.dir + candidate (index experiments)
|
||
run_candidate.sh one headless real-path run, in the netns
|
||
run_index.sh same, with serve_index.py
|
||
```
|
||
|
||
No existing file was edited for this work except this report and the new
|
||
`pcfwork/` files.
|