Reproducing and Patching CVE-2019-13288 - XPDF Infinite recursion & Null pointer dereference
Discovering CVE-2019-13288 in XPDF 3.02 using AFL++ fuzzing and proposing patches.
Vulnerability: infinite recursion; null pointer dereferencing
Introduction
Trong bài viết này, mình ghi chép lại quá trình reproduce lỗ hổng CVE-2019-13288 khiến cho XPDF phiên bản 3.02 bị crash. Lỗ hổng này khiến chương trình chạy đệ quy vô tận khi người dùng nhập vào một file được tạo đặc biệt. Vì mỗi lần gọi hàm, chương trình sẽ cấp một stack frame ở trên stack. Đệ quy vô tận dẫn tới đầy stack và có thể khiến chương trình truy cập vào vùng nhớ ngoài stack, địa chỉ không hợp lệ dẫn đến sigsegv -> crash chương trình.
Bên cạnh đó mình còn phát hiện thêm một lỗ hổng khác đó là dereference đến con trỏ NULL, cũng dẫn đến sigsegv và crash chương trình.
Sau khi xác định được vị trí của các lỗ hổng, mình đã đưa ra một số fix và thành công loại bỏ được 2 lỗ hổng trên (mình nghĩ vậy).
Setting up
Trước hết mình download mã nguồn của XPDF phiên bản 3.02, sau đó build và chạy thử.
Build XPDF
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gz
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ tar -xvzf xpdf-3.02.tar.gz
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/xpdf-3.02$ cd xpdf-3.02
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/xpdf-3.02$ sudo apt update && sudo apt install -y build-essential gcc
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/xpdf-3.02$ ./configure --prefix="/home/ngtuonghung/cve/cve-2019-13288/install/"
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/xpdf-3.02$ make
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/xpdf-3.02$ make installDownload một số example pdfs:
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/xpdf-3.02$ cd ../
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ mkdir inputs
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ cd inputs/
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/inputs$ wget https://example-files.online-convert.com/document/pdf/example.pdf
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/inputs$ wget https://ontheline.trincoll.edu/images/bookdown/sample-local-pdf.pdf
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/inputs$ wget https://pdfobject.com/pdf/sample.pdf
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/inputs$ wget https://github.com/mozilla/pdf.js-sample-files/raw/master/helloworld.pdfTest chương trình đã build:
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ ./install/bin/pdfinfo -box -meta ./inputs/helloworld.pdf
Tagged: no
Pages: 1
Encrypted: no
Page size: 200 x 200 pts
MediaBox: 0.00 0.00 200.00 200.00
CropBox: 0.00 0.00 200.00 200.00
BleedBox: 0.00 0.00 200.00 200.00
TrimBox: 0.00 0.00 200.00 200.00
ArtBox: 0.00 0.00 200.00 200.00
File size: 678 bytes
Optimized: no
PDF version: 1.Ok rồi, bây giờ mình xoá chương trình đi để chút nữa build lại với AFL++.
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ rm -rf ./installInstalling AFL++
Cài đặt AFL++ cũng rất đơn giản, chỉ việc pull docker image về và chạy container. Ở đây mình mount thư mục /home/ngtuonghung/cve/cve-2019-13288 ở ngoài host vào /src trong container để có thể truy cập mã nguồn của XPDF từ trong đó.
docker pull aflplusplus/aflplusplus
docker run -ti -v /home/ngtuonghung/cve/cve-2019-13288:/src aflplusplus/aflplusplusFuzzing with AFL++
[AFL++ 2b754c572908] /AFLplusplus # cd /src
[AFL++ 2b754c572908] /src # cd xpdf-3.02/
[AFL++ 2b754c572908] /src/xpdf-3.02 # make clean
[AFL++ 2b754c572908] /src/xpdf-3.02 # CC=/AFLplusplus/afl-clang-fast CXX=/AFLplusplus/afl-clang-fast++ ./configure --prefix="/src/install"
[AFL++ 2b754c572908] /src/xpdf-3.02 # make
[AFL++ 2b754c572908] /src/xpdf-3.02 # make installMình cấu hình quá trình biên dịch chương trình xpdf với instrumentation của AFL++:
- CC=/AFLplusplus/afl-clang-fast: Đặt trình biên dịch C thành
afl-clang-fast, đây là compiler wrapper của AFL++ cho phép chèn mã giám sát vào chương trình trong quá trình biên dịch. - CXX=/AFLplusplus/afl-clang-fast++: Tương tự cho trình biên dịch C++, sử dụng
afl-clang-fast++để biên dịch các file C++. - ./configure: Chạy script cấu hình của xpdf để chuẩn bị cho quá trình build.
- –prefix="/src/install": Chỉ định thư mục cài đặt là
/src/install, nơi các file binary đã biên dịch sẽ được đặt.
[AFL++ 2b754c572908] /src # cd ..
[AFL++ 2b754c572908] /src # mkdir outputs
[AFL++ 2b754c572908] /src # afl-fuzz -i /src/inputs/ -o /src/outputs/ -s 123 -- /src/install/bin/pdftotext @@ /src/outputs/Ok, bây giờ mình khởi chạy fuzzer AFL++ với chương trình pdftotext với các tham số sau:
- -i /src/inputs/: Chỉ định thư mục input chứa các test case ban đầu (seed corpus) - những file PDF mẫu để fuzzer bắt đầu mutate.
- -o /src/outputs/: Thư mục output nơi AFL++ lưu kết quả, bao gồm các crash, hang, và test case mới tìm được.
- -s 123: Đặt seed cố định cho random number generator là 123, để các phiên fuzzing có kết quả giống nhau (vì AFL sử dụng thuật toán non-deterministic).
- –: Dấu phân cách giữa các tham số của afl-fuzz và lệnh chương trình cần test.
- /src/install/bin/pdftotext: Đường dẫn đến binary pdftotext đã được instrumented (từ lệnh trước).
- @@: Placeholder đặc biệt của AFL++ - sẽ được thay thế bằng đường dẫn đến file input mà fuzzer sinh ra.
- /src/outputs/: Tham số thứ hai cho pdftotext (thư mục output cho text được convert).

