
Заметки по обратной разработке и самодостаточный PoC для гонки в кэше доступа клиента NFS в macOS (CVE-2026-43687), с diff дизассемблирования kext и захватом гонки через dtrace.
Независимая обратная разработка гонки в кэше доступа клиента NFS в macOS (CVE-2026-43687), а также рабочий PoC, который вызывает гонку и фиксирует её в реальном времени с помощью dtrace.
Ошибка представляет собой несинхронизированное чтение указателя кэша
доступа nfsnode в _nfs_vnop_access. Вредоносный сервер NFSv3 может
заставить кэш доступа перераспределиться в тот момент, когда другой поток
читает его, что приводит к раскрытию памяти ядра, на которое может влиять
враждебный сервер. macOS 26.7 исправляет это путём вставки lck_rw_t по
адресу nfsnode+0x158 и взятия его в разделяемом режиме вокруг чтения
кэша.
| Поле | Значение |
|---|---|
| CVE | CVE-2026-43687 |
| Компонент | com.apple.filesystems.nfs (_nfs_vnop_access) |
| Затронуто | macOS Tahoe 26.6 и более ранние, iOS 26.x и более ранние |
| Исправлено в | macOS Tahoe 26.7, macOS Golden Gate 27, iOS 26.7, iOS 27 |
| Влияние по бюллетеню | "Подключение к вредоносному серверу NFS может раскрыть память ядра." |
| CVSS v3.1 | 6.5 (Средний) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N |
| Сообщено | R4mbb из KRsecurity и Peter Malone (согласно бюллетеню Apple) |
Бюллетень Apple по CVE-2026-43687 описывает влияние и версию с исправлением. Он не описывает технический механизм:
nfsnode подвержено гонкеaccess(2), а не stat(2)На момент написания публичного технического разбора найдено не было. Этот репозиторий восполняет этот пробел независимым анализом обратной разработки NFS kext между 26.6 и 26.7, а также рабочим PoC, который воспроизводит гонку на живой цели.
Это не заявка на открытие. CVE был сообщён R4mbb и Peter Malone и исправлен Apple. Вклад здесь — это технический анализ и воспроизведение.
_nfs_vnop_access в клиенте NFS читает nfsnode+0x158 — указатель на
массив кэша доступа для каждого UID — дважды в рамках одного вызова,
не удерживая никакой блокировки:
; macOS 26.6, com.apple.filesystems.nfs
fffffe000b52def4 ldr w8, [x20, #0x160] ; count
fffffe000b52def8 cmp w23, w8
fffffe000b52defc b.ge ...
fffffe000b52df00 ldr x8, [x20, #0x158] ; cache ptr (read #1)
...
fffffe000b52df98 ldr x8, [x20, #0x158] ; cache ptr (read #2)
fffffe000b52dfb0 ldr w21, [x9] ; dereference
Писатель, _nfs_nget, перераспределяет массив с помощью kalloc_data и
сохраняет результат по +0x158 всякий раз, когда клиент видит новый UID
от сервера:
fffffe000b52100c bl 0xfffffe000b6a1dd8 ; kalloc
fffffe000b521010 str x0, [x22, #0x158] ; cache ptr
fffffe000b521018 str w20, [x22, #0x160] ; cache count
Если другой поток достигает _nfs_nget на том же nfsnode между двумя
загрузками читателя, вторая загрузка возвращает новый указатель, тогда как
первое чтение — уже использованное для вычисления смещения — было основано
на старом. Последующее разыменование читает из освобождённой кучи ядра.
Исправление в 26.7:
fffffe000b9e3064 str x0, [x22, #0x168] ; cache ptr moved
fffffe000b9e306c str w28, [x22, #0x170] ; cache count moved
fffffe000b9e307c add x0, x22, #0x158 ; lock slot
fffffe000b9e3084 bl _lck_rw_init ; init RW lock
и в _nfs_vnop_access:
fffffe000b9f0200 add x0, x20, #0x158
fffffe000b9f0204 bl _lck_rw_lock_shared ; take the lock
fffffe000b9f0208 ldr x9, [x20, #0x168] ; cache pointer
...
fffffe000b9f0230 bl _lck_rw_unlock_shared ; release the lock
Изменение структуры:
_nfs_nget и _nfs_vnop_access между
NFS kext 26.6 и 26.7 — см.
docs/PATCH_DIFF.mdnfsnode+0x158 в 26.6lck_rw_t вставлен по +0x158,
указатель кэша перемещён на +0x168, lck_rw_lock_shared берётся
вокруг чтенияaccess(2) входит в _nfs_vnop_access;
stat(2) идёт через _nfs_getattr и никогда не достигает уязвимой
функцииПолный разбор см. в docs/ANALYSIS.md, а адреса и
образцы логов — в docs/ARTIFACTS.md.
poc.sh — самодостаточный PoC в одном файле:
nfsd от Apple, чтобы освободить порт 2049127.0.0.1, который меняет
сообщаемый UID в каждом ответеnoacaccess(2) через test -r / test -wnfs_vnop_access, записывая любой вызов,
в котором nfsnode+0x158 меняется в середине вызова_nfs_vnop_access, наблюдающее
изменение массива в течение одного вызоваГонка — это предварительное условие для раскрытия. Чтобы превратить гонку
в реальную утечку, атакующему потребовалось бы наблюдать, как читатель
использует устаревший указатель, и распространить эти байты туда, откуда
он может их прочитать. На arm64e значение по nfsnode+0x158 подписано
PAC, а ключ, уникальный для каждой загрузки, недоступен из пользовательского
пространства, поэтому один только dtrace может наблюдать гонку, но не
может декодировать указатель. См. раздел "Paths tested and ruled out" в
docs/ANALYSIS.md.
Продемонстрированное влияние — это сама гонка — именно то окно, которое закрывает исправление с RW-блокировкой в 26.7.
dtrace (в некоторых установках может потребоваться
корректировка SIP)python3, dscl, mountPoC полностью выполняется на цели. Вредоносный сервер NFS привязывается
к 127.0.0.1, а монтирование идёт через loopback. Это делает PoC
самодостаточным и воспроизводимым без настройки сети.
chmod +x poc.sh
sudo ./poc.sh
Необязательная настройка через переменные окружения:
sudo HAMMER_COUNT=30 RUN_SECONDS=600 ./poc.sh
HAMMER_COUNT задаёт количество hammer-потоков для каждого пользователя
(используются любые существующие учётные записи nfsuserNNN, при
необходимости создаются). RUN_SECONDS задаёт окно dtrace.
[*] ensuring nfsuser accounts exist (UID 201..240)
nfsuser accounts available: 12
[*] starting evil NFS server on 127.0.0.1:2049
[*] mounting /Users/Shared/nfs_test (with noac)
mount check: hello.txt statable
[*] spawning up to 12 per-user threads + 1 root loop
started 12 user threads + 1 root loop
[*] running dtrace for 180s — looking for RACE lines
[*] stopping dtrace
[*] cleaning up
=================== SUMMARY ===================
RACE events caught: 16
Vulnerable interleaving observed — sample:
RACE nd=fffffe5f53cbb940 in=(0,0) out=(b0967e0023297878,0)
RACE nd=fffffe5f534db940 in=(0,0) out=(84dd7e0023297878,0)
RACE nd=fffffe5f53cbb940 in=(0,0) out=(c9a37e0023297878,0)
RACE nd=fffffe5f538ab940 in=(0,0) out=(1867e0023297878,0)
RACE nd=fffffe5f539db940 in=(0,0) out=(149bfe0023297878,0)
CVE-2026-43687 trigger SUCCESSFUL
===============================================
Каждая строка RACE — это один вызов nfs_vnop_access, в котором
указатель кэша доступа изменился в середине вызова.
На исправленной системе (26.7 / 27) та же нагрузка даёт ноль событий
RACE. Сравнение дизассемблирования см. в
docs/PATCH_DIFF.md.
Две ошибки в сервере PoC были исправлены в ходе разработки и задокументированы здесь, чтобы другие, создающие аналогичные инструменты, не наткнулись на них:
ACCESS3resok требует post_op_attr, а не fattr3. RFC 1813
определяет ответ как post_op_attr obj_attributes; uint32 access;.
post_op_attr включает префикс bool перед fattr3. Пропуск этого
bool делает ответ на 4 байта короче; клиент молча отвергает его, и
монтирование так и не становится рабочим.
LOOKUP для имён AppleDouble (._*) должен возвращать
NFS3ERR_NOENT (2), а не NFS3ERR_STALE (70). macOS проверяет
наличие сайдкаров ._<name> при обычном разрешении пути. Возврат
STALE отравляет монтирование.
Оба случая задокументированы в docs/ANALYSIS.md.
Значения out в выводе RACE имеют постоянный шаблон младших 48 бит
(...7e0023297878) для разных nfsnode, различаясь только в старших
16 битах. Это подтверждает, что поле подписано PAC или обфусцировано,
а не является сырым указателем ядра. Вы можете наблюдать переход
состояния (NULL → заполнено) из пользовательского пространства, но не
можете декодировать или разыменовать указатель без PAC-ключа ядра,
уникального для каждой загрузки.
По той же причине символизация по KDK бесполезна для этих значений — они не являются адресами относительно текста.
Этот репозиторий предоставлен исключительно для оборонительных исследований в области безопасности и образования.
MIT. См. LICENSE.
| Смещение | macOS 26.6 | macOS 26.7 |
|---|
+0x158 | указатель на массив кэша | lck_rw_t |
+0x160 | счётчик кэша | (часть блокировки) |
+0x168 | (другое) | указатель на массив кэша |
+0x170 | (другое) | счётчик кэша |