Étude d’une application Android de messagerie instantanée


À coté des messageries ayant aujourd’hui pignon sur rue comme WhatsApp, Signal, iMessage ou Telegram, on peut trouver sur Google Play tout un maquis d’applications similaires mais plus confidentielles.

J’ai choisi de m’intéresser à une de ses applications, SeaTalk :

Au moment de la rédaction de cet article (fin 2024/début 2025), l’application a fait l’objet de l’ordre de 100000 téléchargements.

L’application fait l’objet d’un site internet (seatalk.io) qui donne accès à diverses informations :

SeaTalk semble destinée en premier lieu à des utilisateurs asiatiques, comme le suggère la liste des langues supportées :

Le même site affirme qu’aucun message n’est stocké sur le serveur, affirmation évidemment invérifiable (ce qui ne signifie pas qu’elle est inexacte !) :

La version testée est la version 3.50.1 :

Prise en main de l’application

L’application s’installe à partir du playstore Google sans difficulté notable.

Utiliser l’application nécessite la création d’un compte. Cette étape préliminaire demande de renseigner une adresse email, choisir un mot de passe, une photo et un pseudonyme.

SeaTalk peut être utilisée dans un contexte professionnel mais propose également les services basiques d’une messagerie instantanée : échange de messages texte et de fichiers, appels vocaux.

C’est à ces fonctionnalités que nous allons nous intéresser.

Afin de créer un environnement de test, Seatalk est installée sur trois téléphones :

  • Installation sur Pixel 7a Android 14 rooté, compte « alice »
  • Installation sur XPeria Android 10 rooté, compte « bob »
  • Installation sur Samsung A20e Android 11 non-rooté, compte « ivan »

L’écran d’accueil de l’application montre la liste des conversations :

La croix bleue en haut à droite de l’application permet d’ajouter un nouveau contact :

Il existe différentes façons d’ajouter un contact, en renseignant l’adresse email du contact par exemple :

Une demande est alors envoyée au contact potentiel. S’il accepte, il est ajouté à la liste des contacts.

Un utilisateur peut accéder à son propre profil, où apparaît son pseudonyme, sa photo ainsi qu’un identifiant numérique sur 10 chiffres, nommé SeaTalk ID :

Au sein d’un chat avec un contact

il est possible d’échanger des messages texte, des fichiers :

ou d’autres actions (envoyer une photo, passer un appel vocal, partager sa localisation…)

Flux réseau induits par l’application

Si l’on capture le trafic du terminal lorsque SeaTalk est en cours d’utilisation, on observe essentiellement du trafic TLS :

$ tshark -r captures/seatalk_capture_0.pcap -Y tls.handshake.type==1 -T fields -e tls.handshake.extensions_server_name | grep -vE '^$' | sort -u
api.haiserve.com
edge-co.haiserve.com
graph.facebook.com
lamssettings-pa.googleapis.com
oa.haiserve.com
s.haiserve.com

Un appel vocal donne de plus lieu à du trafic TLS avec un serveur ayant pour nom de domaine call.haiserve.com :

$ tshark -r captures/seatalk_audiocall_0.pcap -Y tls.handshake.type==1 -T fields -e tls.handshake.extensions_server_name | grep -vE '^$' | sort -u
call.haiserve.com
s.haiserve.com

Ainsi qu’à du trafic udp :

Certains de ces serveurs sont codés en dur dans com.seagroup.seatalk.libenv.servers.STServers :

Interception du trafic

Hormis le trafic vocal auquel nous ne nous intéresserons pas aujourd’hui, tout est transporté par TLS (port 443) vers différents serveurs *.haiserve.com.

Si l’on tente d’intercepter le trafic avec HttpToolkit, on constate que le trafic vers certains serveurs n’est pas correctement géré par HttpToolkit :

