TL;DR: I fuzzed libtsm — the terminal state machine behind kmscon — and found three memory-safety bugs in its escape-sequence parser, all reachable from untrusted terminal output. They were fixed within a few days and shipped in libtsm 4.7.1, with me credited as the commit author. The interesting parts aren’t the out-of-bounds reads; they’re the two decisions around them: how the target was chosen when the easy candidates were exhausted, and catching that the fork I’d fuzzed was not the repository to report against.
The gates get harder to clear
The highest-leverage decision in a fuzzing campaign is which target to fuzz, and it gets harder the longer you do it. My rule of three gates hasn’t changed — a target is only worth harnessing if it (1) parses attacker-controlled input in memory-unsafe C/C++, (2) is not already in OSS-Fuzz, and (3) has no in-tree or active external fuzzing campaign on the same surface. What changes is the hit rate. OSS-Fuzz is enormous now; a sweep that would have surfaced a dozen clean candidates a year ago returns almost nothing but “already fuzzed.”
Working through a batch of niche parsers, most fell at gate 2 or 3: image codecs
with in-repo LLVMFuzzerTestOneInput harnesses, protocol libraries wired into
OSS-Fuzz’s CIFuzz, a medical-imaging toolkit with a wall of open ASan issues.
libtsm was the one that cleared all three:
no OSS-Fuzz project, no CIFuzz workflow, zero in-repo fuzz harness, and zero open
fuzzing/overflow issues. Low-profile, quietly maintained, and — the tell — no
sign anyone had ever pointed a fuzzer at it.
The attack surface is real even if the project is small. A terminal emulator runs
its child program’s stdout straight through tsm_vte_input(): every escape
sequence, every byte. cat a crafted file, or run a program that emits the right
control codes, and untrusted data flows through a hand-written C state machine
that decodes UTF-8, C0/C1 controls, and CSI/OSC/DCS sequences with numeric
parameters. That is exactly the shape of code where bounds bugs hide.
A harness with no plumbing
After a run of targets that needed socket pairs, transaction-id gates, and
CRC-recomputation just to reach the parser, libtsm was a relief. The entry point
is tsm_vte_input(vte, buf, len) — a raw byte buffer. The whole harness is:
create a screen and a VTE with no-op callbacks once, then in the persistent loop
tsm_vte_hard_reset() for determinism and feed the AFL testcase directly. No
files, no sockets, no framing to keep valid. It compiled against a handful of the
library’s own source files and started finding crashes almost immediately.
Ninety saved crashes triaged down to three distinct root causes:
- A heap out-of-bounds read on cursor backward-tab.
tsm_screen_tab_left()indexes the tab-ruler atcursor_x - 1without clampingcursor_xto the screen width. Move the cursor forward withCHT, print a wide glyph at the last column to push it past the edge, thenCBT(ESC [ Z) reads off the end. Eleven bytes. - An out-of-bounds array read in SGR parsing. The extended-colour introducer
(
38/48) readscsi_argv[i + 1]before checkingi + 1against the parsed argument count, so an SGR sequence whose introducer is the sixteenth argument reads one past a fixedint[16]. Twenty bytes. - A wild read via the OSC 4 colour palette. The colour index is parsed as an
unbounded
unsigned, then passed to a function takingint; a large value wraps negative, slips past a< 16check, and indexes the palette out of bounds. The read colour is echoed back in the query reply, so a surviving read is also an information leak.
Each was minimized, reproduced on a clean non-AFL ASan/UBSan build, and given a one-line fix. Standard so far.
The fork was the wrong place to report
Here is the part that would have quietly degraded the whole report. I had cloned
and fuzzed Aetf/libtsm — the fork that shows up first and looks maintained. As
I was packaging the findings I actually read its README, which says, in as many
words, that upstream development had restarted at a different repository:
kmscon/libtsm. The original
dvdhrm/libtsm was gone entirely (404).
Reporting against the fork I happened to fuzz would have been a lower-quality
disclosure: line numbers off, patches that might not apply, and a maintainer
wondering why I was pointing at a repo they’d moved past. So I re-cloned
kmscon/libtsm at its current head, confirmed all three bugs reproduce there,
and regenerated every fix against that tree so the diffs apply cleanly to what
the maintainer actually has. It cost an hour. It’s the difference between a report
a maintainer can apply in one command and one they have to translate.
The lesson generalizes: the repository you fuzzed and the repository you report to are not necessarily the same, and confirming which one is the live upstream — before writing the email — is part of the job.
Proving the fixes are complete
Three bugs of the same shape — an untrusted value used to index an array without
a bound — is a pattern, and patterns have siblings. Before reporting, I swept the
three classes across the whole parser: every cursor-indexed screen access, every
csi_argv[i + N], every colour-table lookup. They all came back guarded — the
main cell accessor clamps the cursor, the argument count is capped at the array
size, the attribute path clamps the colour code. The three bugs were isolated
misses of otherwise-consistent bounds handling, not the tip of a class.
A clean variant sweep is a negative result, but it’s one worth doing and worth stating: it let me tell the maintainer, with justification, that the three patches were believed complete rather than the first three of an unknown number. That kind of thoroughness is what makes a one-person maintainer trust a report.
The outcome
I emailed the fixes to the kmscon/libtsm maintainer, Jocelyn Falempe. He’d been away when it arrived, then merged all three patches, kept me as the commit author, and released them in libtsm 4.7.1. Rebuilding at the release and replaying every proof-of-concept confirms each parses cleanly.
None of the three bugs is exotic. What made this a good campaign was everything around them: clearing three gates that keep getting harder, a harness lean enough to write in one sitting, catching that the live upstream wasn’t the fork on the tin, and closing the door on siblings before sending the report. The bugs were the easy part.
Details, patches, and the crash traces are in the advisory.