Sau gần 50s, mình phát hiện ra 6 crash, mình quyết định dừng ở đây và xem các crash đó xảy ra như thế nào.
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ cd outputs/
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/outputs$ ls -la
total 12
drwxr-xr-x 3 root root 4096 Dec 22 17:21 .
drwxr-xr-x 6 ngtuonghung ngtuonghung 4096 Dec 22 17:21 ..
drwx------ 6 root root 4096 Dec 22 17:22 default
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/outputs$ sudo chown 1000:1000 default/ -Rngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/outputs/default$ ls
cmdline crashes fastresume.bin fuzz_bitmap fuzzer_setup fuzzer_stats hangs plot_data queue target_hash
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/outputs/default$ cd crashes/
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/outputs/default/crashes$ ls -la
total 924
drwx------ 2 ngtuonghung ngtuonghung 4096 Dec 22 17:22 .
drwx------ 6 ngtuonghung ngtuonghung 4096 Dec 22 17:22 ..
-rw------- 1 ngtuonghung ngtuonghung 588 Dec 22 17:22 README.txt
-rw------- 1 ngtuonghung ngtuonghung 153378 Dec 22 17:22 id:000000,sig:11,src:000000,time:7988,execs:3575,op:quick,pos:1232
-rw------- 1 ngtuonghung ngtuonghung 153378 Dec 22 17:22 id:000001,sig:11,src:000000,time:8154,execs:3580,op:quick,pos:1236
-rw------- 1 ngtuonghung ngtuonghung 153378 Dec 22 17:22 id:000002,sig:11,src:000000,time:8297,execs:3596,op:quick,pos:1251
-rw------- 1 ngtuonghung ngtuonghung 153378 Dec 22 17:22 id:000003,sig:11,src:000000,time:8439,execs:3598,op:quick,pos:1252
-rw------- 1 ngtuonghung ngtuonghung 153378 Dec 22 17:22 id:000004,sig:11,src:000000,time:20004,execs:9036,op:flip2,pos:265
-rw------- 1 ngtuonghung ngtuonghung 153378 Dec 22 17:22 id:000005,sig:11,src:000000,time:35846,execs:15942,op:arith8,pos:265,val:-3Đúng là chương trình bị sigsev:
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288/outputs/default/crashes$ ../../../install/bin/pdftotext ./id:000000,sig:11,src:000000,time:7988,execs:3575,o
p:quick,pos:1232 ../../../outputs/
Error (1251): Dictionary key must be a name object
Error (1268): Dictionary key must be a name object
Segmentation fault (core dumped)Examining the crashes
Để quan sát quá trình crash của chương trình, mình cần build lại với đầy đủ symbol và tắt các tối ưu hoá của compiler:
[AFL++ d5edc73eecdd] /src # rm -rf install/
[AFL++ d5edc73eecdd] /src # cd xpdf-3.02/
[AFL++ d5edc73eecdd] /src/xpdf-3.02 # make clean
[AFL++ d5edc73eecdd] /src/xpdf-3.02 # CFLAGS="-g -O0" CXXFLAGS="-g -O0" ./configure --prefix="/src/install"
[AFL++ d5edc73eecdd] /src/xpdf-3.02 # make
[AFL++ d5edc73eecdd] /src/xpdf-3.02 # make installGồm các flag như sau:
- CFLAGS="-g -O0": Đặt compilation flags cho mã C. Thêm debug symbols (thông tin debugging) vào binary, cho phép sử dụng debugger như GDB để theo dõi execution, đặt breakpoint, và kiểm tra biến.
- CXXFLAGS="-g -O0": Đặt compilation flags cho mã C++. Tắt hoàn toàn compiler optimization (optimization level 0), tạo ra code lớn nhất và chậm nhất nhưng giữ nguyên tất cả debug information. Code không được optimize sẽ dễ debug hơn vì cấu trúc code trong binary khớp với source code gốc.
Mặc dù AFL++ phát hiện 6 crashes, nhưng mình thấy chỉ có 2 unique crashes:
Crash 1
Mình sử dụng pwndbg để phân tích crash và trace các lời gọi hàm:
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ gdb --args ./install/bin/pdftotext "./outputs/default/crashes/id:000000,sig:11,src:000000,time:7988,execs:3575,op:quick,pos:1232" ./outputs/Chương trình crash khi đang cố gắng truy cập bộ nhớ tại địa chỉ 0x48 (không hợp lệ):

