
شرح حول إعادة استضافة البرنامج الثابت لجهاز التوجيه Tenda AC15 وإعادة إنتاج استغلال تنفيذ الأوامر عن بُعد (CVE-2020-10987).
توضح هذه المقالة بالضبط كيف قمت بمحاكاة خادم الويب الخاص بالبرامج الثابتة لجهاز AC15 (V15.03.05.19) باستخدام QEMU، وجعلته قابلاً للوصول من متصفح المضيف، واختبرت المعالج الضعيف /goform/setUsbUnload (CVE-2020-10987) للحصول على تنفيذ أوامر داخل نظام الملفات الجذرية المُحاكى.
squashfs من صورة البرامج الثابتة والبدء في الهندسة العكسيةأولاً، نحتاج إلى الحصول على صورة البرامج الثابتة. لم أتمكن من العثور على تنزيل الصورة لـ AC15 V15.03.05.19، لكنني تمكنت من العثور على مستودع GitHub يحتوي على نظام ملفات squashfs المستخرج بالفعل.
إذا كان لدينا ملف الصورة الصحيح، فيمكننا استخراجه باستخدام binwalk كما يلي،```
user@computer $ binwalk -e AC15_V15.03.05.19.bin
64 0x40 TRX firmware header, little endian, image size: 6778880 bytes, CRC32: 0x80AD82D6, flags: 0x0, version: 1, header size: 28 bytes, loader offset: 0x1C, linux kernel offset: 0x1A488C, rootfs offset: 0x0 92 0x5C LZMA compressed data, properties: 0x5D, dictionary size: 65536 bytes, uncompressed size: 4177792 bytes 1722572 0x1A48CC Squashfs filesystem, little endian, version 4.0, compression:xz, size: 5052332 bytes, 848 inodes, blocksize: 131072 bytes, created: 2017-04-19 16:18:08
user@computer $ cd _AC15_V15.03.05.19.bin.extracted
بما أنه ليس لدينا ملف الصورة الصحيح ولكن لدينا مستودع يحتوي على نظام الملفات المستخرج بالفعل، يمكننا فقط استنساخ المستودع.```
git clone https://github.com/lapinpt/Tenda-AC15-Firmware-V15.03.05.19-9061
لقد أنشأت مجلدًا باسم VR واستنسخت هذا المستودع بداخله. rootfs الخاص بي موجود في $HOME/VR/Tenda-AC15-Firmware-V15.03.05.19-9061/rootfs.
رائع، الآن لدينا البرنامج الثابت الخاص بـ AC15 V15.03.05.19. دعنا ننتقل إلى بعض الهندسة العكسية لنرى ما الذي يحدث.
سأستخدم Ghidra 11.4.2 للهندسة العكسية.
دعنا ننتقل إلى الملف الثنائي المستهدف هنا rootfs/bin/httpd ونحمّله في Ghidra.
إذا انتقلنا إلى الدالة formsetUsbUnload فسنرى،```C
uVar1 = FUN_0002bd4c(param_1,"deviceName",&DAT_000f4bdc);
doSystemCmd("cfm post netctrl %d?op=%d,string_info=%s",0x33,3,uVar1);
FUN_0002c6cc(param_1,"HTTP/1.0 200 OK\r\n\r\n");
FUN_0002c6cc(param_1,"{"errCode":0}");
FUN_0002cc14(param_1,200);
return;
هذه هي الثغرة. يتم تمرير المعامل `deviceName` مباشرة إلى `doSystemCmd` مما يسمح لنا بإرسال أي أوامر نريدها.
الآن وبما أننا سنعيد استضافة هذا البرنامج الثابت باستخدام qemu وليس أجهزة الراوتر الأصلية، فبعض البرامج ستحاول الوصول إلى أجهزة غير موجودة مما يؤدي إلى تعطّل بدء التشغيل لدينا.
وبما أن هدفنا هو استغلال خادم الويب (`httpd`)، فقد ركّزت فقط على إعادة استضافة هذا الثنائي وليس كامل عملية بدء التشغيل (`/rootfs/etc_ro/init.d/rcS`). _بعد فوات الأوان، لست متأكدًا من أن هذا كان القرار الصحيح_
لذا عند النظر إلى `rcS`، أردت العثور على أي شيء قد يفعله `rcS` مما قد يحتاجه `httpd`. في نهاية الملف يمكننا رؤية:```bash
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &
rcS يشغّل cfmd في الخلفية قبل أن يعمل باقي الحزمة مباشرة.
بعد مزيد من البحث والاطلاع على كيفية إرسال الدالة الضعيفة للأمر، توصلت إلى الاستنتاج التالي:
httpd يبني cfm postcfm مع cfmd عبر مقبس نطاق UNIX (مثل /var/cfm_socket)في روتين InitServer في cfmd يمكننا رؤية```C
unlink("/var/cfm_socket");
strncpy(sa_unix.sun_path, "/var/cfm_socket", ...);
bind(fd, (sockaddr*)&sa_unix, 0x6e);
listen(fd, 5);
يقوم بإنشاء مقبس UIX ويستمع.
بشكل عام، يقرأ المعالج إطارًا ثابتًا بحجم 0x7e0 بايت من كل عميل (`RecvMsg`/`SendMsg`). أول 4 بايتات هي رمز أمر؛ ثم هناك مخزن مفاتيح بحجم 512 بايت ومخزن قيم بحجم 1500 بايت (يمكنك رؤية أحجام كائنات المكدس في المعالج). يقوم بالتبديل بناءً على رمز العملية ويرد برمز ACK:
- `2` -> Get: `GetCfmValue(key, value)` ثم يرد برمز `3`
- `0` -> Set: `SetCfmValue(key, value)` ثم يرد برمز `1`
- `0x11` -> Unset: `UnSetCfmValue(key)` ثم يرد برمز `0x12`
- `10` -> Commit: `SaveCfm2Flash()` ثم يرد بـ `0x10` (OK) أو `0xB` (خطأ)
لذا، من أجل محاكاة `cfmd`، أنشأت سكربتًا قصيرًا `cfm_stub````C
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <errno.h>
#define SOCK_PATH "/var/cfm_socket"
// minimal UNIX-domain server that httpd expects.
// Replies with an IP string when it sees the key it asks for.
int main(void) {
int s = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {0};
if (s < 0) { perror("socket"); return 1; }
unlink(SOCK_PATH);
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path)-1);
if (bind(s, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
if (listen(s, 5) < 0) { perror("listen"); return 1; }
for (;;) {
int c = accept(s, NULL, NULL);
if (c < 0) { if (errno==EINTR) continue; perror("accept"); break; }
char buf[1024]; ssize_t n = read(c, buf, sizeof(buf));
if (n > 0) {
// In some builds httpd asks for "lan.webiplansslen" etc.
// Any non-empty reply that looks like an IP keeps init happy.
const char *reply = "192.168.0.1";
write(c, reply, strlen(reply));
}
close(c);
}
close(s);
return 0;
}
سأوضح كيفية تجميع هذا بعد الجزء التالي.
ملف المساعدة التالي هو hooks.so. هناك بعض الدوال المستخدمة في httpd وcfm تحاول التفاعل مع عتاد غير موجود.
فيما يلي ما يفترضه برنامجنا عند بدء التشغيل
/dev/nvram) ويعيد قيمًا افتراضية سليمة.تصبح الدوال التالية مشكلة، ولذلك يتعين علينا تطبيق الترقيع باستخدام LD_PRELOAD=/hooks.so.
get_flash_type() -> إذا أعادت 4، يسلك الكود مسارًا قائمًا على الملفات (cfm_file_init)، وإلا يحاول التحدث إلى MTD (وهو ما لا نملكه).get_cfm_blk_size_from_cache() (و/أو البديل j_get_cfm_blk_size_from_cache) يُستشار لتحديد حجم كتلة الإعدادات.bcm_nvram_get)، وإلا سيفترض النظام أن “NVRAM مدمرة” ويتجه إلى منطق الاستعادة/إعادة التشغيل.load_l7setting_file() وrestore_power()، لكنها تتعامل مع عتاد/ملفات غير موجودة.إليك hooks.c الخاص بنا. يعود الفضل إلى azeria-labs في النسخة الأصلية.```C
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <dlfcn.h>
#include <string.h>
int j_get_cfm_blk_size_from_cache(const int i) { puts("j_get_cfm_blk_size_from_cache called....\n"); return 0x20000; // 128 KiB block — what the file path expects }
int get_flash_type() { puts("get_flash_type called....\n"); return 4; // force file-backed CFM init, not MTD }
int load_l7setting_file() { puts("load_l7setting_file called....\n"); return 1; // pretend Layer-7 settings loaded OK }
int restore_power(int a, int b) { puts("restore_power called....\n"); return 0; // success (don’t touch RF/power hardware) }
char *bcm_nvram_get(char *key) { char *value = NULL;
if (strcmp(key, "et0macaddr") == 0) { value = strdup("DE:AD:BE:EF:CA:FE"); // any valid MAC works } if (strcmp(key, "sb/1/macaddr") == 0) { value = strdup("DE:AD:BE:EF:CA:FD"); } if (strcmp(key, "default_nvram") == 0) { value = strdup("default_nvram"); // signals “nvram is OK” }