← Writeups

The right bugs, the wrong repo: fuzzing libtsm and catching an upstream redirect

17 Aug 2026 fuzzinglibtsmcoordinated-disclosurevariant-analysistarget-selectionmemory-safety

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:

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.