rea-reverse

检查自己构建的二进制加固选项:NX、PIE、RELRO、ASLR、CFG 一次看清

基于 REA 6.4.0更新于 约 8 分钟读完
本页目录

结论先行:编译器和链接器的加固选项都会在文件里留下痕迹。Linux 和 Android 的 ELF 看 PT_GNU_STACK、PT_GNU_RELRO 两个程序头,动态段里的 BIND_NOW 和 PIE 标志,以及有没有导入 __stack_chk_fail 和 __*_chk。Windows 的 PE 看 DllCharacteristics 里的 DYNAMIC_BASE、HIGH_ENTROPY_VA、NX_COMPAT、GUARD_CF 四个位。macOS 的 Mach-O 看文件头的 MH_PIE 和代码签名里的 runtime 标志。本站的 ELF / PE / Mach-O 查看器 能在浏览器里把这些一次读出来,文件不上传。

本文把同一个小程序在三个平台上分别用“默认参数”“故意关掉”“全部打开”几种方式编译,逐个拖进查看器,再用 llvm-readelf、llvm-readobj、otool、codesign 交叉核对。下面所有输出都是实际跑出来的。

适合谁读:想在发版前拿出证据、证明构建产物满足 CI 脚本、发行版打包规范或安全评审要求的开发者和发布工程师。

你会学到:

  • 每一项防护是干什么的(每项一段话,不展开利用细节)
  • 各项由哪个参数打开,你的工具链默认给了什么
  • “partial RELRO”“no canary”“CFG: no”分别该怎么理解
  • 怎么用标准工具复核,以及 REA 6.4.0(morluto 的开源项目)能做到哪一步

只检查你自己的构建,或你有权评估的文件。缺某个选项只说明文件是怎么编出来的,不等于有漏洞。本文讲的是怎么把防护打开,不涉及任何绕过思路。

每一项防护是什么

  • NX / DEP:存放数据的内存(栈、堆)不能当代码执行。ELF 上体现为 PT_GNU_STACK 不带 E 标志;PE 上是 NX_COMPAT,Windows 叫它数据执行保护(DEP)。
  • PIE 与 ASLR:位置无关的可执行文件每次运行可以加载到不同的随机地址,这一次的地址到下一次就不成立了。ELF 上是 ET_DYN 加解释器或 DF_1_PIE 标志;PE 上是 DYNAMIC_BASE;Mach-O 上是 MH_PIE。高熵 ASLR(HIGH_ENTROPY_VA)让 64 位 Windows 映像在完整的 64 位地址空间里随机化。
  • RELRO:动态链接器做完重定位后,把数据段的一部分改成只读。部分 RELRO 下,延迟绑定的那部分 GOT 仍然可写;完整 RELRO(-z now,会设置 BIND_NOW)在启动时就绑定所有符号,整个 GOT 都能设成只读。
  • 栈保护(canary):编译器在局部缓冲区旁边放一个哨兵值,函数返回前检查它。值被改了就调用 __stack_chk_fail 直接终止程序,而不是沿着被破坏的栈帧返回。
  • FORTIFY_SOURCE:编译器知道缓冲区大小时,把 strcpy、memcpy 这类调用换成带检查的版本(__strcpy_chk、__memcpy_chk),溢出就中止。
  • 控制流保护(CFG):Windows 上,间接调用先检查目标是不是一个已知的函数入口。编译器和链接器两边的开关都要开。
  • Hardened Runtime:macOS 代码签名里的一个标志,打开额外的运行时限制(比如针对代码注入和未签名内存),需要放开某一项时用 entitlement 单独声明。公证(notarization)要求开启它。

测试用的构建

我写了一个十行左右的 C 程序:把命令行参数拷进 64 字节的栈缓冲区再打印出来;另外还有一个最小的 Go 程序。每个构建都用二进制查看器打开,再用 Android NDK r29 自带的 LLVM 工具核对。

