1. 분석 대상
Xpdf란?
PDF 뷰어 및 관련 명령행 도구들을 사용할 수 있는 오픈 소스 프로그램
Main Topic
- Afl-clang-fast
- Afl-fuzz
- GDB
2. CVE Code
CVE-2019-13288
- Xpdf 3.02 버전에서 Parser.cc 파일의 Parser::getObj() 함수가 무한히 호출되어 DoS 공격이 발생되는 취약점
3. CVE 관련 정보
Reference
https://nvd.nist.gov/vuln/detail/CVE-2019-13288
https://github.com/antonio-morales/Fuzzing101/tree/main/Exercise%201
4. 분석 환경
Ubuntu 20.04 환경에서 진행
- Xpdf 3.02 Download
wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gz
tar -xvzf xpdf-3.02.tar.gz
- build-essential Download
sudo apt install build-essential
build-essential이란?
- 데비안 계열 리눅스에서 개발에 필요한 패키지들을 묶어놓은 패키지로 GCC, configure, make와 같은 도구를 포함하고 있다.
- Xpdf는 소스 코드를 다운받아 컴파일 하여 설치해야 하기 때문에 build-essential을 사용하여 configure > make > make install 의 순서로 설치하게 된다.
- Configure
현재 OS의 종류나 컴파일러의 위치, 종류 등을 파악하고 컴파일이 완료된 프로그램의 디렉터리를 지정할 수 있고 환경에 맞추어 Makefile을 생성해준다.
--prefix 옵션을 사용하면 컴파일된 프로그램들을 원하는 디렉터리에 설치할 수 있다. - Make
configure 명령을 통해 만들어진 Makefile 스크립트를 따라 소스코드를 컴파일한다.
Makefile 스크립트는 컴파일 과정중 의존성 문제나 필요한 명령들을 표준 문법으로 기술한 파일이다. - Make install
make를 통해 컴파일 후 설치를 하는 과정이다. 빌드된 프로그램을 실행할 수 있게 알맞은 위치에 복사한다.
Xpdf Build
cd xpdf-3.02
sudo apt update && sudo apt install -y build-essential gcc
./configure --prefix="$HOME/fuzzing_xpdf/install/"
make
make install
Xpdf Example file download
cd $HOME/fuzzing_xpdf
mkdir pdf_examples && cd pdf_examples
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
root@7216a6b36080:~/fuzzing_xpdf/pdf_examples# xxd sample.pdf
00000000: 2550 4446 2d31 2e33 0d0a 25e2 e3cf d30d %PDF-1.3..%.....
00000010: 0a0d 0a31 2030 206f 626a 0d0a 3c3c 0d0a ...1 0 obj..<<..
00000020: 2f54 7970 6520 2f43 6174 616c 6f67 0d0a /Type /Catalog..
...
AFL +++ (Fuzzer) download
- 의존성 설치
sudo apt-get update
sudo apt-get install -y build-essential python3-dev automake git flex bison libglib2.0-dev libpixman-1-dev python3-setuptools
sudo apt-get install -y lld-11 llvm-11 llvm-11-dev clang-11 || sudo apt-get install -y lld llvm llvm-dev clang
sudo apt-get install -y gcc-$(gcc --version|head -n1|sed 's/.* //'|sed 's/\..*//')-plugin-dev libstdc++-$(gcc --version|head -n1|sed 's/.* //'|sed 's/\..*//')-dev
- AFL +++ 빌드 확인
cd $HOME
git clone https://github.com/AFLplusplus/AFLplusplus && cd AFLplusplus
export LLVM_CONFIG="llvm-config-11"
make distrib
sudo make install
afl-clang-fast 로 Xpdf 재컴파일
- Instrumentation을 사용하기 위해 이전에 컴파일한 내용 삭제
rm -r $HOME/fuzzing_xpdf/install
cd $HOME/fuzzing_xpdf/xpdf-3.02/
make clean
Instrumentation 이란?
- AFL은 커버리지를 넓혀가며 프로그램 제어 흐름에 대한 변경사항을 기록하고 이를 로깅해서 유니크한 크래시를 찾는다.
- 즉, AFL은 커버리지 기반 퍼저이므로 코드 커버리지 측정을 위한 코드를 컴파일 타임에 삽입해야 한다. 이때 코드 삽입하는 것을 Instrumentation 이라고 한다.
*QEMU를 이용하면 런타임시에도 코드 삽입이 가능하다.
컴파일러 변경 후 재빌드
export LLVM_CONFIG="llvm-config-11"
CC=$HOME/AFLplusplus/afl-clang-fast CXX=$HOME/AFLplusplus/afl-clang-fast++ ./configure --prefix="$HOME/fuzzing_xpdf/install/"
make
make install
Fuzzing
afl-fuzz -i $HOME/fuzzing_xpdf/pdf_examples/ -o $HOME/fuzzing_xpdf/out/ -s 123 -- $HOME/fuzzing_xpdf/install/bin/pdftotext @@ $HOME/fuzzing_xpdf/output
- -i
입력 테스트 케이스를 넣을 디렉터리 - -o
결과를 저장할 디렉터리 - -s
사용할 정적 랜덤 시드 - --
-- 뒤에 실제로 넣을 인자들을 작성 - @@
타겟 프로그램이 파일을 입력으로 받는 경우에 사용한다. @@ 위치에 자동으로 대상 파일의 경로와 이름이 커맨드 라인으로 들어간다. 붙이지 않으면 표준 입력으로 처리하는 것으로 간주한다.
- 20분 정도 돌린 결과, 4개의 유니크 크래쉬를 찾았고 위 이미지 부분에서 퍼저를 멈췄다.
5. 루트 커즈 분석
~/fuzzing_xpdf/out 하위 디렉터리에 저장된 크래시 파일을 확인할 수 있다.
root@7216a6b36080:~/fuzzing_xpdf/out/default/crashes# ls
README.txt id:000002,sig:11,src:001821,time:1075540,execs:542355,op:havoc,rep:2
id:000000,sig:11,src:000984,time:190360,execs:106060,op:havoc,rep:8 id:000003,sig:11,src:001373+001870,time:1206211,execs:602076,op:splice,rep:1
id:000001,sig:11,src:001266,time:785876,execs:402153,op:havoc,rep:1
두 번째 크래시 파일을 대상으로 디버깅을 진행
스택의 범위를 벗어난 부분에 쓰기를 시도하려다 오류가 발생한 것을 볼 수 있다.
Object::dictLookup > Parser::makeStream > Parser:getObj > XRef :: fetch를 계속 호출 하다가 스택의 범의를 벗어나 버린 것을 확인할 수 있다.
문제가 발생하는 Parser.cc 를 살펴보기 전 PDF 파일의 구조를 먼저 알아보자.
PDF 파일 구조
PDF 파일은 크게 4가지로 분류되어 있다.
- Header - 총 8바이트 크기로 PDF 시그니처와 문서의 버전 정보를 가지고 있다.
- Body - 실제 문서의 정보들을 포함하는 Object(객체)와 이 객체들을 구성하는 트리 형태로 서로 링크되어 있다.
하나의 오브젝트는 obj - endobj 문자열 사이에 데이터가 기록 되며, obj 문자열 앞에는 10 0과 같은 숫자가 있는데,
10은 오브젝트 번호, 뒤에 0은 생성 번호를 의미한다. - Xref Table - 각 개체들을 참조할 때 사용되는 테이블이다. 객체의 사용 여부와 식별 번호 등이 저장되어 있다.
xref 문자열 뒤로 두 개의 숫자가 존재하는데, 첫 번째는 엔트리 오브젝트 숫자를, 두 번째는 해당 서브섹션이 포함하고 있는 포함하고 있는 엔트리 개수를 의미한다.
각각의 서브 섹션은 3개의 필드로 구성되는데, 첫 번째는 PDF 파일의 시작위치 부터 해당 객체까지의 오프셋, 두 번째는 객체의 생성 번호, 마지막은 해당 객체가 사용중인지 아닌지를 나타내는 플래그이다. - File Trailer - File Body에 존재하는 객체 중 최상위 객체가 어떤 객체인지, Xref Table이 어디에 위치하는지 등이 기록되어있다.
여기서 Object에는 8가지 기본적인 타입을 가지고 있다.
- Boolean Object
True, False의 값을 가지며 배열의 요소나 Dictionary의 엔트리 값으로 사용 가능하다. - Numeric Object
정수형과 실수형의 값을 가진다. - Strings Object
괄호 안에 문자열이 쓰인다. 16진수 스트링은 괄호 <> 로 둘러싸인 16진수 아스키 코드 문자로 이루어진다.
예를 들어 <901FA>는 0x90, 0x1f, 0xa0 로 구성된 3이트 스트링을 말한다. - Name Object
하나의 심볼로써 중복되지 않는 유일한 문자열이다. 0x21~0x7e 범위의 아스키 문자 시퀀스로 표시되며 앞에 슬래쉬(/)를 붙여주어야 한다. 슬래쉬 이후부터 name object가 시작되는 것을 나타낸다. - Arrays Object
다양한 형택의 오브젝트를 엔트리로 가질 수 있다. 대괄호 안에 연속된 객체들을 넣어 사용한다. - Dictionary Object
<<key, value>> 쌍으로 구성된 연계 테이블이다. 여기서 key 값은 반드시 name type으로 고유해야 하며 value는 모든 type을 사용할 수 있다. dictionary의 항목 수는 최대 4096개 까지 가능하다. - Stream Object
1.4 버전 이후로는 stream type이 포함 되었다. 길이의 제한이 없어서 연속적인 바이트 집단으로 크기가 큰 이미지나 파일 내용을 표현할 때 사용된다. 데이터는 stream - endstream 문자열 사이에 기록된다. - Null Object
키워드 "null"로 표시된다. - Indirect Object
PDF 파일에서 레이블된 객체들을 간접 객체라고 한다. 간접 객체들은 고유 식별자를 가지게 되는데, 이 식별자를 이용해서 다른 객체에서 참조할 수 있다. 이 고유 식별자는 위에서 알아본 10 0 과 같은 obj 앞 숫자 이다.
코드 분석
Call Stack을 보면 인덱스 7이 계속해서 전달되는 것을 볼 수 있다. 그렇다면 7번 객체를 살펴보자.
문제의 7번 객체이다. Stream 부분은 제외하고 Dictionary 부분을 살펴보았을 때 7 0 R을 참조하는 것을 알 수 있다.
즉, 7번 객체에서 7 0 R을 참조 한다는 것은 자기 자신을 다시 참조 한다는 것으로, 7번 객체가 자기 자신을 참조하고, 다시 또 7번 객체가 자기 자신을 참조하는 것을 반복 하다가 스택의 범위를 벗어나게 되버린 것이다.
문제의 Parser.cc 소스코드를 살펴보자.
Object *Parser::getObj(Object *obj, Guchar *fileKey,
CryptAlgorithm encAlgorithm, int keyLength,
int objNum, int objGen) {
char *key;
Stream *str;
Object obj2;
int num;
DecryptStream *decrypt;
GString *s, *s2;
int c;
// dictionary or stream
} else if (buf1.isCmd("<<")) {
shift();
obj->initDict(xref);
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));
}
}
inline void Object::dictAdd(char *key, Object *val)
{ dict->add(key, val); }
위 코드는 getObj의 일부 코드 이다.
객체를 파싱하다가 '<<' 문자열을 만나게 되면 '>>'를 만나게 될 때 까지 entries 배열에 key, value의 형태로 저장한다.
dictAdd 함수에 두 번째 인자로 getObj 리턴값을 주는 이유는 value에 해당 하는 데이터의 type이 여러 종류가 올 수 있기 때문에 재귀적으로 값을 가져오기 위함이다.
dictAdd를 하는 과정이 끝나면 Stream을 처리하기 위한 코드로 진행한다.
if (allowStreams && buf2.isCmd("stream")) {
if ((str = makeStream(obj, fileKey, encAlgorithm, keyLength,
objNum, objGen))) {
obj->initStream(str);
} else {
obj->free();
obj->initError();
}
} else {
shift();
}
allowStreams가 참이고 stream 객체이면 makestream 함수를 호출하게 된다. 여기서 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);
inline Object *Object::dictLookup(char *key, Object *obj)
{ return dict->lookup(key, obj); }
makeStream 함수는 "Length"와 obj를 인자로 dictLookup 함수를 호출한다.
그리고 dictLookup은 다시 lookup 함수를 호출한다.
Object *Dict::lookup(char *key, Object *obj) {
DictEntry *e;
return (e = find(key)) ? e->val.fetch(xref, obj) : obj->initNull();
}
inline DictEntry *Dict::find(char *key) {
int i;
for (i = 0; i < length; ++i) {
if (!strcmp(key, entries[i].key))
return &entries[i];
}
return NULL;
}
lookup 함수에서는 find 함수를 통해 entries에 key 값이 "/Length"인 객체가 있으면 fetch 함수를 호출한다.
아까 봤던 7번 객체의 key값을 보면 "/Length"를 포함하는 것을 알 수 있다.
Object *Object::fetch(XRef *xref, Object *obj) {
return (type == objRef && xref) ?
xref->fetch(ref.num, ref.gen, obj) : copy(obj);
}
fetch 함수는 xref table이 존재하고 type이 indirect reference 이면 xref table의 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;
}
...
parser->getObj(obj, encrypted ? fileKey : (Guchar *)NULL,
encAlgorithm, keyLength, num, gen);
obj1.free();
obj2.free();
obj3.free();
delete parser;
break;
fetch 함수는 첫 번째 인자로 넘겨받은 7 0 R로 참조된 7 0 obj의 객체번호 7이 저장되고 이를 다시 getObj 함수에 objNum 인자로 전달하며 getObj 함수를 호출한다.
위 과정을 계복 반복하게 되며 스택이 터지게 된 것이다.
6. 패치 파악
Xpdf 3.03 버전에서 추가된 코드를 보자.
// Max number of nested objects. This is used to catch infinite loops
// in the object structure.
#define recursionLimit 500
주석으로 무한 루프 방지를 위한 최대 숫자라고 적혀있다.
- getObj()
Object *Parser::getObj(Object *obj, GBool simpleOnly,
Guchar *fileKey,
CryptAlgorithm encAlgorithm, int keyLength,
int objNum, int objGen, int recursion)
- 함수의 인자로 GBool 타입의 simpleOnly와 int 타입의 recursion가 추가된 것을 알 수 있다.
else if (!simpleOnly && recursion < recursionLimit && buf1.isCmd("<<")) {
shift();
obj->initDict(xref);
while (!buf1.isCmd(">>") && !buf1.isEOF()) {
if (!buf1.isName()) {
error(errSyntaxError, 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, gFalse,
fileKey, encAlgorithm, keyLength,
objNum, objGen, recursion + 1));
}
}
- simpleOnly 가 0 이어야 하고, recursion의 값이 500을 넘지 않아야 한다는 코드가 추가되었다.
- dictAdd를 호출할 때 recursion + 1을 넘겨주게 되며 따라서 getObj 함수가 호출될 때 마다 recursion의 값이 1씩 증가 하며 500이 넘게 될 경우 재귀 호출이 끝나게 된다.
- makeStream()
Stream *Parser::makeStream(Object *dict, Guchar *fileKey,
CryptAlgorithm encAlgorithm, int keyLength,
int objNum, int objGen, int recursion) {
Object obj;
BaseStream *baseStr;
Stream *str;
Guint pos, endPos, length;
// get stream start position
lexer->skipToNextLine();
if (!(str = lexer->getStream())) {
return NULL;
}
pos = str->getPos();
// get length
dict->dictLookup("Length", &obj, recursion);
if (obj.isInt()) {
length = (Guint)obj.getInt();
obj.free();
} else {
error(errSyntaxError, getPos(), "Bad 'Length' attribute in stream");
obj.free();
return NULL;
}
- 마찬가지로 recursion 인자가 추가되었다.
- lexer->getStream() 함수의 반환값이 0이면 종료되는 구문도 추가되었다.
Stream *getStream()
{ return curStr.isNone() ? (Stream *)NULL : curStr.getStream(); }
GBool isNone() { return type == objNone; }
- getStream 함수는 isNone() 함수의 반환값이 참이면 NULL을 반환한다.
- isNone 함수는 type이 objNone 이면 참을 반환한다. (여기서 type으로는 객체 타입을 말한다.)
- objNone을 알아보기 위해 free 함수를 살펴보자.
void Object::free() {
switch (type) {
case objString:
delete string;
break;
...
#ifdef DEBUG_MEM
--numAlloc[type];
#endif
type = objNone;
}
- free 함수에서는 객체를 해제할 때 type을 objNone으로 설정하는 것을 볼 수 있다.
- 따라서 이미 해제된 객체를 다시 참조하려 할 때 makeStream 함수가 종료된다.
정리
- PDF 파일의 dict과 stream 타입을 포함한 객체가 dict의 key 값으로 자기 자신을 참조할 때 스택 오버플로우를 유발하여 DoS 공격이 야기될 수 있다. (CVE-2019-13288)
- 이로 인해 getObj() 함수와 makeStream() 함수에 재귀 호출 반복을 방지하는 코드를 추가하여 패치 하였다.
'Fuzzing' 카테고리의 다른 글
[Fuzzing101] 5. Libxml2 (CVE-2017-9048) (0) | 2024.01.12 |
---|---|
[Fuzzing101] 4. LibTIFF (CVE-2016-9297) (1) | 2024.01.02 |
[Fuzzing101] 3. TCPdump (CVE-2017-13028) (1) | 2023.12.26 |
[Fuzzing101] 2. libexif (CVE-2009-3895) (0) | 2023.11.07 |