Post

DGSE CTF - Mission 5 (Mobile)

DGSE CTF - Mission 5 (Mobile)

🔍 Reconnaissance

L’application se prĂ©sente de la maniĂšre suivante :

  • Une section Messages on previous devices contenant des messages chiffrĂ©s, probablement avec les identifiants d’un ancien appareil.
  • Une section Messages on this device affichant les messages en clair destinĂ©s Ă  l’appareil actuel.

Desktop View Liste des messages chiffrés

Desktop View Liste des messages en clair

🌐 Interception du traffic HTTP

Avant de passer Ă  la dĂ©compilation de l’APK, j’ai commencĂ© par intercepter le trafic HTTP via un proxy avec Burp Suite. Voici le guide proposĂ© par PortSwigger pour configurer un appareil Android avec Burp : Configuring an Android device to work with Burp Suite

Voici la requĂȘte intĂ©ressante que j’ai interceptĂ© :

Desktop View Interception du traffic HTTP avec Burpsuite

On peut voir que l’application envoie une requĂȘte GET Ă  /messages avec un paramĂštre id=5vmObbpVKWesTEbiSxlCzXSEXFUwues1nVIGiMYOED8=. En rĂ©ponse, on obtient une liste de messages, chacun contenant :

  • content : le contenu du message
  • isEncrypted : un boolĂ©en indiquant si le message est chiffrĂ©
  • sender: l’éxpĂ©diteur
  • timestamp: la date d’envoi

Ce qu’on observe ici, c’est que tous les messages renvoyĂ©s par l’API sont chiffrĂ©s, alors que certains apparaissent en clair dans l’application. Cela suggĂšre que le dĂ©chiffrement est effectuĂ© localement, dans l’application elle-mĂȘme, avant l’affichage.

đŸ§© Reverse de l’APK

