Nightjar

Overflowing the DjVuLibre MMR decoder with zero-length runs

Identifier
CVE-2025-53367
Software
DjVuLibre
Affected
DjVuLibre 3.5.28 (advisory's tested version)
Fixed in
3.5.29, released 3 July 2025
Reported by
Antonio Morales, GitHub Security Lab
Disclosed
01 July 2025

In the demo video, Kevin Backhouse clicks on a document in his ~/Downloads folder named poc.pdf. The default viewer opens it, works out that the file is not a PDF at all, and hands it to a different library to decode. A moment later Chrome launches and Rick Astley appears.

The file extension is the part worth pausing on, because it is the part that makes this reachable. The GitHub Security Lab write-up: "even when a DjVu file is given a filename with a .pdf extension, Evince/Papers will automatically detect that it is a DjVu document and run DjVuLibre to decode it". Content sniffing is the right behaviour for a document viewer. It also means that "don't open DjVu files from strangers" is not advice anyone can act on.

The bug is CVE-2025-53367 (GHSL-2025-055). Antonio Morales of GitHub Security Lab found it while fuzzing the Evince document reader. Backhouse wrote the exploit. The two of them published the write-up together on 3 July 2025, the day DjVuLibre 3.5.29 shipped.

Background#

DjVu is a document format for scanned pages. Morales describes it as "an open source alternative to PDF that was popular in the late 1990s and early 2000s for compressing scanned documents". He notes it "has become much less widely used since the standardization of the PDF format in 2008".

Nobody sends you DjVu files any more. DjVuLibre is still installed, because Evince and Papers ship support for it by default. Those two are the standard document viewers on a lot of Linux distributions. It's the kind of dependency that gets added once and then never gets a reason to be removed.

The bug lives in the MMR decoder, which handles the black-and-white layer of a scanned page. MMR is fax coding. The DjVuLibre source doesn't hide this. The error it throws is called invalid_mmr_data, and one of the comments refers to the CCITT, the standards body behind the Group 3 and Group 4 fax recommendations.

Fax coding works on runs. A scanline of a black-and-white image is not stored pixel by pixel. It is stored as alternating run lengths: 137 white, 4 black, 22 white, and so on. The compression comes from coding each line against the line above it, because on a page of text the line above usually looks a lot like the line you are decoding. So the decoder always has two arrays in flight. One holds the runs of the previous line, which it reads. The other holds the runs of the current line, which it writes.

In DjVuLibre those two arrays are called prevruns and lineruns. Both are members of the decoder object:

c
//libdjvu/MMRDecoder.h
class DJVUAPI MMRDecoder : public GPEnabled
{
...
public:
  unsigned short *lineruns;
...
  unsigned short *prevruns;
...
}

Both are allocated once, in the constructor, sized width+4 unsigned shorts, where width is the width of the image being decoded. One entry per possible run, plus a bit of slack.

The scanruns loop#

MMRDecoder::scanruns() decodes one scanline. It starts by swapping the two buffers, since this line's output becomes the next line's reference. Then it walks forward through both:

c
//libdjvu/MMRDecoder.cpp
const unsigned short *
MMRDecoder::scanruns(const unsigned short **endptr)
{
...
  // Swap run buffers
  unsigned short *pr = lineruns;
  unsigned short *xr = prevruns;
  prevruns = pr;
  lineruns = xr;
...
  for(a0=0,rle=0,b1=*pr++;a0 < width;)
    {
      ...
      *xr = rle; xr++; rle = 0;
      ...
      *xr = inc+rle-a0;
      xr++;
}

pr reads the reference line and xr writes the new one. The loop runs until the horizontal position a0 reaches width. The advisory says it in one line: "scanruns does not check that those pointers remain within the bounds of the allocated buffers".

The condition on the loop is a0 < width, which sounds like a bound and is not one. It counts pixels. xr counts runs, one step per run emitted, and the two only stay in step if every run has a length of at least one. Look at the write again: *xr = rle; xr++;. Nothing there requires rle to be non-zero. A run of length zero costs a slot in the buffer and buys no progress across the scanline. A file that keeps emitting them walks xr off the end of a width+4 array while a0 sits still. Each step past the end is a two-byte write into whatever the allocator put next door. That is heap corruption, plus a matching out-of-bounds read through pr for the same reason.

lineruns: one slot per run emitted allocated: width+4 slots next allocation 0 0 0 0 0 0 0 0 0 0 0 0 0 0 xr++ once per run, one run per MMR code two bytes each, past the end the scanline: width pixels a0 stays here: a run of length zero moves nothing loop test: a0 < width
Every zero-length run costs one slot and gains no pixels, so the write pointer leaves the buffer while the loop condition stays true.

There's a passage a bit further down the same function that tells you the leniency was deliberate. After the loop, if the decoded line came out longer than the image is wide, the code walks xr backwards to trim it. The comment above that cleanup:

c
  // At this point we should have A0 equal to WIDTH
  // But there are buggy files around (Kofax!)
  // and we are not the CCITT police.

That's the correct instinct for a document viewer, and it's also why the loop was written to keep going through input that doesn't add up. The cleanup runs after the damage.

The patch#

Léon Bottou committed it the day after the report. Here it is in full:

c
@@ -589,6 +589,9 @@
   int a0,rle,b1;
   for(a0=0,rle=0,b1=*pr++;a0 < width;)
     {
+      // Check for buffer overflow
+      if (xr > lineruns+width+2 || pr > prevruns+width+2)
+	G_THROW(invalid_mmr_data);
       // Process MMR codes
       const int c=mrtable->decode(src);
       switch ( c )
@@ -714,7 +717,7 @@
                         rle++;
                         a0++;
                       }
-                    if (a0 > width)
+                    if (a0 > width || xr > lineruns+width+2)
                       G_THROW(invalid_mmr_data);

Three added lines and one widened condition. The pointer names look inverted until you remember the swap at the top. After lineruns = xr, lineruns is the base of the buffer xr is walking, and prevruns is the base of pr's.

The second hunk is the one I'd have missed. It sits inside the uncompressed-mode handler, which has an inner while loop of its own. That inner loop emits runs without ever going back around the outer loop, so a check placed only at the top of the outer loop wouldn't fire. It begins with a comment that has clearly been there a while:

c
                // ---THE-FOLLOWING-CODE-IS-POORLY-TESTED---
where a bounds check actually runs top of the outer loop first check: xr, pr still in range decode one MMR code uncompressed inner while loop *xr = rle; xr++; repeats without leaving second check goes here back to the top only when the inner loop ends
The inner loop never comes back to the check at the top. The patch adds a second check inside it.

Exploitation and AppArmor#

Backhouse's proof of concept runs on a fully patched Ubuntu 25.04 on x86-64 with the standard mitigations on, and it defeats ASLR. It has a known reliability limit: "it'll work 10 times in a row and then suddenly stop working for several minutes."

The Rick Astley thing has an engineering reason. /usr/bin/papers runs under an AppArmor profile that "prohibits you from starting an arbitrary process but makes an exception for google-chrome". So the shortest path from heap corruption to something visible on screen was a call to system("google-chrome https://www.youtube.com/…"). None of that is containment. The same profile lets you write arbitrary files into the user's home directory, minus the obvious ones like ~/.bashrc.

Reported 1 July 2025, by email, to Léon Bottou, Bill Riemers and Yann LeCun. Two of them replied the same day. Fix committed 2 July. 3.5.29 released 3 July. That is roughly the fastest a coordinated disclosure can physically go.

Fuzzing coverage#

Six months later Morales published a longer piece, "Bugs that survive the heat of continuous fuzzing". It answers the question you would expect a bug like this to raise. DjVuLibre is a decoder for attacker-supplied binary input, sitting behind the file-open dialog of a default desktop application. It is exactly what continuous fuzzing exists for.

It wasn't being fuzzed. Morales's point is that the gap is invisible from the outside: the viewer is covered, the format handler the viewer silently hands your file to is not. So he went and fuzzed the one nobody was fuzzing.

Poppler, the PDF library sitting in the same viewer, is enrolled in OSS-Fuzz with 16 fuzzers and roughly 60% coverage. DjVuLibre: not enrolled, no fuzzers, and a heap overflow in the fax decoder.

Sources

  1. 1GHSL-2025-055: OOB write in MMRDecoder::scanruns() in DjVuLibresecuritylab.github.com
  2. 2CVE-2025-53367: an exploitable out-of-bounds write in DjVuLibregithub.blog
  3. 3Bugs that survive the heat of continuous fuzzinggithub.blog