Nix로 PDF 파서 퍼즈 테스트하기 (2부)
이 글은 Nix로 퍼즈 테스팅 워크플로를 자동화하는 방법에 대한 포스트의 후반부입니다.
현재 pdftotext에 대해 honggfuzz를 실행할 수는 있지만, 시작하려면 약간의 수작업이 필요합니다. 1부에서 설치부터 퍼징까지 모든 과정을 단일 명령어로 끝내겠다고 약속드렸습니다.
까다로운 PDF 다운로드하기
임시로 퍼징을 수행할 때는 IRS 웹사이트에서 PDF를 직접 다운로드했습니다. 먼저 이 단계를 자동화하겠습니다.
자동화하는 김에 PDF 한 개보다 더 나은 구성을 만들 수 있을 것 같습니다. 퍼징에서는 PDF 파일 형식의 다양한 부분을 실행시킬 수 있도록 가능한 한 폭넓고 다양한 PDF를 확보하는 것이 목표입니다.
Adobe가 흥미로운 테스트용 PDF 코퍼스를 제공한 적이 있지만, 지금은 내려간 상태입니다.
제가 찾은 파싱하기 까다로운 PDF 모음 중 가장 좋은 것은 Mozilla의 pdf.js 프로젝트에 있었습니다. 해당 프로젝트는 자사 도구에서 파싱 버그를 일으켰던 PDF 700개를 포함하고 있어, 같은 PDF들이 다른 PDF 파서에서도 문제를 일으킬 가능성이 높습니다.
Mozilla의 pdf.js 프로젝트에서 모든 PDF를 다운로드하는 새로운 빌드 단계를 Nix flake에 만들었습니다:
{
packages = rec {
...
sample-pdfs = pkgs.stdenv.mkDerivation rec {
pname = "sample-pdfs";
version = "4.7.76";
src = pkgs.fetchzip {
url = "https://github.com/mozilla/pdf.js/archive/refs/tags/v${version}.zip";
hash = "sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=";
};
buildCommand = ''
mkdir -p $out
cp $src/test/pdfs/*.pdf $out
'';
};이 시점의 flake.nix는 다음과 같아야 합니다.
다음 명령어로 새로운 sample-pdfs 빌드 단계를 실행합니다:
nix build .#sample-pdfs이어서 Nix가 700개의 PDF를 성공적으로 다운로드했는지 확인합니다:
$ ls ./result | head -n 5
160F-2019.pdf
alphatrans.pdf
annotation-border-styles.pdf
annotation-button-widget.pdf
annotation-caret-ink.pdf
$ ls ./result | wc --lines
700새로운 빌드 단계를 통해 엣지 케이스 PDF로 구성된 초기 코퍼스를 확보했으며, 이를 통해 PDF 파싱 코드에서 자주 실행되지 않는 코드 경로를 시험해 볼 수 있기를 기대합니다.
더 많은 까다로운 PDF 다운로드하기
pdf.js 저장소에는 PDF 자체 외에도 외부 PDF의 URL을 담은 .link 파일이 수백 개 들어 있습니다.
.link 파일의 URL에서 PDF를 다운로드하는 방법을 알 수 없었습니다. mkDerivation 단계에서는 인터넷 접속이 차단되기 때문입니다. fetchUrl 명령을 쓸 수도 있지만, 수백 개를 일일이 작성해야 합니다.
Anton Mosich님이 모든 .link 파일에서 PDF를 다운로드하는 우아한 방법을 알려주었습니다. outputHash, outputHashMode, outputHashAlgo를 지정하면 Nix가 규칙을 완화해 빌드 중 인터넷 접속을 허용한다고 했습니다.
처음에는 curl로 각 파일을 다운로드해 보았지만, 파일을 순차적으로 다운로드하기 때문에 속도가 지나치게 느렸습니다. URL을 병렬로 다운로드하는 aria2c라는 유틸리티를 찾았고, 덕분에 과정이 훨씬 빨라졌습니다:
{
packages = rec {
...
sample-pdfs = pkgs.stdenv.mkDerivation rec {
pname = "sample-pdfs";
version = "4.7.76";
src = pkgs.fetchzip {
url = "https://github.com/mozilla/pdf.js/archive/refs/tags/v${version}.zip";
hash = "sha256-2xt8j2xJ3Teg/uiwjbWnpR6zckdxsp3LVbfsbBc3Dco=";
};
nativeBuildInputs = [
pkgs.aria2
];
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
buildPhase = ''
# Extract the URLs and filenames from .link files into an input
# file of URLs for aria2c.
url_file=$(mktemp)
for pdf in $src/test/pdfs/*.pdf.link; do
url=$(sed 's/\r$//' "$pdf")
filename=$(basename "$pdf" .link)
echo "$url" >> "$url_file"
echo " out=$filename" >> "$url_file"
done
aria2c \
--input-file="$url_file" \
--max-tries=5 \
--retry-wait=20 \
--auto-file-renaming=false \
--max-concurrent-downloads=5 \
--max-connection-per-server=1 \
--dir=.
cp $src/test/pdfs/*.pdf .
'';
installPhase = ''
mkdir -p $out
cp -r . $out
'';
# We need to specify the output hash so that Nix allows Internet
# access during the build.
outputHash = "sha256-lcPF6AQNVsXH2RIiyGZQpp5VjcaBhtolQxmbqSduCNs=";
outputHashMode = "recursive";
outputHashAlgo = "sha256";
};업데이트된 sample-pdfs 단계를 nix build로 실행합니다:
nix build .#sample-pdfs그리고 ./result 디렉터리를 확인해 보면, 저장소에 있는 PDF 파일만 복사했을 때보다 파일이 437개 더 늘어난 것을 볼 수 있습니다:
$ ls ./result | wc --lines
1137퍼즈 실행 자동화하기
이 시리즈 1부에서는 Nix dev shell에서 honggfuzz를 수동으로 실행하는 방법을 보여드렸습니다. Nix flake에 퍼저 실행 명령을 정의하면 이 과정을 더욱 간편하게 만들 수 있습니다.
먼저 honggfuzz를 실행하기 위한 새로운 셸 스크립트를 추가합니다:
{
packages = rec {
xpdf = pkgs.stdenv.mkDerivation rec {
...
}
fuzz-xpdf = pkgs.writeShellScriptBin "fuzz-xpdf" ''
readonly CORPUS_DIR='fuzz-corpus'
mkdir -p "$CORPUS_DIR"
# Copy the source corpus into a new directory for active fuzzing.
cp --force ${sample-pdfs}/*.pdf "$CORPUS_DIR"
${pkgs.honggfuzz}/bin/honggfuzz \
--input "$CORPUS_DIR" \
--instrument \
--timeout 10 \
-- ${xpdf}/bin/pdftotext ___FILE___
'';다음 명령어로 셸 스크립트를 빌드합니다:
nix build .#fuzz-xpdf그러면 Nix가 ./result/bin/fuzz-xpdf에 bash 스크립트를 생성합니다:
$ cat ./result/bin/fuzz-xpdf
#!/nix/store/1xhds5s320nfp2022yjah1h7dpv8qqns-bash-5.2p32/bin/bash
readonly CORPUS_DIR='fuzz-corpus'
mkdir -p "$CORPUS_DIR"
# Copy the source corpus into a new directory for active fuzzing.
cp --force /nix/store/gncc6jy3cry5lwbkd2b54h1dg46wfkdc-sample-pdfs-4.7.76/*.pdf "${CORPUS_DIR}"
/nix/store/kb9vkjv4admbdixrjyanfb1i9dd3cbmm-honggfuzz-2.6/bin/honggfuzz \
--input "$CORPUS_DIR" \
--instrument \
-- /nix/store/pixq8qiqyy6iwsc4wisb1vrmgy7l1kas-xpdf-4.05/bin/pdftotext ___FILE___셸 스크립트에서 Nix는 모든 Nix 변수를 절대 경로로 치환했습니다. bash 변수(CORPUS_DIR)는 스크립트 실행 시 bash가 해석할 수 있도록 심볼 그대로 남아 있습니다.
이 셸 스크립트를 실행하면 새로운 퍼징 세션이 시작됩니다:
./result/bin/fuzz-xpdf해당 명령을 실행하면 다음과 같은 화면이 나타나야 합니다:

이제 런처 스크립트로 honggfuzz를 실행할 수 있습니다
이 방법도 동작은 하지만, 셸 스크립트를 실행할 때마다 먼저 nix build 명령을 실행해야 한다는 점을 기억해야 합니다. Nix는 apps 기능으로 한층 더 간단한 해결책을 제공합니다.
Nix flake의 packages 섹션 뒤에 apps 정의를 추가합니다:
{
packages = rec {
...
};
apps = {
default = self.apps.${system}.fuzz-xpdf;
fuzz-xpdf = {
type = "app";
program = "${self.packages.${system}.fuzz-xpdf}/bin/fuzz-xpdf";
};
};fuzz-xpdf 앱을 마련했으니, 이제 단일 명령어로 퍼징을 시작할 수 있습니다:
nix run .#fuzz-xpdf이 flake의 기본 앱으로 fuzz-xpdf를 선언했으므로, 사실 더 간단한 명령어를 사용할 수도 있습니다:
nix run이 시점의 flake.nix는 다음과 같아야 합니다.
이제 퍼징 워크플로가 완성되었습니다.
이 Nix flake를 완전히 새로운 디렉터리에 넣고 nix run을 실행하면, 까다로운 PDF를 모두 다운로드하고 xpdf를 컴파일한 뒤 퍼징을 시작합니다. honggfuzz를 무기한으로 실행해 두며 어떤 크래시가 발생하는지 지켜볼 수 있습니다.

Nix flake에 fuzz-xpdf 앱을 추가함으로써 완전한 퍼징 워크플로를 갖추게 되었습니다. 퍼저를 무기한으로 실행하며 버그를 찾도록 둘 수 있습니다.
ASAN으로 미묘한 메모리 오류를 명확한 크래시로 바꾸기
이 시점에서 퍼징 워크플로는 동작하지만, 더 효율적으로 실행할 수 있습니다.
퍼즈 테스팅에서는 대상 애플리케이션이 크래시될 때만 흥미로운 버그를 찾았다는 것을 알 수 있습니다. 문제는 프로그램을 크래시시키지 않고도 오작동하게 만드는 방법이 많다는 것입니다.
크래시 없이 발생하는 보안 버그의 가장 유명한 사례 중 하나는 2014년 OpenSSL의 Heartbleed 버그입니다. 이 버그는 공격자가 웹 서버에서 민감한 정보를 추출할 수 있게 했지만 서버를 크래시시키지는 않았습니다. 프로그램이 의도된 경계를 벗어나 메모리를 읽거나 쓰도록 유도해도 항상 크래시가 발생하는 것은 아닙니다.
다행히도 원래는 크래시를 일으키지 않을 메모리 오류를 즉시 크래시로 강제하는 도구가 있습니다. Address Sanitizer(ASAN)는 프로그램의 메모리 읽기·쓰기에 추가적인 안전 검사를 삽입해, 변수의 메모리 범위를 벗어나 읽거나 쓰려고 시도하면 디버그 출력과 함께 크래시를 발생시킵니다.
퍼징 워크플로에 ASAN을 추가하면 그렇지 않았을 때보다 더 많은 메모리 버그를 찾을 수 있습니다. ASAN을 활성화한 상태로 xpdf를 컴파일하려면 xpdf 컴파일 단계에 -fsanitize=address를 추가합니다:
{
{
packages = rec {
xpdf = pkgs.stdenv.mkDerivation rec {
...
preConfigure = ''
export CC=${pkgs.honggfuzz}/bin/hfuzz-clang
export CXX=${pkgs.honggfuzz}/bin/hfuzz-clang++
# Use address sanitizer (ASAN).
export CFLAGS="$CFLAGS -fsanitize=address"
export CXXFLAGS="$CXXFLAGS -fsanitize=address"
'';
};이 시점의 flake.nix는 다음과 같아야 합니다.
이제 마침내 퍼저를 실행해 버그를 찾도록 할 준비가 되었습니다. Nix flake가 있으므로 다음 명령어만 실행하면 됩니다:
nix run첫 번째 크래시 발견하기
honggfuzz를 두 시간 동안 실행해 둔 뒤 확인해 보니 첫 번째 크래시를 발견한 것을 알 수 있었습니다:

honggfuzz는 크래시를 일으킨 PDF를 다음 이름의 파일로 저장했습니다:
SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz
크래시를 재현하기 위해 다음 명령어를 실행했습니다:
# Specify the path to the crashing PDF.
CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'
# Rebuild pdftotext in the result folder
nix build
# Run pdftotext with the crashing PDF.
./result/bin/pdftotext "${CRASHING_PDF}" /dev/null실제로 프로그램이 크래시되었고, ASAN은 버퍼 오버플로를 감지했다고 보고했습니다:
==1259902==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x557c3230bd58 bp 0x7ffd7f070cf0 sp 0x7ffd7f070ce8
READ of size 1 at 0x60200002228f thread T0
#0 0x557c3230bd57 (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
#1 0x557c3231f6ea (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
#2 0x557c32327a0d (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)
#3 0x557c3232733c (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ff33c)
#4 0x557c322bfaa7 (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x397aa7)
...
SUMMARY: AddressSanitizer: heap-buffer-overflow (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
Shadow bytes around the buggy address:
0x602000022000: fa fa fd fd fa fa fd fa fa fa fd fd fa fa fd fa
0x602000022080: fa fa fd fd fa fa fd fd fa fa 00 01 fa fa fd fd
0x602000022100: fa fa 00 03 fa fa fd fa fa fa 00 00 fa fa fd fa
0x602000022180: fa fa fd fa fa fa fd fd fa fa 00 02 fa fa fd fa
0x602000022200: fa fa fd fa fa fa fd fa fa fa fd fa fa fa 00 00
=>0x602000022280: fa[fa]00 fa fa fa fd fa fa fa fd fa fa fa fd fa
0x602000022300: fa fa fd fa fa fa fd fa fa fa 03 fa fa fa fd faASAN의 오류 메시지는 다소 난해하지만, pdftotext가 할당된 버퍼 바깥의 1바이트를 읽으려다 ASAN에 의해 감지되었다는 의미입니다.
그렇다면 이 크래시의 원인을 어떻게 더 깊이 파고들 수 있을까요?
디버그 심볼 개선하기
pdftotext가 크래시되었을 때 소스 파일명과 줄 번호가 포함된 스택 트레이스를 기대했지만, 출력에는 바이너리 오프셋만 있어 디버깅이 더 어려웠습니다:
#0 0x557c3230bd57 (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3e3d57)
#1 0x557c3231f6ea (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3f76ea)
#2 0x557c32327a0d (/nix/store/l774c0m9kh6z7iq1jn5m31kzy77kwffc-xpdf-4.05/bin/pdftotext+0x3ffa0d)이상하게도 이 전체 과정에서 가장 어려웠던 부분은 크래시 덤프에서 소스 정보를 볼 수 있도록 디버그 심볼을 제대로 동작하게 만드는 방법을 알아내는 것이었습니다.
먼저 nix run의 로그 출력에서 바이너리에서 디버그 출력을 스트립한다는 내용을 우연히 발견했습니다. 알고 보니 Nix에는 기본값이 false인 dontStrip 옵션이 있어, 자동으로 디버그 정보를 제거합니다.
또한 xpdf의 컴파일 설명서에 CMAKE_BUILD_TYPE 옵션이 언급된 것을 발견했습니다. 문서화되어 있지는 않지만 소스를 검색해 보니 Debug 값을 받는다는 것을 알 수 있었습니다.
xpdf 바이너리에 디버그 심볼을 유지하기 위해 xpdf 패키지 정의 끝에 다음 옵션들을 추가했습니다:
{
{
packages = rec {
xpdf = pkgs.stdenv.mkDerivation rec {
...
preConfigure = ''
...
'';
cmakeFlags = [
"-DCMAKE_BUILD_TYPE=Debug"
];
# Don't strip debug information from binaries, as the debug symbols
# are usefule during crash analysis.
dontStrip = true;
};이 시점에서 이상한 일이 일어났습니다. 파일명과 줄 번호가 포함된 풍부한 스택 트레이스를 얻을 수 있었지만, 몇 시간 뒤에는 신기하게도 다시 동작하지 않았습니다. 아직도 원인을 모르겠습니다.
풍부한 스택 트레이스를 일관되게 동작시키려면 이전에 들어본 적 없는 llvm-symbolizer라는 도구를 사용해야 했습니다. 다행히 llvm-symbolizer는 널리 쓰이는 llvm_18 Nix 패키지에 포함되어 있어, Nix flake에 해당 패키지를 포함시키고 해당 바이너리를 가리키는 ASAN_SYMBOLIZER_PATH 환경 변수를 추가했습니다.
Nix flake의 변경 사항은 파일 곳곳에 분산되어 있어 보여주기 어렵기 때문에, diff를 직접 보는 것이 가장 쉽습니다.
Nix flake를 변경한 뒤에는 풍부한 스택 트레이스를 보려면 Nix dev shell에 진입해야 합니다. 그래야 올바른 ASAN_SYMBOLIZER_PATH 환경 변수 값이 설정됩니다.
# Enter the nix dev shell.
nix develop
# Rebuild pdftotext in the result folder
nix build
# Specify the path to the crashing PDF.
CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'
# Run pdftotext with the crashing PDF.
./result/bin/pdftotext "${CRASHING_PDF}" /dev/null그러면 마침내 파일명이 포함된 스택 트레이스를 볼 수 있습니다:
=================================================================
==1461608==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
READ of size 1 at 0x60200002228f thread T0
#0 0x55555592fff4 in GString::getChar(int) /build/source/goo/GString.h:82:32
#1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /build/source/xpdf/GfxFont.cc:553:20
#2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /build/source/xpdf/GfxFont.cc:1732:3
#3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /build/source/xpdf/GfxFont.cc:190:16
...
#22 0x7ffff7a7110d in __libc_start_call_main (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a10d) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
#23 0x7ffff7a711c8 in __libc_start_main@GLIBC_2.2.5 (/nix/store/r8qsxm85rlxzdac7988psm7gimg4dl3q-glibc-2.39-52/lib/libc.so.6+0x2a1c8) (BuildId: 323d12eb412f4a20879fb07d3514ca673c5aee20)
#24 0x555555698a04 in _start (/nix/store/x59ccyx8gz0ap74zapdi7k8ssgypmipm-xpdf-4.05/bin/pdftotext+0x144a04)성공했습니다! 이제 소스 파일명, 줄 번호, 함수명을 확인할 수 있습니다.
하지만 아직 문제가 하나 남아 있습니다. 파일 경로를 한번 보십시오.
/build/source/xpdf/GfxFont.cc
^^^^^^^^^^^^^
Where is this coming from?xpdf 소스는 모두 /build/source라는 루트 폴더를 가리키지만, 제 시스템에는 해당 경로가 존재하지 않습니다:
$ ls /build/source
ls: cannot access '/build/source': No such file or directory이 (편집자 주: 이 접두사는 소스에서 빌드할 때의 루트 디렉터리를 정의하는 Nix의 /build/source 경로가 Nix의 특성인지 xpdf 빌드 설정의 특성인지 잘 모르겠습니다.sandbox-build-dir 옵션에서 비롯됩니다. 명확히 해 주신 Dionysis Grigoropoulos님께 감사드립니다.)
/build/source 접두사를 고칠 수 있었던 유일한 방법은 clang의 -fdebug-prefix-map 플래그를 사용해 컴파일 시점에 잘못된 경로를 올바른 경로로 치환하는 다소 지저분한 해킹이었습니다:
{
{
packages = rec {
xpdf = pkgs.stdenv.mkDerivation rec {
...
preConfigure = ''
...
# For some reason, without these flags, the debug symbols point to
# source files at the base filesystem /build/source, so we
# manually fix the source path.
export CXXFLAGS="$CXXFLAGS -fdebug-prefix-map=/build/source=${xpdf.src}"
'';nix build 순서를 다시 실행하면, 마침내 스택 트레이스가 올바르게 표시됩니다:
==1498830==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002228f at pc 0x55555592fff5 bp 0x7fffffffac70 sp 0x7fffffffac68
READ of size 1 at 0x60200002228f thread T0
#0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
#1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
#2 0x5555559423da in GfxCIDFont::GfxCIDFont(XRef*, char const*, Ref, GString*, GfxFontType, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:1732:3
#3 0x55555594a065 in GfxFont::makeFont(XRef*, char const*, Ref, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:190:16
#4 0x55555594a065 in GfxFontDict::load(char*, GfxFontDictEntry*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:2393:12해당 파일 경로를 sed에 넣어 보면 파일 내용이 출력됩니다:
$ sed -n '548,558p' /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc
i -= 2;
} else if (i > 7 && !strncmp(name->getCString() + i - 7, "Oblique", 7)) {
flags |= fontItalic;
i -= 7;
}
char c = name->getChar(i-1);
if (!((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9'))) {
--i;
}이 시점의 flake.nix는 다음과 같아야 합니다.
크래시 이해하기
이제 일관되게 크래시를 일으키는 범위 밖 메모리 읽기가 있습니다. 버그를 이해하는 데 필요한 모든 디버깅 정보를 갖추었으니, 소스를 파헤쳐 볼 차례입니다.
스택 트레이스의 최상단은 다음 줄을 가리킵니다:
// goo/GString.h
// Get <i>th character.
char getChar(int i) { return s[i]; }s는 char 버퍼이므로 아마도 C 스타일 문자열을 담고 있을 것입니다. getChar 함수는 호출자가 i에 대해 유효한 값을 전달하는지 확인하는 경계 검사를 수행하지 않으므로, s에 할당된 버퍼 밖의 메모리를 읽게 됩니다.
한 단계 뒤로 돌아가 크래시 직전에 getChar가 어떻게 호출되었는지 확인해 보겠습니다. 스택 트레이스의 다음 줄은 여기를 가리킵니다:
// xpdf/GfxFont.cc
void GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
...
// scan font name for bold/italic tags and update the flags
if (name) {
i = name->getLength();
if (i > 2 && !strncmp(name->getCString() + i - 2, "MT", 2)) {
i -= 2;
}
...
char c = name->getChar(i-1); // <<< CRASH사실 이건 꽤 단순한 버그입니다.
readFontDescriptor는 name이 NULL이 아닌지 검사하지만, 길이가 최소 1 이상이라고 가정합니다. name이 빈 문자열(길이 0)인 경우 getChar 호출은 name->getChar(-1)로 평가됩니다. 그러면 getChar는 s[-1]을 반환하는데, 이는 s에 할당된 메모리 버퍼보다 1바이트 앞에 있는 영역입니다.
크래시의 디버그 출력은 제 가설을 뒷받침합니다:
==241578==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200002360f at pc 0x55555592fff5 bp 0x7fffffffa650 sp 0x7fffffffa648
READ of size 1 at 0x60200002360f thread T0
#0 0x55555592fff4 in GString::getChar(int) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32
#1 0x55555592fff4 in GfxFont::readFontDescriptor(XRef*, Dict*) /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/xpdf/GfxFont.cc:553:20
...
SUMMARY: AddressSanitizer: heap-buffer-overflow /nix/store/alirmx60yanq6g8ym5v3laa7ncw2h9nm-source/goo/GString.h:82:32 in GString::getChar(int)
Shadow bytes around the buggy address:
...
=>0x602000023600: fa[fa]00 fa fa fa fd fd fa fa fd fa fa fa fd faASAN은 크기가 1인 비정상적인 읽기라고 보고했는데, getChar가 1바이트짜리 문자 하나를 읽으려 하기 때문에 이치에 맞습니다.
ASAN은 fa 바이트를 포함한 메모리 주소를 읽은 위치의 메모리 레이아웃도 보여줍니다. 해당 바이트는 00을 포함한 주소 바로 앞에 나타납니다. 변수 s는 빈 문자열을 담고 있으며, C++에서는 이를 메모리에 00으로 표현하므로, pdftotext는 빈 문자열 바로 앞의 바이트를 읽으려 한 것입니다. 이 읽기는 s 변수에 할당되지 않은 데이터를 포함하므로 비정상적인 접근입니다.
버그 수정하기
제 가설이 맞다면 이 버그는 쉽게 고칠 수 있습니다. readFontDescriptor에서 다음 줄을
if (name) {이렇게 바꾸면 됩니다:
if (name && (name->getLength() > 0)) {이렇게 하면 함수가 빈 문자열을 널 포인터와 동일하게 취급해 더 이상 처리하지 않게 됩니다.
가설을 검증하기 위해 이 변경 사항으로 패치를 만들고 xpdf를 재컴파일한 뒤, 동일한 PDF에 대해 pdftotext를 다시 실행해 수정 사항이 동일한 크래시를 막는지 확인하겠습니다.
xpdf는 오픈소스 프로젝트치고는 git 저장소를 공개하지 않고 주기적으로 tarball만 배포한다는 점에서 조금 특이합니다. 하지만 괜찮습니다. 코드를 편집할 작업 공간을 만들기 위해 임시 git 저장소를 직접 만들겠습니다:
ORIGINAL_SRC="$(nix eval --raw .#xpdf.src.outPath)"
MODIFIED_SRC="$(mktemp --directory)"
pushd "${MODIFIED_SRC}" && \
cp --recursive --verbose $ORIGINAL_SRC/* . && \
chmod -R u+w . && \
git init && \
git add --all && \
git commit --message "Dummy base commit"이제 새로운 git 저장소에 xpdf 소스 코드의 복사본이 생겼습니다. GfxFont.cc를 편집해 수정 사항을 적용합니다:
vim xpdf/GfxFont.cc편집을 마치고 파일을 저장한 뒤 git diff를 호출해 패치 파일을 만들면 다음과 같이 표시됩니다:
diff --git a/xpdf/GfxFont.cc b/xpdf/GfxFont.cc
index c3db4e8..7074354 100644
--- a/xpdf/GfxFont.cc
+++ b/xpdf/GfxFont.cc
@@ -535,7 +535,7 @@ void GfxFont::readFontDescriptor(XRef *xref, Dict *fontDict) {
obj1.free();
// scan font name for bold/italic tags and update the flags
- if (name) {
+ if (name && (name->getLength() > 0)) {
i = name->getLength();
if (i > 2 && !strncmp(name->getCString() + i - 2, "MT", 2)) {
i -= 2;패치를 퍼즈 테스팅 디렉터리로 다시 복사합니다:
# Create a patch file for the fix.
git diff > check-font-name-length.patch
# Get back to fuzz-xpdf git repo.
popd
mv "${MODIFIED_SRC}/check-font-name-length.patch" .패치 파일이 flake.nix와 같은 디렉터리에 있으므로, Nix flake를 업데이트해 xpdf를 컴파일할 때 커스텀 패치를 적용하도록 합니다:
{
xpdf = pkgs.stdenv.mkDerivation rec {
...
src = pkgs.fetchzip {
url = "https://dl.xpdfreader.com/${pname}-${version}.tar.gz";
extension = "tar.gz";
};
# Add a custom patch to fix the font name length bug.
patches = [
./check-font-name-length.patch
];이 시점의 flake.nix는 다음과 같아야 합니다.
수정을 테스트할 준비가 되었습니다. 패치를 적용해 xpdf를 재컴파일하고, 이전에 크래시를 일으켰던 PDF에 대해 실행합니다:
# Specify the path to the crashing PDF.
$ CRASHING_PDF='SIGABRT.PC.55555592fff5.STACK.1bb46b81df.CODE.-6.ADDR.0.INSTR.mov____%eax,%edx.fuzz'
# Rebuild pdftotext in the result folder
$ nix build
# Run pdftotext with the crashing PDF.
$ ./result/bin/pdftotext "${CRASHING_PDF}"
Syntax Error: Couldn't read xref table
Syntax Warning: PDF file is damaged - attempting to reconstruct xref table...
Syntax Error (2800): Bad dynamic code table in flate stream
Syntax Error (2800): Bad block header in flate stream
Syntax Error (2158): Dictionary key must be a name object
Syntax Error: Unterminated string
Syntax Error: Leftover args in content stream결과는 성공입니다! pdftotext가 많은 오류를 보고하지만 프로그램은 더 이상 크래시되지 않습니다. 수정이 제대로 동작했습니다.
수정을 찾은 뒤에 알게 되었지만, 제가 이 크래시를 발견하기 몇 주 전에 이미 다른 사람이 같은 취약점을 보고한 적이 있었습니다. 그래도 여전히 유용한 연습이었습니다.
마무리
Nix는 퍼즈 테스팅 워크플로를 만드는 데 탁월한 도구라고 생각합니다.
퍼징에 활용하기 위해 Nix의 이런저런 세부 사항을 파악하는 데 약간의 노력이 필요했지만, 이제는 다른 PDF 파서를 넣거나 다른 퍼저를 사용하는 일이 쉬워졌습니다. Nix의 장점은 구성이 잘 조합된다는 점이며, 덕분에 워크플로 내에서 다양한 컴포넌트를 쉽게 교체할 수 있습니다.
이 프로젝트는 퍼징과 Nix 모두에 대해 더 많이 배울 수 있는 좋은 기회가 되었습니다. 지난 1년간 Nix를 조금씩 다뤄왔지만, 이렇게 Nix를 활용하면서 그동안 모호했던 많은 개념이 명확해졌습니다.
소스 코드
제 퍼징 워크플로의 전체 소스는 GitLab에서 확인할 수 있습니다:
xpdf 발췌 부분은 GPLv3 라이선스에 따라 사용되었습니다. 이 포스트 작성에 도움을 주신 Anton Mosich님께 감사드립니다.
글을 무작위로 읽기