Comme HttpToolkit ne fonctionne pas correctement ici, on interceptera le trafic avec Burp (en suivant l’excellent tutoriel https://knifecoat.com/Posts/Installing+Burp+Suite+CA+on+Android+14).

Comme une partie du trafic ne passe pas au travers du proxy (notamment les messages textes), on utilisera également d’autres méthodes d’interception du trafic :

  • D’une part, comme vu auparavant (https://tolva.fr/index.php/2024/08/26/dechiffrer-le-trafic-tls-dune-application-android-avec-frida/), on utilisera un script frida permettant de provoquer la génération d’un fichier SSLKEYLOGFILE ;
  • D’autre part, on utilisera un script frida interceptant les fonctions SSL_write et SSL_read de lecture/écriture dans un canal TLS :
const SSL_read_ptr = Module.getExportByName('libssl.so', 'SSL_read');
const SSL_write_ptr = Module.getExportByName('libssl.so', 'SSL_write');

var read_buf_ptr = null;
var read_buf_len = -1;

Interceptor.attach(SSL_read_ptr, {
    onEnter: function(args) {
        read_buf_ptr = args[1];
        read_buf_len = parseInt(args[2]);
    },

    onLeave: function(retval) {
        console.log("SSL_read()");
        if (read_buf_ptr != null && read_buf_len != -1)
        {
            console.log("SSL_read, num : " + read_buf_len);
            var bufferContent = Memory.readByteArray(read_buf_ptr, read_buf_len);
            console.log("SSL_read, buf:\n" + hexdump(bufferContent));
        }

        read_buf_ptr = null;
        read_buf_len = -1;
    }
});

Interceptor.attach(SSL_write_ptr, {
    onEnter: function(args) {
        console.log("SSL_write()");

        var buf = args[1];
        var num = parseInt(args[2]);

        console.log("SSL_write, num : " + num);
        var bufferContent = Memory.readByteArray(buf, num);
        console.log("SSL_write, buf:\n" + hexdump(bufferContent));
    },
        onLeave: function(retval) {
    }
});

L’analyse du trafic obtenu par ces différentes méthodes aboutit à ces conclusions partielles :

  • Les messages textuels ne semblent pas directement présents dans le trafic interceptés avec Burp. Cela peut être dû à la présence d’une couche de chiffrement supplémentaire, ou au fait que le trafic transportant les messages textes ne pasent pas dans le proxy, ou bien les deux.
  • Le trafic intercepté par Burp est du HTTP2. C’est notamment le cas du trafic contenant les fichiers échangés.

Envoi de fichiers

Contrairement aux messages texte, les fichiers sont envoyés tels quels vers un des serveurs *.haiserve.com.

En effet, si l’on crée un fichier texte bbbbbbbbbbbbbbbbbbbbbb.txt :

1|lynx:/sdcard/Download $ echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" > bbbbbbbbbbbbbbbbbbbbbb.txt
lynx:/sdcard/Download $ ls -l bbbbbbbbbbbbbbbbbbbbbb.txt                                                                                                                                                          
-rw-rw---- 1 u0_a242 media_rw 66 2024-11-29 13:02 bbbbbbbbbbbbbbbbbbbbbb.txt

Et que l’on intercepte avec Burp le trafic consécutif à l’envoi du fichier, on constate qu’une première requête contenant le nom du fichier est envoyée à f.haiserve.com :

Analysons quelques-uns des éléments de cette requête :

  • En-tête Seatalk-User-Id : C’est l’identifiant numérique de l’émetteur. Il est à noter que l’identifiant utilisé (641926) diffère de celui mentionné dans la GUI de l’application (9323603334). Une opération de translation de l’identifiant « user-friendly » vers l’identifiant réel est donc réalisées quelque part.
  • Cet identifiant numérique apparaît aussi dans le paramètre sender du corps de la requête.
  • champ md5 : Ce champ contient le haché md5 du fichier envoyé. En effet :
$ md5sum bbbbbbbbbbbbbbbbbbbbbb.txt 
db3f892a3d4a4320cf060c0e8a9b48b8  bbbbbbbbbbbbbbbbbbbbbb.txt
  • champ filesize : Longueur totale du fichier envoyé.
  • champ filename : On s’en doute, c’est le nom du fichier à envoyer.

Le corps du fichier est envoyé dans des requêtes POST ultérieures vers le même serveur f.haiserve.com :

Interception des messages textes

L’interception du trafic avec Burp permet de se convaincre assez rapidement que les messages textes ne passent pas dans le proxy :

  • aucune requête en rapport avec les frappes clavier n’apparaît dans l’historique Burp,
  • les messages texte arrivent même lorsque Burp bloque les requêtes interceptées.

Lancer tcpdump sur le téléphone montre du trafic non proxifié échangé avec 143.92.74.215, qui est l’adresse IP de edge-co.haiserve.com (adresse rencontrée plus haut).

Au fil des hooks, on finit par aboutir à la classe ChatMessage.

Cette classe contient une méthode toString() qui affiche une grande partie des composants d’un objet ChatMessage, hormis le champ content :

Un hook sur cette méthode :

chatMessage.toString.implementation = function()
{
    console.log("appel de com.garena.ruma.model.ChatMessage.toString()");
    console.log("clientId : " + this.clientId.value);
    console.log("sessionId : " + this.sessionId.value);

    var contentVal = new Uint8Array(this.content.value);

    /* honestly i don't understand the meaning of these 11 and 160 */
    var msgLen = contentVal[11] - 160;

    var msgTxtRaw = contentVal.subarray(12, 12 + msgLen);
    var msgTxtPlain = new String();
    for (var i = 0; i < msgTxtRaw.length; i++)
    {
        msgTxtPlain += String.fromCharCode(msgTxtRaw[i]);
    }

    console.log("Message content length : " + msgLen);
    console.log("Message content : " + msgTxtPlain);

    var result = toString.call(this);
    console.log("resultat : " + result);

    console.log("appel de ChatMessage.toString() - pile d'appel :");
    console.log(stackTraceHere());

    return result;
};

permet d’accéder au contenu d’un message texte :

appel de com.garena.ruma.model.ChatMessage.toString()
clientId : 440
sessionId : 641936
Message content length : 17
Message content : J'aime la semoule
resultat : ChatMessage{clientId=440, msgId=0, sessionMsgId=0, sessionId=641936, order=297, fromId=641926, tag='text', content=[...], extraContent=[...], type=514, state=16, timestamp=1734640291, client ts=1734640291051, requestId=0, createTime=1734640291, whisperDuration=0, quote=[...], fromPush=false, options=0, ftsUpdated=0, forward=false, seenTime=-1, isTranslating=false, scenario=normal, disappearTimestamp=0}
appel de ChatMessage.toString() - pile d'appel :
java.lang.Exception
    at com.garena.ruma.model.ChatMessage.toString(Native Method)
    at java.lang.String.valueOf(String.java:4092)
    at java.lang.StringBuilder.append(StringBuilder.java:179)
    at com.garena.seatalk.message.chat.task.send2server.BaseSendMessageToServerTask.l(BaseSendMessageToServerTask.kt:140)
    at com.garena.seatalk.message.chat.task.send2server.BaseSendMessageToServerTask.u(BaseSendMessageToServerTask.kt:59)
    at com.garena.seatalk.message.chat.task.send2server.BaseSendMessageToServerTask.c(BaseSendMessageToServerTask.kt:1)
    at com.garena.ruma.framework.taskmanager.IBaseCoroutineTask$run$2.invokeSuspend(IBaseCoroutineTask.kt:33)
    at com.garena.ruma.framework.taskmanager.IBaseCoroutineTask$run$2.invoke(IBaseCoroutineTask.kt:13)
    at kotlinx.coroutines.intrinsics.UndispatchedKt.a(Undispatched.kt:5)
    at kotlinx.coroutines.BuildersKt.f(Unknown Source:75)
    at com.garena.ruma.framework.taskmanager.IBaseCoroutineTask$DefaultImpls.a(IBaseCoroutineTask.kt:12)
    at com.garena.ruma.framework.taskmanager.BaseCoroutineTask.f(BaseCoroutineTask.kt:1)
    at com.garena.ruma.framework.taskmanager.CoroutineTaskSchedulerKt.a(CoroutineTaskScheduler.kt:65)
    at com.garena.ruma.framework.taskmanager.CoroutineTaskScheduler$execute$taskChannel$1$1$channel$1.invokeSuspend(CoroutineTaskScheduler.kt:333)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:9)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:116)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:99)

En plus du contenu (champ content), un ChatMessage contient diverses informations :

  • clientId : Un identifiant de message, incrémenté d’un message à l’autre
  • sessionId : L’identifiant du destinataire
  • order : Un autre identifiant de message
  • fromId : L’identifiant de l’émetteur
  • timestamp : Un Horodatage
  • clientts : Un autre horodatage
  • createTime : Un dernier horodatage
  • whisperDuration : La durée de vie du message, en seconde, la valeur 0 signifiant que le message n’expire pas

(cette liste n’est pas exhaustive !)

