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.
13 KiB
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:
- 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
- whether reordering the TOC so BITMAPS precedes METRICS would leave
pCS->nChars == 0whenpcfReadBitmapsruns, 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=b46a3e442ccb86134d2356bd70d22fd2before and after. - No check disabled, no
harness/serve.pyedit, no invented loader behaviour. - I did not use
overflow.lua(jumping straight into3f984e70) 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.pcfis reported as a METRICS failure even though that makes a "nicer" BITMAPS story impossible.
7. Reproduction
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.