# CVE-2019-13288 ## Description CVE: https://www.cve.org/CVERecord?id=CVE-2019-13288 # Compile ## Download 这里选择的是 [Xpdf Source Code - xpdf 3.02](https://www.xpdfreader.com/old-versi ons.html),而不是 _4.01.01_。 ```shellsession wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gz tar -zxvf xpdf-3.02.tar.gz ``` ## Build 先用 LTO mode 插下桩: ```shellsession cd xpdf-3.02 CC=afl-clang-lto CXX=afl-clang-lto++ ./configure --prefix="$PWD/install" make -j`nproc` make install ``` 考虑到插桩后的代码影响调试,所以我们再单独编译一个调试用的版本: ```shellsession 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 虚拟环境,以免搞乱系统环境: ```shellsession uv venv .venv uv pip install reportlab pillow ``` 生成样本的脚本如下,使用 `uv run python .venv/gen_corpus.py` 运行。 ```python title="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 xx9cx03x00x00x00x00x01 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 了: ```shellsession 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, ob jGen=0` 作为参数,而 `Object::fetch` 和 `XRef::fetch` 的调用虽然早于 `Parser::ge tObj`,并且也在后续 backtrace 中重复出现,但从名字来看就知道,它们只是用于转发参 数的函数,而不是真正创建 / 重入对象的函数,所以,我们应该从 `Parser::getObj` 开 始分析,暂且认为它是递归环入口。
在 `Parser.cc:94` 下个断点,我们发现它直接进入了 `makeStream`,一路 `n` 下去,最 后进入了 `addFilters`:
所以 `addFilters` 才是递归环的入口。 ```cpp 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_ 长这样: ```plaintext showLineNumbers=false collapse={1-37, 45-67} %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 Librar y - (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,0UH3TpNYT^QKk?tc>IP,;W#U1^23ihPEM_?CW4KISi9!25KZ"cI79neZ[Kb,ht$3`$^8YH ZB~>endstream endobj 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, ver sion 1.3](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfrefer ence1.3.pdf) 。之所以看 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` 的元素,由于类型为 `obj Name`,所以之后会进入 `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` 成立,导致再次 fetc h 了 `(objNum=7, objGen=0)`,如此循环,陷入无限递归,最终耗尽了栈空间,BOOM! <cneter>
</cneter> # Fix 查询规格文档,我们发现 Filter 的值只能是 Name 或 Name 数组,那就很好解决了。
我们可以直接禁止 Filter 中出现 Indirect Reference,或者检测 Stream 自引用等……出 于性能考虑,我选择直接挡 `objRef` 类型: ```cpp ins={22, 33-27} 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.arrayGetNF(i, &obj2); if (params.isArray()) params.arrayGet(i, ¶ms2); else params2.initNull(); if (obj2.isRef()) { error(getPos(), "Indirect reference is not allowed in Filter"); str = new EOFStream(str); obj2.free(); params2.free(); break; } 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; } ``` 可以看到,我们成功将无限递归扼杀在了摇篮里 xD