Si les messages sont envoyés à edge-co.haiserve.com et n’apparaissent pas dans l’historique Burp, il est intéressant de constater que chaque envoi de message donne lieu à l’envoi d’une requête à https://api.haiserve.com, qui embarque des métadonnées relatives au message :

Une autre classe d’intérêt est la classe CryptoUtils, qui contient des méthodes de chiffrement/déchiffrement.

Cependant, tenter de tracer les appels aux méthodes de CryptoUtils sur le Pixel 7a sous Android 14 ne donne rien : En réalité ces méthodes sont pourtant bien utilsées, comme l’on s’en rend compte en répétant l’opération avec un téléphone utilisant Android 10 : Il semble que frida n’arrive pas à tracer correctement certaines méthodes sur un terminal sous Android 14, en particulier les appels aux méthodes d’une classe prenant en argument une instance de ladite classe, comme ci-dessous :

public class Toto
{
    ...
    public static a(Toto toto, byte [] a, int b)
    {
        ...
    }
}

La classe CryptoUtils contient 5 méthodes, a, b, c, d et et e.

La méthode a effectue le déchiffrement AES256-GCM d’un tableau content :

La clé de déchiffrement est le byte [] key, deuxième argument de la méthode. Le nonce GCM utilisé provient des 12 premiers octets de content.

À l’inverse, la méthode b chiffre en AES256-GCM le tableau bArr avec la clé bArr2 :

Le nonce GCM est tiré au sort (appel b.nextBytes(bArr3), sachant que b est un SecureRandom).

La méthode retourne result, qui est la concaténation du nonce GCM, du message chiffré et du tag GCM.

La méthode c réalise le chiffrement RSA de bArr en utilisant la clé publique publicKey :

Enfin, les méthodes d et e réalisent des appels à HmacSHA256.

méthode e :

Ces deux méthodes sont utilisées pour des opérations de dérivation de clé, sur lesquelles nous reviendront ultérieurement.

Capturons un autre message texte, où le corps du message est la phrase « j’aime la galette » :

appel de com.garena.ruma.model.ChatMessage.toString()
clientId : 360
sessionId : 641926
Message content length : 57
Message content : &J'aime la galette savez vous comment ?¡l£iplÂ
resultat : ChatMessage{clientId=360, msgId=0, sessionMsgId=0, sessionId=641926, order=327, fromId=641936, tag='text', content=[...], extraContent=[...], type=514, state=16, timestamp=1735246536, client ts=1735246535780, requestId=0, createTime=1735246536, whisperDuration=0, quote=[...], fromPush=false, options=0, ftsUpdated=0, forward=false, seenTime=-1, isTranslating=false, scenario=normal, disappearTimestamp=0}
appel de ChatMessage.toString() - pile d'appel :
java.lang.Exception
    at com.garena.ruma.model.ChatMessage.toString(Native Method)
    at java.lang.String.valueOf(String.java:2924)
    at java.lang.StringBuilder.append(StringBuilder.java:132)
    at com.garena.seatalk.message.chat.task.send2server.BaseSendMessageToServerTask.l(BaseSendMessageToServerTask.kt:140)
    at com.garena.seatalk.message.chat.task.send2server.BaseSendMessageToServerTask.u(BaseSendMessageToServerTask.kt:59)
    at com.garena.seatalk.message.chat.task.send2server.BaseSendMessageToServerTask.c(BaseSendMessageToServerTask.kt:1)
    at com.garena.ruma.framework.taskmanager.IBaseCoroutineTask$run$2.invokeSuspend(IBaseCoroutineTask.kt:33)
    at com.garena.ruma.framework.taskmanager.IBaseCoroutineTask$run$2.invoke(IBaseCoroutineTask.kt:13)
    at kotlinx.coroutines.intrinsics.UndispatchedKt.a(Undispatched.kt:5)
    at kotlinx.coroutines.BuildersKt.f(Unknown Source:75)
    at com.garena.ruma.framework.taskmanager.IBaseCoroutineTask$DefaultImpls.a(IBaseCoroutineTask.kt:12)
    at com.garena.ruma.framework.taskmanager.BaseCoroutineTask.f(BaseCoroutineTask.kt:1)
    at com.garena.ruma.framework.taskmanager.CoroutineTaskSchedulerKt.a(CoroutineTaskScheduler.kt:65)
    at com.garena.ruma.framework.taskmanager.CoroutineTaskScheduler$execute$taskChannel$1$1$channel$1.invokeSuspend(CoroutineTaskScheduler.kt:333)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:9)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:116)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:99)

Chiffrement des messages textes

Si l’on trace également les appels aux méthodes de CryptoUtils, on retrouve ce message dans un appel à la méthode de chiffrement CryptoUtils.b :

com.seagroup.seatalk.utils.CryptoUtils.b (AES-GCM encryption)
b, bArr (plaintext) : 
85 a1 69 cf 07 6d c2 b4 22 58 40 7b a1 74 cf 26
0a 3b 98 00 09 cb 90 a1 6d de 00 12 a3 63 69 64 
cd 01 68 a3 63 74 73 cf 00 00 01 94 04 c0 dc 64
a1 63 c4 3b 85 a2 65 74 00 a2 65 76 00 a1 63 d9 
26 4a 27 61 69 6d 65 20 6c 61 20 67 61 6c 65 74
74 65 20 73 61 76 65 7a 20 76 6f 75 73 20 63 6f 
6d 6d 65 6e 74 20 3f a1 6c 90 a3 69 70 6c c2 a3
64 74 73 00 a1 75 ce 00 09 cb 90 a2 69 64 00 a4 
72 6d 69 64 00 a3 70 74 73 00 a1 6f 00 a1 71 c4
00 a5 72 74 6d 69 64 00 a2 6e 61 c2 a3 73 74 73 
00 a3 6d 69 64 00 a1 74 a4 74 65 78 74 a2 74 73
ce 67 6d c2 c8 a2 74 6f 00 a1 77 00 a1 62 ce 00 
09 cb 86 a1 72 00
b, bArr (plaintext) : (...)J'aime la galette savez vous comment ?(...)
b, bArr2 (key) : 
2c 3e c5 6f 4a 09 39 68 4d 72 f6 97 9c cc f9 d2 ce 76 b1 c1 5b ca f5 c9 1d 57 94 41 d4 6e e2 c4 