这次的环境里没有 Linux 机器,所以 ELF 来自两条交叉工具链:NDK r29 的 clang(clang 21,目标 x86_64-linux-android29),以及 Go 1.27.1(linux/amd64)。PE 用同一个 NDK 里的 clang-cl 和 lld-link 编译。lld-link 接受和微软 link.exe 相同的开关,但我手上没有 MSVC 本身。Mach-O 用 macOS 27 上的 Apple clang 21。

第 1 步:Linux 和 Android(ELF)

同一个 C 文件编四次:

CC="clang --target=x86_64-linux-android29"
$CC -O2 hello.c -o elf-default
$CC -O2 -fno-stack-protector -no-pie -Wl,-z,norelro -Wl,-z,lazy -Wl,-z,execstack hello.c -o elf-weak
$CC -O2 -Wl,-z,lazy hello.c -o elf-partial
$CC -O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -pie \
    -Wl,-z,relro,-z,now -Wl,-z,noexecstack hello.c -o elf-hardened

查看器“安全特性”表的结果(括号里是“依据”一栏):

构建 NX PIE RELRO 栈保护 FORTIFY
elf-weak 否(PT_GNU_STACK rwx) 否(ET_EXEC) 否 否 否
elf-default 是 是(ET_DYN, DF_1_PIE) 是 否 否
elf-partial 是 是 部分 否 否
elf-hardened 是 是 是(PT_GNU_RELRO + BIND_NOW) 是(__stack_chk_fail) 是(__strcpy_chk)

有两点值得注意。第一,像这样直接调用 NDK 的 clang,默认就有 PIE、完整 RELRO 和不可执行栈,但没有栈保护。canary 和 FORTIFY 的导入只有加了 -fstack-protector-strong -D_FORTIFY_SOURCE=2 才出现。第二,只要多一个 -z lazy,完整 RELRO 就退回到部分 RELRO。

用 llvm-readelf 核对加固版:

llvm-readelf -hlW elf-hardened | grep -E "Type:|GNU_STACK|GNU_RELRO"
llvm-readelf -dW elf-hardened | grep FLAGS
llvm-nm -D --undefined-only elf-hardened | grep chk
  Type:                              DYN (Shared object file)
  GNU_RELRO      0x000830 0x0000000000008830 0x0000000000008830 0x0001a0 0x0007d0 R   0x1
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x0
  0x000000000000001e (FLAGS)           BIND_NOW
  0x000000006ffffffb (FLAGS_1)         NOW PIE
                 U __stack_chk_fail@LIBC
                 U __strcpy_chk@LIBC

同样的命令跑 elf-weak,输出是 Type: EXEC、GNU_STACK ... RWE,没有 GNU_RELRO 那一行;跑 elf-partial,GNU_RELRO 在,但 FLAGS_1 只有 PIE,没有 BIND_NOW。

Go 不一样

Go 不走 C 工具链的默认值。三个 linux/amd64 构建(CGO_ENABLED=0)的结果:

命令 查看器结果
go build NX 是,PIE 否(ET_EXEC),RELRO 否,静态链接
go build -buildmode=pie PIE 是,RELRO 部分(PT_GNU_RELRO without BIND_NOW)
go build -buildmode=pie -ldflags=-bindnow PIE 是,RELRO 是(FLAGS: BIND_NOW、FLAGS_1: NOW PIE)

纯 Go 程序不会导入 __stack_chk_fail,所以“栈保护:否”是预期结果。Go 在运行时自己做边界检查。

第 2 步:Windows(PE)

一个不依赖 C 运行库的小控制台程序,编三个版本:

clang-cl --target=x86_64-pc-windows-msvc /O2 /c hello-win.c
clang-cl --target=x86_64-pc-windows-msvc /O2 /guard:cf /c hello-win.c /Fohello-win-cfg.obj
lld-link hello-win.obj kernel32.lib /entry:start /subsystem:console /out:pe-default.exe
lld-link hello-win.obj kernel32.lib /entry:start /subsystem:console \
    /dynamicbase:no /nxcompat:no /highentropyva:no /out:pe-weak.exe
lld-link hello-win-cfg.obj loadcfg.obj kernel32.lib /entry:start /subsystem:console \
    /dynamicbase /highentropyva /nxcompat /guard:cf /out:pe-hardened.exe

