记一次全设备通杀未授权RCE的挖掘经历

想来上一次挖洞还在一年前的大一下,然后就一直在忙活写论文,感觉挺枯燥的(可能是自己不太适合弄学术吧QAQ),所以年初1~2月的时候,有空的时候就又会挖一挖国内外各大知名厂商的设备,拿了几份思科、小米等大厂商的公开致谢,也分配到了一些CVECNVD编号,之后就没再挖洞,继续忙活论文了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中,有loginsingleLoginmergecheckNet四个方法。其中,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中),其中检测了传入的encrylimit字段的真假值,并在这两个字段中写入了相应的固定字符串,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.luamerge函数中,调用了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.luafetch函数中。其中,会将从第四个开始的参数(包括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.luafetch函数中,这里的cmdset方式,modulenetworkId_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.souf_client_call函数中,先将传入的data等字段转为Json格式的数据,作为param字段的内容。然后将Json数据通过uf_socket_msg_writesocket套接字(分析可知,此处采用的是本地通信的方式)进行数据传输

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字段的偏移为24v5QWORD类型,八字节)。

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的时候,偏移7677的位置都为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是偏移81from_url字段,这是我们不可控的。若是该字段为假,会将data字段内容传给v85[19]v85int64类型,八字节),并直接跳转到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偏移89的位置为a1偏移7776的位置(上文分析过,此时这两个偏移的值均为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函数中,显然前面的部分无关紧要,不过需要注意一下v5v6分别是a3a4,根据传入的值,均为零。因此,进入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);

此处,a10a2存入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 console=tty0" \

    -net nic,macaddr=00:16:3e:00:00:01 \

    -net tap \

    -nographic

需要特别注意的是,这里设定了cpu74kf,因为若不特别说明,默认是24Kc,而该固件需要较高版本的cpu,不然在之后chroot切换根目录的时候就会出现Illegal instruction(非法指令)错误。可用qemu-system-mipsel -cpu help命令查看qemu-system-mipsel所有支持的cpu版本。

在正式开始仿真前,需要先进行网络配置。用ip addrifconfig命令查看一下主机的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 root@192.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中的procdev目录(仿真系统只是切换了根目录,本质还是qemu虚拟机的系统,故procdev这两个重要的系统目录仍应该是这个系统本身的目录,即qemu虚拟机的系统目录,而切换了根目录后,procdev也被切换,因此需要挂载为原先的目录),最后用chrootrootfs切换为根目录,完成准备工作。

以上都是些用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文件中新增了stringtojsonis_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.luamerge函数中对传入的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吧。

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/711296.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

---String类---

在c语言中要使用字符串&#xff0c;只能通过字符指针或者字符数组&#xff0c;然后再通过函数进行各种操作&#xff0c;这种将变量和变量方法分开的方式显然不符合面向对象的编程&#xff0c;所以java中添加了String这个类 String类构造 而对于string有很多的方法 字符串长度…

UWB技术定位系统源码,智慧工厂人员定位系统,独特的射频处理,配合先进的位置算法

UWB技术定位系统源码&#xff0c;高精度人员定位系统源码&#xff0c;智慧工厂人员定位系统源码&#xff0c;室内定位系统源码 本套系统运用UWB定位技术&#xff0c;开发的高精度人员定位系统&#xff0c;通过独特的射频处理&#xff0c;配合先进的位置算法&#xff0c;可以有…

结构体对齐,与 触发 segment fault 为什么是 1024*132 ,而不是1024*128

1, 简单的小示例代码 按理说 malloc 的size 是 1024*128&#xff0c;这里却需要 1024*132才能及时触发 segmentation fault #include <stdlib.h> #include <stdio.h> #define SIZE 1024*131int main() {char *p 0;p malloc(SIZE);p[SIZE -1] a;free(p);printf(…

【Mongodb-02】springboot整合mongodb(详解)

springBoot整和mongodb 一&#xff0c;springboot整合mongodb1&#xff0c;依赖加入2&#xff0c;yml文件配置3&#xff0c;_class 字段过滤(可选)4&#xff0c;实体类定义5&#xff0c;索引创建6&#xff0c;数据插入6.1&#xff0c;insert方式6.2&#xff0c;使用save的方式实…

Elixir学习笔记——输入输出和文件系统

本章介绍输入/输出机制、文件系统相关任务以及相关模块&#xff08;如 IO、File 和 Path&#xff09;。IO 系统提供了一个很好的机会来阐明 Elixir 和 Erlang VM 的一些思维模式和新奇思想。 输入输出模块 输入输出模块是 Elixir 中读写标准输入/输出 (:stdio)、标准错误 (:s…

Linux 终端窗口设置为透明

Linux 终端窗口设置为透明 打开终端 右键鼠标 选择Profile Preferences 点击Background 选择 Transparent background 拖动滑条调整透明度 完成。

【机器学习】集成学习方法:Bagging与Boosting的应用与优势

&#x1f525; 个人主页&#xff1a;空白诗 文章目录 引言一、集成学习的定义二、Bagging方法1. 随机森林&#xff08;Random Forest&#xff09;2. 其他Bagging方法 二、Boosting方法1. 梯度提升树&#xff08;Gradient Boosting Machine, GBM&#xff09;解释GBM的基本原理和…

笔记本开机原理

