Tetrak
GitHub ↗
The field

Read the tag before you unroll the scroll

In 2026 the number of published vulnerabilities more than doubled, and in October Pillow fixed thirty-eight security flaws in one release. A scanning pipeline cannot stop using decoders, but it can decide, from a file's first bytes and its header, whether a decoder should see it at all. We built that decision for ourselves, then made it a library, and this is why

A library in the ancient world kept its books as rolled scrolls, and you can’t see what is on a scroll from outside. To know, you need to unroll it, which is slow and wears the papyrus, and in a large collection is no way to find anything. So a small parchment tag, a sillybos (say it SIL-lee-boss; Cicero’s Latin made it sittybus), hung from the end of each roll with the title and the author on it. Cicero had a set made for his library. The reader checked the tag, and only then unrolled the scroll, or did not.

An OCR pipeline is handed files it did not make, and every one of them is like a scroll: you don’t know what is in it until you open it. The decoders that open them, Pillow for images and poppler for PDFs, are large and old and read formats of great complexity, and they have flaws. This article is about the month that made us take those flaws seriously, what we decided to do about them, and why the result is a library anyone can use rather than a corner of our own code.

The October storm

Something changed in 2026 in how fast security flaws are found. The numbers are stark. An analysis of the CVE Program’s full list, taken on 23 October, counted 86,292 CVEs published since the start of the year, against 38,569 in the same window of 2025: more than double, and already more than the whole of 2025. The third quarter of 2026 alone held more CVEs than any full year before 2024. September had three and a half times as many as the September before.

The analysis also counted the records that credit an AI system, or an AI lab’s research team, as the finder: 34 in the whole of 2025 and 769 by late October 2026, with the monthly figure rising through the year from 18 in January to 199 in October with a week still to run. That is under one per cent of the year’s CVEs and the author calls it a floor, since most vendors do not say who found their bugs. Anthropic’s own disclosure dashboard gives a sense of the volume behind the floor: as of 2 October it had disclosed 6,157 vulnerabilities across 591 open source projects, from nearly thirty thousand candidates, and it says in plain words that human triage is the rate-limiting step and that there are confirmed bugs not yet reported to maintainers for want of capacity. That’s more than just a spike; it’s most likely a new rate.

The press called it the October vulnerability storm, and serious vulnerabilities in the language PHP dominated the headlines with many websites (many of them from governments) shutting down until they could patch. But for a project that reads scans, the storm had a different epicentre. On 16 October Pillow released version 13.0.0 with thirty-eight security entries in its notes, two of them critical, most of them reachable by opening one crafted file. Two in particular show the shape of the problem.

The first was in the EPS reader. Pillow does not decode PostScript itself; it hands the file to Ghostscript, as a separate program, with the file’s name on the command line. Nothing checked that name, so a name beginning with a hyphen was read by Ghostscript as an option, and a researcher showed the route from there to running a command, past Ghostscript’s own safe mode (CVE-2026-112900). The second was in the FlashPix reader, an old Microsoft format kept in an OLE container: with the olefile package installed, opening a file could run code (CVE-2026-112398).

Neither of these formats has any business in a scanning pipeline. No scanner writes PostScript or FlashPix. The reason they were reachable at all is that Pillow, sensibly, decides what a file is from its contents and not from its name. A PostScript file called page.png is a PostScript file, and until October it went to the PostScript reader. The extension was never a defence, and for years nobody who ran scans through Pillow had reason to notice.

A fix that has been released is not a fix that is installed. The tools built on top of Pillow decide which Pillow you run, and they are not in a hurry. Marker, the PDF-to-markdown converter we had used as one of our OCR backends, published a new release on 29 October, two weeks after Pillow 13.0.0, and it still requires a Pillow below version 11, two major versions behind the fix; Surya, the recogniser underneath it, pins the same. Anyone who installs Marker today gets a Pillow with October’s flaws in it, and there is nothing they can do about that short of forking. We withdrew the Marker backend from Tetrak OCR rather than hold the whole pipeline on an old Pillow, and we will bring it back when its pin moves. That is the general shape of the problem: your decoder’s version is set by the slowest pin in the tree above it, and a defence that depends on the fixed version being installed is a defence you do not control.

What we decided

We read the October advisories against our own code in a sitting, and the first conclusion was uncomfortable: the only gate in front of our decoders was the file’s extension. The second was more useful. Nearly everything the advisories needed was visible from outside the file, before any decoder ran.

So we decided to build a layer whose only job is to decide whether a file may be opened, and we staged it. Stage zero was an afternoon: Pillow’s Image.open takes a formats argument, a list of the readers it may try, and passing ["JPEG", "PNG", "TIFF"] means a PostScript file is not a PostScript file any more but an unrecognised one, and no reader runs on it. That one argument, with an explicit pixel limit and a page cap on PDF rendering, closed the October paths. Stage one was the check proper: the format from the first bytes, the name against the contents, the header read without decoding, the features of a PDF that a scan never has, and a refusal that goes to the pipeline’s triage queue with a reason rather than a silent drop. Stage two is decoding in a boxed process with memory and time limits, for the flaws nobody has announced yet. Stage three is the same check as the front door of the platform, where files will arrive from partner catalogues, one more step removed from anyone we know.