loadcfg.obj 是我补的一个最小 load config 结构,正常情况下由 MSVC 的 C 运行库提供。CFG 的函数表就登记在这里,用 MSVC 正常构建时不需要这一步。

查看器的结果:

构建 ASLR 高熵 ASLR DEP 控制流保护
pe-weak.exe 否 否 否 否
pe-default.exe 是 是 是 否
pe-hardened.exe 是 是 是 是(GUARD_CF)

链接器默认就打开 ASLR、高熵 ASLR 和 DEP,这和微软文档里 link.exe 对 64 位映像的默认值一致。CFG 不主动开就一直是关的。用 MSVC 时,编译器 cl 要加 /guard:cf,链接器也要加 /GUARD:CF。在 Visual Studio 里对应 C/C++ > 代码生成下的控制流保护选项。

用 llvm-readobj 核对(装了 Visual Studio 的话也可以用 dumpbin /headers /loadconfig):

llvm-readobj --file-headers pe-hardened.exe
llvm-readobj --coff-load-config pe-hardened.exe
  Characteristics [ (0xC160)
    IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE (0x40)
    IMAGE_DLL_CHARACTERISTICS_GUARD_CF (0x4000)
    IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA (0x20)
    IMAGE_DLL_CHARACTERISTICS_NX_COMPAT (0x100)
    IMAGE_DLL_CHARACTERISTICS_TERMINAL_SERVER_AWARE (0x8000)
  ]
