ROP chains against mismatched gadget hygiene
Control flow integrity was supposed to kill return oriented programming. it didn't
Control flow integrity was supposed to kill return oriented programming. it didn't
I was checking whether a test binary actually shipped the CET protections I told gcc to build in, and readelf -n came back with x86 ISA needed: x86-64-baseline and nothing else. No IBT, no SHSTK. I’d compiled with -fcf-protection=full. The shadow stack was gone, and the only thing different about that build was a single object I’d linked in with -fcf-protection=none. That’s the whole bug: one dirty object disarms CET for the entire image, silently, at link time. If you ship anything that links third-party static archives or hand-written asm, you probably want to check your own binaries before reading further.
That gap is why return-oriented programming is still a working technique in 2026, and it has almost nothing to do with the quality of CET. It has to do with how the GNU property note composes across objects, and the fact that nothing tells you when it composes down to nothing.
CET’s enforcement state for a binary lives in .note.gnu.property as the IBT (indirect branch tracking) and SHSTK (shadow stack) feature bits. Compile clean and you get both:
$ gcc -O0 -fcf-protection=full -o svc_cet svc.c$ readelf -n svc_cet | grep -i feature Properties: x86 feature: IBT, SHSTKThe catch is how those bits combine at link time. The property is intersected across input objects, not unioned. So one object that opted out drops the feature for the whole output:
$ gcc -O0 -fcf-protection=full -c m.c -o m.o # hardened$ gcc -O0 -fcf-protection=none -c a.c -o a.o # one dirty object$ gcc m.o a.o -o svc_mixed$ readelf -n svc_mixed | grep -i "feature\|ISA needed" Properties: x86 ISA needed: x86-64-baselineIBT and SHSTK are gone. The loader reads that note at exec time, sees no SHSTK bit, and never arms the shadow stack for the process. Your 99% CET-clean binary runs with zero CET because one translation unit, maybe a vendored .o, maybe an assembly stub, maybe a prebuilt static lib, didn’t carry the marking.
This is the real issue, and it is an opt-out failure mode wearing an opt-in costume. You do not turn CET off by attacking it. You turn it off by accident, at build time, and the toolchain helps you do it quietly.
Worth noting that the vulnerable binary itself is rarely your gadget supply. The mixed target above yields almost nothing:
$ ROPgadget --binary svc_mixed | tail -1Unique gadgets found: 66Sixty-six gadgets, most of them bare ret. You will not build a chain from that. The supply is libc, which is mapped into every dynamically linked process:
$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | tail -1Unique gadgets found: 107301$ ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep "pop rdi ; ret$"0x000000000010f78b : pop rdi ; ret107,301 gadgets, including the pop rdi ; ret you need to set up the first execve argument. This is the part that surprised me when I checked the markings: that same libc on Ubuntu 24.04 ships CET.
$ readelf -n /lib/x86_64-linux-gnu/libc.so.6 | grep -i "IBT\|SHSTK" Properties: x86 feature: IBT, SHSTKSo the gadget warehouse is marked hardened. It does not matter. CET enforcement is a property of the process, decided by the main executable’s note at exec time. If your executable’s note lost SHSTK because of one dirty object, libc’s diligence buys you nothing. The shadow stack is never armed, and a return into 0x10f78b inside libc returns cleanly with no mismatch to detect.
With SHSTK disarmed, the classic stack-smash-to-ret2libc chain works unchanged. Overflow buf, overwrite the saved return address, land on a libc gadget:
payload = b"A" * 72 # fill to saved RIPpayload += p64(libc + 0x10f78b) # pop rdi ; retpayload += p64(libc + bin_sh_offset) # "/bin/sh"payload += p64(libc + ret_offset) # stack-align retpayload += p64(libc + system_offset) # system("/bin/sh")What breaks this in 2026 is not CET, because CET was never armed. What breaks it is ASLR on the libc base, and that is a leak problem, not a control-flow-integrity problem. You still need an address disclosure to resolve libc + offset. The defense that actually costs the attacker something here is the one nobody markets: a leak primitive is the real gate, and it predates CET by a decade.
I expected the shadow stack to be the wall. It was not even in the room. The position I’ll defend: spending review effort auditing whether CET is enabled in your kernel config is mostly theater if you have not first audited whether every object in your link carries the property note. The kernel knob is downstream of a build-time marking you probably aren’t checking.
Add this to CI for anything that links third-party objects or static archives:
# Fail the build if the final binary lost CET feature bitsif ! readelf -n "$BIN" | grep -q "IBT, SHSTK"; then echo "CET feature bits missing from $BIN — a linked object opted out" >&2 exit 1fiRun it on the output, not the objects, because the intersection only happens at link time and an object-level check will pass while the binary fails. I learned that ordering the hard way: a per-.o lint that was green for weeks while the shipped binary had no shadow stack at all.
The open question I do not have a clean answer to: the linker will tell you, but only if you ask. ld -z cet-report=error makes it error on any input object missing the IBT/SHSTK properties, and cet-report=none is the default, so out of the box it says nothing while it intersects the bits away. Many build systems never set it, and nothing propagates it through pkg-config or vendored archives. Until cet-report=error is the default rather than an opt-in, “we enabled CET” is a claim about intent, not about what shipped. Check the note.