# 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 ` 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.