java.lang.Exception
    at com.seagroup.seatalk.utils.CryptoUtils.b(Native Method)
    at com.garena.ruma.network.tcp.STTcpPacketCodec.d(STTcpPacketCodec.kt:116)
    at com.garena.ruma.network.tcp.STTcpPacketCodec.c(STTcpPacketCodec.kt:31)
    at com.garena.ruma.network.tcp.lib.TcpClient.f(TcpClient.kt:26)
    at com.garena.ruma.network.tcp.TcpHandler$SendTcpInterceptor.a(TcpHandler.kt:499)
    at com.garena.ruma.network.tcp.TcpHandler$CoordinatorChain.c(TcpHandler.kt:56)
    at com.garena.ruma.framework.network.guard.CaptchaInterceptor.a(CaptchaInterceptor.kt:10)
    at com.garena.ruma.network.tcp.TcpHandler$CoordinatorChain.c(TcpHandler.kt:56)
    at com.garena.ruma.framework.network.guard.TcpMonitorInterceptor.a(TcpMonitorInterceptor.kt:57)
    at com.garena.ruma.network.tcp.TcpHandler$CoordinatorChain.c(TcpHandler.kt:56)
    at com.garena.ruma.framework.network.guard.RequestFilterInterceptor.a(RequestFilterInterceptor.kt:108)
    at com.garena.ruma.network.tcp.TcpHandler$CoordinatorChain.c(TcpHandler.kt:56)
    at com.garena.ruma.network.tcp.TcpHandler.g(TcpHandler.kt:429)
    at com.garena.ruma.network.tcp.TcpHandler.handleMessage(TcpHandler.kt:95)
    at android.os.Handler.dispatchMessage(Handler.java:107)
    at android.os.Looper.loop(Looper.java:359)
    at android.os.HandlerThread.run(HandlerThread.java:67)

b, result : 
aa 53 05 01 5d d9 3c 83 ef 16 78 4e 70 7d 31 67
b1 17 97 37 9c 53 40 f0 1f db 07 22 d3 79 fe 29 
df f0 92 f7 1d e6 b5 d9 23 ea 98 f6 18 cd 52 cb
fb 18 28 b4 cc 6f b5 f5 37 70 8d 45 12 1f 9c c4 
69 61 3d 56 06 53 fc 69 df e3 57 2c 75 7b 8e ea
c8 e5 1a 70 60 a7 8b 56 65 11 32 eb 64 c4 b6 e3 
37 fd f0 76 07 9b df 11 b6 da ac 86 a5 e6 13 62
cf d9 7b 90 18 63 7a 13 18 e8 a2 c1 e7 3f bf 50 
11 1c 61 8d cb 3c 15 2d d3 8c ad 2f 22 19 ff 70
d6 49 46 b7 44 5c 3c 81 c7 96 df 68 8a df 0b 17 
bb fe 1f ea b5 d0 27 3c 09 03 de 10 f5 4c 8a ad
f7 95 8a 9b 34 14 ec 8e 0e d1 79 21 89 e3 05 9e 
e2 81 bb 2f 45 8d f7 1a 88 9e db a1 cc 3b f5 4b
12 20 8b bf 29 ba be c1 06 c7 b9 32 5a 77 5e fe 
f8 79 

Structure partielle du message texte

La méthode ChatMessage.toString() permet d’obtenir des informations partielles sur la structure du message clair :

85 a1 69 cf 07 6d c2 b4 22 58 40 7b a1 74 cf 26
0a 3b 98 00 09 cb 90 a1 6d de 00 12 a3 63 69 64 
cd 
   01 68 /* clientId */
         a3 63 74 73 cf 00 00 
                              01 94 04 c0 dc 64 /* client ts */
a1 63 c4 3b 85 a2 65 74 00 a2 65 76 00 a1 63 d9 
26 /* longueur du contenu */
   4a 27 61 69 6d 65 20 6c 61 20 67 61 6c 65 74
74 65 20 73 61 76 65 7a 20 76 6f 75 73 20 63 6f 
6d 6d 65 6e 74 20 3f /* contenu du message texte */
                     a1 6c 90 a3 69 70 6c c2 a3
64 74 73 00 a1 75 ce 00
                        09 cb 90 /* fromId */
                                 a2 69 64 00 a4 
72 6d 69 64 00 a3 70 74 73 00 a1 6f 00 a1 71 c4
00 a5 72 74 6d 69 64 00 a2 6e 61 c2 a3 73 74 73 
00 a3 6d 69 64 00 a1 74 a4 74 65 78 74 a2 74 73
ce 
   67 6d c2 c8 /* timestamp/createTime */
               a2 74 6f 00 a1 77 00 a1 62 ce 00 
09 cb 86 /* sessionId */
         a1 72 00

Devenir du message chiffré

Un buffer contenant le chiffré AES-GCM du message (rencontré plus haut) est passé en paramètre de la fonction SSL_write :

SSL_write()
SSL_write, num : 226
SSL_write, buf:
           0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
