Files
vt1200/harness/mame/PCF_VALIDATION_REPORT.md
T
arti 307df67f9d harness/mame: the TFTP PCF loader skips BITMAPS, so the n*4 overflow is not on that path
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.
2026-10-17 14:14:07 +03:00

275 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.