Stack protection on mips64
Bug
On #gentoo-toolchain Matt Turner reported an obscure SIGSEGV of top program on one of mips boxes:
07:01 <@mattst88> since upgrading to >=glibc-2.25 on mips:
07:01 <@mattst88> bcm91250a-be ~ # top
07:01 <@mattst88> *** stack smashing detected ***: <unknown> terminated
07:01 <@mattst88> Aborted
A simple (but widely used) tool seemingly managed to overwrite it’s stack. Looks straightforward, right? Let’s see!
Reproducing
As I don’t have any mips hardware I will fake some:
# crossdev -t mips64-unknown-linux-gnu
# <set a few profile variables>
# mips64-unknown-linux-gnu-emerge -1 procps
We’ve built a toolchain that targets mips64 (N32 ABI). Checking if it crashes:
$ LD_LIBRARY_PATH=/lib64 qemu-mipsn32 -L /usr/mips64-unknown-linux-gnu /usr/mips64-unknown-linux-gnu/usr/bin/top
qemu: uncaught target signal 11 (Segmentation fault) - core dumped
Segmentation fault
This is not exactly stack smash. But who knows, maybe my set of tools is slightly different from Matt’s and the same bug manifests in a slightly different way.
First workaround
Gentoo recently enabled stack protection and already exposed a bug at least on powerpc32.
First, let’s check if it’s related to stack protector option:
# EXTRA_ECONF=--enable-stack-protector=no emerge -v1 cross-mips64-unknown-linux-gnu/glibc
$ LD_LIBRARY_PATH=/lib64 qemu-mipsn32 -L /usr/mips64-unknown-linux-gnu /usr/mips64-unknown-linux-gnu/usr/bin/top
<running top>
That worked! To unbreak mips users I disabled glibc stack self-protection by passing –enable-stack-protector=no.
Into the rabbit hole
Now let’s try to find out what is at fault here. In case of powerpc it was bad gcc code generation. Could it be something similar here?
First, we need a backtrace from qemu. Unfortunately qemu-generated .core files are truncated and are not directly readable by gdb:
$ gdb --quiet /usr/mips64-unknown-linux-gnu/usr/bin/top qemu_top_20171216-220221_14697.core
Reading symbols from /usr/mips64-unknown-linux-gnu/usr/bin/top...done.
BFD: Warning: /home/slyfox/qemu_top_20171216-220221_14697.core is truncated: expected core file size >= 8867840, found: 1504.
Let’s try gdbserver mode instead. Running qemu with -g 12345 to wait for gdb session:
$ LD_LIBRARY_PATH=/lib64 qemu-mipsn32 -g 12345 -L /usr/mips64-unknown-linux-gnu /usr/mips64-unknown-linux-gnu/usr/bin/top
And in second terminal fire up gdb:
$ gdb --quiet /usr/mips64-unknown-linux-gnu/usr/bin/top
Reading symbols from /usr/mips64-unknown-linux-gnu/usr/bin/top...done.
(gdb) set sysroot /usr/mips64-unknown-linux-gnu
(gdb) target remote :12345
Remote debugging using :12345
Reading symbols from /usr/mips64-unknown-linux-gnu/lib32/ld.so.1...
Reading symbols from /usr/lib64/debug//usr/mips64-unknown-linux-gnu/lib32/ld-2.26.so.debug...done.
done.
0x40801d10 in __start () from /usr/mips64-unknown-linux-gnu/lib32/ld.so.1
(gdb) continue
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0x408cb908 in _dlerror_run (operate=operate@entry=0x408cadf0 <dlopen_doit>, args=args@entry=0x407feb58)
at dlerror.c:163
163 result->errcode = _dl_catch_error (&result->objname, &result->errstring,
(gdb) bt
#0 0x408cb908 in _dlerror_run (operate=operate@entry=0x408cadf0 <dlopen_doit>, args=args@entry=0x407feb58)
at dlerror.c:163
#1 0x408caf4c in __dlopen (file=file@entry=0x10012d58 "libnuma.so", mode=mode@entry=1) at dlopen.c:87
#2 0x1000306c in before (me=0x407ff3b6 "/usr/mips64-unknown-linux-gnu/usr/bin/top") at top/top.c:3308
#3 0x10001a10 in main (dont_care_argc=<optimized out>, argv=0x407ff1d4) at top/top.c:5721
Woohoo! Nice backtrace! Matt confirmed he sees the same backtrace.
Curious fact: top tries to load libnuma.so opportunistically (top/top.c):
// ...
#ifndef NUMA_DISABLE
#if defined(PRETEND_NUMA) || defined(PRETEND8CPUS)
= Numa_max_node() + 1;
Numa_node_tot #else
// we'll try for the most recent version, then a version we know works...
if ((Libnuma_handle = dlopen("libnuma.so", RTLD_LAZY))
|| (Libnuma_handle = dlopen("libnuma.so.1", RTLD_LAZY))) {
= dlsym(Libnuma_handle, "numa_max_node");
Numa_max_node = dlsym(Libnuma_handle, "numa_node_of_cpu");
Numa_node_of_cpu if (Numa_max_node && Numa_node_of_cpu)
= Numa_max_node() + 1;
Numa_node_tot else {
(Libnuma_handle);
dlclose= NULL;
Libnuma_handle }
}
#endif
#endif
// ...
As I did not have libnuma installed it should not matter which library we try to load. I tried to write a minimal reproducer that only calls dlopen():
#include <dlfcn.h>
// mips64-unknown-linux-gnu-gcc dlopen-bug.c -o dlopen-bug -ldl
int main() {
void * h = dlopen("libdoes-not-exist.so", RTLD_LAZY);
}
$ mips64-unknown-linux-gnu-gcc dlopen-bug.c -o dlopen-bug -ldl
$ qemu-mipsn32 -L /usr/mips64-unknown-linux-gnu ./dlopen-bug
qemu: uncaught target signal 11 (Segmentation fault) - core dumped
Segmentation fault
The backtrace is the same. OK, that’s bad. If it’s a stack overflow it definitely happens somewhere in glibc internals and not in the client.
Reproducing on master
Chances are we will need to fix glibc to get our stack protection back. I tried to reproduce the same failure on master branch:
$ ../glibc/configure \
--enable-stack-protector=all \
--enable-stackguard-randomization \
--enable-kernel=3.2.0 \
--enable-add-ons=libidn \
--without-selinux \
--without-cvs \
--disable-werror \
--enable-bind-now \
--build=x86_64-pc-linux-gnu \
--host=mips64-unknown-linux-gnu \
--disable-profile \
--without-gd \
--with-headers=/usr/mips64-unknown-linux-gnu/usr/include \
--prefix=/usr \
--sysconfdir=/etc \
--localstatedir=/var \
--libdir='$(prefix)/lib32' \
--mandir='$(prefix)/share/man' \
--infodir='$(prefix)/share/info' \
--libexecdir='$(libdir)/misc/glibc' \
--disable-multi-arch \
--disable-systemtap \
--disable-nscd \
--disable-timezone-tools \
CFLAGS="-O2 -ggdb3"
$ make
$ qemu-mipsn32 ./elf/ld.so --library-path .:dlfcn ~/tmp/dlopen-bug
No failure. It could mean the bug was fixed in master or something else introduces the error.
I checked out glibc-2.26 and retried above:
$ qemu-mipsn32 ./elf/ld.so --library-path .:dlfcn ~/tmp/dlopen-bug
qemu: uncaught target signal 11 (Segmentation fault) - core dumped
Segmentation fault
Aha, the problem is still there in latest release.
I bisected glibc from 2.26 to master to find the commit that fixes SIGSEGV. My bisection stopped at commit 2449ae7b
commit 2449ae7b2da24c9940962304a3e44bc80e389265
Author: Florian Weimer <fweimer@redhat.com>
Date: Thu Aug 10 13:40:22 2017 +0200
ld.so: Introduce struct dl_exception
This commit separates allocating and raising exceptions. This
simplifies catching and re-raising them because it is no longer
necessary to make a temporary, on-stack copy of the exception message.
Looking hard at that commit I have found nothing that could fix a bug. The change shuffled a few things around but did not change behaviour too much.
I decided to fetch parent commit f87cc2bfb and spend some time to understand the failure mode.
First, the crash happens in _dlerror_run() code:
0x40801c70 in __start () from /home/slyfox/tmp/lib32/ld.so.1
(gdb) continue
Continuing.
Program received signal SIGSEGV, Segmentation fault.
_dlerror_run (operate=operate@entry=0x40838df0 <dlopen_doit>, args=args@entry=0x407ff058) at dlerror.c:163
163 result->errcode = _dl_catch_error (&result->objname, &result->errstring,
(gdb) bt
#0 _dlerror_run (operate=operate@entry=0x40838df0 <dlopen_doit>, args=args@entry=0x407ff058) at dlerror.c:163
#1 0x40838f4c in __dlopen (file=<optimized out>, mode=<optimized out>) at dlopen.c:87
#2 0x1000076c in main ()
(gdb) list
158 if (result->malloced)
159 free ((char *) result->errstring);
160 result->errstring = NULL;
161 }
162
163 result->errcode = _dl_catch_error (&result->objname, &result->errstring,
164 &result->malloced, operate, args);
165
166 /* If no error we mark that no error string is available. */
167 result->returned = result->errstring == NULL;
Simplified version of _dlerror_run() looks like this:
static struct dl_action_result last_result;
static struct dl_action_result *static_buf = &last_result
// ...
int
internal_function(void (*operate) (void *), void *args)
_dlerror_run {
struct dl_action_result *result;
= static_buf;
result if (result->errstring != NULL)
{
if (result->malloced)
((char *) result->errstring);
free ->errstring = NULL;
result}
->errcode = _dl_catch_error (&result->objname, &result->errstring,
result&result->malloced, operate, args);
/* If no error we mark that no error string is available. */
->returned = result->errstring == NULL;
resultreturn result->errstring != NULL;
}
The SIGSEGV happens when we try to store result of _dl_catch_error() into result->errcode.
I poked in gdb at the values of result right before _dl_catch_error() call and after it. And values are different! Time to look at where result is physically stored:
(gdb) disassemble /s _dlerror_run
163 result->errcode = _dl_catch_error (&result->objname, &result->errstring,
=> 0x40839918 <+184>: sw v0,0(s0)
(gdb) print (void*)$s0
$1 = (void *) 0x40834c44 <__stack_chk_guard>
The instruction stores single word(32 bits) at address of s0 register. But s0 points not to last_result (it did right before the call) but at __stack_chk_guard.
How stack checks work on mips
What is __stack_chk_guard? Has to do something about stack checks. Short answer: it’s a read-only variable that holds stack canary value. glibc is not supposed to write to it after it is initialized. Something leaked out address of that variable into s0 (callee-save register).
Let’s familiarize ourselves with mips ABI a bit and look at how stack checks look in generated code in a simple example:
void g(void) {}
; mips64-unknown-linux-gnu-gcc -S b.c -fno-stack-protector -O1
file 1 "b.c"
.section .mdebug.abiN32
.
.previous
.nan legacy=64
.module fp
.module oddspreg
.abicalls
.textalign 2
.
.globl gset nomips16
.set nomicromips
.
.ent g, @function
.type gg:
$sp,0,$31 # vars= 0, regs= 0/0, args= 0, gp= 0
.frame 0x00000000,0
.mask 0x00000000,0
.fmask set noreorder
.set nomacro
.$31
jr nop
set macro
.set reorder
.end g
., .-g
.size g"GCC: (Gentoo 7.2.0 p1.1) 7.2.0" .ident
31 register is also known as ra, return address. The code has a lot of pragmas but they are needed only for debugging. The real code is two insructions: jr $31; nop.
Let’s check what fstack-protector-all does with our code:
; mips64-unknown-linux-gnu-gcc -S b.c -fstack-protector-all -O1
file 1 "b.c"
.section .mdebug.abiN32
.
.previous
.nan legacy=64
.module fp
.module oddspreg
.abicalls
.textalign 2
.
.globl gset nomips16
.set nomicromips
.
.ent g, @function
.type gg:
$sp,32,$31 # vars= 16, regs= 2/0, args= 0, gp= 0
.frame 0x90000000,-8
.mask 0x00000000,0
.fmask set noreorder
.set nomacro
.$sp,$sp,-32 ; allocate 32 bytes on stack
addiu $31,24($sp) ; backup $31 (ra)
sd $28,16($sp) ; backup $28 (gp)
sd $28,%hi(__gnu_local_gp) ; compute address of GOT
lui $28,$28,%lo(__gnu_local_gp) ; (requires two instructions
addiu $2,%got_disp(__stack_chk_guard)($28) ; read offset of __stack_chk_guard in GOT
lw $3,0($2) ; read canary value of __stack_chk_guard
lw $3,12($sp) ; store canary on stack
sw ; ... time to check our canary!
$3,12($sp) ; load canary from stack
lw $2,0($2) ; load canary from __stack_chk_guard
lw $3,$2,.L4 ; check canary value and crash the program
bne $31,24($sp) ; restore return address
ld $28,16($sp) ; restore gp
ld $31 ; (restore stack pointer and) return
jr $sp,$sp,32
addiu
.L4:
$25,%call16(__stack_chk_fail)($28)
lw 1f,R_MIPS_JALR,__stack_chk_fail
.reloc $25
1: jalr nop
set macro
.set reorder
.end g
., .-g
.size g"GCC: (Gentoo 7.2.0 p1.1) 7.2.0" .ident
Here is a quick table of mips registers.
15 instructions are doing the following: intermediate registers to hold canary value 2(v0) and 3(v1) are written on stack (sp register), read back and checked against value stored in __stack_chk_guard.
Quite straightforward.
Who broke s0?
Back to our _dl_catch_error() why did s0 change? mips ABI says s0 is callee-save. It means s0 should not be changed by callee functions.
To get more clues we need to dive into **_dl_catch_error()**
struct catch
{
const char **objname; /* Object/File name. */
const char **errstring; /* Error detail filled in here. */
bool *malloced; /* Nonzero if the string is malloced
by the libc malloc. */
volatile int *errcode; /* Return value of _dl_signal_error. */
; /* longjmp here on error. */
jmp_buf env};
// ...
int
internal_function(const char **objname, const char **errstring,
_dl_catch_error bool *mallocedp, void (*operate) (void *), void *args)
{
/* We need not handle `receiver' since setting a `catch' is handled
before it. */
/* Only this needs to be marked volatile, because it is the only local
variable that gets changed between the setjmp invocation and the
longjmp call. All others are just set here (before setjmp) and read
in _dl_signal_error (before longjmp). */
volatile int errcode;
struct catch c;
/* Don't use an initializer since we don't need to clear C.env. */
.objname = objname;
c.errstring = errstring;
c.malloced = mallocedp;
c.errcode = &errcode;
c
struct catch *const old = catch_hook;
= &c;
catch_hook
/* Do not save the signal mask. */
if (__builtin_expect (__sigsetjmp (c.env, 0), 0) == 0)
{
(*operate) (args);
= old;
catch_hook *objname = NULL;
*errstring = NULL;
*mallocedp = false;
return 0;
}
/* We get here only if we longjmp'd out of OPERATE. _dl_signal_error has
already stored values into *OBJNAME, *ERRSTRING, and *MALLOCEDP. */
= old;
catch_hook return errcode;
}
This code is straightforward (but very scary): it wraps call of operate callback into __sigsetjmp() (really just a setjmp()).
setjmp() is a simple-ish function: it stores most of current registers into c.env() and later longjmp() restores them. Caller-saves are not saved because longjmp() looks like a normal C function call.
Normally longjmp() is called only when error condition happens. In our case it’s called when dlopen() fails (we are opening non-existent file). longjmp() restores all registers stored by setjmp() including instruction pointer pc, stack pointer sp, caller-saves s0..s7 and others.
The question arises: why and where do we lose s0 register? At save (setjmp()) or at restore (longjmp())?
As we can see setjmp() and longjmp() are functions very sensitive to ABI. Let’s check how __sigsetjmp() is implemented at sysdeps/mips/mips64/setjmp.S
ENTRY (__sigsetjmp)
SETUP_GP(v0, C_SYMBOL_NAME (__sigsetjmp))
SETUP_GP64_REG , sp
move a2, fp
move a3, __sigsetjmp_aux
PTR_LA t9
RESTORE_GP64_REG, gp
move a4
jr t9END (__sigsetjmp)
Note how __sigsetjmp does almost nothing here: only saves gp, sp and fp and defers everything to __sigsetjmp_aux. Let’s peek at that in sysdeps/mips/mips64/setjmp_aux.c
Suddenly, its implementation is in C:
int
(jmp_buf env, int savemask, long long sp, long long fp,
__sigsetjmp_aux long long gp)
{
/* Store the floating point callee-saved registers... */
volatile ("s.d $f20, %0" : : "m" (env[0].__jmpbuf[0].__fpregs[0]));
asm volatile ("s.d $f22, %0" : : "m" (env[0].__jmpbuf[0].__fpregs[1]));
asm volatile ("s.d $f24, %0" : : "m" (env[0].__jmpbuf[0].__fpregs[2]));
asm volatile ("s.d $f26, %0" : : "m" (env[0].__jmpbuf[0].__fpregs[3]));
asm volatile ("s.d $f28, %0" : : "m" (env[0].__jmpbuf[0].__fpregs[4]));
asm volatile ("s.d $f30, %0" : : "m" (env[0].__jmpbuf[0].__fpregs[5]));
asm
/* .. and the PC; */
volatile ("sd $31, %0" : : "m" (env[0].__jmpbuf[0].__pc));
asm
/* .. and the stack pointer; */
[0].__jmpbuf[0].__sp = sp;
env
/* .. and the FP; it'll be in s8. */
[0].__jmpbuf[0].__fp = fp;
env
/* .. and the GP; */
[0].__jmpbuf[0].__gp = gp;
env
/* .. and the callee-saved registers; */
volatile ("sd $16, %0" : : "m" (env[0].__jmpbuf[0].__regs[0]));
asm volatile ("sd $17, %0" : : "m" (env[0].__jmpbuf[0].__regs[1]));
asm volatile ("sd $18, %0" : : "m" (env[0].__jmpbuf[0].__regs[2]));
asm volatile ("sd $19, %0" : : "m" (env[0].__jmpbuf[0].__regs[3]));
asm volatile ("sd $20, %0" : : "m" (env[0].__jmpbuf[0].__regs[4]));
asm volatile ("sd $21, %0" : : "m" (env[0].__jmpbuf[0].__regs[5]));
asm volatile ("sd $22, %0" : : "m" (env[0].__jmpbuf[0].__regs[6]));
asm volatile ("sd $23, %0" : : "m" (env[0].__jmpbuf[0].__regs[7]));
asm
/* Save the signal mask if requested. */
return __sigjmp_save (env, savemask);
}
The function duly stores every caller-save (including 16 aka s0) and more into c.env. But what happens when that function is being compiled with -fstack-protector-all? How does it preserve original registers?
Unfortunately the answer is: it does not.
Let’s compare assembly output with and without -fstack-protector-all:
; **-fno-stack-protector**:
:
Dump of assembler code for function __sigsetjmp_auxsp,sp,-16
addiu ,0(sp)
sd gp,0x16
lui gp,gp,t9
addu gp,8(sp)
sd ra,gp,7248
addiu gp$f20,104(a0)
sdc1 $f22,112(a0)
sdc1 $f24,120(a0)
sdc1 $f26,128(a0)
sdc1 $f28,136(a0)
sdc1 $f30,144(a0)
sdc1 ,0(a0)
sd ra,8(a0)
sd a2,80(a0)
sd a3,88(a0)
sd a4,16(a0)
sd s0,24(a0)
sd s1,32(a0)
sd s2,40(a0)
sd s3,48(a0)
sd s4,56(a0)
sd s5,64(a0)
sd s6,72(a0)
sd s7,-32236(gp)
lw t90x30000 <__sigjmp_save>
bal nop
,8(sp)
ld ra,0(sp)
ld gp
jr rasp,sp,16 addiu
; **-fstack-protector-all**:
:
Dump of assembler code for function __sigsetjmp_auxsp,sp,-48
addiu ,32(sp)
sd gp,0x18
lui gp,gp,t9
addu gp,gp,-23968
addiu gp,24(sp) ; here we backup s0
sd s0,-27824(gp) ; and load into s0 stack canary address
lw s0,40(sp)
sd ra,0(s0)
lw v1,12(sp)
sw v1$f20,104(a0)
sdc1 $f22,112(a0)
sdc1 $f24,120(a0)
sdc1 $f26,128(a0)
sdc1 $f28,136(a0)
sdc1 $f30,144(a0)
sdc1 ,0(a0)
sd ra,8(a0)
sd a2,80(a0)
sd a3,88(a0)
sd a4,16(a0)
sd s0,24(a0)
sd s1,32(a0)
sd s2,40(a0)
sd s3,48(a0)
sd s4,56(a0)
sd s5,64(a0)
sd s6,72(a0)
sd s7,-32100(gp)
lw t90x31940 <__sigjmp_save>
bal nop
,12(sp)
lw a0,0(s0)
lw v1,v1,0x31c7c <__sigsetjmp_aux+156>
bne a0,40(sp)
ld ra,32(sp)
ld gp,24(sp)
ld s0
jr rasp,sp,48
addiu ,-32644(gp)
lw t9
jalr t9nop
Or in diff form:
--- no-sp 2017-12-16 23:53:51.591627849 +0000
+++ spa 2017-12-16 23:53:37.952647838 +0000
@@ -1 +1 @@
- ; **-fno-stack-protector**:
+ ; **-fstack-protector-all**:
@@ -3,3 +3,3 @@
- addiu sp,sp,-16
- sd gp,0(sp)
- lui gp,0x16
+ addiu sp,sp,-48
+ sd gp,32(sp)
+ lui gp,0x18
@@ -7,2 +7,6 @@
- sd ra,8(sp)
- addiu gp,gp,7248
+ addiu gp,gp,-23968
+ sd s0,24(sp) ; here we backup s0
+ lw s0,-27824(gp) ; and load into s0 stack canary address
+ sd ra,40(sp)
+ lw v1,0(s0)
+ sw v1,12(sp)
@@ -27,2 +31,2 @@
- lw t9,-32236(gp)
- bal 0x30000 <__sigjmp_save>
+ lw t9,-32100(gp)
+ bal 0x31940 <__sigjmp_save>
@@ -30,2 +34,6 @@
- ld ra,8(sp)
- ld gp,0(sp)
+ lw a0,12(sp)
+ lw v1,0(s0)
+ bne a0,v1,0x31c7c <__sigsetjmp_aux+156>
+ ld ra,40(sp)
+ ld gp,32(sp)
+ ld s0,24(sp)
@@ -33 +41,4 @@
- addiu sp,sp,16
+ addiu sp,sp,48
+ lw t9,-32644(gp)
+ jalr t9
+ nop
The minimal reproducer and fix
To fully nail down the problem I’d like to have something nice for upstream. Here is the minimal reproducer:
#include <setjmp.h>
#include <stdio.h>
int main() {
;
jmp_buf jbvolatile register long s0 asm ("$s0");
= 1234;
s0 if (setjmp(jb) == 0)
(jb, 1);
longjmp("$s0 = %lu\n", s0);
printf }
$ qemu-mipsn32 -L ~/bad-libc ./mips-longjmp-bug
$s0 = 1082346564
$ qemu-mipsn32 -L ~/fixed-libc ./mips-longjmp-bug
$s0 = 1234
And the fix is to disable stack protection of __sigsetjmp_aux() in all build modes of glibc. This does work:
--- a/sysdeps/mips/mips64/setjmp_aux.c
+++ b/sysdeps/mips/mips64/setjmp_aux.c
@@ -25,6 +25,7 @@
access them in C. */
int+inhibit_stack_protector
__sigsetjmp_aux (jmp_buf env, int savemask, long long sp, long long fp,
long long gp) {
Parting words
So it was not a stack corruption after all. But the register corruption triggered by a security feature.
What more interesting is why Matt got stack smashing detected and not SIGSEGV. It means that write into __stack_chk_guard actually succeeded and caused canary check failure not because on-stack canary copy changed but because global canary changed. __stack_chk_guard sits in .data.rel.ro section. qemu maps it as read-only and crashes my process. How it behaves on real target is an exercise to the reader with mips device :)
Fun facts:
- It took me 12 days to find out the cause of failure. Working gdb and qemu-user made the fix happen.
- setjmp()/longjmp() are not so opaque for me and hopefully for you. But make sure you have read all the volatility gotchas (Notes section)
- glibc uses setjmp()/longjmp() for error handling
- mips ABI is very pleasant to work with and not as complicated as I thought :)
- qemu-mipsn32 needs a fix to generate readable .core files
Have fun!