Mitigating __pointer_chk_guard_local exposure and pointer mangling in ld.so

Adhemerval Zanella Netto adhemerval.zanella@linaro.org
Fri May 29 20:30:44 GMT 2026



On 29/05/26 13:27, Anderson Nascimento wrote:
> Hello,
> 
> I have been investigating the behavior of Pointer Guard Encryption
> when it is configured to use Thread Local Storage (TLS), and I noticed
> an architectural asymmetry that inadvertently exposes an easier target
> for attackers looking to bypass pointer mangling.
> 
> Even when TLS pointer guarding is enabled, glibc exposes the variable
> __pointer_chk_guard_local to the target binary with its full random
> value intact.
> 
> Initially, looking at security_init() in elf/rtld.c, it looks like an
> unhandled condition. The block that checks if the pointer guard
> feature is configured to use TLS lacks an alternative assignment
> mechanism like __stack_chk_guard above it:
> 
> ```
> 821 #ifdef THREAD_SET_STACK_GUARD
> 822   THREAD_SET_STACK_GUARD (stack_chk_guard);
> 823 #else
> 824   __stack_chk_guard = stack_chk_guard;
> 825 #endif
> 826
> 827   /* Set up the pointer guard as well, if necessary.  */
> 828   uintptr_t pointer_chk_guard
> 829     = _dl_setup_pointer_guard (_dl_random, stack_chk_guard);
> 830 #ifdef THREAD_SET_POINTER_GUARD
> 831   THREAD_SET_POINTER_GUARD (pointer_chk_guard);
> 832 #endif
> 833   __pointer_chk_guard_local = pointer_chk_guard;
> ```
> 
> Consequently, the __pointer_chk_guard_local variable retains the
> secret guard value throughout the lifetime of the process, which can
> be observed at runtime via GDB:
> 
> ```
> $ gdb test
> ...
> Breakpoint 1, 0x000000000040048b in main ()
> (gdb) p/x __pointer_chk_guard_local
> $1 = 0x2d24c8353dd46c
> (gdb)
> ```
> 
> While __pointer_chk_guard_local is marked with attribute_hidden and is
> not exported to the dynamic symbol table (.dynsym), keeping it
> populated introduces a significant security asymmetry once an attacker
> gains an arbitrary read primitive:
> 
> To leak the guard from the Thread Control Block (TCB), an attacker
> typically needs to locate the thread descriptor (%fs). That seems
> harder than the approach described here.
> 
> To leak __pointer_chk_guard_local, an attacker merely needs to
> calculate the base address of ld.so. Because of how dynamic linking
> works, pointers to ld.so structures are routinely exposed. An attacker
> can trivially traverse the link_map from the main binary's GOT to
> locate ld.so. Once the base is known, __pointer_chk_guard_local
> resides at a predictable, static offset.
> 
> This doesn't change much in practice because at least in the case of
> the exit handlers, attackers armed with an arbitrary read primitive
> have been able to obtain the pointer guard encryption key through
> other ways, as discussed in [1] and [2], but by leaving this copy of
> the secret key populated in memory, we provide attackers with a
> significantly more accessible target to defeat pointer mangling
> mitigations.
> 
> I understand this behavior is needed due to some initialization
> requirements. At compile time, IS_IN (rtld) forces PTR_MANGLE and
> PTR_DEMANGLE to use __pointer_chk_guard_local because setjmp must run
> early in the dynamic linker lifecycle before TLS or the thread
> descriptor is ready:
> 
> ```
> 25 #if IS_IN (rtld)
> 26 /* We cannot use the thread descriptor because in ld.so we use setjmp
> 27    earlier than the descriptor is initialized.  */
> 28 # ifdef __ASSEMBLER__
> 29 #  define PTR_MANGLE(reg)       xor
> __pointer_chk_guard_local(%rip), reg;     \
> 30                                 rol $2*LP_SIZE+1, reg
> 31 #  define PTR_DEMANGLE(reg)     ror $2*LP_SIZE+1, reg;
>           \
> 32                                 xor __pointer_chk_guard_local(%rip), reg
> 33 # else
> ```
> 
> I initially tested a naive patch that nullified
> __pointer_chk_guard_local inside dl_main right before applying RELRO
> protections:
> 
> ```
> diff --git a/elf/rtld.c b/elf/rtld.c
> index 12e1b4dd71..d5b7aee00b 100644
> --- a/elf/rtld.c
> +++ b/elf/rtld.c
> @@ -2355,6 +2355,8 @@ dl_main (const ElfW(Phdr) *phdr,
>         _dl_debug_post_relocate (main_map);
>       }
> 
> +  __pointer_chk_guard_local = 0;
> +
>     /* All ld.so initialization is complete.  Apply RELRO.  */
>     _dl_protect_relro (&_dl_rtld_map);
> 
> ```
> 
> ```
> $ gdb test
> GNU gdb (Fedora Linux) 17.1-4.fc43
> ...
> Reading symbols from test...
> (gdb) b main
> Breakpoint 1 at 0x40048b
> (gdb) r
> Starting program: /tmp/test
> 
> Breakpoint 1, 0x000000000040048b in main ()
> (gdb) p/x __pointer_chk_guard_local
> $1 = 0x0
> (gdb)
> ```
> 
> While this cleanly neutralizes the variable in simple tests and makes
> a code using atexit() to function properly, I believe it weakens the
> security. Because ld.so is statically defined to use
> __pointer_chk_guard_local for its entire lifecycle, zeroing it out
> might effectively disable pointer mangling for all subsequent ld.so
> runtime activities. I didn't investigate this behavior.
> 
> Given that leaving this secret key in memory weakens the efficacy of
> the pointer guard mitigation against arbitrary read primitives, has
> any thought been given to refactoring how rtld handles mangling
> post-initialization?