从按下开机键开始&#xff0c;机器是如何开到OS的呢&#xff1f;今天这篇文章和大家极少EC-BIOS-OS的整个开机流程。首先大家要对笔记本的基本架构有所了解&#xff0c;基本架构如下图所示&#xff08;主要组成部分为大写黑体内容&#xff09;。 一、按下PowerButton按钮&#…

手把手带你搞定用户权限控制 | 纯干货

在实际的软件项目开发过程中&#xff0c;用户权限控制可以说是所有运营系统中必不可少的一个重点功能&#xff0c;根据业务的复杂度&#xff0c;设计的时候可深可浅&#xff0c;但无论怎么变化&#xff0c;设计的思路基本都是围绕着用户、角色、菜单这三个部分展开。 如何设计…

Matlab的Simulink系统仿真(simulink调用m函数)

这几天要用Simulink做一个小东西&#xff0c;所以在网上现学现卖&#xff0c;加油&#xff01; 起初的入门是看这篇文章MATLAB 之 Simulink 操作基础和系统仿真模型的建立_matlab仿真模型搭建-CSDN博客 写的很不错 后面我想在simulink中调用m文件 在 Simulink 中调用 MATLA…

Git 基础操作(一)

Git 基础操作 配置Git 安装完Git后&#xff0c;首先要做的事情是设置你的 用户名 和 e-mail 地址。这样在你向仓库提交代码的时候&#xff0c;就知道是谁提交的&#xff0c;以及提交人的联系方式。 配置用户名和邮箱 使用git config [--global] user.name "你的名字&qu…

失眠焦虑?这些维生素或许能帮你找回好眠!

&#x1f4a4; 失眠、焦虑&#xff0c;是现代生活中不少人都可能遇到的问题。长期的失眠与焦虑&#xff0c;不仅影响身体健康&#xff0c;更会对精神状态造成不小的冲击。其实&#xff0c;除了调整作息和放松心情&#xff0c;适当的维生素补充也可能有助于改善这些症状。 &…

SpringCloud-远程调用OpenFeign-基本使用

目录 1 直接使用RestTemplate发起Http请求 1.1 将RestTemplate注册为SpringBean 1.2 在service实现类中注入RestTemplate 1.3 使用注入的RestTemplate 传入参数后发起http请求 2 引入Nacos后使用RestTemplate发起Http请求 2.1 基础知识 2.2 Nacos的使用 2.2.1 引入nac…

Hvv--知攻善防应急响应靶机--Linux2

HW–应急响应靶机–Linux2 所有靶机均来自 知攻善防实验室 靶机整理&#xff1a; 夸克网盘&#xff1a;https://pan.quark.cn/s/4b6dffd0c51a#/list/share百度云盘&#xff1a;https://pan.baidu.com/s/1NnrS5asrS1Pw6LUbexewuA?pwdtxmy 官方WP&#xff1a;https://mp.weixin.…

Asp.Net Core 读取配置接口 IOptions、IOptionsMonitor以及IOptionsSnapshot

&#x1f340;简介 Options是.net Core Web api框架自带的功能&#xff0c;Options模式通过定义强类型的类来表示相关配置设置的集合&#xff0c;使得配置管理更为结构化和类型安全。 IOptions、IOptionsMonitor和IOptionsSnapshot是用于处理配置的依赖注入接口。这些接口允许…

Jenkins三种构建类型

目录 传送门前言一、概念二、前置处理&#xff08;必做&#xff09;1、赋予777权限2、让jenkins用户拥有root用户的kill权限3、要运行jar包端口号需要大于1024 三、自由风格软件项目&#xff08;FreeStyle Project&#xff09;&#xff08;推荐&#xff09;三、Maven项目&#…

【YashanDB知识库】PHP使用OCI接口使用数据库绑定参数功能异常

【问题分类】驱动使用 【关键字】OCI、驱动使用、PHP 【问题描述】 PHP使用OCI8连接yashan数据库&#xff0c;使用绑定参数获取数据时&#xff0c;出现报错 如果使用PDO_OCI接口连接数据库&#xff0c;未弹出异常&#xff0c;但是无法正确获取数据 【问题原因分析】 开启O…

springcloud第4季 分布式事务seata作用服务搭建

一 seata作用 1.1 作用 二 seata服务端搭建 2.1 seata搭建 2.2.1 seata 服务端下载安装 下载地址&#xff1a; Seata-Server下载 | Apache Seata 截图如下&#xff1a; 2.2.2 使用mysql初始化seata所需表 1.下载脚本地址&#xff1a;incubator-seata/script/server/db/…

2024最新D卷 华为OD统一考试题库清单(按算法分类),如果你时间紧迫,就按这个刷

目录 专栏导读华为OD机试算法题太多了&#xff0c;知识点繁杂&#xff0c;如何刷题更有效率呢&#xff1f; 一、逻辑分析二、数据结构1、线性表① 数组② 双指针 2、map与list3、队列4、链表5、栈6、滑动窗口7、二叉树8、并查集9、矩阵 三、算法1、基础算法① 贪心思维② 二分查…

Nas实现软路由OpenWrt安装

文章目录 基本配置步骤 基本配置 NAS&#xff1a;TS-264C 宇宙魔方 步骤 1.下载软路由OpenWrt 下载地址&#xff1a;https://openwrt.org/ 2.下载好以后&#xff0c;需要下载虚拟盘转换工具&#xff08;StarWind V2V Convert&#xff09; 下载地址&#xff1a;https://…