网站的后端用什么软件做,小程序制作方案,wap是什么意思啊,门户网站建设服务想来上一次挖洞还在一年前的大一下#xff0c;然后就一直在忙活写论文#xff0c;感觉挺枯燥的#xff08;可能是自己不太适合弄学术吧QAQ#xff09;#xff0c;所以年初1~2月的时候#xff0c;有空的时候就又会挖一挖国内外各大知名厂商的设备#xff0c;拿了几份思科…想来上一次挖洞还在一年前的大一下然后就一直在忙活写论文感觉挺枯燥的可能是自己不太适合弄学术吧QAQ所以年初1~2月的时候有空的时候就又会挖一挖国内外各大知名厂商的设备拿了几份思科、小米等大厂商的公开致谢也分配到了一些CVE和CNVD编号之后就没再挖洞继续忙活论文了QAQ。
某捷算是国内挺大的厂商了我对其某系统进行了漏洞挖掘并发现了一个可远程攻击的未授权命令执行漏洞可以通杀使用该系统的所有路由器、交换机、中继器、无线接入点AP以及无线控制器AC等众多设备危害还是相当严重的。
根据厂商的要求在修补后的固件未发布前我对该漏洞细节进行了保密。如今新版本固件都已经发布在这里给大家分享一下这一次的漏洞挖掘经历包括固件解密、仿真模拟、挖掘思路等希望能给各位师傅带来些许启发大师傅们请绕道QAQ。
声明 本文仅供用于安全技术的交流与学习文中涉及到的敏感内容都进行了删减或脱敏处理仅分析了漏洞链。若读者将本文内容用作其他用途由读者承担全部法律及连带责任文章作者不承担任何法律及连带责任。
固件解密
可以从厂商官网下载到最新固件然而可以发现其中的固件大多都是加密的用binwalk是无法解开的
这大概是想要分析该固件所需迈过的第一道坎了不过好在还是比较容易解密的。原因在于只是大部分固件都被加密了但是仍有少部分固件或过渡版本的固件并未加密很容易想到这些固件升级的过程中肯定也会使用到解密的程序因此可以通过解开这些未加密固件找到解密程序并逆向分析出相关算法这也是固件解密最常用的一种手段。并且一般一个厂商的固件加密算法都是相同的故这样所有的固件我们都能够解开了。
此时我们惊喜地发现xxx系列产品的xxx型号固件并没有被加密可以成功解开。然而如何找到固件的解密程序呢显然固件的解密操作肯定是在刷固件之前进行的因此我们可以查找OpenWrt中用于刷固件的mtd命令进行定位 很显然此处的rg-upgrade-crypto自然就是我们所要找到固件解密程序并找到它的路径/usr/sbin/rg-upgrade-crypto对其逆向分析。
由于该加解密算法仍然被广泛应用于某捷的各类核心产品中故这里不放出具体逆向分析的过程此处省略xxx字........
因此我们只需要根据rg-upgrade-crypto写出解密脚本即可成功解开固件了 之后解开不同类别、不同型号设备的固件可以发现众多设备均使用的是该系统因此只要挖出一个洞就可通杀所有设备了。由于授权洞的实际影响并不算太大所以我们期望挖出未授权远程命令执行漏洞。
漏洞分析
此部分以xxx固件为例进行分析该固件是aarch64架构的。其他固件也许架构或部分字段的偏移不同但均存在该漏洞。
找到无鉴权的API接口
显然此类固件的cgi部分是用Lua所写的。我们既然想要挖未授权的漏洞那么首先就要找到无鉴权的API接口定位到/usr/lib/lua/luci/controller/eweb/api.lua文件。
可以看到只有对/cgi-bin/luci/api/auth发送请求的时候不需要权限验证 1 entry({api, auth}, call(rpc_auth), nil).sysauth false
根据调用的rpc_auth函数可见此处对应的处理文件是/usr/lib/lua/luci/modules/noauth.lua 1 2 3 4 5 6 function rpc_auth() ... local _tbl require luci.modules.noauth ... ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write) end
进一步分析/usr/lib/lua/luci/utils/jsonrpc.lua中的handle及其相关函数可以得知这里通过JSON数据的method字段定位并调用noauth.lua中对应的函数同时将Json数据的params字段内容作为参数传入由于与该漏洞原理关系不大此处不展开分析。
寻找可能存在漏洞的入口
在noauth.lua中有loginsingleLoginmerge和checkNet四个方法。其中singleLogin函数无可控制的参数不用看checkNet函数中参数可控的字段只有params.host并拼接入了命令字符串执行但是在之前有tool.checkIp(params.host)对其的合法性进行了检查无法绕过。
再来看到login登录验证函数这里可控的字段乍一看比较多比如params.passwordparams.encryparams.limit等字段。其中对params.password字段用tool.includeXxs函数过滤了危险字符故大概率之后会有相关的命令执行点。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function login(params) ... if params.password and tool.includeXxs(params.password) then tool.eweblog(INVALID DATA, LOGIN FAILED) return end ... local checkStat { password params.password, username admin, -- params.username, encry params.encry, limit params.limit } local authres, reason tool.checkPasswd(checkStat) ... end
再来看到继续调用的tool.checkPasswd函数在/usr/lib/lua/luci/utils/tool.lua中其中检测了传入的encry和limit字段的真假值并在这两个字段中写入了相应的固定字符串checkStat.username又是传入的固定用户名admin因此真正可控的只有password字段并调用了cmd.devSta.get函数进一步处理。 1 2 3 4 5 6 7 8 9 10 11 function checkPasswd(checkStat) ... local _data { type checkStat.encry and enc or noenc, password checkStat.password, name checkStat.username, limit checkStat.limit and true or nil } local _check cmd.devSta.get({module adminCheck, device pc, data _data}) ... end
然而虽然password字段用includeXxs函数同样在tool.lua中过滤了危险字符但是并没有过滤\n这个命令分隔符。因此若之后当真存在命令执行点的话似乎还是有希望完成命令注入的。 1 2 3 4 function includeXxs(str) local ngstr [$;|] return string.match(str, ngstr) ~ nil end
继续往下看到/usr/lib/lua/luci/modules/cmd.luadevSta.get对应着如下处理函数其中opt[i]循环匹配到get方式会通过doParams函数对传入的Json参数进行解析将其中的data等字段分离出来传入fetch函数做进一步处理。 1 2 3 4 5 6 devSta[opt[i]] function(params) local model require dev_sta params.method opt[i] params.cfg_cmd dev_sta local data, back, ip, password, shell doParams(params) return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
然而注意到doParams函数中对data字段进行提取的时候用到了luci.json.encode函数。这里的data字段就是上述checkPasswd函数中传入devSta.get作为Json参数的_data的内容我们的疑似注入点password字段就在其中。此处的luci.json.encode函数会对\n即\u000a类字符进行转义也就不会被解析成换行符了不论我们后续再如何传参这个疑似的漏洞点已经被封堵住了。 1 2 3 4 if params.data then data luci.json.encode(params.data) _shell _shell .. .. data .. end
因此我们只能将目光聚焦于noauth.lua中最后一个可能的入口merge方法了。这个函数比较简单调用了devSta.set函数其Json参数中的data字段就是传入的POST报文中params的内容。 1 2 3 4 function merge(params) local cmd require luci.modules.cmd return cmd.devSta.set({device pc, module networkId_merge, data params, async true}) end
这里merge方法的入口处没有任何过滤不过之后是否存在字符过滤和命令执行点还需要进一步分析。
进一步分析参数传递过程
在noauth.lua的merge函数中调用了devSta.set函数同样是对应着cmd.lua中的如下位置此时opt[i]循环到了set方式。此时由于之前没有任何过滤无需使用换行符作为命令分隔符最简单的分号、反引号之类的即可故doParams函数中的encode不会造成影响。 1 2 3 4 5 6 devSta[opt[i]] function(params) local model require dev_sta params.method opt[i] params.cfg_cmd dev_sta local data, back, ip, password, shell doParams(params) return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
接着我们可控的data字段将被传入cmd.lua的fetch函数中。其中会将从第四个开始的参数包括data字段均传递到第一个参数所指向的函数中即/usr/lib/lua/dev_sta.lua中的fetch函数。 1 2 3 4 5 local function fetch(fn, shell, params, ...) ... local _res fn(...) ... end
在/usr/lib/lua/dev_sta.lua的fetch函数中这里的cmd是set方式module是networkId_merge而此处的param就是我们可控的data字段即最初POST报文中params的内容。可见对一些字段赋予了真假值后最终将参数都传递给了/usr/lib/lua/libuflua.so中的client_call函数。接下来就是对二进制文件逆向分析并寻找是否存在命令执行点了。 1 2 3 4 5 6 function fetch(cmd, module, param, back, ip, password, force, not_change_configId, multi) local uf_call require libuflua ... local stat uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi) ... end
然而分析libuflua.so可以发现Lua中所调用的client_call函数其实是uf_client_call函数这是在其他共享库中定义的函数。查找对比一下不难发现这个函数定义在/usr/lib/libunifyframe.so中。 在/usr/lib/libunifyframe.so的uf_client_call函数中先将传入的data等字段转为Json格式的数据作为param字段的内容。然后将Json数据通过uf_socket_msg_write用socket套接字分析可知此处采用的是本地通信的方式进行数据传输。 1 2 3 4 5 6 7 8 9 10 json_object_object_add(v22, data, v35); LABEL_82: ... json_object_object_add(v5, params, v22); v44 (const char *)json_object_to_json_string(v5); ... v45 uf_socket_client_init(0LL); ... v51 strlen(v44); uf_socket_msg_write(v45, v44, v51);
既然这里采用uf_socket_msg_write进行数据发送那么肯定有某个地方会使用uf_socket_msg_read进行数据接收再进一步处理。匹配一下一共三个文件很容易锁定/usr/sbin/unifyframe-sgi.elf文件。又发现在初始化脚本/etc/init.d/unifyframe-sgi中启动了unifyframe-sgi.elf即说明unifyframe-sgi.elf一直挂在进程中。因此我们可以确定unifyframe-sgi.elf就是接收libunifyframe.so所发数据的文件这里采用了Ubus总线进行进程间通信。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 $ cat etc/init.d/unifyframe-sgi ... PROG/usr/sbin/unifyframe-sgi.elf ... if [ -f $IPKG_INSTROOT/lib/functions/procd.sh ]; then ... else ... start() { ... echo Starting $PROG ... service_start $PROG ... } stop() { ... } fi
接下来就是最核心的部分对unifyframe-sgi.elf二进制文件进行逆向分析并寻找命令执行点了。
逆向分析并寻找命令执行点 由于篇幅限制笔者无法对所有细节都做到详细分析故建议读者在复现此部分内容之前自己先逆向分析一遍体会一下。 在unifyframe-sgi.elf中对uf_socket_msg_read函数交叉引用找到socket数据接收点。如下方代码段v6 0x432000简单计算一下可知v57即为uf_socket_msg_read函数其中接收到的数据存储在v56[1]中。接收到的Json数据形如{method:devSta.set, params:{module:networkId_merge, async:true, data:xxx}}可结合上文自行分析得出其中data字段可控。 1 2 3 4 5 6 v6 0x432000uLL; ... v57 *(__int64 (__fastcall **)(__int64, unsigned int **))(v6 1784); // uf_socket_msg_read v58 *v46; *v56 v46; v59 v57(v58, v56 1);
接下来就是根据调试等级向日志写入相关信息的部分不需要管。之后会调用parse_content函数从这个名字就可以看出是对v56中的Json数据进行解析的。解析成功后就会将处理后的v56作为参数传入add_pkg_cmd2_task函数。 1 2 3 4 5 6 if ( !(*(unsigned int (__fastcall **)(unsigned int **))(v6 3328))(v56) ) // 0x432D00 parse_content { ... if ( !(*(unsigned int (__fastcall **)(unsigned int **))(v6 1776))(v56) ) // 0x4326F0 add_pkg_cmd2_task ... }
我们先来看parse_content函数显然method字段不包含cmdArr因此进入else分支其中调用parse_obj2_cmd函数进行数据解析。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 if ( (unsigned int)json_object_object_get_ex(v4, method, v15) 1 ) { v6 (const char *)json_object_get_string(v15); ... if ( strstr(v6, cmdArr) ) { ... } else { *(_DWORD *)(a1 60) 1; v13 malloc_cmd(); if ( v13 ) { v14 parse_obj2_cmd(v4, string); ... } else { ... } }
在parse_obj2_cmd函数中需要注意记录一下各字段存储位置的偏移后续逆向过程需要用到。这里暂且只记录两个from_url字段的偏移为81我们可控的data字段的偏移为24v5是QWORD类型八字节。 1 2 3 4 5 6 7 8 9 10 if ( (unsigned int)json_object_object_get_ex(v42, from_url, v43) 1 ) { v21 (const char *)sub_4069B8(v43); v22 (char *)v21; if ( v21 ) { if ( *v21 49 || !strcmp(v21, true) ) *((_BYTE *)v5 81) 1; free(v22); } 1 2 3 4 5 6 7 8 9 10 11 12 _QWORD *v5; // x20 ... if ( (unsigned int)json_object_object_get_ex(v42, data, v43) 1 (unsigned int)json_object_get_type(v43) - 4 2 ) { if ( json_object_get_string(v43) ) { v31 ((__int64 (*)(void))strdup)(); v5[3] v31; ... } }
此外当async字段为false的时候偏移76和77的位置都为1。但这里的async字段为true故这两个偏移处均为初始值0。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 if ( (unsigned int)json_object_object_get_ex(v42, async, v43) 1 ) { v15 (const char *)sub_4069B8(v43); v16 (char *)v15; if ( v15 ) { if ( *v15 48 || !strcmp(v15, false) ) { *((_BYTE *)v5 76) 1; *((_BYTE *)v5 77) 1; } ... } }
再来看到add_pkg_cmd2_task函数前面部分是一些检查和无关操作就不仔细分析了。很容易发现最后调用了一个很敏感的函数uf_cmd_call看名字应该是命令执行相关的。 1 2 3 4 if ( (unsigned int)uf_cmd_call(*v4, v4 1) ) v13 2; else v13 1;
在uf_cmd_call函数中乍一看貌似有一个命令执行点这里的v20是偏移24的位置也就是data字段内容之后将data字段中的数据转成Json格式存入v24然后从中提取url字段的内容拼接入命令字符串中并用popen执行。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 v20 *((_QWORD *)a1 3); ... v24 json_tokener_parse(v20); ... v26 json_object_object_get(v24, url); v27 v26; ... v28 (const char *)json_object_get_string(v27); ... v33 snprintf(v30, v32 127, curl -m 5 -s -k -X GET \%s, v28); ... while ( 1 ) { ufm_popen(v30, v84);
然而仔细分析一番就会发现这是空欢喜一场。因为v20是偏移81的from_url字段这是我们不可控的。若是该字段为假会将data字段内容传给v85[19]v85是int64类型八字节并直接跳转到LABEL_96处也就无法执行到上方的程序片段了。 1 2 3 4 5 6 7 v19 *((unsigned __int8 *)a1 81); ... if ( !v19 ) { v85[19] *((_QWORD *)a1 3); goto LABEL_96; }
从LABEL_96处开始是一堆字段的提取存放入v85数组中还有一些关于v85数组中数据的处理。这里需要关注的是v85偏移8和9的位置为a1偏移77和76的位置上文分析过此时这两个偏移的值均为0。 1 2 LOBYTE(v85[1]) *((_BYTE *)a1 77); BYTE1(v85[1]) *((_BYTE *)a1 76);
既然从LABEL_96开始都是对v85数组进行操作的那么v85指针肯定会作为参数传递给下一个调用的函数以这个思路就很容易定位到下面的ufm_handle(v85)了。 1 2 3 v8 ufm_handle(v85); pthread_mutex_unlock((pthread_mutex_t *)(v85[23] 152)); pthread_cleanup_pop(v84, 0LL);
在ufm_handle函数中由于我们是set方式因此会调用到sub_410140函数。 1 2 3 4 5 6 7 8 9 if ( strcmp((const char *)v6, get) ) { v1 uniframe_sgi/error.log; if ( !strcmp((const char *)v6, set) || !strcmp((const char *)v6, add) || !strcmp((const char *)v6, del) || !strcmp((const char *)v6, update) ) { v33 sub_410140(v3);
进入sub_410140函数首先sn字段为空的条件满足跳转到LABEL_36。 1 2 3 v6 json_object_object_get(a1[22], sn); if ( !v6 ) goto LABEL_36;
接着会调用到sub_40DA38函数。 1 2 3 LABEL_36: ... v5 sub_40DA38(a1, a1 21, 0LL, 0LL);
在sub_40DA38函数中显然前面的部分无关紧要不过需要注意一下v5和v6分别是a3和a4根据传入的值均为零。因此进入else分支这里会将data字段的内容前文分析过此处偏移19*8的位置也被赋为了data字段的内容拼接入两个单引号内。此处v4字符串形如/usr/sbin/module_call set networkId_merge xxx可自行分析得出很显然是一个命令并且单引号内的内容我们可控所以我们只需要左右分别闭合单引号中间注入恶意命令并用分隔符隔开即可完成命令注入。不过这里还没到命令执行点由于不确定之后是否会有过滤我们需要接着往下看。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 LODWORD(v5) a3; v6 a4; ... if ( (_DWORD)v5 ) { ... } else if ( v6 ) { ... } else { v84 snprintf( v4, v75, /usr/sbin/module_call %s %s, *((const char **)v7 5), (const char *)(*((_QWORD *)v7 23) 16LL)); v85 v4[v84]; v86 (const char *)*((_QWORD *)v7 19); if ( v86 ) v85 snprintf(v4[v84], v75, %s, v86); // data字段拼接入单引号内 ... }
接着由之前的分析此处v7偏移8的位置为0async不是false故进入else分支其中会将v4传入ufm_commit_add函数作为第二个参数。 1 2 3 4 5 6 7 8 9 if ( (!v79 || !strcmp(v78, commit) || (_DWORD)v5) v7[8] ) { ... } else { ... v13 ufm_commit_add(0LL, v4, 0LL, a2); }
然后继续进入async_cmd_push_queue函数。 1 2 3 4 __int64 __fastcall ufm_commit_add(__int64 a1, __int64 a2, unsigned __int8 a3, const char **a4) { ... v6 async_cmd_push_queue(a1, a2, a3);
此处a1为0将a2存入v4偏移6*8字节处然后跳转到LABEL_28的位置。 1 2 3 4 5 6 7 8 9 10 if ( !a1 ) { if ( a2 ) { v23 strdup(a2); v4[6] v23; if ( v23 ) goto LABEL_28; ... }
在LABEL_28处注意到最后使用sem_post的原子操作将信号量加上了1。因此应该会有其他地方在检测到信号量发生改变后对数据进行处理。 1 2 3 4 5 6 7 8 LABEL_28: ... *((_BYTE *)v4 56) v7; dword_4336B8 v22 1; if ( !v7 ) sem_init((sem_t *)((char *)v4 60), 0, 0); pthread_mutex_unlock((pthread_mutex_t *)stru_433670[1]); sem_post(stru_433670);
通过对此处的信号量stru_433670交叉引用可以定位到sub_419584函数。这里偏移56的位置即为上述代码段中的v7对应传入的第三个参数根据上文分析其值为0。因此会将6*8字节偏移处的数据上文分析过该偏移位置存放着命令字符串作为popen的参数执行且没有任何过滤。此处采用的是异步执行的方式。 1 2 3 4 5 6 v11 *((_QWORD *)v5 6); if ( !*((_BYTE *)v5 56) ) { v10 ufm_popen(v11, v5 24); goto LABEL_18; }
至此该未授权RCE漏洞的调用链分析完毕。
PoC 由于该漏洞影响较大Poc暂不公开。各位师傅可根据上文分析自行复现。 1 暂不公开
真机演示
对某远程测试靶机攻击后无需身份验证即得到了该设备的最高控制权 仿真模拟
此部分仿真采用的是xxx型号的固件因为这款是mipsel架构的仿真起来方便一些。
由于目前没有很完美的仿真工具比较常用的FirmAEEMUXfirmware-analysis-plus等也都存在各种问题至少直接仿真大多数设备都是不太行的所以笔者一般都采用qemu-system自行仿真模拟再者该系统的固件不涉及到nvram采用的是uci命令完成类似的效果故也不需要用上述仿真工具来hook相关函数了。
首先从https://people.debian.org/~aurel32/qemu/mipsel下载vmlinux-3.2.0-4-4kc-malta内核与debian_squeeze_mipsel_standard.qcow2文件系统这里提供的文件虽然是比较老的了较新版可以在https://pub.sergev.org/unix/debian-on-mips32下载但不影响我们使用。
下面直接给出qemu的启动脚本 1 2 3 4 5 6 7 8 9 10 11 #!/bin/bash sudo qemu-system-mipsel \ -cpu 74Kf \ -M malta \ -kernel vmlinux-3.2.0-4-4kc-malta \ -hda debian_squeeze_mipsel_standard.qcow2 \ -append root/dev/sda1 consoletty0 \ -net nic,macaddr00:16:3e:00:00:01 \ -net tap \ -nographic
需要特别注意的是这里设定了cpu为74kf因为若不特别说明默认是24Kc而该固件需要较高版本的cpu不然在之后chroot切换根目录的时候就会出现Illegal instruction非法指令错误。可用qemu-system-mipsel -cpu help命令查看qemu-system-mipsel所有支持的cpu版本。
在正式开始仿真前需要先进行网络配置。用ip addr或ifconfig命令查看一下主机的ip如下图为eth0或ens33对应的192.168.192.129若是没有手动用sudo ifconfig eth0 xx.xx.xx.xx分配一下即可。 然后用上面的脚本启动qemu-system先需要配置一下/etc/qemu-ifup初始账号密码root/root。在qemu中也需要给网卡分配一下ip这样主机和qemu间就能通信了可以互相ping通。 我们将固件打包成rootfs.tar.gz再通过scp rootfs.tar.gz root192.168.192.135:/root/rootfs传输给qemu虚拟机然后在qemu虚拟机中tar zxvf rootfs.tar.gz解压即可打包之后传输更快。接着在qemu中依次执行以下命令 1 2 3 4 5 cd rootfs chmod -R 777 ./ mount --bind /proc proc mount --bind /dev dev chroot . /bin/sh
解释一下这里chmod给全部文件都赋予所有权限是为了方便在仿真过程中不用再考虑权限问题的影响了。之后使用mount将/proc和/dev系统目录挂载到rootfs中的proc和dev目录仿真系统只是切换了根目录本质还是qemu虚拟机的系统故proc和dev这两个重要的系统目录仍应该是这个系统本身的目录即qemu虚拟机的系统目录而切换了根目录后proc和dev也被切换因此需要挂载为原先的目录最后用chroot将rootfs切换为根目录完成准备工作。
以上都是些用qemu对设备仿真模拟的基本操作接下来正式开始对这款设备的固件进行仿真。首先对于OpenWRT来说内核加载完文件系统后首先会启动/sbin/init进程其中会进一步执行/etc/preinit和/sbin/procd进行初步初始化。这当然也是仿真模拟的第一步在启动/sbin/init后会卡住挂在进程中我们可以再ssh开一个新窗口进行后续操作也可以用/sbin/init 将其作为后台进程执行。 接着真实系统会根据/etc/inittab中按编号次序执行/etc/rc.d中的初始化脚本而/etc/rc.d中的文件都是/etc/init.d中对应文件的软链接。虽然说真实系统会依次执行所有的初始化脚本但我们此处的仿真只是为了验证我们的漏洞因此只需要部分仿真即可。
显然我们最开始肯定是需要启动httpd服务的对应/etc/init.d/lighttpd初始化脚本。用/etc/init.d/lighttpd start命令启动服务后发现缺少了/var/run/lighttpd.pid文件 这是因为我们是部分仿真的没有按照次序故之前没有创建这个文件而通过查看该初始化脚本可以发现此处/rom/etc/lighttpd/lighttpd.conf的缺失并无影响。因此创建/var/run/lighttpd.pid文件后再次/etc/init.d/lighttpd start启动服务即可。
可以看到此时进程中已经成功执行lighttpd程序并且通过浏览器可以正常访问该漏洞入口的api。 接着我们需要启动unifyframe-sgi.elf了对应/etc/init.d/unifyframe-sgi的初始化脚本。用/etc/init.d/unifyframe-sgi start直接启动后报错Failed to connect to ubus
是因为unifyframe-sgi.elf中用到了ubus总线进行进程间通信因此需要先执行/sbin/ubusd启动ubus通信才能启动uf_ubus_call.elf继而才能再启动unifyframe-sgi.elf。
按照上述步骤启动后可以发现进程中有了uf_ubus_call.elf但是仍然没有unifyframe-sgi.elf同时procd守护进程收到了一个Segmentation fault段错误的信号意味着启动unifyframe-sgi.elf的时候出现了段错误。 接下来我们需要分析unifyframe-sgi.elf为何会出现段错误大概率是由于缺少一些文件或目录所导致的。首先发现此时/tmp/uniframe_sgi中已经存在record文件夹但并未创建sgi.log日志文件进入unifyframe-sgi.elf的主函数容易定位到reserve_core函数其中需要打开/tmp/coredump目录但这个目录此时是不存在的因此造成了段错误。 创建/tmp/coredump目录后运行/usr/sbin/unifyframe-sgi.elf程序因缺少/tmp/rg_device/rg_device.json文件报错 这里的rg_device.json大概率是在某前置操作中从其他位置复制过来的故搜索同名文件不过有很多
为了确定是哪一个我们定位到ufm_init函数中发现此处之后会从rg_device.json中读取dev_type字段的内容。 可以发现除了/sbin/hw/default/rg_device.json中都有此字段这里随便复制一个/sbin/hw/60010081/rg_device.json到/tmp/rg_device目录下。之后再执行/usr/sbin/unifyframe-sgi.elf程序就发现没有新的报错执行成功了。 此时进程中也有/usr/sbin/unifyframe-sgi.elf程序在运行。 最后我们利用该漏洞注入telnetd命令启动相应服务可以看到代表telnet服务的23号端口被成功开启。
至此利用仿真模拟的环境对该漏洞的验证完成总体来说对该设备的仿真还是比较容易的。
补丁分析
补丁1
遗憾的是部分型号设备的固件在第一次修补之后仍存在漏洞笔者已上报给厂商并修复。这里以xxx固件为例使用Diaphora对新旧版本的unifyframe-sgi.elf文件进行二进制对比分析。
容易发现在新版固件的unifyframe-sgi.elf文件中新增了stringtojson和is_independ_format函数 在stringtojson函数中调用了is_independ_format函数判断是否存在单引号和反引号。若不存在就返回而返回的内容无法通过单引号闭合也就无法执行任意命令。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 __int64 __fastcall stringtojson(__int64 a1) { ... v2 0x433000uLL; v3 0x433000uLL; if ( !a1 ) goto LABEL_2; while ( 2 ) { v5 (_BYTE *)a1; if ( !(*(unsigned __int8 (**)(void))(v2 2968))() // is_independ_format || ... ) { LABEL_2: v1 0LL; goto LABEL_3; // - return xxx; } 1 2 3 4 5 6 7 8 bool __fastcall is_independ_format(const char *a1) { if ( !a1 ) return 0LL; if ( strchr(a1, ) ) return 1LL; return strchr(a1, \) ! 0LL; }
若是存在单引号且前面没有转义符则对其Unicode编码即\\u0027。反引号也同理。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 while ( 1 ) { v13 (unsigned __int8)*v11; if ( !*v11 ) break; v15 v11 1; if ( v13 \ ) goto LABEL_22; ... LABEL_22: if ( v11 ! (_BYTE *)1 ) { v13 u; v16 *(v11 - 1) ! \\; if ( (unsigned __int64)v11 v10 ) goto LABEL_24; // mark goto LABEL_38; } goto LABEL_19; ... LABEL_24: if ( !v16 ) goto LABEL_40; v17 (__int64 *)v22; LOBYTE(v22[1]) v13; // mark LABEL_26: if ( v13 u ) { (*(void (__fastcall **)(__int64, const char *, _QWORD))(v3 3680))( // sprintf (__int64)v17 v16 4, %02x, (unsigned __int8)*(v15 - 1)); // mark v18 (unsigned int)(v16 6); }
进一步交叉引用在sub_40DB48函数中对可控的数据用stringtojson函数进行了过滤。然而这里的过滤并不严谨接着往下看。 由上述可知若这里的v74不为空则存放着stringtojson函数过滤后的内容否则说明不存在单引号或反引号也就未通过stringtojson函数进行过滤。又由于stringtojson函数处理后会带有双引号故若包含了单引号或反引号该命令在新版固件中实际为/usr/sbin/module_call set networkId_merge {...}。虽然由于JSON数据中双引号得是\才行stringtojson函数也会将双引号编码为\\u0022无法闭合绕过双引号但是在双引号内是可以用反引号或$()执行命令的而这里只过滤了反引号并未过滤$()也就给了攻击者可趁之机。 不过在新版本固件中也在其他方面加强了一定的安全检查和防护例如在初始化脚本/etc/init.d/factory中通过rm usr/sbin/telnetd命令删除了telnetd程序也就无法通过开启远程登录而控制设备了。但是不难想到还可以通过反弹shell获取设备权限这里笔者采用的是telnet反弹的方式。
请求报文 1 暂不公开
演示效果
补丁2
其实只要在noauth.lua的merge函数中对传入的params加个过滤即可如下 1 2 3 4 5 6 7 8 9 10 11 12 function merge(params) local cmd require luci.modules.cmd local tool require(luci.utils.tool) local _strParams luci.json.encode(params) if tool.includeXxs(_strParams) or tool.includeQuote(_strParams) then -- 过滤危险字符和单引号 tool.eweblog(_strParams, MERGE FAILED INVALID DATA) return INVALID DATA end return cmd.devSta.set({device pc, module networkId_merge, data params, async true}) end
此处通过includeXxs函数过滤了各种危险字符以及用includeQuote函数过滤了单引号 1 2 3 4 function includeXxs(str) local ngstr [\n$;|] return string.match(str, ngstr) ~ nil end 1 2 3 function includeQuote(str) return string.match(str, ([])) ~ nil end
可见在新版本固件中将换行符\n也过滤了提高了安全性。
总结
这篇文章是在挖到这个0day挺久之后写的了。依稀记得当时刚挖到这个漏洞的时候有着些许兴奋但更多的是感到不易因为我当时觉得这条调用链还挺深的里面也牵涉到了不少东西。但是当我如今再梳理这个漏洞的相关细节的时候我觉得这条调用链其实也就那样吧整个挖掘思路和利用思路都不算难抛开影响范围并算不上品相多好的洞QAQ。
在挖这个洞的时候我遇到的最大挑战就是逆向分析了我觉得这里的逆向难度还是比较大的当然我逆向水平也很菜。在实际逆向分析的过程中并没有文章中写的那么流畅当时的挖掘思路也不可能是完全按照文章的流程来的比如需要多考虑一些东西例如文章中一直都在找命令注入的洞但其实也有可能是可控的params字段造成的缓冲区溢出等等这些在初次挖掘的时候也都需要考虑当然也走了不少弯路但好在最终是坚持下来了。
当时我只知道params字段是可控的而params内也是Json的格式于是猜测是其中的某个特定的字段可能会造成命令注入或缓冲区溢出等问题因此就一路挖到底了。不过如今再看来其实就这个洞而言是否采用自动化的方式会更简单呢当然就工业界来说IoT的全自动化漏扫我并没有看到过实际效果很好的工具基本都是半自动化提高效率
进一步地从宏观上来看二进制漏洞的挖掘思路无非就是从危险函数出发和从交互入口出发两种方式显然前者在筛掉明显无法利用的危险函数点之后所涉及的支路会更少挖起来也会更容易而后者基本是要从交互入口一路挖到中断点甚至挖到底的。然而该漏洞却是采用后者的思路进行挖掘的当时主要是考虑到只有一个可能的未授权入口因此很自然地采用了后者的思路。现在想来这里若是采用前者的思路可能并不会那么容易地挖到此漏洞。如何更好地结合上述两种思路特别是对于自动化漏扫来说我觉得仍是值得思考的问题。
说了些自己粗浅的理解和感受就说到这里吧。希望这篇文章能给各位像我一样刚入门IoT漏洞挖掘的师傅带来些启发也欢迎各位大师傅与我交流。最后希望我在不久的将来能挖到在挖掘思路和利用手法上都有所创新的高质量0day吧。