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

13 KiB
Raw Blame History

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

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.