检查自己构建的二进制加固选项:NX、PIE、RELRO、ASLR、CFG 一次看清
本页目录
结论先行:编译器和链接器的加固选项都会在文件里留下痕迹。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 面板里自己验证。
下一步
- 检查你自己的构建:ELF / PE / Mach-O 查看器
- 逐个切片查看 macOS 通用二进制:这个 Mac 应用是不是通用二进制?
- 看导入函数背后的代码:用 AI、Ghidra 和 REA 分析 Windows EXE / DLL
- 改完参数后对比两个版本:用 AI agent 做二进制补丁对比
- 参考:REA 的 DISCLAIMER.md