00000000  aa 53 05 01 5d d9 3c 83 ef 16 78 4e 70 7d 31 67  .S..].<...xNp}1g
00000010  b1 17 97 37 9c 53 40 f0 1f db 07 22 d3 79 fe 29  ...7.S@....".y.)
00000020  df f0 92 f7 1d e6 b5 d9 23 ea 98 f6 18 cd 52 cb  ........#.....R.
00000030  fb 18 28 b4 cc 6f b5 f5 37 70 8d 45 12 1f 9c c4  ..(..o..7p.E....
00000040  69 61 3d 56 06 53 fc 69 df e3 57 2c 75 7b 8e ea  ia=V.S.i..W,u{..
00000050  c8 e5 1a 70 60 a7 8b 56 65 11 32 eb 64 c4 b6 e3  ...p`..Ve.2.d...
00000060  37 fd f0 76 07 9b df 11 b6 da ac 86 a5 e6 13 62  7..v...........b
00000070  cf d9 7b 90 18 63 7a 13 18 e8 a2 c1 e7 3f bf 50  ..{..cz......?.P
00000080  11 1c 61 8d cb 3c 15 2d d3 8c ad 2f 22 19 ff 70  ..a..<.-.../"..p
00000090  d6 49 46 b7 44 5c 3c 81 c7 96 df 68 8a df 0b 17  .IF.D\<....h....
000000a0  bb fe 1f ea b5 d0 27 3c 09 03 de 10 f5 4c 8a ad  ......'<.....L..
000000b0  f7 95 8a 9b 34 14 ec 8e 0e d1 79 21 89 e3 05 9e  ....4.....y!....
000000c0  e2 81 bb 2f 45 8d f7 1a 88 9e db a1 cc 3b f5 4b  .../E........;.K
000000d0  12 20 8b bf 29 ba be c1 06 c7 b9 32 5a 77 5e fe  . ..)......2Zw^.
000000e0  f8 79                                            .y

Le message chiffré est donc envoyé dans la session TLS avec 143.92.74.215, c’est-à-dire edge-co.haiserve.com.

Provenance de la clé de chiffrement du message texte

Dans notre exemple, la clé utilisée par CryptoUtils.b pour chiffrer a pour valeur

2c 3e c5 6f 4a 09 39 68 4d 72 f6 97 9c cc f9 d2 ce 76 b1 c1 5b ca f5 c9 1d 57 94 41 d4 6e e2 c4

Dans la trace obtenue avec les différents scripts frida, cette clé apparaît pour la première fois comme résultat de CryptoUtils.d :

com.seagroup.seatalk.utils.CryptoUtils.e
e, bArr : 
cd 59 58 f8 0b 9e ff b4 6a bf e7 2c 35 ea e2 ae 1b cf e3 ed 79 a6 0b 3c 0e 84 71 6e d9 4a 8e 20 

e, serverFactor : 
42 38 bf bc b9 ae f0 c3 ad 61 fb d7 a5 6d ec da b7 f8 7d 94 a6 24 76 87 4a 83 7a 6e cb e8 e2 c9 

java.lang.Exception
    at com.seagroup.seatalk.utils.CryptoUtils.e(Native Method)
    at com.garena.ruma.framework.network.TcpManager.r(TcpManager.kt:523)
    at com.garena.ruma.framework.network.TcpManager$negotiateCipher$1.invokeSuspend(TcpManager.kt:14)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:9)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:116)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:301)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
    at java.lang.Thread.run(Thread.java:919)


com.seagroup.seatalk.utils.CryptoUtils.d
d, bArr (key) : 
e6 3b 5e 6b 87 12 73 81 32 66 00 d1 5d 83 74 24 37 8d f7 6f c0 85 d6 6e 2e e9 56 4d fd 45 f1 79 

java.lang.Exception
    at com.seagroup.seatalk.utils.CryptoUtils.d(Native Method)
    at com.seagroup.seatalk.utils.CryptoUtils.e(CryptoUtils.kt:52)
    at com.seagroup.seatalk.utils.CryptoUtils.e(Native Method)
    at com.garena.ruma.framework.network.TcpManager.r(TcpManager.kt:523)
    at com.garena.ruma.framework.network.TcpManager$negotiateCipher$1.invokeSuspend(TcpManager.kt:14)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:9)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:116)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:301)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
    at java.lang.Thread.run(Thread.java:919)

d, result : 
2c 3e c5 6f 4a 09 39 68 4d 72 f6 97 9c cc f9 d2 ce 76 b1 c1 5b ca f5 c9 1d 57 94 41 d4 6e e2 c4 

e, result : 
2c 3e c5 6f 4a 09 39 68 4d 72 f6 97 9c cc f9 d2 ce 76 b1 c1 5b ca f5 c9 1d 57 94 41 d4 6e e2 c4 

La clé de chiffrement des messages (nommons-la k2) est donc le résultat de CryptoUtils.d, qui est lui-même le résultat de CryptoUtils.e.

Attardons-nous sur le code de CryptoUtils.e :

public final byte[] e(@NotNull byte[] bArr, @NotNull byte[] serverFactor) {
    Intrinsics.f(serverFactor, "serverFactor");
    try {
        int length = bArr.length;
        int length2 = serverFactor.length;
        byte[] result = Arrays.copyOf(bArr, length + length2);
        System.arraycopy(serverFactor, 0, result, length, length2);
        Intrinsics.e(result, "result");
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(new byte[mac.getMacLength()], "HmacSHA256"));
        byte[] doFinal = mac.doFinal(result);
        Intrinsics.e(doFinal, "mac.doFinal(ikm)");
        return d(this, doFinal);
    } catch (Exception e) {
        Log.b("CryptoUtils", r5.g(e, new StringBuilder("fail to generate secret key spec: ")), new Object[0]);
        return new byte[0];
    }
}

La méthode CryptoUtils.e commence par calculer le HMAC-SHA256 de la concaténation barr + serverFactor de ses deux arguments d’entrée,

la clé utilisée étant une clé constituée d’octets nuls. Le hmac obtenu est utilisé comme argument bArr par la méthode CryptoUtils.d :

public static byte[] d(CryptoUtils cryptoUtils, byte[] bArr) {
    cryptoUtils.getClass();
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(bArr, "HmacSHA256"));
    int i = 1;
    int macLength = ((mac.getMacLength() + 32) - 1) / mac.getMacLength();
    if (macLength <= 255) {
        ByteBuffer allocate = ByteBuffer.allocate(32);
        byte[] bArr2 = new byte[0];
        if (1 <= macLength) {
            while (true) {
                mac.update(bArr2);
                mac.update((byte) i);
                bArr2 = mac.doFinal();
                Intrinsics.e(bArr2, "mac.doFinal()");
                allocate.put(bArr2, 0, Math.min(allocate.remaining(), bArr2.length));
                if (i == macLength) {
                    break;
                }
                i++;
            }
        }
        byte[] array = allocate.array();
        Intrinsics.e(array, "okm.array()");
        return array;
    }
    throw new IllegalArgumentException("Requested output length too long");
}

En définitive, cette fonction calcule le HMAC-SHA256 d’une chaîne de caractère réduite à l’octet 0x01, comme le démontre le snippet python ci-dessous, qui reproduit la dérivation de clé réalisée :

$ cat poc_hmac.py 
#!/usr/bin/python3

import binascii
import hashlib
import hmac

k1 = b'\xcd\x59\x58\xf8\x0b\x9e\xff\xb4\x6a\xbf\xe7\x2c\x35\xea\xe2\xae\x1b\xcf\xe3\xed\x79\xa6\x0b\x3c\x0e\x84\x71\x6e\xd9\x4a\x8e\x20'
serverFactor = b'\x42\x38\xbf\xbc\xb9\xae\xf0\xc3\xad\x61\xfb\xd7\xa5\x6d\xec\xda\xb7\xf8\x7d\x94\xa6\x24\x76\x87\x4a\x83\x7a\x6e\xcb\xe8\xe2\xc9'
null_key = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

padding_vector = b'\x01'

result0 = hmac.new(null_key,  k1 + serverFactor, hashlib.sha256)
print(binascii.hexlify(result0.digest()))

result1 = hmac.new(result0.digest(), padding_vector, hashlib.sha256)
print(binascii.hexlify(result1.digest()))

$ python ./poc_hmac.py 
b'e63b5e6b87127381326600d15d837424378df76fc085d66e2ee9564dfd45f179'
b'2c3ec56f4a0939684d72f6979cccf9d2ce76b1c15bcaf5c91d579441d46ee2c4'

Une fois cette dérivation réalisée, la clé k2 est utilisée pour chiffrer tous les messages émis avec CryptoUtils.b, et déchiffrer les messages reçus avec CryptoUtils.a.

Il est intéressant de remarquer que cette clé est utilisée quelque soit le destinataire : Le (sur)chiffrement des messages n’est donc pas fait de bout en bout.

Continuons de tirer le fil de la pelote et intéressons-nous aux arguments d’entrée de CryptoUtils.e.

Le deuxième argument de CryptoUtils.e, serverFactor n’apparaît qu’une seule autre fois, comme résultat d’un appel à la fonction de déchiffrement CryptoUtils.a :

com.seagroup.seatalk.utils.CryptoUtils.a (AES-GCM decryption)
a, key : cd 59 58 f8 0b 9e ff b4 6a bf e7 2c 35 ea e2 ae 1b cf e3 ed 79 a6 0b 3c 0e 84 71 6e d9 4a 8e 20 

a, content (ciphertext) : 
3a 21 57 63 fb 72 15 6e 07 04 3b de b0 a8 ac 7a 0a 79 99 ec 2e b6 12 ad fc 68 aa 3b ed 00 a6 35 
f4 9d 2e 70 3c 95 d2 71 c9 eb 20 b1 0e c3 89 b1 1d 75 2c 06 2c ee 35 26 70 a2 d9 8f 10 3e aa c7 
a6 6c dd c4 5c ab 18 01 ef 59 77 a7 66 e0 f6 c7 a2 5d 1e 6a 0b 7d 70 7c 
java.lang.Exception
    at com.seagroup.seatalk.utils.CryptoUtils.a(Native Method)
    at com.garena.ruma.framework.network.TcpManager.r(TcpManager.kt:387)
    at com.garena.ruma.framework.network.TcpManager$negotiateCipher$1.invokeSuspend(TcpManager.kt:14)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:9)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:116)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:301)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
    at java.lang.Thread.run(Thread.java:919)

a, result : 
84 a2 69 65 c3 a2 75 70 c2 a2 73 72 c4 20 42 38 bf bc b9 ae f0 c3 ad 61 fb d7 a5 6d ec da b7 f8 
7d 94 a6 24 76 87 4a 83 7a 6e cb e8 e2 c9 a2 65 61 aa 41 45 53 32 35 36 2d 47 43 4d 

Remarquons que le serverFactor (également nommé serverRandom dans le code) est précédé par la chaîne de caractère sr (octets 73 72), et qu’on retrouve peu après la chaîne de caractères

AES256-GCM (octets 41 45 53 32 35 36 2d 47 43 4d).

Le ciphertext passé à CryptoUtils.a provient de edge-co.haiserve.com :

SSL_read()
SSL_read, num : 105
SSL_read, buf:
           0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
00000000  82 a1 69 cf 07 6d c2 b4 22 58 40 48 a2 6e 72 c4  ..i..m.."X@H.nr.
00000010  58 3a 21 57 63 fb 72 15 6e 07 04 3b de b0 a8 ac  X:!Wc.r.n..;....
00000020  7a 0a 79 99 ec 2e b6 12 ad fc 68 aa 3b ed 00 a6  z.y.......h.;...
00000030  35 f4 9d 2e 70 3c 95 d2 71 c9 eb 20 b1 0e c3 89  5...p<..q.. ....
00000040  b1 1d 75 2c 06 2c ee 35 26 70 a2 d9 8f 10 3e aa  ..u,.,.5&p....>.
00000050  c7 a6 6c dd c4 5c ab 18 01 ef 59 77 a7 66 e0 f6  ..l..\....Yw.f..
00000060  c7 a2 5d 1e 6a 0b 7d 70 7c                       ..].j.}p|

Le serverRandom est donc obtenu en déchiffrant un bloc de donnée reçu du serveur avec une clé que nous nommerons dorénavant k1.

Il intéressant de noter que cette clé k1 est également utilisée comme premier argument CryptoUtils.e.

Enfin, la première occurrence de la clé k1 est observée lors d’un appel à la fonction CryptoUtils.c de chiffrement RSA :

com.seagroup.seatalk.utils.CryptoUtils.c (RSA encryption)
c, bArr : 
cd 59 58 f8 0b 9e ff b4 6a bf e7 2c 35 ea e2 ae 1b cf e3 ed 79 a6 0b 3c 0e 84 71 6e d9 4a 8e 20
c, publicKey : 
30 82 01 22 30 0d 06 09 2a 86 48 86 f7 0d 01 01 01 05 00 03 82 01 0f 00 30 82 01 0a 02 82 01 01 
00 bf 5c f1 f5 02 f0 ad 9c 93 3c 63 c9 39 84 09 dd 56 4f d8 c0 67 d4 61 c8 a3 28 7c eb f6 92 6a 
12 e5 b3 fc 97 3a 81 76 7f bd ef ed 92 1e a8 12 ef 49 70 7e d3 58 e6 38 f8 19 40 25 7b 04 e4 9d 
b2 b7 c0 71 dd 74 08 d4 47 b6 36 d1 89 b8 52 55 f1 d1 03 13 24 42 1e 68 d1 78 93 2a 87 26 2a 14 
9b 35 af 6d 12 75 68 f0 28 42 7e 5d 53 03 4f d0 c6 00 92 0d 36 38 fc 06 6d d3 df ad 03 d3 b0 25 
fa 3e 2b 3d df f6 75 01 55 6b ad 65 48 b5 cc ec e7 c3 f9 21 33 56 fd c1 e8 27 58 7e 20 09 e3 40 
3a 5f 6c 6a 5a 81 f1 ff 7d bc 36 c1 12 d9 a6 24 6b 40 b7 3c 17 c8 47 44 de 48 7f 03 3a e6 85 10 
6c 7b 13 03 9c 2f ac c3 0b 2f 2a 51 21 f0 92 e9 c2 6d a8 da 30 82 8b 6b cb d0 3c ee 6c 40 37 a2 
57 85 e5 42 71 07 8c 63 7a 5f 4c 27 7b 3a ab 88 68 d5 b1 d5 41 c7 20 7a 33 81 25 f8 94 97 c4 70 
75 02 03 01 00 01
java.lang.Exception
    at com.seagroup.seatalk.utils.CryptoUtils.c(Native Method)
    at com.garena.ruma.framework.network.TcpManager.r(TcpManager.kt:320)
    at com.garena.ruma.framework.network.TcpManager.f(TcpManager.kt:429)
    at com.garena.ruma.framework.network.TcpManager$listenContextChanges$3$1.b(TcpManager.kt:95)
    at com.garena.ruma.framework.network.TcpManager$listenContextChanges$3$1.a(TcpManager.kt:3)
    at kotlinx.coroutines.flow.SharedFlowImpl.m(SharedFlow.kt:187)
    at kotlinx.coroutines.flow.SharedFlowImpl$collect$1.invokeSuspend(SharedFlow.kt:13)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:9)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:116)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:301)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
    at java.lang.Thread.run(Thread.java:919)

c, result : 
ad e3 bd 55 6b 51 64 46 88 30 b7 d7 3b c9 0b 86 39 c8 57 e7 ce f0 19 39 36 b5 e8 2b 28 fa 20 66 
6e 76 00 87 84 d5 12 8e ab 51 04 31 08 fe d1 64 d8 44 56 45 3b df 74 6c 84 30 61 71 93 41 2d 64 
86 32 45 c3 87 63 72 ff 4d 23 98 ad c5 81 41 fc 18 56 95 a9 aa cb ce 84 3d db 9b 67 86 ff 68 f9 
2d a8 22 2b ee ed 36 d5 bd be 55 64 8f c2 95 8a cb cd 00 f8 df 4f f1 ca bc 57 cc 1d d9 27 c6 33 
05 16 a2 35 aa 30 7b 08 44 e6 43 02 f0 f9 80 17 7e d1 23 1c 75 60 98 ca a5 a4 be e2 f7 7f 8a 12 
c6 38 ac 26 7a 94 29 3b 3e cf 0f 6e 0e 16 f8 10 2b eb 67 20 87 d1 c3 6d 27 21 37 75 6c a8 a2 76 
1e fe 90 30 4e 30 3f ec a3 f3 7d db 85 aa 13 f7 ae cc 90 03 70 32 f8 25 aa fa 61 f5 e2 2f d5 51 
51 b8 64 18 54 9b 53 55 44 b2 6d 8f 8f ff 31 d0 10 91 f8 14 24 fa 4c 17 81 0d b0 0a 61 08 97 a0 

La valeur chiffrée de k1 est ensuite envoyée à edge-co.haiserve.com :

SSL_write()
SSL_write, num : 311
SSL_write, buf:
           0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
00000000  86 a1 69 cf 07 6d c2 b4 22 58 40 48 a7 74 69 6d  ..i..m.."X@H.tim
00000010  65 6f 75 74 cd 13 88 a2 63 72 c5 01 00 ad e3 bd  eout....cr......
00000020  55 6b 51 64 46 88 30 b7 d7 3b c9 0b 86 39 c8 57  UkQdF.0..;...9.W
00000030  e7 ce f0 19 39 36 b5 e8 2b 28 fa 20 66 6e 76 00  ....96..+(. fnv.
00000040  87 84 d5 12 8e ab 51 04 31 08 fe d1 64 d8 44 56  ......Q.1...d.DV
00000050  45 3b df 74 6c 84 30 61 71 93 41 2d 64 86 32 45  E;.tl.0aq.A-d.2E
00000060  c3 87 63 72 ff 4d 23 98 ad c5 81 41 fc 18 56 95  ..cr.M#....A..V.
00000070  a9 aa cb ce 84 3d db 9b 67 86 ff 68 f9 2d a8 22  .....=..g..h.-."
00000080  2b ee ed 36 d5 bd be 55 64 8f c2 95 8a cb cd 00  +..6...Ud.......
00000090  f8 df 4f f1 ca bc 57 cc 1d d9 27 c6 33 05 16 a2  ..O...W...'.3...
000000a0  35 aa 30 7b 08 44 e6 43 02 f0 f9 80 17 7e d1 23  5.0{.D.C.....~.#
000000b0  1c 75 60 98 ca a5 a4 be e2 f7 7f 8a 12 c6 38 ac  .u`...........8.
000000c0  26 7a 94 29 3b 3e cf 0f 6e 0e 16 f8 10 2b eb 67  &z.);>..n....+.g
000000d0  20 87 d1 c3 6d 27 21 37 75 6c a8 a2 76 1e fe 90   ...m'!7ul..v...
000000e0  30 4e 30 3f ec a3 f3 7d db 85 aa 13 f7 ae cc 90  0N0?...}........
000000f0  03 70 32 f8 25 aa fa 61 f5 e2 2f d5 51 51 b8 64  .p2.%..a../.QQ.d
00000100  18 54 9b 53 55 44 b2 6d 8f 8f ff 31 d0 10 91 f8  .T.SUD.m...1....
00000110  14 24 fa 4c 17 81 0d b0 0a 61 08 97 a0 a2 65 73  .$.L.....a....es
00000120  91 aa 41 45 53 32 35 36 2d 47 43 4d a2 70 76 02  ..AES256-GCM.pv.
00000130  a1 75 ce 00 09 cb 90                             .u.....