...
  GuardCFFunctionCount: 4
  GuardFlags [ (0x10500)
    CF_FUNCTION_TABLE_PRESENT (0x400)
    CF_INSTRUMENTED (0x100)

查看器只读 GUARD_CF 这一位(见 src/tools/binary/pe.ts)。代码到底有没有插桩要看 load config 里的 GuardFlags,CFG 是硬性要求时两边都查一下。Go 1.27.1 的 windows/amd64 构建有 ASLR、高熵 ASLR 和 DEP,没有 CFG。

32 位映像还会多一行 SafeSEH:映像设置了 NO_SEH,或 load config 里有 SafeSEH 处理函数表,就显示“是”。64 位映像用基于表的异常处理,这一行显示“不适用”。

第 3 步:macOS(Mach-O)

clang -O2 hello.c -o mac-default
cp mac-default mac-runtime
codesign -s - -f -o runtime mac-runtime

Apple clang 的默认加固比直接调用 NDK clang 多。只写 -O2,产物就已经有 MH_PIE、不可执行栈,还同时导入了 ___stack_chk_fail 和 ___strcpy_chk(nm -u 能看到)。两个文件在查看器里 PIE、NX、栈保护都是“是”,区别在签名上:

构建 代码签名 Hardened Runtime
mac-default 是,flags=0x20002(adhoc,linker-signed) 否
mac-runtime 是,flags=0x10002(adhoc,runtime) 是(CS_RUNTIME)

codesign -dv 打出的标志完全一样。在 Xcode 里,它对应 Hardened Runtime 能力(构建设置 ENABLE_HARDENED_RUNTIME);不用 Xcode 时,就是用你的 Developer ID 证书执行 codesign --options runtime。查看器只检测签名是否存在,不做验证。能不能通过公证,以 codesign --verify 和 Apple 公证服务的结果为准。Mach-O 的更多内容见用 REA 分析 macOS 应用。

常见结果怎么理解

  • “Partial RELRO” / 部分:有 PT_GNU_RELRO,但没有 BIND_NOW。C/C++ 加 -Wl,-z,now,Go 加 -ldflags=-bindnow,再确认构建后面的步骤没有又传 -z lazy。
  • “No canary found” / 栈保护:否:要么编译时没加 -fstack-protector-strong,要么根本没有函数需要它。-strong 模式下,没有数组、也没有取地址的局部变量的函数不插 canary,所以很小的程序显示“否”也正常。查看器只看有没有导入 __stack_chk_fail,静态链接的程序即使有 canary 也可能显示“否”。
  • FORTIFY:否:加 -D_FORTIFY_SOURCE=2(较新的 GCC 和 Clang 可以用 3)。在 glibc 上它只在开优化(-O1 及以上)时生效。另外,编译器能证明每个调用都安全、用不着 _chk 版本时,也会显示“否”。
  • .so 或 .dylib 的 PIE 显示“不适用”:共享库本来就是位置无关的,PIE 只针对可执行文件。
  • NX:未知:ELF 里根本没有 PT_GNU_STACK,这时默认行为取决于平台。链接时加 -Wl,-z,noexecstack 把它写明。
  • 别人的 DLL 显示 CFG:否:只能等厂商修。自己的代码,编译和链接两步都加 /guard:cf。

把参数固化到构建里

参数要放在每次构建都会用到的地方,而不是某个人的 shell 里。以 CMake 为例:

set(CMAKE_POSITION_INDEPENDENT_CODE ON)
include(CheckPIESupported)
check_pie_supported()
target_compile_options(app PRIVATE -fstack-protector-strong -D_FORTIFY_SOURCE=2)
target_link_options(app PRIVATE -Wl,-z,relro,-z,now -Wl,-z,noexecstack)

然后让 CI 检查真正发出去的产物,也就是 strip 和打包之后的那个文件。对 llvm-readelf -dW 的输出 grep BIND_NOW,或对 llvm-readobj --file-headers 的输出 grep GUARD_CF,很快就能让不合格的构建失败。查看器的下载 JSON 按钮能把同一份检查结果存成文件,附到发布评审里。

REA 6.4.0 能做什么,做不到什么

以下核对的是 npm 上发布的 rea-agents 6.4.0 包,不是仓库 main 分支:

  • ELF:rea inspect-binary-layout <path> 返回一个 mitigations 对象,字段有 position_independent、nx_indicator、executable_stack_indicator、stack_canary_indicator 和 relro("Partial"、"Full" 或 null),并标注 evidence_kind: "inferred"(dist/domain/native/binaryLayout.js)。这些值来自 pwntools 的 ELF 对象(bridge/pwntools/layout_impl.py),没有 FORTIFY 字段。它只支持 ELF64 x86-64,只能在 Linux x64 上运行,还要通过 REA_PWNTOOLS_PYTHON 指定你自己装好的 Python(pwntools 4.15.0、pyelftools 0.33、Unicorn 2.1.2)。在我的 Mac 上,它按文档说明拒绝执行:

    {"code":"capability_unavailable","category":"unsupported_provider","message":"The initial real-verified pwntools artifact profile supports Linux x64 only.", ...}

    所以这次没能用它跑上面的样本。有 Linux x64 主机的话,可以拿它给 ELF 那几行做第二意见。

  • Mach-O:rea inspect-signature <path> 需要 macOS 和 Xcode 命令行工具。对 mac-runtime 它返回 "signed": true 和 "hardened_runtime": true。rea inspect-macho 返回的文件头标志是原始数值(0x00200085,其中包含 MH_PIE),没有逐位解读。

  • PE:6.4.0 没有检查 DllCharacteristics 或 CFG 的功能,用查看器或 llvm-readobj。

如果想更进一步,比如找出到底是哪个函数需要 canary,可以按用 REA 和 Ghidra 免费分析原生二进制里的方法,在 Ghidra 里打开它。

常见问题

所有选项都开了,二进制就“安全”了吗? 不是。这些选项让某些内存错误更难被进一步利用,但不会修掉错误本身。把这张清单当作发布门槛,而不是安全结论。

这能替代 checksec 吗? 它看的是同一类证据(文件头、标志位、导入),而且 ELF、PE、Mach-O 都能查,不用装任何东西。流水线里已经在跑 checksec 脚本的话,继续用就好;有人发来一个文件、手边又没有这些工具时,查看器更方便。

为什么我的 Android 库 PIE 显示“不适用”? .so 是共享对象,PIE 只针对可执行文件。NX 和 RELRO 对库同样适用。

查看器会上传我的文件吗? 不会。文件在浏览器的 Web Worker 里解析。工具页写了怎么在 Network 面板里自己验证。

下一步