持续模糊测试Python C扩展:从环境搭建到漏洞挖掘

本文详细介绍了如何使用Atheris对Python C扩展进行持续模糊测试,包括Docker环境配置、测试工具编写、与OSS-Fuzz集成,并以cbor2库为例展示了实际漏洞挖掘过程。

持续模糊测试Python C扩展

反序列化、解码和处理不可信输入是项目需要模糊测试的明显迹象。是的,即使是Python项目也不例外。模糊测试有助于减少用各种编程语言开发的高可靠性软件中的错误。幸运的是,Python生态系统有了Google发布的Atheris,这是一个覆盖引导的模糊测试器,适用于纯Python代码和Python C扩展。对于Python项目来说,如果你在寻找一个成熟的模糊测试器,Atheris几乎是唯一的选择。

模糊测试纯Python代码通常会发现意外异常,最终可能导致拒绝服务。而模糊测试Python C扩展可能会发现内存错误、数据竞争、未定义行为和其他类型的错误。副作用包括:内存损坏、远程代码执行,以及更一般地说,所有我们在C语言中熟悉和喜爱的那些令人头疼的问题。本文将重点讨论模糊测试Python C扩展。

我们将引导你使用Atheris来模糊测试Python C扩展,将Python项目添加到OSS-Fuzz中,并通过OSS-Fuzz集成的CIFuzz工具设置持续模糊测试。OSS-Fuzz是Google为开源项目提供的持续模糊测试服务,使其成为开源开发者的宝贵工具;截至2023年8月,它已帮助发现并修复了超过10,000个安全漏洞和36,000个错误。在我们的模糊测试活动中,我们将以cbor2 Python库为目标。这个库是完美的目标,因为它执行类似JSON的二进制格式的序列化和反序列化,并且有一个可选的C扩展实现以提高性能。此外,简明二进制对象表示(CBOR)在区块链社区中被广泛使用,这往往有很高的可靠性和安全性要求。

最终,我们在cbor2中发现了多个内存损坏错误,这些错误在特定情况下可能成为安全漏洞。

模糊测试Python C扩展

在底层,Atheris使用libFuzzer执行模糊测试。由于libFuzzer构建在LLVM和Clang之上,我们需要安装Clang来模糊测试我们的目标。为了简化安装过程,我编写了一个Dockerfile,将所有必要的组件打包到一个Docker镜像中。这为模糊测试当前目标创建了一个可重复的过程,并为模糊测试未来目标提供了一个易于扩展的工件。生成的Docker镜像包括一个Python模糊测试工具来启动模糊测试过程。

首先,我们将讨论这个Dockerfile的一些有趣部分,然后我们将研究fuzz.py模糊测试工具,最后我们将构建并运行Docker镜像,并发现一些内存损坏错误!

模糊测试环境

Dockerfile是创建自文档化、可重现环境的绝佳方式。由于模糊测试往往更像艺术而非科学,本节还将讨论Dockerfile中一些有趣且不明显的部分。以下Dockerfile用于模糊测试cbor2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
FROM debian:12-slim