L’appel à CryptoUtils.c est réalisé dans la fonction TcpManager.r.

Dans cette fonction, la clé k1 est la variable bArr2, générée par un appel à une méthode du champ b de l’objet CryptoUtils, qui est un SecureRandom() :

La clé RSA utilisée est une des deux clés stockées dans la classe CipherPubKey :

Après un peu de nettoyage, les clés utilisées sont :

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv1zx9QLwrZyTPGPJOYQJ
3VZP2MBn1GHIoyh86/aSahLls/yXOoF2f73v7ZIeqBLvSXB+01jmOPgZQCV7BOSd
srfAcd10CNRHtjbRibhSVfHRAxMkQh5o0XiTKocmKhSbNa9tEnVo8ChCfl1TA0/Q
xgCSDTY4/AZt09+tA9OwJfo+Kz3f9nUBVWutZUi1zOznw/khM1b9wegnWH4gCeNA
Ol9salqB8f99vDbBEtmmJGtAtzwXyEdE3kh/AzrmhRBsexMDnC+swwsvKlEh8JLp
wm2o2jCCi2vL0DzubEA3oleF5UJxB4xjel9MJ3s6q4ho1bHVQccgejOBJfiUl8Rw
dQIDAQAB
-----END PUBLIC KEY-----

et