On peut dĂ©compiler l’APK grĂące Ă  un outil comme jadx ou directement avec androidstudio. En fouillant un peu, on tombe sur mĂ©thode decryptMessage trĂšs intĂ©rĂ©ssante dans smali\out\com\nullvastation\cryssage\ui\home\HomeViewModel.smali :

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
.method private final decryptMessage(Ljava/lang/String;)Ljava/lang/String;
    .registers 8

    .line 73
    :try_start_0
    sget-object v0, Landroid/os/Build;->MODEL:Ljava/lang/String;

    const-string v1, "MODEL"

    invoke-static {v0, v1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullExpressionValue(Ljava/lang/Object;Ljava/lang/String;)V

    sget-object v1, Landroid/os/Build;->BRAND:Ljava/lang/String;

    const-string v2, "BRAND"

    invoke-static {v1, v2}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullExpressionValue(Ljava/lang/Object;Ljava/lang/String;)V

    invoke-direct {p0, v0, v1}, Lcom/nullvastation/cryssage/ui/home/HomeViewModel;->hashDeviceId(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

    move-result-object v0

    .line 74
    const-string v1, "s3cr3t_s@lt"

    invoke-direct {p0, v0, v1}, Lcom/nullvastation/cryssage/ui/home/HomeViewModel;->deriveKey(Ljava/lang/String;Ljava/lang/String;)[B

    move-result-object v0

    .line 75
    const-string v1, "LJo+0sanl6E3cvCHCRwyIg=="

    const/4 v2, 0x0

    invoke-static {v1, v2}, Landroid/util/Base64;->decode(Ljava/lang/String;I)[B

    move-result-object v1

    .line 77
    const-string v3, "AES/CBC/PKCS5Padding"

    invoke-static {v3}, Ljavax/crypto/Cipher;->getInstance(Ljava/lang/String;)Ljavax/crypto/Cipher;

    move-result-object v3

    .line 78
    new-instance v4, Ljavax/crypto/spec/SecretKeySpec;

    const-string v5, "AES"

    invoke-direct {v4, v0, v5}, Ljavax/crypto/spec/SecretKeySpec;-><init>([BLjava/lang/String;)V

    .line 79
    new-instance v0, Ljavax/crypto/spec/IvParameterSpec;

    invoke-direct {v0, v1}, Ljavax/crypto/spec/IvParameterSpec;-><init>([B)V

    .line 80
    check-cast v4, Ljava/security/Key;

    check-cast v0, Ljava/security/spec/AlgorithmParameterSpec;

    const/4 v1, 0x2

    invoke-virtual {v3, v1, v4, v0}, Ljavax/crypto/Cipher;->init(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V

    .line 82
    invoke-static {p1, v2}, Landroid/util/Base64;->decode(Ljava/lang/String;I)[B

    move-result-object p1

    .line 83
    invoke-virtual {v3, p1}, Ljavax/crypto/Cipher;->doFinal([B)[B

    move-result-object p1

    .line 84
    invoke-static {p1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNull(Ljava/lang/Object;)V

    sget-object v0, Ljava/nio/charset/StandardCharsets;->UTF_8:Ljava/nio/charset/Charset;

    const-string v1, "UTF_8"

    invoke-static {v0, v1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullExpressionValue(Ljava/lang/Object;Ljava/lang/String;)V

    new-instance v1, Ljava/lang/String;

    invoke-direct {v1, p1, v0}, Ljava/lang/String;-><init>([BLjava/nio/charset/Charset;)V
    :try_end_50
    .catch Ljava/lang/Exception; {:try_start_0 .. :try_end_50} :catch_51

    return-object v1

    :catch_51
    move-exception p1

    .line 86
    const-string v0, "Error decrypting message"

    check-cast p1, Ljava/lang/Throwable;

    const-string v1, "DECRYPT_ERROR"

    invoke-static {v1, v0, p1}, Landroid/util/Log;->e(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)I

    .line 87
    const-string p1, "[Encrypted] This message was encrypted with old device credentials"

    return-object p1
.end method

Cette mĂ©thode implĂ©mente un dĂ©chiffrement AES en mode CBC avec un padding PKCS5. Voici ce qu’elle fait :

  • RĂ©cupĂšre les valeurs MODEL et BRAND du tĂ©lĂ©phone.
  • ConcatĂšne ces deux valeurs pour former un identifiant unique d’appareil.
  • DĂ©rive une clĂ© AES Ă  partir de cet identifiant et d’un sel fixe (s3cr3t_s@lt).
  • Utilise un IV fixe (LJo+0sanl6E3cvCHCRwyIg==, encodĂ© en Base64).
  • DĂ©chiffre le message passĂ© en argument (lui aussi encodĂ© en Base64).

Si le dĂ©chiffrement Ă©choue (par exemple si le message a Ă©tĂ© chiffrĂ© avec un autre appareil), l’application retourne ce message par dĂ©faut : [Encrypted] This message was encrypted with old device credentials

Voici également la méthode deriveKey utilisée pour générer la clé AES :

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
.method private final deriveKey(Ljava/lang/String;Ljava/lang/String;)[B
    .registers 5

    .line 66
    new-instance v0, Ljava/lang/StringBuilder;

    invoke-direct {v0}, Ljava/lang/StringBuilder;-><init>()V

    invoke-virtual {v0, p1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    move-result-object p1

    const/16 v0, 0x3a

    invoke-virtual {p1, v0}, Ljava/lang/StringBuilder;->append(C)Ljava/lang/StringBuilder;

    move-result-object p1

    invoke-virtual {p1, p2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

    move-result-object p1

    invoke-virtual {p1}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

    move-result-object p1

    .line 67
    const-string p2, "SHA-256"

    invoke-static {p2}, Ljava/security/MessageDigest;->getInstance(Ljava/lang/String;)Ljava/security/MessageDigest;

    move-result-object p2

    .line 68
    sget-object v0, Ljava/nio/charset/StandardCharsets;->UTF_8:Ljava/nio/charset/Charset;

    const-string v1, "UTF_8"

    invoke-static {v0, v1}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullExpressionValue(Ljava/lang/Object;Ljava/lang/String;)V

    invoke-virtual {p1, v0}, Ljava/lang/String;->getBytes(Ljava/nio/charset/Charset;)[B

    move-result-object p1

    const-string v0, "this as java.lang.String).getBytes(charset)"

    invoke-static {p1, v0}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullExpressionValue(Ljava/lang/Object;Ljava/lang/String;)V

    invoke-virtual {p2, p1}, Ljava/security/MessageDigest;->digest([B)[B

    move-result-object p1

    const-string p2, "digest(...)"

    invoke-static {p1, p2}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullExpressionValue(Ljava/lang/Object;Ljava/lang/String;)V

    return-object p1
.end method

Cette fonction concatĂšne l’identifiant d’appareil avec le sel, puis calcule le SHA-256 de la chaĂźne rĂ©sultante pour produire la clĂ© de 256 bits.

J’ai réécrit les fonctions DecryptMessage et DeriveKey en Python, puis j’ai tentĂ© de dĂ©chiffrer le dernier message — dont nous connaissions le contenu dĂ©chiffrĂ© — en utilisant l’identifiant interceptĂ© via BurpSuite : :

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
import hashlib
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# Fonction pour déchiffrer le message
def decrypt_message(encrypted_message, device_id):
    try:
        key = derive_key(device_id, "s3cr3t_s@lt")
        iv = base64.b64decode("LJo+0sanl6E3cvCHCRwyIg==")
        encrypted_data = base64.b64decode(encrypted_message)
        cipher = AES.new(key, AES.MODE_CBC, iv)
        decrypted_data = unpad(cipher.decrypt(encrypted_data), AES.block_size)
        return decrypted_data.decode('utf-8')
    except Exception:
        return None
    
# Fonction pour dériver la clé
def derive_key(device_id, salt):
    device_id_with_salt = f"{device_id}:{salt}"
    derived_key = hashlib.sha256(device_id_with_salt.encode('utf-8')).digest()
    return derived_key

encrypted_message = "XeRtXUezp7oP9fqmL1mCho48gIf1weTI6CK09Nl7ZtmonWC9yOFTqbqlSeTgFRXVrYPTPYLtynBIieLafbViGo2JXQ6/E2rPPpS+7afHr/S28VtsIncUNx66q6qPvFT9"
device_id = "5vmObbpVKWesTEbiSxlCzXSEXFUwues1nVIGiMYOED8="

print(decrypt_message(encrypted_message, device_id))

Le résultat est concluant :

1
2
$ python3 decrypt.py
URGENT: DGSE is on our trail. They got one of us. Destroy all traces and go dark

🔑 DĂ©chiffrement des autres messages

Maintenant que nous maĂźtrisons la logique de dĂ©chiffrement, il nous faut retrouver l’identifiant (device_id) utilisĂ© sur l’ancien appareil. D’aprĂšs le brief de mission, celui-ci est gĂ©nĂ©rĂ© Ă  partir du MODEL et du BRAND de l’appareil.

Voici la fonction Python qui permet de reproduire cette génération :

1
2
3
4
def hash_device_id(model, brand):
    device_id = f"{model}:{brand}"
    hashed_device_id = hashlib.sha256(device_id.encode('utf-8')).digest()
    return base64.encodebytes(hashed_device_id).decode('utf-8').strip()

DOn sait Ă©galement que l’appareil saisi appartient Ă  la marque Google. On peut donc itĂ©rer sur tous les modĂšles d’appareils Google connus, gĂ©nĂ©rer leur device_id, et tenter de dĂ©chiffrer les messages un par un. La liste officielle des appareils compatibles avec Google Play nous servira de base.

Voici le script final :

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
import hashlib
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import csv

# Fonction pour hasher l'identifiant du device
def hash_device_id(model, brand):
    device_id = f"{model}:{brand}"
    hashed_device_id = hashlib.sha256(device_id.encode('utf-8')).digest()
    return base64.encodebytes(hashed_device_id).decode('utf-8').strip()

# Fonction pour dériver la clé
def derive_key(device_id, salt):
    device_id_with_salt = f"{device_id}:{salt}"
    derived_key = hashlib.sha256(device_id_with_salt.encode('utf-8')).digest()
    return derived_key

# Fonction pour déchiffrer le message
def decrypt_message(encrypted_message, model, brand):
    try:
        device_id = hash_device_id(model, brand)
        key = derive_key(device_id, "s3cr3t_s@lt")
        iv = base64.b64decode("LJo+0sanl6E3cvCHCRwyIg==")
        encrypted_data = base64.b64decode(encrypted_message)
        cipher = AES.new(key, AES.MODE_CBC, iv)
        decrypted_data = unpad(cipher.decrypt(encrypted_data), AES.block_size)
        return decrypted_data.decode('utf-8')
    except Exception:
        return None

# Chargement des configs depuis un fichier CSV
device_configs = []
with open("devices.csv", newline='', encoding='utf-8') as csvfile:
    reader = csv.reader(csvfile)
    next(reader)  # ignorer l'en-tĂȘte
    for brand, model in reader:
        if brand == "Google":
            device_configs.append((model, brand))

# Message chiffré
encrypted_message = "Swz/ycaTlv3JM9iKJHaY+f1SRyKvfQ5miG6I0/tUb8bvbOO+wyU5hi+bGsmcJD3141FrmrDcBQhtWpYimospymABi3bzvPPi01rPI8pNBq8="

# Bruteforce
for model, brand in device_configs:
    print(f"Testing with model: {model}, brand: {brand}")
    decrypted_message = decrypt_message(encrypted_message, model, brand)
    if decrypted_message:
        print(f"✅ Decrypted message: {decrypted_message}")
        break
    else:
        print("❌ Failed to decrypt\n")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python3 decrypt.py
Testing with model: guybrush, brand: Google
❌ Failed to decrypt

Testing with model: skyrim, brand: Google
❌ Failed to decrypt

Testing with model: zork, brand: Google
❌ Failed to decrypt

...

Testing with model: Yellowstone, brand: Google
✅ Decrypted message: Keep this safe. RM{788e6f3e63e945c2a0f506da448e0244ac94f7c4}

Nous apprenons ainsi que l’appareil utilisĂ© Ă©tait un Google Yellowstone. Et nous rĂ©cupĂ©rons le flag : RM{788e6f3e63e945c2a0f506da448e0244ac94f7c4}

Voici tous les autres messages dĂ©chiffrĂ©s avec ce mĂȘme identifiant :

1
2
3
4
5
6
7
8
✅ Decrypted message: Target acquired. Hospital network vulnerable. Initiating ransomware deployment.
✅ Decrypted message: New target identified. School district network. Estimated payout: 500k in crypto.
✅ Decrypted message: New ransomware strain ready for deployment. Testing phase complete.
✅ Decrypted message: Security patch released. Need to modify attack vector. Meeting at usual place.
✅ Decrypted message: New zero-day exploit in a linux binary discovered. Perfect for next operation. Details incoming.
✅ Decrypted message: Payment received. Sending decryption keys now. Next target: City infrastructure.
✅ Decrypted message: Encryption complete. Ransom note deployed. 48h countdown started.
✅ Decrypted message: URGENT: DGSE is on our trail. They got one of us. Destroy all traces and go dark.
This post is licensed under CC BY 4.0 by the author.