Using Nix to Fuzz Test a PDF Parser (Part Two)

Michael Lynch

Nix로 PDF 파서 퍼즈 테스트하기 (2부)

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

이 글은 Nix로 퍼즈 테스팅 워크플로를 자동화하는 방법에 대한 포스트의 후반부입니다.

이 시점에서 pdftotext에 대해 honggfuzz를 실행할 수 있지만, 시작하려면 약간의 수작업이 필요하다. 1부에서 설치와 퍼징 전 과정을 단 하나의 명령어로 줄이겠다고 약속했다.

까다로운 PDF 내려받기

임시로 진행한 퍼징에서는 IRS 웹사이트에서 PDF를 직접 내려받았다. 먼저 이 단계부터 자동화하겠다.

자동화하는 김에 단일 PDF보다 더 나은 방법을 쓸 수 있을 것이다. 퍼징에서의 목표는 PDF 파일 포맷의 다양한 부분을 건드릴 수 있도록 가능한 한 폭넓고 다양한 PDF를 확보하는 것이다.

Adobe는 흥미로워 보이는 테스트용 PDF 코퍼스를 제공한 적이 있다가, 지금은 내려간 상태다.

내가 찾은 것 중 파싱하기 가장 어려운 PDF 모음은 Mozilla의 pdf.js 프로젝트에 있었다. 이 프로젝트는 해당 도구에서 파싱 버그를 일으켰던 700개의 PDF를 포함하고 있다가, 그래서 동일한 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 파일이 들어 있다.

mkDerivation 단계에서는 인터넷 접속이 차단되기 때문에 .link 파일의 URL에서 PDF를 내려받는 방법을 알 수 없었다. 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로 빌드한다:

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 기능으로 더욱 간단한 해결책을 제공한다.

packages 섹션 뒤에 apps 정의를 Nix flake에 추가한다:

{
    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

fuzz-xpdf를 이 flake의 기본 앱으로 선언했으므로, 실제로는 더 간단한 명령을 쓸 수도 있다:

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 fa

ASAN의 오류 메시지는 다소 난해하지만, pdftotext 코드가 할당한 버퍼 바깥의 1바이트를 pdftotext가 읽으려다 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에는 기본값이 falsedontStrip 옵션이 있어 자동으로 디버그 정보를 제거한다.

또한 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

/build/source 경로가 Nix의 특성인지 xpdf 빌드 설정의 특성인지 잘 모르겠다. (편집: 이 접두사는 소스에서 빌드할 때 루트 디렉터리를 정의하는 Nix의 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]; }

좋다, schar 버퍼이므로 아마 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

좋다, 사실 이건 꽤 단순한 버그다.

readFontDescriptornameNULL이 아닌지 확인하지만, 길이가 최소 1 이상이라고 가정한다. 만약 name이 빈 문자열(길이 0)이라면, getChar 호출은 name->getChar(-1)로 평가된다. 그러면 getChars[-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 fa

ASAN은 크기가 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와 같은 디렉터리에 둔 뒤, xpdf를 컴파일할 때 내 커스텀 패치를 적용하도록 Nix flake를 업데이트한다:

{
  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에게 감사드립니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글