Exercise1: Xpdf

  这个练习要求我们 fuzz 出 Xpdf CVE-2019-13288 漏洞。攻击者可以使程序陷入无限递归,从而达到 DoS 目的。

0x01 准备工作

  首先编译 xpdf:

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

CC=/afl/afl-clang-lto CXX=/afl/afl-clang-lto++ ./configure --prefix="/home/xpdf/out"
# ...
# configure: WARNING: -- You will be able to compile pdftops, pdftotext,
#        pdfinfo, pdffonts, and pdfimages, but not xpdf or pdftoppm

make
# strcmp: length 3/3 "def"
# AUTODICTIONARY: 309 strings found
# [+] Instrumented 20658 locations (1357 selects) without collisions (2939 collisions have been avoided) (non-hardened mode).
# make[1]: 离开目录“/home/xpdf/xpdf-3.02/xpdf”

make install

  编译完成,程序可以跑起来了:

  弄来一些测试用例:

wget https://github.com/mozilla/pdf.js-sample-files/raw/master/helloworld.pdf
wget http://www.africau.edu/images/default/sample.pdf
wget https://www.melbpc.org.au/wp-content/uploads/2017/10/small-example-pdf-file.pdf

0x02 fuzz

  我们的 fuzz 目标是 pdftotext 程序。这个程序负责从 pdf 中提取文本:

  执行 fuzz(这里的指令和教程不一样,笔者把程序输出放到了 stdout):

/afl/afl-fuzz -i inputs/ -o out -s 123 -- ./pdftotext @@ -

  五分多钟之后,出现几个 crash:

  再跑一会:

  我们的 fuzzing 效率比 Fuzzing101 教程文档快不少,几乎是一倍的速度。来看一下 crash 输入:

  测试一下:

  复现 segmentation fault。接下来,我们尝试分析这个漏洞。

0x03 漏洞分析

  拿 lldb 去调试。首先用 crash 输入让它崩溃,然后看看 stack 现场:thread backtrace unique

  这相当明显是无限递归了。用 VS Code 调试,先看看这个环的入口:

  因此这一个 Object::fetch(XRef*, Object*) 应该是入口。先看看程序从 main 到这个位置的过程。

  VS Code 提示 main 执行 info.isDict() 时调用了 displayPages(),然而事实上这个 isDict() 如下:

class Object {
public:

  // Default constructor.
  Object():
    type(objNone) {}
	
  // ...
  GBool isArray() { return type == objArray; }
  GBool isDict() { return type == objDict; }
  GBool isStream() { return type == objStream; }
  GBool isRef() { return type == objRef; }
  
  // ...

  这显然不可能调用 displayPages(),分析过程出错了。想到可能 -O2 选项对 debug 有影响,我们用 -O0 重新编译一遍:

./configure --prefix="/home/blue/Desktop/workspace/xpdf" CFLAGS="-O0 -g" CXXFLAGS="-O0 -g" CC=afl-clang-lto CXX=afl-clang-lto++
make

  于是在 main() 中指示了正确的位置。

  这段代码的逻辑是:准备一个输出设备(本例中是 stdout),就绪后调用 doc->displayPages() 输出文本。跟进 displayPages

void PDFDoc::displayPages(OutputDev *out, int firstPage, int lastPage,
			  double hDPI, double vDPI, int rotate,
			  GBool useMediaBox, GBool crop, GBool printing,
			  GBool (*abortCheckCbk)(void *data),
			  void *abortCheckCbkData) {
  int page;

  for (page = firstPage; page <= lastPage; ++page) {
    displayPage(out, page, hDPI, vDPI, rotate, useMediaBox, crop, printing,
		abortCheckCbk, abortCheckCbkData);
  }
}

