Re: [PATCH v4 07/11] gdb: Handle shadow stack pointer register unwinding for amd64 linux.

Thiago Jung Bauermann
2025-06-23T23:27:07Z

Hello Christina,

"Schimpe, Christina" <christina.schimpe@intel.com> writes:

>> -----Original Message-----
>> From: Thiago Jung Bauermann <thiago.bauermann@linaro.org>
>> Sent: Friday, June 20, 2025 3:43 AM
>> To: Luis Machado <luis.machado@arm.com>
>> Cc: Schimpe, Christina <christina.schimpe@intel.com>; gdb-
>> patches@sourceware.org; eliz@gnu.org
>> Subject: Re: [PATCH v4 07/11] gdb: Handle shadow stack pointer register
>> unwinding for amd64 linux.
>>
>> Luis Machado <luis.machado@arm.com> writes:
>>
>> > On 6/17/25 13:11, Christina Schimpe wrote:
>> >> + Using /proc/PID/smaps we can only check if the current shadow
>> >> + stack pointer SSP points to shadow stack memory. Only if this is
>> >> + the case a valid previous shadow stack pointer can be
>> >> + calculated. */
>> >> + std::pair<CORE_ADDR, CORE_ADDR> range;
>> >> + if (linux_address_in_shadow_stack_mem_range (ssp, &range))
>> >> + {
>> >> + /* The shadow stack grows downwards. To compute the previous
>> >> + shadow stack pointer, we need to increment SSP. */
>> >> + CORE_ADDR new_ssp
>> >> + = ssp + amd64_linux_shadow_stack_element_size_aligned
>> >> +(gdbarch);
>> >> +
>> >> + /* If NEW_SSP points to the end of or before (<=) the current
>> >> + shadow stack memory range we consider NEW_SSP as valid (but
>> >> + empty). */
>> >
>> > I couldn't quite understand the difference between the empty case and
>> > the unavailable case. But maybe I just don't fully understand the feature.
>> >
>> > Would it be possible to make the comment a bit more clear?
>>
>> I understood it to mean that if new_ssp == range.second, then it points to
>> the top of the stack and there aren't any elements.
>>
>> Whereas if new_ssp points outside of the shadow stack area, then it's
>> garbage and we failed to unwind it, hence the unavailable value.
>>
>> Christina, please correct me if I'm wrong.
>>
>> But now looking at this again, I think there's an off-by-one error:
>> range.second is the first address outside of the memory range, so the
>> comparison needs to be strictly less than. And the shadow stack will be
>> empty if new_ssp == range.second - 1 (there's no need to check for that,
>> though).
>>
>> >> + if (new_ssp <= range.second)
>> >> + return frame_unwind_got_address (this_frame, regnum, new_ssp);
>> >> + }
>> >> + }
>> >> +
>> >> + /* Return a value which is marked as unavailable in case we could not
>> >> + calculate a valid previous shadow stack pointer. */
>> >> + value *retval
>> >> + = value::allocate_register (get_next_frame_sentinel_okay (this_frame),
>> >> + regnum, register_type (gdbarch, regnum));
>> >> + retval->mark_bytes_unavailable (0, retval->type ()->length ());
>> >> + return retval;
>> >> +}
>
> Hi Thiago,
>
>> Whereas if new_ssp points outside of the shadow stack area, then it's
>> garbage and we failed to unwind it, hence the unavailable value.
>
> Yes, this is how it should be. 😊
>
> But I think the current implementation should be correct.
> I wrote a small testprogram, which enables shadow stack manually using ARCH_PRCTL in enable_ssp:
>
> ~~~
>
> int call ()
> {
> return 0;
> }
>
> int enable_ssp ()
> {
> int ret;
> if (ARCH_PRCTL(ARCH_SHSTK_ENABLE, ARCH_SHSTK_SHSTK)) {
> printf("[FAIL]\tEnabling shadow stack failed\n");
> return 1;
> }
>
> ret = call (); // stop in enable_ssp
>
> if (ARCH_PRCTL(ARCH_SHSTK_DISABLE, ARCH_SHSTK_SHSTK)) {
> ret = 1;
> printf("[FAIL]\tDisabling shadow stack failed\n");
> }
> return ret;
> }
>
> int main ()
> {
> return enable_ssp ();
> }
>
> ~~~
>
> If we stop in enable_ssp, ("ret = call ()"), we have the scenario that shadow stack is enabled,
> but no call instruction has been executed yet.
> So we'll have an empty shadow stack, but SSP is valid and pointing to the end address
> (==range.second) of the shadow stack address space:

Thanks for the program. I see that Intel and AArch64 behave differently
in this respect. I believe that this is because on AArch64, a new stack
contains an initial value of 0 at the top. From the Linux kernel
documentation¹:

When a stack is allocated by enabling GCS or during thread creation
then the top 8 bytes of the stack will be initialised to 0 and
GCSPR_EL0 will be set to point to the address of this 0 value, this
can be used to detect the top of the stack.