I agree that it weakens the efficacy of the pointer guard. But if an 
attacker already has enough control over the process to traverse the 
link_map and access the main binary's GOT to search for the pointer 
guard address, I think it has enough control that hardening internal 
pointers isn't really effective.

Also, other ABIs that place the pointer guard on TCB (i686, sparc,
powerpc) just use the generic implementation 
(sysdeps/generic/pointer_guard.h), which is a no-op. So the question 
is whether we really need setjmp/longjmp for the loader (I would say 
it makes sense, and not enabling it across all ABIs is an oversight).

Is this a vector for an exploit? Because it means that ABIs
that use the global definition (aarch64, etc.) are subject to potential
issue since libc.so __pointer_chk_guard@GLIBC_PRIVATE is an alias to the
ld.so one.

> 
> For instance, could PTR_MANGLE within rtld be adapted to dynamically
> switch to checking the thread descriptor once a global initialization
> flag is set? Alternatively, is there a cleaner way to isolate or clear
> this easier target without introducing regressions to runtime linker
> activities?
> 

I don't think we can easily change the startup sequence to set up the
thread pointer prior to ELF mapping, and it would most likely be a large
change that would need to be validated on multiple ABIs.

I think the cleanest solution would be something like we have for malloc:
loader uses an internal implementation until the libc.so is reallocated
then it switches to use the libc one.

This would also allow us to consolidate and enable PTR_MANGLE on all ABIs,
instead of making them an ABI-specific hardening.

I did a POC for this [1], and enabled it only for x86_64. I need to check
if everything does work correctly for other ABIs, and possibly enable
on them as well (and remove the usage of the generic implementation).
Maybe it would be better to *always* enable PTR_MANGLE and remove the
generic no-op code.

But I am not 100% convinced about this hardening, it does improve the
information leakage but the thread model is not really clear to me.
It also does not improve the information leakage on other ABIs, which
is suboptimal.


[1] https://sourceware.org/git/?p=glibc.git;a=shortlog;h=refs/heads/azanella/pointer-guard-hardening

> I would love to hear thoughts on whether this is an area the community
> wants to harden, and I am glad to assist in developing and testing a
> proper patch.
> 
> References:
> 
> [1] - Code execution part 1: from exit to system
> https://blog.rop.la/en/exploiting/2024/06/11/code-exec-part1-from-exit-to-system.html
> 
> [2] - Notes on abusing exit handlers, bypassing pointer mangling and
> glibc ptmalloc hooks
> https://m101.github.io/binholic/2017/05/20/notes-on-abusing-exit-handlers.html
> 
> Regards,



More information about the Libc-alpha mailing list