-----BEGIN RSA PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApfUjEnCtvJR4z/WowV/O
HZNdreAmYz6etsdbJMJ3YIot3MTx6+HqrI2MwUICt1JLgGMskgqhSHWZvEKuRyNP
3A0kS0P2tHn8kMdo+u1eR1tW1HWTLZsXZwXwfR8PTy2O91Nkmxz3qIi/dvFE2Fh3
EuG4e4tCM414KEBv3FjBiCPLQ9VLmEWmSbfbcPZuO26gFPLLV9FykcYdHOA+JOPg
VrphK/IlnCRvIHorwBC6NVHthG6rMyTLwEOoSkHeqCiMMDkhNX7gsbJ7o0cES3tC
DyqGikXTe4+ojtu5BT85xwP3MHcFgakr1N2wWe8OUb2TniNTmO/y6RDTGZmQmWIq
fQIDAQAB
-----END RSA PUBLIC KEY-----

Récapitulatif de la génération de la clé de chiffrement des messages textes

Le chiffrement des messages est donc réalisé par une clé k2 obtenue par le procédé suivant :

  • Dans TcpManager.r, le client génère une clé k1 (appel à CryptoUtils.b, qui est un SecureRandom()),
  • immédiatemment après, le client transmet au serveur la valeur de k1 chiffrée avec une clé RSA présente en dur dans l’application,
  • Le serveur génère et chiffre son serverRandom avec la clé k1 en AES-GCM et envoie le résultat au client (précédé par le tag nr),
  • La clé k2 est calculée comme le résultat de l’appel à CryptoUtils.e (qui appelle à son tour CryptoUtils.d) avec les arguments suivants :
  • un bArr égal à k1
  • Un serverFactor égal au serverRandom

