Check Binary Hardening Flags: NX, PIE, RELRO, ASLR and CFG
On this page
Short answer: compiler and linker hardening leaves marks in the file. On Linux and Android, look at the ELF PT_GNU_STACK and PT_GNU_RELRO program headers, the BIND_NOW and PIE dynamic flags, and imports of __stack_chk_fail and __*_chk. On Windows, look at the PE DllCharacteristics bits DYNAMIC_BASE, HIGH_ENTROPY_VA, NX_COMPAT and GUARD_CF. On macOS, look at the MH_PIE header flag and the runtime flag in the code signature. This site’s ELF, PE and Mach-O viewer reads all of them in your browser, without uploading the file.
This guide builds the same small program with and without the usual flags on all three platforms, runs each build through the viewer, and cross-checks the result with llvm-readelf, llvm-readobj, otool and codesign. All the output below is real.
Who this is for: developers and release engineers who want proof that a build has the protections their CI script, distro policy or security review asks for, before it ships.
What you will learn:
- What each flag protects against, in one paragraph each
- Which flags turn each one on, and what your toolchain does by default
- How to read “partial RELRO”, “no canary” and “CFG: no”
- How to cross-check with standard tools, and where REA 6.4.0 (the open-source project by morluto) fits
Check only builds you own or are authorized to assess. A missing flag is a fact about how a file was built. It is not proof of a vulnerability, and this guide is about turning protections on, not getting around them.
What each protection does
- NX / DEP. Memory that holds data (the stack, the heap) is not executable. On ELF this is the
PT_GNU_STACKheader without theEflag; on PE it isNX_COMPAT, which Windows calls Data Execution Prevention. - PIE and ASLR. A position-independent executable can be loaded at a different random address each time it runs, so addresses in one run do not hold in the next. On ELF this means
ET_DYNplus an interpreter or theDF_1_PIEflag; on PE it isDYNAMIC_BASE; on Mach-O it isMH_PIE. High-entropy ASLR (HIGH_ENTROPY_VA) lets a 64-bit Windows image use the full 64-bit address range for randomization. - RELRO. After the dynamic linker has applied relocations, it makes part of the data segment read-only. With partial RELRO, the lazily bound part of the GOT stays writable. With full RELRO (
-z now, which setsBIND_NOW), all symbols are bound at startup, so the whole GOT can be made read-only. - Stack canary. The compiler puts a guard value next to local buffers and checks it before the function returns. A mismatch calls
__stack_chk_failand stops the program instead of returning through a corrupted frame. - FORTIFY_SOURCE. When the compiler knows a buffer’s size, calls like
strcpyandmemcpyare replaced with checked versions (__strcpy_chk,__memcpy_chk) that abort on overflow. - Control Flow Guard (CFG). On Windows, indirect calls go through a check that the target is a known function start. It needs both the compiler and the linker switch.
- Hardened runtime. On macOS, a code-signing flag that turns on extra runtime restrictions (for example on code injection and unsigned memory), with entitlements to relax specific ones. Notarization requires it.
The test builds
I built a ten-line C program that copies an argument into a 64-byte stack buffer and prints it, plus a minimal Go program. Every build was then opened in the binary viewer and checked with the LLVM tools that ship in Android NDK r29.
There is no Linux machine in this setup, so the ELF files come from two cross-toolchains: the NDK r29 clang (clang 21) for x86_64-linux-android29, and Go 1.27.1 for linux/amd64. The PE files come from clang-cl and lld-link from the same NDK. lld-link accepts the same switches as Microsoft’s link.exe, but I did not have MSVC itself. The Mach-O files come from Apple clang 21 on macOS 27.
Step 1: Linux and Android (ELF)
Four builds of the same C file:
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
What the viewer’s Security features table showed (feature, status, evidence):
| Build | NX | PIE | RELRO | Canary | FORTIFY |
|---|---|---|---|---|---|
elf-weak |
no (PT_GNU_STACK rwx) |
no (ET_EXEC) |
no | no | no |
elf-default |
yes | yes (ET_DYN, DF_1_PIE) |
yes | no | no |
elf-partial |
yes | yes | partial | no | no |
elf-hardened |
yes | yes | yes (PT_GNU_RELRO + BIND_NOW) |
yes (__stack_chk_fail) |
yes (__strcpy_chk) |
Two things stand out. First, the NDK’s clang, when run directly like this, already gives you PIE, full RELRO and a non-executable stack, but no stack canary. The canary and the FORTIFY imports only appeared with -fstack-protector-strong -D_FORTIFY_SOURCE=2. Second, -z lazy is all it takes to drop from full to partial RELRO.
Cross-check with llvm-readelf. For the hardened build:
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
For elf-weak, the same commands printed Type: EXEC, GNU_STACK ... RWE and no GNU_RELRO line. For elf-partial, GNU_RELRO was there but FLAGS_1 said only PIE, with no BIND_NOW.
Go is different
Go does not use the C toolchain’s defaults. My three linux/amd64 builds (CGO_ENABLED=0) gave:
| Command | Viewer result |
|---|---|
go build |
NX yes, PIE no (ET_EXEC), RELRO no, statically linked |
go build -buildmode=pie |
PIE yes, RELRO partial (PT_GNU_RELRO without BIND_NOW) |
go build -buildmode=pie -ldflags=-bindnow |
PIE yes, RELRO yes (FLAGS: BIND_NOW, FLAGS_1: NOW PIE) |
Go programs never import __stack_chk_fail, so “canary: no” is expected for pure Go code. Go does its own bounds checking at run time.
Step 2: Windows (PE)
Three builds of a small console program, without a C runtime:
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 is a small stand-in for the load-configuration structure that the MSVC C runtime normally supplies. CFG stores its function table there, so a normal MSVC build does not need it.
What the viewer showed:
| Build | ASLR | High-entropy ASLR | DEP | Control Flow Guard |
|---|---|---|---|---|
pe-weak.exe |
no | no | no | no |
pe-default.exe |
yes | yes | yes | no |
pe-hardened.exe |
yes | yes | yes | yes (GUARD_CF) |
The linker’s defaults turn on ASLR, high-entropy ASLR and DEP, which matches Microsoft’s documented defaults for link.exe on 64-bit images. CFG stays off until you ask for it. With MSVC, that means /guard:cf on the compiler (cl) and /GUARD:CF on the linker. In Visual Studio it is the Control Flow Guard setting under C/C++ > Code Generation.
Cross-check with llvm-readobj (or dumpbin /headers /loadconfig if you have Visual Studio):
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)
The viewer reads only the GUARD_CF bit (src/tools/binary/pe.ts). The load-config GuardFlags show whether the code was actually instrumented, so check them too when CFG matters. The Go 1.27.1 windows/amd64 build had ASLR, high-entropy ASLR and DEP, and no CFG.
On 32-bit images the viewer also reports SafeSEH: yes when the image sets NO_SEH or has a SafeSEH handler table in its load config. 64-bit images use table-based exception handling, so the row says n/a.
Step 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 hardens more by default than the NDK does when you call clang yourself. Plain -O2 already produced MH_PIE, a non-executable stack and both ___stack_chk_fail and ___strcpy_chk imports (nm -u lists them). The viewer showed PIE, NX and canary as yes for both files. The difference is the signature:
| Build | Code signature | Hardened runtime |
|---|---|---|
mac-default |
yes, flags=0x20002(adhoc,linker-signed) |
no |
mac-runtime |
yes, flags=0x10002(adhoc,runtime) |
yes (CS_RUNTIME) |
codesign -dv printed the same flags. In Xcode, hardened runtime is the Hardened Runtime capability (ENABLE_HARDENED_RUNTIME). Outside Xcode, it is codesign --options runtime with your Developer ID. The viewer only detects the signature; it does not verify it. For notarization, codesign --verify and Apple’s notary service are the authority. For more about Mach-O files, see reverse engineering a macOS app with REA.
Reading the common results
- “Partial RELRO”. You have
PT_GNU_RELRObut noBIND_NOW. Add-Wl,-z,now(C/C++) or-ldflags=-bindnow(Go), and check that nothing later in the build passes-z lazy. - “No canary found” / canary: no. Either the code was built without
-fstack-protector-strong, or no function needed one. With-strong, functions without arrays or address-taken locals get no canary, so a tiny program can legitimately show “no”. The viewer only looks for the imported__stack_chk_fail, so a statically linked binary can show “no” even when it has canaries. - FORTIFY: no. Add
-D_FORTIFY_SOURCE=2(or3with newer GCC and Clang). With glibc it only works when optimizing (-O1or higher). It also shows “no” when the compiler could prove every call safe and needed no_chkcall. - PIE: n/a on a
.soor.dylib. Shared libraries are position-independent by design; the question only applies to executables. - NX: unknown. The ELF has no
PT_GNU_STACKat all, and the default then depends on the platform. Link with-Wl,-z,noexecstackto make it explicit. - CFG: no on a DLL you do not build. Only the vendor can fix that. For your own code, add
/guard:cfto both the compile and the link step.
Keep the flags in the build
Put the flags where every build gets them, not in a developer’s shell. With CMake, for example:
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)
Then make CI check the artifact you actually ship, after stripping and packaging. A grep on llvm-readelf -dW for BIND_NOW, or on llvm-readobj --file-headers for GUARD_CF, fails a build quickly. The viewer’s Download JSON button gives you the same checklist as a file to attach to a release review.
Where REA 6.4.0 fits, and where it stops
I checked the published rea-agents 6.4.0 package, not the repository’s main branch:
-
ELF.
rea inspect-binary-layout <path>returns amitigationsobject withposition_independent,nx_indicator,executable_stack_indicator,stack_canary_indicatorandrelro("Partial","Full"ornull), markedevidence_kind: "inferred"(dist/domain/native/binaryLayout.js). The values come from pwntools’ELFobject (bridge/pwntools/layout_impl.py). It has no FORTIFY field. It supports only ELF64 x86-64 and runs only on Linux x64 with a Python you supply throughREA_PWNTOOLS_PYTHON(pwntools 4.15.0, pyelftools 0.33, Unicorn 2.1.2). On my Mac it refused, as documented:{"code":"capability_unavailable","category":"unsupported_provider","message":"The initial real-verified pwntools artifact profile supports Linux x64 only.", ...}So I could not run it on these samples. If you have a Linux x64 host, it is a reasonable second opinion for the ELF rows above.
-
Mach-O.
rea inspect-signature <path>needs macOS with the Xcode command-line tools. Onmac-runtimeit returned"signed": trueand"hardened_runtime": true.rea inspect-machoreturns the header flags as a raw number (0x00200085, which includesMH_PIE), not decoded. -
PE. 6.4.0 has no check for
DllCharacteristicsor CFG. Use the viewer orllvm-readobj.
To go further than flags, for example to find the function that needs the canary, open the binary in Ghidra through REA as described in native binary analysis with REA and Ghidra.
FAQ
Is a binary with every flag “secure”? No. The flags make some kinds of memory bugs harder to turn into something worse. They do not fix the bugs. Treat the checklist as a release gate, not a security verdict.
Does this replace checksec? It checks the same kind of evidence (headers, flags and imports) and works on ELF, PE and Mach-O without installing anything. If your pipeline already runs a checksec script, keep it. The viewer is useful when someone sends you a file on a machine without those tools.
Why does my Android library show PIE n/a?
A .so is a shared object. PIE only applies to executables. NX and RELRO still apply to the library.
Does the viewer upload my binary? No. It parses the file in a Web Worker in your browser. The tool page explains how to check this in the Network tab.
Next steps
- Check your own builds: ELF, PE and Mach-O viewer
- Inspect a macOS universal binary slice by slice: Is this Mac app universal?
- Look at the code behind the imports: Reverse engineer a Windows EXE or DLL with AI, Ghidra and REA
- Compare two releases after changing flags: Binary patch diffing with an AI agent
- Reference: REA’s DISCLAIMER.md