> ~~~
> (gdb) r
> Starting program: /tmp/main
> [Thread debugging using libthread_db enabled]
> Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
>
> Breakpoint 1, enable_ssp () at main.c:52
> 52 ret = call (); // stop in enable_ssp
> (gdb) p $pl3_ssp
> $1 = (void *) 0x7ffff7c00000
> (gdb) info proc mappings
> process 94192
> Mapped address spaces:
>
> Start Addr End Addr Size Offset Perms File
> [...]
> 0x0000555555558000 0x0000555555559000 0x1000 0x3000 rw-p /tmp/main
> 0x00007ffff7400000 0x00007ffff7c00000 0x800000 0x0 rw-p
> [...]

On AArch64 the GCSPR is within the GCS memory range:

(gdb) r
Starting program: /home/bauermann/tmp/test-gcs
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".

Breakpoint 1, enable_gcspr () at test-gcs.c:63
⚠ warning: Source file is more recent than executable.
63 ret = call (); // stop in enable_ssp
(gdb) p $gcspr
$1 = (void *) 0xfffff7bffff8
(gdb) info proc mappings
process 3228
Mapped address spaces:

Start Addr End Addr Size Offset Perms File
⋮
0x0000fffff7800000 0x0000fffff7c00000 0x400000 0x0 rw-p

> ~~~
>
> Checking the unwinding we see that one frame above SSP becomes unavailable:
> ~~~
> (gdb) up
> #1 0x00005555555551d4 in main () at main.c:63
> 63 return enable_ssp ();
> (gdb) p $pl3_ssp
> $3 = <unavailable>
> (gdb) down
> #0 enable_ssp () at main.c:52
> 52 ret = call (); // stop in enable_ssp
> (gdb) p $pl3_ssp
> $4 = (void *) 0x7ffff7c00000

What I get on AArch64. The last command shows the zeroed entry at the
top of the stack.

(gdb) up
#1 0x0000aaaaaaaa0900 in main () at test-gcs.c:77
77 return enable_gcspr ();
(gdb) p $gcspr
$2 = <unavailable>
(gdb) down
#0 enable_gcspr () at test-gcs.c:63
63 ret = call (); // stop in enable_ssp
(gdb) p $gcspr
$3 = (void *) 0xfffff7bffff8
(gdb) x/xg $gcspr
0xfffff7bffff8: 0x0000000000000000

> ~~~
>
> Or unwinding from call:
> ~~~
> Breakpoint 1, call () at main.c:41
> 41 return 0;
> (gdb) p $pl3_ssp
> $1 = (void *) 0x7ffff7bffff8
> (gdb) up
> #1 0x000055555555518a in enable_ssp () at main.c:52
> 52 ret = call (); // stop in enable_ssp
> (gdb) p $pl3_ssp
> $2 = (void *) 0x7ffff7c00000
> (gdb) up
> #2 0x00005555555551d4 in main () at main.c:63
> 63 return enable_ssp ();
> (gdb) p $pl3_ssp
> $3 = <unavailable>
> (gdb) down
> #1 0x000055555555518a in enable_ssp () at main.c:52
> 52 ret = call (); // stop in enable_ssp
> (gdb) p $pl3_ssp
> $4 = (void *) 0x7ffff7c00000
> (gdb) down
> #0 call () at main.c:41
> 41 return 0;
> (gdb) p $pl3_ssp
> $5 = (void *) 0x7ffff7bffff8

In this example the GCSPR is also always within the GCS range. And you
can see the zeroed entry at the top as well:

Breakpoint 2, call () at test-gcs.c:50
50 return 0;
(gdb) p $gcspr
$4 = (void *) 0xfffff7bffff0
(gdb) x/xg $gcspr
0xfffff7bffff0: 0x0000aaaaaaaa08a4
(gdb) x/2xg $gcspr
0xfffff7bffff0: 0x0000aaaaaaaa08a4 0x0000000000000000
(gdb) up
#1 0x0000aaaaaaaa08a4 in enable_gcspr () at test-gcs.c:63
63 ret = call (); // stop in enable_ssp
(gdb) p $gcspr
$5 = (void *) 0xfffff7bffff8
(gdb) x/xg $gcspr
0xfffff7bffff8: 0x0000000000000000
(gdb) up
#2 0x0000aaaaaaaa0900 in main () at test-gcs.c:77
77 return enable_gcspr ();
(gdb) p $gcspr
$6 = <unavailable>
(gdb) down
#1 0x0000aaaaaaaa08a4 in enable_gcspr () at test-gcs.c:63
63 ret = call (); // stop in enable_ssp
(gdb) p $gcspr
$7 = (void *) 0xfffff7bffff8
(gdb) down
#0 call () at test-gcs.c:50
50 return 0;
(gdb) p $gcspr
$8 = (void *) 0xfffff7bffff0

> ~~~
>
> So the check
>
> "if (new_ssp <= range.second)"
>
> seems correct to me.

This is a subtle difference between our systems. Thanks for the example
and the explanation. It was very helpful.

--
Thiago

¹ https://www.kernel.org/doc/html/latest/arch/arm64/gcs.html#allocation-of-guarded-control-stacks