Xảy ra tại instruction ObjectStream::getObject()+65 trong XRef.cc:183:

Cụ thể mã nguồn ở đó như sau:
Object *ObjectStream::getObject(int objIdx, int objNum, Object *obj) {
if (objIdx < 0 || objIdx >= nObjects || objNum != objNums[objIdx]) {
return obj->initNull();
}
return objs[objIdx].copy(obj);
}Nhưng mình thấy như vậy vẫn khó xác định rằng lỗ hổng nằm chính xác ở đâu trong đoạn code này, nên mình đã sử dụng IDA để dịch ngược chương trình đã build, sau đó ánh xạ mã giả và assembly:

Mình phân tích một vài instruction trước đó như sau:
mov rax, [rbp+this]: Gán địa chỉ củathis(mộtObjectStream) vàorax.mov rdx, [rax+10h]: Gán địa chỉ củaobjNumsvàordx.mov eax, [rbp+objIdx]: Gán giá trị củaobjIdxvàoeax.cdqe: Extend giá trị 32bit củaeaxvàorax64bit.shl rax, 2: NhânobjIdxvới 4 (do là int).add rax, rdx: Gán địa chỉobjNums + objIdxvàorax.mov eax, [rax]: Lấy giá trị tại địa chỉobjNums + objIdxđặt vàoeax.
Vậy chương trình đang cố gắng truy cập this->objNums[objIdx] nhưng không thành bởi vì objNums đang là NULL, dẫn đến objNums + objIdx = 0x48.
Trước đó trong constructor của ObjectStream, objNums ban đầu là NULL, sau đó được cấp phát bằng hàm gmallocn(). Tuy nhiên nếu gặp lỗi trước khi cấp phát, hàm sẽ nhảy đến err1 và objNums vẫn là NULL.
ObjectStream::ObjectStream(XRef *xref, int objStrNumA) {
Stream *str;
Parser *parser;
int *offsets;
Object objStr, obj1, obj2;
int first, i;
objStrNum = objStrNumA;
nObjects = 0;
objs = NULL;
objNums = NULL;
if (!xref->fetch(objStrNum, 0, &objStr)->isStream()) {
goto err1;
}
if (!objStr.streamGetDict()->lookup("N", &obj1)->isInt()) {
obj1.free();
goto err1;
}
nObjects = obj1.getInt();
obj1.free();
if (nObjects <= 0) {
goto err1;
}
if (!objStr.streamGetDict()->lookup("First", &obj1)->isInt()) {
obj1.free();
goto err1;
}
first = obj1.getInt();
obj1.free();
if (first < 0) {
goto err1;
}
objs = new Object[nObjects];
objNums = (int *)gmallocn(nObjects, sizeof(int));
offsets = (int *)gmallocn(nObjects, sizeof(int));
...
}Crash 2
ngtuonghung@ngtuonghung-pc:~/cve/cve-2019-13288$ gdb --args ./install/bin/pdftotext "./outputs/default/crashes/id:000005,sig:11,src:000000,time:35846,execs:15942,op:arith8,pos:265,val:-3" ./outputs/Chương trình đang cố gắng truy cập một địa chỉ nhìn có vẻ như địa chỉ stack:

Backtrace sẽ thấy bộ nhớ stack bị đầy do đệ quy vô tận, đây chính là CVE-2019-13288:

Đây là địa chỉ nằm ngoài vùng stack:

Chạy backtrace một lúc mình có được nơi bắt đầu của đệ quy đó là hàm XRef::fetch tại XRef.cc:84:

Sau một hồi tìm hiểu cấu trúc của PDF, ý tưởng hoạt động của chương trình với Claude, thì mình biết được rằng từ PDF phiên bản 1.5 trở đi, object trong PDF có 2 loại đó là Uncompressed và Compressed. Xref entry của uncompressed object có offset trỏ trực tiếp tới vị trí của object trong body. Còn Xref entry của compressed object có offset trỏ tới một xref entry khác. Các compressed object sẽ nằm trong một objectstream, và objectstream cũng là một loại object, nhưng nó cần phải là uncompressed object. Khi fetch một compressed object, chương trình sẽ cần phải fetch objectstream trước, rồi mới nhảy đến objectstream đó để lấy compressed object đó.
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:
...
break;
case xrefEntryCompressed:
if (gen != 0) {
goto err;
}
if (!objStr || objStr->getObjStrNum() != (int)e->offset) {
if (objStr) {
delete objStr;
}
objStr = new ObjectStream(this, e->offset);
}
objStr->getObject(e->gen, num, obj);
break;
default:
goto err;
}
return obj;
err:
return obj->initNull();
}Tuy nhiên cần lưu ý rằng, một objectstream cần phải là uncompressed object, nếu không sẽ dẫn đến đệ quy vô tận bởi cứ liên tục fetch objectstream của một compressed object. Vậy vấn đề ở đây là, khi fetch objectstream, chương trình không kiểm tra xem liệu lần fetch tiếp theo liệu có đang fetch một uncompressed object hay không.
ObjectStream::ObjectStream(XRef *xref, int objStrNumA) {
Stream *str;
Parser *parser;
int *offsets;
Object objStr, obj1, obj2;
int first, i;
objStrNum = objStrNumA;
nObjects = 0;
objs = NULL;
objNums = NULL;
if (!xref->fetch(objStrNum, 0, &objStr)->isStream()) {
goto err1;
}
...
}Nếu không, hàm XRef::fetch lại tiếp tục đi vào nhánh của case xrefEntryCompressed và gọi ObjectStream::ObjectStream(), có thể dẫn đến đệ quy vô tận.
Fixing the crash
Crash 1
Sửa crash 1 khá đơn giản, chỉ cần check xem liệu objNums có khác NULL hay không.
Object *ObjectStream::getObject(int objIdx, int objNum, Object *obj) {
if (!objNums || objIdx < 0 || objIdx >= nObjects || objNum != objNums[objIdx]) {
return obj->initNull();
}
return objs[objIdx].copy(obj);
}Crash 2
Đối với crash 2, trước khi fetch objectstream, mình sẽ kiểm tra trước đó là type của object cần fetch có phải uncompressed hay không. Và cũng cần lưu ý kiểm tra objStrNum nằm trong giới hạn kích thước của Xref entry để tránh out-of-bound.
ObjectStream::ObjectStream(XRef *xref, int objStrNumA) {
Stream *str;
Parser *parser;
int *offsets;
Object objStr, obj1, obj2;
int first, i;
objStrNum = objStrNumA;
nObjects = 0;
objs = NULL;
objNums = NULL;
if (!xref || objStrNum < 0 || objStrNum >= xref->getSize()) {
goto err1;
}
if (xref->getEntry(objStrNum)->type != xrefEntryUncompressed || !xref->fetch(objStrNum, 0, &objStr)->isStream()) {
goto err1;
}
...
err1:
objStr.free();
return;
}Confirmation Fuzzing
Sau khi patch xong mã nguồn. Mình build lại với instruments của AFL++. Và kết quả sau hơn 5 phút fuzz, không phát hiện crash nào.

Conclusion
Ok vậy patch của mình đã thành công, ít nhất đã fix được 2 lỗ hổng kia, CVE-2019-13288 và một lỗ hổng khác mình phát hiện ra.