RUN apt update && apt install -y \
    git \
    python3-full \
    python3-pip \
    wget \
    xz-utils \
    && rm -rf /var/lib/apt/lists/*

RUN python3 --version

ENV APP_DIR "/app"
ENV CLANG_DIR "$APP_DIR/clang"
RUN mkdir $APP_DIR
RUN mkdir $CLANG_DIR
WORKDIR $APP_DIR

ENV VIRTUAL_ENV "/opt/venv"
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH "$VIRTUAL_ENV/bin:$PATH"

ARG CLANG_URL=https://github.com/llvm/llvm-project/releases/download/llvmorg-17.0.6/clang+llvm-17.0.6-aarch64-linux-gnu.tar.xz
ARG CLANG_CHECKSUM=6dd62762285326f223f40b8e4f2864b5c372de3f7de0731cb7cd55ca5287b75a

ENV CLANG_FILE clang.tar.xz
RUN wget -q -O $CLANG_FILE $CLANG_URL && \
    echo "$CLANG_CHECKSUM  $CLANG_FILE" | sha256sum -c - && \
    tar xf $CLANG_FILE -C $CLANG_DIR --strip-components 1 && \
    rm $CLANG_FILE

# https://github.com/google/atheris#building-from-source
RUN LIBFUZZER_LIB=$($CLANG_DIR/bin/clang -print-file-name=libclang_rt.fuzzer_no_main.a) \
    python3 -m pip install --no-binary atheris atheris

# https://github.com/google/atheris/blob/master/native_extension_fuzzing.md#step-1-compiling-your-extension
ENV CC "$CLANG_DIR/bin/clang"
ENV CFLAGS "-fsanitize=address,undefined,fuzzer-no-link"
ENV CXX "$CLANG_DIR/bin/clang++"
ENV CXXFLAGS "-fsanitize=address,undefined,fuzzer-no-link"
ENV LDSHARED "$CLANG_DIR/bin/clang -shared"

ARG BRANCH=master

# https://github.com/agronholm/cbor2
ENV CBOR2_BUILD_C_EXTENSION "1"
RUN git clone --branch $BRANCH https://github.com/agronholm/cbor2.git
RUN python3 -m pip install cbor2/

# Allow Atheris to find fuzzer sanitizer shared libs
# https://github.com/google/atheris/blob/master/native_extension_fuzzing.md#option-a-sanitizerlibfuzzer-preloads
ENV LD_PRELOAD "$VIRTUAL_ENV/lib/python3.11/site-packages/asan_with_fuzzer.so"

# Subject to change by upstream, but it's just a sanity check
RUN nm $(python3 -c "import _cbor2; print(_cbor2.__file__)") | grep asan \
    && echo "Found ASAN" \
    || echo "Missing ASAN"

# 1. Skip allocation failures and memory leaks for now, they are common, and low impact (DoS)
# 2. https://github.com/google/atheris/blob/master/native_extension_fuzzing.md#leak-detection
# 3. Provide the symbolizer to turn virtual addresses to file/line locations
ENV ASAN_OPTIONS "allocator_may_return_null=1,detect_leaks=0,external_symbolizer_path=$CLANG_DIR/bin/llvm-symbolizer"

COPY fuzz.py fuzz.py

ENTRYPOINT ["python3", "fuzz.py"]
CMD ["-help=1"]

Dockerfile的以下部分与自定义或未来项目相关,值得进一步讨论:

  • 从llvm-project仓库安装Clang
  • 使用Docker构建参数(例如ARG)在构建时自定义镜像
  • 安装cbor2项目
  • 使用nm检查编译的cbor2 C扩展的AddressSanitizer(ASan)符号
  • 使用ASAN_OPTIONS自定义模糊测试过程

首先,从llvm-project仓库安装Clang:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ENV APP_DIR "/app"
ENV CLANG_DIR "$APP_DIR/clang"
...
RUN mkdir $CLANG_DIR
...
ARG CLANG_URL=https://github.com/llvm/llvm-project/releases/download/llvmorg-17.0.6/clang+llvm-17.0.6-aarch64-linux-gnu.tar.xz
ARG CLANG_CHECKSUM=6dd62762285326f223f40b8e4f2864b5c372de3f7de0731cb7cd55ca5287b75a
...
ENV CLANG_FILE clang.tar.xz
RUN wget -q -O $CLANG_FILE $CLANG_URL && \
    echo "$CLANG_CHECKSUM  $CLANG_FILE" | sha256sum -c - && \
    tar xf $CLANG_FILE -C $CLANG_DIR --strip-components 1 && \
    rm $CLANG_FILE

这段代码安装了17.0.6-aarch64-linux-gnu的Clang压缩包。这个压缩包除了是为AArch64和Linux构建的之外,没有什么特别之处。如果你在不同的架构上运行这个Docker容器,你需要使用相应的发布压缩包。然后,你可以根据需要指定CLANG_URL和CLANG_CHECKSUM构建参数,或者简单地根据系统要求修改Dockerfile。

Dockerfile还提供了一个BRANCH构建参数。这允许构建者指定他们想要模糊测试的Git分支或标签。例如,如果你正在处理一个拉取请求,并想要模糊测试其对应的分支,你可以使用这个构建参数来实现。

接下来,安装cbor2项目:

1
2
3
ENV CBOR2_BUILD_C_EXTENSION "1"
RUN git clone --branch $BRANCH https://github.com/agronholm/cbor2.git
RUN python3 -m pip install cbor2/

这是从GitHub而不是PyPI安装cbor2包。这是必要的,因为我们需要编译底层的C扩展。我们可以从PyPI源分发安装包,但使用Git为我们提供了更多控制,可以安装哪个分支、标签或提交。

CBOR2_BUILD_C_EXTENSION环境变量指示setup.py确保构建C扩展:

1
2
3
4
5
6
7
8
9
30    cpython = platform.python_implementation() == "CPython"
31    windows = sys.platform.startswith("win")
32    use_c_ext = os.environ.get("CBOR2_BUILD_C_EXTENSION", None)
33    if use_c_ext == "1":
34        build_c_ext = True
35    elif use_c_ext == "0":
36        build_c_ext = False
37    else:
38        build_c_ext = cpython and (windows or check_libc())

构建C扩展的环境标志(setup.py#30–38)

这是带有C扩展的Python包的常见模式。研究项目的setup.py是更好地理解C扩展如何构建的好方法。更多信息,请参见setuptools关于构建扩展模块的文档。

接下来是检查编译的C扩展:

1
2
3
RUN nm $(python3 -c "import _cbor2; print(_cbor2.__file__)") | grep asan \
    && echo "Found ASAN" \
    || echo "Missing ASAN"

这个命令在编译的C扩展符号表中搜索ASan符号。如果它们存在,那么我们知道C扩展已正确编译。有趣的是,__file__属性也适用于Python中的共享对象,因此启用了这个检查:

1
2
$ python3 -c "import _cbor2; print(_cbor2.__file__)"
/opt/venv/lib/python3.11/site-packages/_cbor2.cpython-311-aarch64-linux-gnu.so

最后,让我们深入了解ASAN_OPTIONS:

1
ENV ASAN_OPTIONS "allocator_may_return_null=1,detect_leaks=0,external_symbolizer_path=$CLANG_DIR/bin/llvm-symbolizer"

我们指定了三个选项:

  • allocator_may_return_null=1:我们禁用这个检查,因为模糊测试运行产生了Python MemoryError异常。我们只寻找C内存损坏错误,而不是Python异常。
  • detect_leaks=0:这个选项是Atheris文档推荐的。
  • external_symbolizer_path=$CLANG_DIR/bin/llvm-symbolizer:这使LLVM符号化器能够将虚拟地址转换为模糊测试输出中的文件/行位置。

你可以在Google的sanitizers仓库中找到完整的ASan清理器标志列表和常见清理器选项。

模糊测试工具

用于cbor2的模糊测试工具很大程度上受到了Google的oss-fuzz仓库中ujson使用的工具的启发。这个仓库中有数百个项目正在进行模糊测试。阅读它们的模糊测试工具是为你的模糊测试项目收集想法的好方法。

以下是作为模糊测试工具的Python代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/python3

import sys
import atheris

# _cbor2 ensures the C library is imported
from _cbor2 import loads

def test_one_input(data: bytes):
    try:
        loads(data)
    except Exception:
        # We're searching for memory corruption, not Python exceptions
        pass

def main():
    atheris.Setup(sys.argv, test_one_input)
    atheris.Fuzz()

if __name__ == "__main__":
    main()

记住,我们只模糊测试C扩展,而不是Python代码。工具的两个特性实现了这种行为:导入_cbor2而不是cbor2,以及在loads调用周围的try/except块。再次查看setup.py,我们看到_cbor2是C扩展的Python模块名称:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
47    if build_c_ext:
48        _cbor2 = Extension(
49            "_cbor2",
50            # math.h routines are built-in to MSVCRT
51            libraries=["m"] if not windows else [],
52            extra_compile_args=["-std=c99"] + gnu_flag,
53            sources=[
54                "source/module.c",
55                "source/encoder.c",
56                "source/decoder.c",
57                "source/tags.c",
58                "source/halffloat.c",
59            ],
60            optional=True,
61        )
62        kwargs = {"ext_modules": [_cbor2]}
63    else:
64        kwargs = {}

_cbor2 Python模块名称(setup.py#47–64)

这就是我们知道导入_cbor2而不是cbor2的方式。除了导入之外,try/except块有效地忽略了由Python异常引起的崩溃。

有了Docker镜像提供的模糊测试环境和Python代码提供的模糊测试工具,我们准备好进行一些模糊测试了!

运行模糊测试器

首先,将Dockerfile和Python代码分别复制到名为Dockerfile和fuzz.py的文件中。然后,你可以使用以下命令构建Docker镜像:

1
$ docker build --build-arg BRANCH=5.5.1 -t cbor2-fuzz -f Dockerfile

请注意,APT包和Clang安装需要大量下载,因此构建可能需要一段时间。由于版本5.5.1是发现这些错误时的最新cbor2发布版本,我们正在构建该Git标签以重现崩溃。构建完成后,你可以使用以下命令启动模糊测试过程:

1
$ docker run -v $(pwd):/tmp/output/ cbor2-fuzz -artifact_prefix=/tmp/output/

将/tmp/output指定为Docker卷和libFuzzer的artifact_prefix将导致任何崩溃输出文件持久化到主机的文件系统,而不是容器的临时文件系统。有关可以在运行时传递的标志的更多信息,请参见libFuzzer选项文档。

运行模糊测试器应该很快产生以下崩溃:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/usr/include/python3.11/object.h:537:15: runtime error: member access within null pointer of type 'PyObject' (aka 'struct _object')
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /usr/include/python3.11/object.h:537:15 in 
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0xffff921a94b4 bp 0xffffe8dc8ce0 sp 0xffffe8dc8ca0 T0)
==1==The signal is caused by a READ memory access.
==1==Hint: address points to the zero page.
    #0 0xffff921a94b4 in Py_DECREF /usr/include/python3.11/object.h:537:9
    #1 0xffff921a94b4 in decode_definite_string /app/cbor2/source/decoder.c:653:9
    #2 0xffff921a94b4 in decode_string /app/cbor2/source/decoder.c:718:15
    #3 0xffff921a5cc8 in decode /app/cbor2/source/decoder.c:1735:27
    #4 0xffff921b1d98 in CBORDecoder_decode_stringref_ns /app/cbor2/source/decoder.c:1456:15
    #5 0xffff921ab90c in decode_semantic /app/cbor2/source/decoder.c:973:31
    #6 0xffff921a5d48 in decode /app/cbor2/source/decoder.c:1738:27
    #7 0xffff921aac90 in decode_map /app/cbor2/source/decoder.c:909:27
    #8 0xffff921a5d28 in decode /app/cbor2/source/decoder.c:1737:27
    #9 0xffff921d4e28 in CBOR2_load /app/cbor2/source/module.c:318:19
    #10 0xffff921d4e28 in CBOR2_loads /app/cbor2/source/module.c:367:19
    ...
==1==ABORTING
MS: 1 ChangeByte-; base unit: 096adbe21e6ccdcdaf3b466eae0eecc042a4ce48
0xa9,0xd9,0x1,0x0,0x67,0x0,0xfa,0xfa,0x0,0x0,0x4,0x4,
\251\331\001\000g\000\372\372\000\000\004\004
artifact_prefix='/tmp/output/'; Test unit written to /tmp/output/crash-092ce4a82026ba5ca35d4ee4ef5c9ba41623d61d
Base64: qdkBAGcA+voAAAQE

输出为我们提供了完整的堆栈跟踪和一个崩溃文件来重现问题:

1
2
$ python -m cbor2.tool -p crash-092ce4a82026ba5ca35d4ee4ef5c9ba41623d61d 
Segmentation fault: 11

崩溃发生在decode_definite_string中的Py_DECREF调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
640    PyObject *ret = NULL;
641    char *buf;
642    
643    buf = PyMem_Malloc(length);
644    if (!buf)
645       return PyErr_NoMemory();
646    
647    if (fp_read(self, buf, length) == 0)
648       ret = PyUnicode_DecodeUTF8(
649               buf, length, PyBytes_AS_STRING
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计