  也就是对于 pdf 中的每一页,调用 displayPage 将其转文字输出。跟进 displayPage

void PDFDoc::displayPage(OutputDev *out, int page,
			 double hDPI, double vDPI, int rotate,
			 GBool useMediaBox, GBool crop, GBool printing,
			 GBool (*abortCheckCbk)(void *data),
			 void *abortCheckCbkData) {
  if (globalParams->getPrintCommands()) {
    printf("***** page %d *****\n", page);
  }
  catalog->getPage(page)->display(out, hDPI, vDPI,
				  rotate, useMediaBox, crop, printing, catalog,
				  abortCheckCbk, abortCheckCbkData);
}

  通过 catalog->getPage(page) 来获取指定页码,再将其展示出来。由于我们 pdf 只有一页,这个 getPage 结果肯定是第一页。跟进 Page::display

void Page::display(OutputDev *out, double hDPI, double vDPI,
		   int rotate, GBool useMediaBox, GBool crop,
		   GBool printing, Catalog *catalog,
		   GBool (*abortCheckCbk)(void *data),
		   void *abortCheckCbkData) {
  displaySlice(out, hDPI, vDPI, rotate, useMediaBox, crop,
	       -1, -1, -1, -1, printing, catalog,
	       abortCheckCbk, abortCheckCbkData);
}

  是直接调用 displaySlice 输出。跟进:

void Page::displaySlice(OutputDev *out, double hDPI, double vDPI,
			int rotate, GBool useMediaBox, GBool crop,
			int sliceX, int sliceY, int sliceW, int sliceH,
			GBool printing, Catalog *catalog,
			GBool (*abortCheckCbk)(void *data),
			void *abortCheckCbkData) {
#ifndef PDF_PARSER_ONLY
  PDFRectangle *mediaBox, *cropBox;
  PDFRectangle box;
  Gfx *gfx;
  Object obj;
  Annots *annotList;
  Dict *acroForm;
  int i;

  if (!out->checkPageSlice(this, hDPI, vDPI, rotate, useMediaBox, crop,
			   sliceX, sliceY, sliceW, sliceH,
			   printing, catalog,
			   abortCheckCbk, abortCheckCbkData)) {
    return;
  }

  rotate += getRotate();
  if (rotate >= 360) {
    rotate -= 360;
  } else if (rotate < 0) {
    rotate += 360;
  }

  makeBox(hDPI, vDPI, rotate, useMediaBox, out->upsideDown(),
	  sliceX, sliceY, sliceW, sliceH, &box, &crop);
  cropBox = getCropBox();

  if (globalParams->getPrintCommands()) {
    mediaBox = getMediaBox();
    printf("***** MediaBox = ll:%g,%g ur:%g,%g\n",
	   mediaBox->x1, mediaBox->y1, mediaBox->x2, mediaBox->y2);
    printf("***** CropBox = ll:%g,%g ur:%g,%g\n",
	   cropBox->x1, cropBox->y1, cropBox->x2, cropBox->y2);
    printf("***** Rotate = %d\n", attrs->getRotate());
  }

  gfx = new Gfx(xref, out, num, attrs->getResourceDict(),
		hDPI, vDPI, &box, crop ? cropBox : (PDFRectangle *)NULL,
		rotate, abortCheckCbk, abortCheckCbkData);
  contents.fetch(xref, &obj);
  // 在这个 fetch() 内进入无限递归
  
  
  if (!obj.isNull()) {
    gfx->saveState();
    gfx->display(&obj);
    gfx->restoreState();
  }
  obj.free();

  // draw annotations
  annotList = new Annots(xref, catalog, getAnnots(&obj));
  obj.free();
  acroForm = catalog->getAcroForm()->isDict() ?
               catalog->getAcroForm()->getDict() : NULL;
  if (acroForm) {
    if (acroForm->lookup("NeedAppearances", &obj)) {
      if (obj.isBool() && obj.getBool()) {
	annotList->generateAppearances(acroForm);
      }
    }
    obj.free();
  }
  if (annotList->getNumAnnots() > 0) {
    if (globalParams->getPrintCommands()) {
      printf("***** Annotations\n");
    }
    for (i = 0; i < annotList->getNumAnnots(); ++i) {
      annotList->getAnnot(i)->draw(gfx, printing);
    }
    out->dump();
  }
  delete annotList;

  delete gfx;
#endif
}

