在 Hexacon 2024 上关注到了这么一个议题 《Exploiting File Writes in Hardened Environments - From HTTP Request to ROP Chain in Node.js 》, 同时该作者发了一个简单的 Blog 讲述了下这个原理以及部分细节。[1] 这里简单快速复现一下。
do { r = read(loop->signal_pipefd[0], buf + bytes, sizeof(buf) - bytes);
if (r == -1 && errno == EINTR) continue; ... /* `end` is rounded down to a multiple of sizeof(uv__signal_msg_t). */ end = (bytes / sizeof(uv__signal_msg_t)) * sizeof(uv__signal_msg_t);
for (i = 0; i < end; i += sizeof(uv__signal_msg_t)) { msg = (uv__signal_msg_t*) (buf + i); handle = msg->handle;
由于 FROM node:18@sha256:f910225c96b0f77b0149f350a3184568a9ba6cddba2a7c7805cc125a50591605 我们这个方式拉取的 node 程序本身是没有开PIE的
1 2 3 4 5 6 7 8 9
osboxes@osboxes:~$ checksec node [*] '/home/osboxes/node' Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) Stripped: No Debuginfo: Yes
defread_mem(addr, size): if0x0000000000400000< addr< 0x0000000004ff1000: base = 0x0000000000400000 data = mem1[addr-base: addr+size-base] elif0x00000000051f1000 < addr < 0x00000000051f4000: base = 0x00000000051f1000 data = mem2[addr-base: addr+size-base] elif0x00000000051f4000 < addr < 0x000000000520f000: base = 0x00000000051f4000 data = mem3[addr-base: addr+size-base] else: returnNone return data
defis_useful_gadget(out): dis_list = out.split('\n') for n, x inenumerate(dis_list): if x == 'ret': for _ inrange(0, n): if'bad'in dis_list[_] : returnFalse returnTrue returnFalse
CVE-2024-41592 是 forescout 一篇为 《Breaking Into DrayTekRouters Before Threat Actors Do It Again》[1]的漏洞报告其中的一个漏洞。
漏洞产生于 GetCGI() 函数中, 在该函数中处理字符串参数会造成越界导致栈溢出。
漏洞分析
固件解压和调试准备
这里以Draytek 3910的 4.3.1 的版本作为调试 测试版本,进行展开分析。固件的解密和解压不展开赘述,可以参考之前 《HEXACON2022 - Emulate it until you make it! Pwning a DrayTek Router by Philippe Laulheret》 [2]slide 或者其他研究员的文章。
解压后能在 rootfs/firmware/vqemu/sohod64.bin 目录下找到主程序, Draytek 3910 采用了奇葩的 Linux + Qemu + RTOS 的奇葩架构,即在 arm linux操作系统上使用qemu 运行 drayos 的RTOS 操作系统。这里的调试方式采用的是使用编译 Draytek 开源的qemu代码进行编译,然后就可以正常调试。
Although this seems straightforward, challenges exist. Consider the “FreeCtrlName()” function called when a CGI handler returns (Figure 13). This function “frees” all the POST/GET request data structures, including the query string buffer. It simply iterates over the 32-bit pointers located in the lower 4 bytes of the stack 21 DRAY:BREAK - BREAKING INTO DRAYTEK ROUTERS BEFORE THREAT ACTORS DO IT AGAIN addresses and frees them, zeroing out the pointer values as well. Oddly, the higher 4-byte addresses (e.g., pointers to query string parameters values) are never freed
FreeCtrlName 函数伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
__int64 __fastcall FreeCtrlName(__int64 result) { int v1; // [xsp+1Ch] [xbp+1Ch] int i; // [xsp+2Ch] [xbp+2Ch]
这个函数的 free 逻辑是, 遍历栈上的指针, 一直free 直到为 0 为止, 因此我们需要找到一个函数可以在栈上写一个 0 , 这样就能避免这个问题。在原文[1] 甚至后来 12月在 Blackhat EU 《When (Remote) Shells Fall Into The Same Hole: Rooting DrayTekRouters Before Attackers Can Do It Again》[3]的slide 上都没有提及这个所谓的 [vulnerable-cgi-page].cgi 是什么。
1.《Breaking Into DrayTekRouters Before Threat Actors Do It Again》https://www.forescout.com/resources/draybreak-draytek-research/↩
2.《HEXACON2022 - Emulate it until you make it! Pwning a DrayTek Router by Philippe Laulheret》https://www.youtube.com/watch?v=CD8HfjdDeuM↩
3.《When (Remote) Shells Fall Into The Same Hole: Rooting DrayTekRouters Before Attackers Can Do It Again》 https://i.blackhat.com/EU-24/Presentations/EU24-Dashevskyi-When-Remote-Shells-Fall-Into-The-Same-Hole.pdf↩
──(root㉿kali)-[/home/kali/Desktop] └─# lvdisplay --- Logical volume --- LV Path /dev/groupA/home LV Name home VG Name groupA LV UUID vPWDHH-AlTq-GvBS-UAnf-orT1-yT2d-TdbWyK LV Write Access read/write LV Creation host, time (none), 2025-01-09 17:28:21 -0500 LV Status NOT available LV Size <4.87 GiB Current LE 1246 Segments 1 Allocation inherit Read ahead sectors auto
--- Logical volume --- LV Path /dev/groupA/runtime LV Name runtime VG Name groupA LV UUID dFDVOl-kYQR-J3N5-3HNC-toXc-9947-sj0yzc LV Write Access read/write LV Creation host, time (none), 2025-01-09 17:28:39 -0500 LV Status NOT available LV Size <19.46 GiB Current LE 4981 Segments 2 Allocation inherit Read ahead sectors auto
--- Logical volume --- LV Path /dev/groupZ/home LV Name home VG Name groupZ LV UUID cOTBS1-oaYw-PlAt-puTS-Uvq5-6C91-pK6QHK LV Write Access read/write LV Creation host, time (none), 2024-10-07 06:47:49 -0400 LV Status NOT available LV Size 6.72 GiB Current LE 1721 Segments 1 Allocation inherit Read ahead sectors auto
# swing @ sw in ~/Dropbox/Attachments/SafetyEquipment/VPN/ivc/2.3 [17:53:53] $ file out2.bak out2.bak: gzip compressed data, last modified: Sat Oct 5 17:32:45 2024, max compression, from Unix, original size modulo 2^32 118361088
$ ./openconnect 172.16.64.222 --protocol=pulse --dump-http-traffic -vvv Attempting to connect to server 172.16.64.222:443 Connected to 172.16.64.222:443 SSL negotiation with 172.16.64.222 Server certificate verify failed: signer not found
Asus, as a leading consumer electronics manufacturer, offers a wide range of IoT devices, but its router products have historically faced significant challenges in security, including critical vulnerabilities such as the cfgserver issue in the Tianfu Cup and the httpd authentication bypass vulnerability. These incidents reveal potential shortcomings in the security design of ASUS router products.
This presentation will provide a systematic attack surface analysis of ASUS router devices, focusing on a review of some key historical vulnerabilities and a deep dive into the lighttpd component within the aicloud service to identify potential security risks. Our analysis will cover multiple vulnerabilities and their associated remote code execution (RCE) vulnerability chains, assess their impact scope and potential consequences, and offer recommendations for future improvements.
for (int i = 0; i < NSS_DATABASE_COUNT; ++i) if (staging->services[i] == NULL) { ok = nss_database_select_default (&cache, i, &staging->services[i]); if (!ok) break; }
staticbool nss_database_check_reload_and_get (struct nss_database_state *local, nss_action_list *result, enum nss_database database_index) { struct __stat64_t64str; /* Acquire MO is needed because the thread that sets reload_disabled may have loaded the configuration first, so synchronize with the Release MO store there. */ if (atomic_load_acquire (&local->data.reload_disabled)) { *result = local->data.services[database_index]; /* No reload, so there is no error. */ returntrue; } structfile_change_detectioninitial; if (!__file_change_detection_for_path (&initial, _PATH_NSSWITCH_CONF)) returnfalse; __libc_lock_lock (local->lock); if (__file_is_unchanged (&initial, &local->data.nsswitch_conf)) { /* Configuration is up-to-date. Read it and return it to the caller. */ *result = local->data.services[database_index]; __libc_lock_unlock (local->lock); returntrue; } int stat_rv = __stat64_time64 ("/", &str); if (local->data.services[database_index] != NULL) { /* Before we reload, verify that "/" hasn't changed. We assume that errors here are very unlikely, but the chance that we're entering a container is also very unlikely, so we err on the side of both very unlikely things not happening at the same time. */ if (stat_rv != 0 || (local->root_ino != 0 && (str.st_ino != local->root_ino || str.st_dev != local->root_dev))) { /* Change detected; disable reloading and return current state. */ atomic_store_release (&local->data.reload_disabled, 1); *result = local->data.services[database_index]; __libc_lock_unlock (local->lock); returntrue; } } if (stat_rv == 0) { local->root_ino = str.st_ino; local->root_dev = str.st_dev; } __libc_lock_unlock (local->lock); /* Avoid overwriting the global configuration until we have loaded everything successfully. Otherwise, if the file change information changes back to what is in the global configuration, the lookups would use the partially-written configuration. */ structnss_database_datastaging = { .initialized = true, }; bool ok = nss_database_reload (&staging, &initial); if (ok) { __libc_lock_lock (local->lock); /* See above for memory order. */ if (!atomic_load_acquire (&local->data.reload_disabled)) /* This may go back in time if another thread beats this thread with the update, but in this case, a reload happens on the next NSS call. */ local->data = staging; *result = local->data.services[database_index]; __libc_lock_unlock (local->lock); } return ok; }
if (atomic_load_acquire (&local->data.reload_disabled)) { *result = local->data.services[database_index]; /* No reload, so there is no error. */ returntrue; }
然后是判断/etc/nsswitch.conf文件是否修改:
1 2 3 4 5 6 7 8 9 10 11 12
structfile_change_detectioninitial; if (!__file_change_detection_for_path (&initial, _PATH_NSSWITCH_CONF)) returnfalse; __libc_lock_lock (local->lock); if (__file_is_unchanged (&initial, &local->data.nsswitch_conf)) { /* Configuration is up-to-date. Read it and return it to the caller. */ *result = local->data.services[database_index]; __libc_lock_unlock (local->lock); returntrue; }
if (local->data.services[database_index] != NULL) { /* Before we reload, verify that "/" hasn't changed. We assume that errors here are very unlikely, but the chance that we're entering a container is also very unlikely, so we err on the side of both very unlikely things not happening at the same time. */ if (stat_rv != 0 || (local->root_ino != 0 && (str.st_ino != local->root_ino || str.st_dev != local->root_dev))) { /* Change detected; disable reloading and return current state. */ atomic_store_release (&local->data.reload_disabled, 1); *result = local->data.services[database_index]; __libc_lock_unlock (local->lock); returntrue; } }
>end (gdb) i b Num Type Disp Enb Address What 3 breakpoint keep y <MULTIPLE> 3.1 y 0x00007ffff7d2b050 in pivot_root at ../sysdeps/unix/syscall-template.S:120 3.2 y 0x00007ffff7eb59b0 in pivot_root at ./pivot.c:39 4 breakpoint keep y 0x00007ffff7eb5b00 in unpivot_root at ./pivot.c:64 5 breakpoint keep y 0x00007ffff7d52300 in nss_database_check_reload_and_get at ./nss/nss_database.c:396 i r rdx c (gdb)
Breakpoint 3.2, pivot_root (new_root=0x5555555a701c "woot", state=0x7fffffffcc38) at ./pivot.c:39 39 { (gdb) c Continuing. Download failed: Invalid argument. Continuing without source file ./nss/./nss/nss_database.c.
Breakpoint 5, nss_database_check_reload_and_get (local=0x5555555a1ad0, result=0x7fffffffc510, database_index=nss_database_initgroups) at ./nss/nss_database.c:396 warning: 396 ./nss/nss_database.c: No such file or directory rdx 0x6 6
Breakpoint 5, nss_database_check_reload_and_get (local=0x5555555a1ad0, result=0x7fffffffc510, database_index=nss_database_group) at ./nss/nss_database.c:396 396 in ./nss/nss_database.c rdx 0x2 2
Breakpoint 4, unpivot_root (state=state@entry=0x7fffffffcc38) at ./pivot.c:64 64 { (gdb) c Continuing. Download failed: Invalid argument. Continuing without source file ./nss/./nss/nss_database.c.
Breakpoint 5, nss_database_check_reload_and_get (local=0x5555555a1ad0, result=0x7ffff7e10b68 <__nss_group_database>, database_index=nss_database_group) at ./nss/nss_database.c:396 warning: 396 ./nss/nss_database.c: No such file or directory rdx 0x2 2
Breakpoint 5, nss_database_check_reload_and_get (local=0x5555555a1ad0, result=0x7ffff7e10b68 <__nss_group_database>, database_index=nss_database_group) at ./nss/nss_database.c:396 396 in ./nss/nss_database.c rdx 0x2 2
Breakpoint 5, nss_database_check_reload_and_get (local=0x5555555a1ad0, result=0x7ffff7e10b00 <__nss_shadow_database>, database_index=nss_database_shadow) at ./nss/nss_database.c:396 396 in ./nss/nss_database.c rdx 0xf 15 Downloading separate debug info for libnss_/woot1337.so.2 Download failed: Invalid argument. Continuing without source file ./nss/./nss/nss_database.c.
当走到 if (local->data.services[database_index] != NULL) 判断的时候
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
if (local->data.services[database_index] != NULL) { /* Before we reload, verify that "/" hasn't changed. We assume that errors here are very unlikely, but the chance that we're entering a container is also very unlikely, so we err on the side of both very unlikely things not happening at the same time. */ if (stat_rv != 0 || (local->root_ino != 0 && (str.st_ino != local->root_ino || str.st_dev != local->root_dev))) { /* Change detected; disable reloading and return current state. */ atomic_store_release (&local->data.reload_disabled, 1); *result = local->data.services[database_index]; __libc_lock_unlock (local->lock); returntrue; } }
由于 local->data.services[database_index] 不为空, 因此会进入 if 的逻辑。 且此时
/* Internal implementation of __nss_module_load. */ staticbool module_load (struct nss_module *module) { if (strcmp (module->name, "files") == 0) return module_load_nss_files (module); if (strcmp (module->name, "dns") == 0) return module_load_nss_dns (module); void *handle; { char *shlib_name; if (__asprintf (&shlib_name, "libnss_%s.so%s", module->name, __nss_shlib_revision) < 0) /* This is definitely a temporary failure. Do not update module->state. This will trigger another attempt at the next call. */ returnfalse; handle = __libc_dlopen (shlib_name); free (shlib_name); } /* Failing to load the module can be caused by several different scenarios. One such scenario is that the module has been removed from the disk. In which case the in-memory version is all that we have, and if the module->state indidates it is loaded then we can use it. */ if (handle == NULL) { /* dlopen failure. We do not know if this a temporary or permanent error. See bug 22041. Update the state using the double-checked locking idiom. */ __libc_lock_lock (nss_module_list_lock); bool result = result; switch ((enum nss_module_state) atomic_load_acquire (&module->state)) { case nss_module_uninitialized: atomic_store_release (&module->state, nss_module_failed); result = false; break; case nss_module_loaded: result = true; break; case nss_module_failed: result = false; break; } __libc_lock_unlock (nss_module_list_lock); return result; } nss_module_functions_untyped pointers; /* Look up and store locally all the function pointers we may need later. Doing this now means the data will not change in the future. */ for (size_t idx = 0; idx < array_length (nss_function_name_array); ++idx) { char *function_name; if (__asprintf (&function_name, "_nss_%s_%s", module->name, nss_function_name_array[idx]) < 0) { /* Definitely a temporary error. */ __libc_dlclose (handle); returnfalse; } pointers[idx] = __libc_dlsym (handle, function_name); free (function_name); PTR_MANGLE (pointers[idx]); }
/* PFMERGE dest src1 src2 src3 ... srcN => OK */ voidpfmergeCommand(client *c){ uint8_t max[HLL_REGISTERS]; structhllhdr *hdr; int j; int use_dense = 0; /* Use dense representation as target? */
/* Compute an HLL with M[i] = MAX(M[i]_j). * We store the maximum into the max array of registers. We'll write * it to the target variable later. */ memset(max,0,sizeof(max)); for (j = 1; j < c->argc; j++) { ... /* Merge with this HLL with our 'max' HLL by setting max[i] * to MAX(max[i],hll[i]). */ if (hllMerge(max,o) == C_ERR) { // hllMerge [1] stack oob write ... } }
/* Convert the destination object to dense representation if at least * one of the inputs was dense. */ if (use_dense && hllSparseToDense(o) == C_ERR) { // hllSparseToDense [2] heap oob write ... }
/* If the representation is already the right one return ASAP. */ hdr = (struct hllhdr*) sparse; if (hdr->encoding == HLL_DENSE) return C_OK;
/* Create a string of the right size filled with zero bytes. * Note that the cached cardinality is set to 0 as a side effect * that is exactly the cardinality of an empty HLL. */ dense = sdsnewlen(NULL,HLL_DENSE_SIZE); hdr = (struct hllhdr*) dense; *hdr = *oldhdr; /* This will copy the magic and cached cardinality. */ hdr->encoding = HLL_DENSE;
/* Now read the sparse representation and set non-zero registers * accordingly. */ p += HLL_HDR_SIZE; while(p < end) { if (HLL_SPARSE_IS_ZERO(p)) { runlen = HLL_SPARSE_ZERO_LEN(p); idx += runlen; p++; } elseif (HLL_SPARSE_IS_XZERO(p)) { runlen = HLL_SPARSE_XZERO_LEN(p); idx += runlen; p += 2; } else { runlen = HLL_SPARSE_VAL_LEN(p); regval = HLL_SPARSE_VAL_VALUE(p); if ((runlen + idx) > HLL_REGISTERS) break; /* Overflow. */ while(runlen--) { HLL_DENSE_SET_REGISTER(hdr->registers,idx,regval); idx++; } p++; } }
/* If the sparse representation was valid, we expect to find idx * set to HLL_REGISTERS. */ if (idx != HLL_REGISTERS) { sdsfree(dense); return C_ERR; }
/* Free the old representation and set the new one. */ sdsfree(o->ptr); o->ptr = dense; return C_OK; }
while 循环之前是对 HLL 数据的的部分 header 解析,之后是一个转换过程。 HLL 数据是一种 SDS [2]字符串的表示。 我们可以用 set 命令来伪造一个 HLL 数据。
// 1. HLL 总体结构 structhllhdr { char magic[4]; /* "HYLL" */ uint8_t encoding; /* HLL_DENSE or HLL_SPARSE. */ uint8_t notused[3]; /* Reserved for future use, must be zero. */ uint8_t card[8]; /* Cached cardinality, little endian. */ uint8_t registers[]; /* Data bytes. */ };
#define HLL_P 14 /* The greater is P, the smaller the error. */ #define HLL_REGISTERS (1<<HLL_P) /* With P=14, 16384 registers. */ #define HLL_DENSE_SIZE (HLL_HDR_SIZE+((HLL_REGISTERS*HLL_BITS+7)/8))