Stage one is where we noticed that the thing we were building was not really about OCR. A thumbnailer needs it. An upload form needs it. A service that converts documents needs it. Anything that runs Pillow or poppler over files other people made has exactly our problem, and most of the people who have it will never install an OCR tool. So the check became a library of its own, sillybos, MIT licensed, with no dependencies at all, because a security library that pulls in a parser adds to the attack surface it exists to reduce. Tetrak OCR was its first user and calls it in front of every scan.

What sillybos does

The check reads what can be read from the outside of a file, in order, and stops at the first thing it does not like.

The size, from the file system, before anything reads the file.

The format, from the first bytes. Every format a scanner writes announces itself in its first few bytes, and so do most other formats: PostScript, GIF, BMP, WebP, HEIF, JPEG 2000, BigTIFF, Photoshop, the OLE container that holds FlashPix and old Office files, zip, gzip, icons, XML. Reading those bytes is a table lookup, not a parse, and it lets a refusal say what the file was, which is the first thing the person running the batch wants to know. Only JPEG, PNG, TIFF and PDF can pass.

The name against the contents. A JPEG called .png would decode perfectly well. It is refused anyway, because a file whose name is a lie is not to be trusted, and because the lie is the thing the operator wants to be told about.

The header, read with code of our own. This is where it became a library rather than a wrapper. A JPEG’s dimensions, how its frame is coded, and the size of its colour profile are in its segment headers. A PNG’s dimensions, bit depth, colour type and interlacing are in its first chunk, and the size of every other chunk is in front of that chunk. A TIFF’s directory chain carries, for every frame, the dimensions, compression scheme, colour model, samples and bits, tile size and subsampling, and the position and size of every tag’s data. Reading each of these turned out to be a few dozen lines, with no decoder involved and no dependency on one.

For a PDF, what poppler will say without rendering. pdfinfo reports the page count, encryption, the size of every page in points and whether the document carries JavaScript or a form; pdfdetach lists attached files. Both run as separate processes with a time limit, so a file that makes a parser loop costs one file’s failure and nothing more.

The limits, against what the header said, from a policy set from your own largest real files. A file is refused for being too large, for having too many pages, for a page that would decode to more pixels than the machine should allocate, for a side longer than any scanner writes, for a frame coding no scanner uses, for a compression scheme that lives only in the least-exercised corner of libtiff, for a metadata block the size of the file, for a PDF that would make a viewer act rather than draw. Each refusal is a sentence: a lossless JPEG; the policy allows baseline, extended and progressive frames. The sentence is for the person, never a code.

from sillybos import check

verdict = check("inbox/page-012.png")
if not verdict.ok:
    quarantine(verdict.path, verdict.reasons)   # ('says PNG, is PostScript',)

With Pillow installed, open_checked runs the check and then opens the file with Pillow’s formats list set from the policy. That is the October fix, packaged, with everything the header can add to it in front. There is a command, sillybos scans/*, for a shell pipeline or a Dockerfile.

The limits are more important than the formats

The format check is what stops the October flaws. The limits are what stop the next ones, because they are not just belt-and-braces; they map onto real advisories that we found by analysing the past 3 years of imaging CVEs.

Take a pixel budget. Pillow has had one for years, the decompression-bomb threshold, and it is a global that any library in the process can raise. We enforce the same number ourselves, from the header, before Pillow is asked. But a pixel budget alone lets through a page a few pixels wide and a million tall, and an abnormally tall TIFF is exactly what one flaw in libtiff’s RGBA reader needed to write colour data to an arbitrary address (CVE-2025-9900). So the policy also holds both axes of a page, a frame, or a tile to a ceiling. No scanner writes a width or height longer than a few tens of thousands of pixels; the default is three times the longest side of an A3 page at 600 dpi, and nothing we have scanned comes near it.

Or take TIFF compression. A scanner writes one of six schemes. libtiff knows nineteen, and most of its security history lives in the other thirteen. A scan never needs PixarLog, so the policy refuses it by name, and the most recent PixarLog flaw (CVE-2026-12912) becomes a file that is never opened.

Or take a multi-frame TIFF whose second frame has a different colour mode from its first. A decoder that sized its buffers for frame one and then reads frame two writes past them (CVE-2026-96639, one of October’s two critical entries). We walk the whole chain before the file is opened and require every frame to agree.

In each case the check reads something the header already says, costs microseconds, and turns a decoder’s flaw into a file that was refused with a reason. The pattern repeats often enough that we have made it a rule: a published advisory that can be prevented from a header field becomes a policy field, with a default, a command-line option, and a case in a hostile corpus that builds the triggering file from bytes and proves the check refuses it. The security watch is the record of which advisories that has been done for, and which it cannot be done for, and why. How two people keep that record current against the rate described above is a story of its own, told in Keeping watch on the decoders.

What it does not do

A security library carries an implied promise, so we need to be clear about the limits of this library.

It does not protect against a flaw in the readers you do allow. A crafted JPEG that exploits your JPEG decoder is a JPEG of a sensible size, and it passes. What the check removes is every format you did not mean to read, every file too large to be safe, every coding a scanner never writes, and every structural lie the header can be caught in. For the readers you keep, the advice is the old advice: update them, and decode in a process with its own memory and time limits, which is a recipe we have written down in the library’s documentation.

It does not scan for malware, repair a file, or read any format beyond the four a scan can be. Adding a format is adding attack surface, and we will add one when someone shows us a scan in it.

Read the tag. Then decide whether to unroll the scroll.

Further reading

The storm

The flaws

Reading files safely

The formats