  我们是在执行 contents.fetch(xref, &obj) 时陷入无限递归。 contents 是一个 Object,type 为 objRef,里面有一个 union, Ref 二元组为 (num=7, gen=0)。 来研究一下 xref,它是 Page 类里面的成员,定义:

class Page {

private:
  XRef *xref;			// the xref table for this PDF file
  int num;			// page number
  PageAttrs *attrs;		// page attributes
  Object annots;		// annotations array
  Object contents;		// page contents
  GBool ok;			// true if page is valid
}

  注释里面表示 xref 是 pdf 文件的 xref table。查阅资料:

PDF Cross Reference Table | Appligent Labs | PDF
The PDF Cross Reference Table (xref) is the third major section of a PDF File. It is the index by which all the indirect objects in the PDF file are located.

  简而言之,xref table 是文件里所有 object 的索引表。编码规则如下:

  来看看示例文件 helloworld.pdf 的 xref 表:

  另一篇资料中提到,xref table 的目的是允许随机访问文件中的 object 而无需先读入整个 pdf。那么,当 xref 表损坏时,我们应当可以从文件中重构 xref 表。

  分析 crash 输入,有的 input 不存在 xref table(但有 startxref 字符串),有的 xref table 损坏。xref table 损坏的例子:

  动态调试后发现,“xref 损坏”与“xref 不存在”的例子,崩溃路径并不相同。所以我们还是继续分析原来的 xref 不存在的例子。

  在这里,xref 仅有一个 entry,其 offset 为 4294967295,明显不合理。跟进这个 contents.fetch()

Object *Object::fetch(XRef *xref, Object *obj) {
  return (type == objRef && xref) ?
         xref->fetch(ref.num, ref.gen, obj) : copy(obj);
}

  就是包装了一下 xref 对象自己的 fetch(),其中由于 contents 共用体里面 ref.num=7, ref.gen=0,所以这里调用的是 xref->fetch(7, 0, &obj)。现在我们去跟进 XRef::fetch

Object *XRef::fetch(int num, int gen, Object *obj) {
  XRefEntry *e;
  Parser *parser;
  Object obj1, obj2, obj3;

  // check for bogus ref - this can happen in corrupted PDF files
  if (num < 0 || num >= size) {
    goto err;
  }

  e = &entries[num];
  switch (e->type) {

  case xrefEntryUncompressed:
    if (e->gen != gen) {
      goto err;
    }
    obj1.initNull();
    parser = new Parser(this,
	       new Lexer(this,
		 str->makeSubStream(start + e->offset, gFalse, 0, &obj1)),
	       gTrue);
    parser->getObj(&obj1);
    parser->getObj(&obj2);
    parser->getObj(&obj3);
    if (!obj1.isInt() || obj1.getInt() != num ||
	!obj2.isInt() || obj2.getInt() != gen ||
	!obj3.isCmd("obj")) {
      obj1.free();
      obj2.free();
      obj3.free();
      delete parser;
      goto err;
    }
    parser->getObj(obj, encrypted ? fileKey : (Guchar *)NULL,
		   encAlgorithm, keyLength, num, gen);
    obj1.free();
    obj2.free();
    obj3.free();
    delete parser;
    break;

  case xrefEntryCompressed:
    // ...
    break;

  default:
    goto err;
  }

  return obj;

 err:
  return obj->initNull();
}

  其中,这一句 parser->getObj(obj, encrypted ? fileKey : (Guchar *)NULL, encAlgorithm, keyLength, num, gen) 进入死递归。动态调试到这里,它的实际参数是: getObj(obj, NULL, <RC4>, 0, 7, 0)。推测语义为把 obj 置为 id=7 的 object。跟进 getObj

Object *Parser::getObj(Object *obj, Guchar *fileKey,
		       CryptAlgorithm encAlgorithm, int keyLength,
		       int objNum, int objGen) {
  // ...

  // array
  if (buf1.isCmd("[")) {
    // ...
  // dictionary or stream
  } else if (buf1.isCmd("<<")) {
    shift();
    obj->initDict(xref);     // 初始化 obj 为 objDict
    while (!buf1.isCmd(">>") && !buf1.isEOF()) {
      if (!buf1.isName()) {
	error(getPos(), "Dictionary key must be a name object");
	shift();
      } else {
	key = copyString(buf1.getName());
	shift();
	if (buf1.isEOF() || buf1.isError()) {
	  gfree(key);
	  break;
	}
	obj->dictAdd(key, getObj(&obj2, fileKey, encAlgorithm, keyLength,
				 objNum, objGen));
      }
    }
    if (buf1.isEOF())
      error(getPos(), "End of file inside dictionary");
    
    // stream objects are not allowed inside content streams or
    // object streams
    if (allowStreams && buf2.isCmd("stream")) {
      if ((str = makeStream(obj, fileKey, encAlgorithm, keyLength,
			    objNum, objGen))) {
      // 这个 makeStrem 进入无限递归
      // ...

  这里调用 Parser::makeStream,参数列表与上文的 getObj 参数基本相同。跟进:

Stream *Parser::makeStream(Object *dict, Guchar *fileKey,
			   CryptAlgorithm encAlgorithm, int keyLength,
			   int objNum, int objGen) {
  Object obj;
  BaseStream *baseStr;
  Stream *str;
  Guint pos, endPos, length;

  // get stream start position
  lexer->skipToNextLine();
  pos = lexer->getPos();

  // get length
  dict->dictLookup("Length", &obj);
  // 在此处进入无限递归
  
  if (obj.isInt()) {
    length = (Guint)obj.getInt();
    obj.free();
  } else {
    error(getPos(), "Bad 'Length' attribute in stream");
    obj.free();
    return NULL;
  }
  // ...

  这里的 dict 就是我们传进来的 obj,即 Page 类所调用的 contents.fetch(xref, &obj) 里面的 obj。它现在已经被组装成一个 objDict,顾名思义应该是一个 dictionary。那么猜测这一行 dict->dictLookup("Length", &obj) 的语义是“从 dict 中查找键 Length 对应的 value”。跟进:

inline Object *Object::dictLookup(char *key, Object *obj)
  { return dict->lookup(key, obj); }

  应当注意上面代码中的 dict 是这个 Object 的 private 成员(类型为 Dict ),而非这个名为 dictObject 本身。如下图,dictionary 里面存在名为 Length 的键。

跟进这个 lookup

Object *Dict::lookup(char *key, Object *obj) {
  DictEntry *e;

  return (e = find(key)) ? e->val.fetch(xref, obj) : obj->initNull();
}

  这里 find(key) 是寻找到 dictionary 里面的一条 entry,寻找成功之后,调用 e->val.fetch(xref, obj)。动态调试知,这里的 e->val 是一条 objRef

  发现这个 objRefref 属性是二元组 (num=7, gen=0),与 contents 一致。跟进这个 (e->val).fetch

Object *Object::fetch(XRef *xref, Object *obj) {
  return (type == objRef && xref) ?
         xref->fetch(ref.num, ref.gen, obj) : copy(obj);
}

  实际调用的是 xref->fetch(7, 0, &newobj),而我们之前讨论过 contents.fetch(xref, &obj) 触发的 xref->fetch(7, 0, &obj),至此,我们成功还原了递归链条:

  1. main 经过一些过程之后,调用 displaySlice 输出一些文本
  2. displaySlice 调用contents.fetch(xref, &obj) ,其中 contents 是一个 objRef,共用体 ref 二元组为 (num=7, gen=0)
  3. xref->fetch(ref.num, ref.gen, obj) 被调用,实际上 call 了 xref->fetch(7, 0, obj)
  4. xref->fetch 过程中,检测到这条 entry 是未被压缩的,调用 parser->getObj(obj, fileKey=NULL, encAlgorithm=<RC4>, keyLength0, num=7, gen=0),以获取 num=7, gen=0 这个 pdf object
  5. Parser::getObj 过程中,首先通过 obj->initDict(xref)objobjNone 初始化成一个 objDict,调用 makeStream(obj, fileKey=NULL, encAlgorithm=<RC4>, keyLength=0, objNum=7, objGen=0) 生成一个 Stream
  6. Parser::makeStream 过程中,调用 obj->dictLookup("Length", &newobj),意图是从现在已经是 objDictobj 里面取 key 为 "Length" 的键值对,把 value 给 newobj
  7. 上述 dictLookup 是一个简单封装,调用 obj->dict->lookup("Length", &newobj)
  8. 上述 lookupobj->dict 这个 dictionary 里面寻找到 key 为 "Length" 的 entry e: (key="Length", val=<objRef>),且这里的这个类型为 objRefvalref 二元组为 (num=7, gen=0)。调用 val.fetch(xref, &newobj)
  9. 上述 val.fetch(xref, &newobj)由于 valref 二元组为 (num=7, gen=0),所以会调用 xref->fetch(7, 0, &newobj),这个 call 与第 3 条分析的 call 作用相同,至此进入无限递归。

  上述过程中,最违背常理的是第 6 条,Length 这个键为什么会对应一个 objRef 而不是 objInt。我们再看一遍 makeStream 过程:

  // get length
  dict->dictLookup("Length", &obj);  // 在这里陷入无限递归
  if (obj.isInt()) {
    length = (Guint)obj.getInt();
    obj.free();
  } else {
    error(getPos(), "Bad 'Length' attribute in stream");
    obj.free();
    return NULL;
  }

  这说明开发者也期望 Length 键对应一个整数,如果不是整数的话会报错。可惜程序没等到运行至 isInt() 检测,就进入死递归了。

  正常的运行逻辑应该是: Length 键对应一个 objInt 类型的 val,调用 val.fetch(xref, &newobj)

Object *Object::fetch(XRef *xref, Object *obj) {
  return (type == objRef && xref) ?
         xref->fetch(ref.num, ref.gen, obj) : copy(obj);
}

  如果 valobjInt 类型,那么它将会返回自己这个 objInt 的副本。程序将会正常运行,而不是递归 fetch 下去。

0x04 修复

  在研究清楚为什么 crash 之后,我们的修复方案已经明了:若 Length 所对应的 val 不是 objInt,则不能执行第 6 条分析里的 obj->dictLookup("Length", &newobj) 。这可以通过在各种入口处特判来实现,但考虑到 lookup 使用非常频繁,入口处判断容易影响性能;且如果我们在 lookup 前就终止程序,那么原有的 makeStream 中判断 val 是否合法的代码就不能复用。故笔者写了 Length 查询特制版的 Dict::lookupLengthObject::dictLookupLength

Object *Dict::lookupLength(char *key, Object *obj) {
  DictEntry *e;

  return (e = find(key)) ? e->val.copy(obj) : obj->initNull();
}

inline Object *Object::dictLookupLength(char *key, Object *obj)
  { return dict->lookupLength(key, obj); }

  并将 Parser::makeStream 里的代码做如下修改。这样 patch 不影响程序性能。

// get length
[-] dict->dictLookup("Length", &obj);
[+] dict->dictLookupLength("Length", &obj);

  完整的改动列表可以在 git diff 中查看:https://github.com/Ruanxingzhi/Fuzzing101/commit/57d3...

  正常功能测试:

▲ 原程序和 patch 后程序均正常处理

  crash0 测试:

▲ 原程序段错误,patch 后程序正常处理

  crash1 测试:

▲ 原程序段错误,patch 后程序正常处理

  可见我们成功修复了 bug。至此,我们完成了 exercise 1 的 fuzzing、复现和修复。

0x05 总结

  • -O2 可能影响调试。调试时应该采用 -O0
  • 复现漏洞需要一些耐心。