La clé k2 est ensuite utilisée pour chiffrer le message en AES-GCM via CryptoUtils.b.

Les APIs de chiffrement sont-elles bien utilisées ?

Quelques éléments de réponse :

  • Le nonce GCM est généré à partir de SecureRandom() dans CryptoUtils.b(),
  • La méthode CryptoUtils.a() vérifie bien le tag GCM est bien vérifié,

Un.e cryptologue tatillon.ne froncera peut-être les sourcils en voyant la clé k1 utilisée à la fois comme clé de chiffrement du serverRandom puis comme élément d’entrée de la fonction CryptoUtils.e, et en observant que la boucle while (true) {...} de CryptoUtils.d ne réalise – lors de la dérivation de k2 en tout cas – qu’un appel à HMAC_SHA256.

Ceci mis à part, la dérivation de k2 ne comporte pas de problème « catastrophique », si ce n’est l’absence de perfect forward secrecy : Si la clé publique chiffrant k1 est compromise, l’édifice s’écroule puisqu’il est alors possible d’obtenir la valeur de k1, du serverRandom, et donc de k2.

Notons tout de même que cela nécessite de compromettre la clé privée en question, mais aussi d’accéder au trafic de handshake, qui est échangé au dessus du canal TLS établi entre le terminal et le backend de l’application.

De même, aucune faille cryptographique n’a été observée dans le chiffrement/déchiffrement des messages textes, si ce n’est l’absence de chiffrement de bout en bout : Les administrateur.rices du backend de l’application peuvent théoriquement déchiffrer les messages de deux personnes utilisant SeaTalk.

Stockage des données

Toute personne soucieuse de la sécurité de son application de messagerie instantanée s’interrogera non seulement sur les mécanismes de chiffrement déployés pour échanger les messages, mais aussi sur la façon dont l’application protège sur le terminal les messages échangés.

Un premier test élémentaire pour se faire un avis sur la question est de rechercher dans le répertoire de l’application une occurrence d’un message échangé auparavant :

H8765:/data/data/com.seagroup.seatalk # grep -nre "Bibifoc le roi des phoques" ./
Binary file ./databases/641936-wal matches
Binary file ./databases/641936 matches
H8765:/data/data/com.seagroup.seatalk # file databases/641*
641936      641936-shm  641936-wal
H8765:/data/data/com.seagroup.seatalk # file databases/641936*
databases/641936:     data
databases/641936-shm: data
databases/641936-wal: data

Le test est donc un échec, puisque les messages texte ne font l’objet d’aucune protection spécifique.

Les fichiers où les messages échangés sont observés sont des bases de donnée SQLite :

$ file 641936
641936: SQLite 3.x database, user version 114, last written using SQLite version 3022000

Cette base de donnée comporte une table buddy_message :

ainsi qu’une autre table buddy_message_index :

qui contiennent toutes les deux les messages texte, ainsi que différentes métadonnées pour la première.

Bref, si vous utilisez SeaTalk et que votre téléphone tombe entre les mains d’une personne susceptible d’en dumper le contenu, vous êtes fichu.e !

Whisper mode

SeaTalk permet d’envoi des message ephémères, censés s’autodétruire après une durée valant par défaut 60 secondes.

Dans l’image ci-dessous, Alice envoie un message ephémère à Bob :

Examinons maintenant les bases de données sqlite de l’application après expiration du délai de 60 secondes.

Coté émetteur :

lynx:/data/data/com.seagroup.seatalk # grep -nre "lira ceci" ./
Binary file ./databases/641926 matches
Binary file ./databases/641926-wal matches
Binary file ./databases/641926-wal matches
Binary file ./databases/641926-wal matches
Binary file ./databases/641926-wal matches
Binary file ./databases/641926-wal matches
Binary file ./databases/641926-wal matches
Binary file ./databases/641926-wal matches

Et coté récepteur :

H8765:/data/data/com.seagroup.seatalk # grep -nre "Waf" ./
Binary file ./databases/641936-wal matches
Binary file ./databases/641936 matches
H8765:/data/data/com.seagroup.seatalk # grep -nre "lira ceci" ./
Binary file ./databases/641936-wal matches
Binary file ./databases/641936 matches

Si l’on examine la table buddy_message du récepteur après expiration de la durée de vie du message ephémère, on constate donc qu’il est toujours là…

Conclusions

SeaTalk n’utilise pas de chiffrement de bout en bout pour les messages textes, chose que l’on peut raisonnablement attendre d’une application de messagerie instantanée à l’état de l’art : Quelqu’un capable de compromettre l’infrastructure de SeaTalk pourra également mettre la main sur vos messages texte.

Une faille plus prosaïque est présente dans SeaTalk : Les messages ephémères ne le sont pas vraiment !


Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *