当前位置: 首页 > news >正文

记一次全设备通杀未授权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吧。

 

相关文章:

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

想来上一次挖洞还在一年前的大一下&#xff0c;然后就一直在忙活写论文&#xff0c;感觉挺枯燥的&#xff08;可能是自己不太适合弄学术吧QAQ&#xff09;&#xff0c;所以年初1~2月的时候&#xff0c;有空的时候就又会挖一挖国内外各大知名厂商的设备&#xff0c;拿了几份思科…...

【数据库编程-SQLite3(一)】sqlite3数据库在Windows下的配置及测试

学习分析 1、资源准备2、环境配置2.1、将资源包下载解压缩保存。2.2、在QT中创建工程,配置环境 3、测试配置3.1、 sqlite3_open函数3.2、sqlite3_close函数3.3、代码测试 1、资源准备 资源包 2、环境配置 2.1、将资源包下载解压缩保存。 解压缩得到以下文件 2.2、在QT中创建…...

YOLOv10改进 | 主干篇 | YOLOv10引入华为VanillaNet替换Backbone

1. VanillaNet介绍 1.1 摘要: 基础模型的核心是“越多越好”的理念,计算机视觉和自然语言处理领域取得的惊人成功就是例证。 然而,优化的挑战和变压器模型固有的复杂性要求范式向简单性转变。 在这项研究中,我们介绍了 VanillaNet,一种设计优雅的神经网络架构。 通过避免…...

C++ 迷宫问题

描述 定义一个二维数组 N*M &#xff0c;如 5 5 数组下所示&#xff1a; int maze[5][5] { 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, }; 它表示一个迷宫&#xff0c;其中的1表示墙壁&#xff0c;0表示可以走的路&#xff0c;只能横着走…...

【Linux】Linux文件系统中主要文件夹列举_作用说明

在Linux系统中&#xff0c;文件夹&#xff08;或称为目录&#xff09;的组织结构是系统功能和用户数据的重要组成部分。以下是Linux系统中一些主要文件夹的列举及其作用说明&#xff1a; / (根目录): 是Linux文件系统的起点。通常只包含其他目录&#xff0c;不建议直接在其中存…...

移植案例与原理 - HDF驱动框架-驱动配置(1)

HCS(HDF Configuration Source)是HDF驱动框架的配置描述源码&#xff0c;内容以Key-Value为主要形式。它实现了配置代码与驱动代码解耦&#xff0c;便于开发者进行配置管理。应该&#xff0c;类似Linux DTS(Device Tree Source)设备树。 HC-GEN(HDF Configuration Generator)是…...

坚持刷题|反转链表

文章目录 题目思考实现1. 迭代方式实现链表翻转2. 递归方式实现链表翻转 Hello&#xff0c;大家好&#xff0c;我是阿月。坚持刷题&#xff0c;老年痴呆追不上我&#xff0c;今天继续链表&#xff1a;反转链表 题目 LCR 024. 反转链表 思考 翻转链表是一个常见的算法问题&a…...

升级和维护老旧LabVIEW程序

在升级老旧LabVIEW程序至64位环境时&#xff0c;需要解决兼容性、性能和稳定性等问题。本文从软件升级、硬件兼容性、程序优化、故障修复等多个角度详细分析。具体包括64位迁移注意事项、修复页面跳转崩溃、解决关闭程序后残留进程的问题&#xff0c;确保程序在新环境中的平稳运…...

sqlite数据库整体迁移进mysql整个流程并解决中文异常问题

咨询【QQ】 sqlite轻量数据还行&#xff0c;随着数据量增大&#xff0c;不得不迁移进mysql 首先 电脑执行 sqlite3 db.sqlite3 .dump > dump.sql 会把整个sqlite的数据导出进 dump.sql中 紧接着我们把sqlite的sql转换成mysql的sql语句&#xff0c;因为mysql语句和 sq…...

Hadoop3:MapReduce中的Partition原理及自定义Partition

一、默认Partition分区配置 以WC案例来进行验证。 1、设置setNumReduceTasks 修改的代码 这行代码&#xff0c;确定了reduceTask的数量&#xff0c;也确定了分区逻辑 在mapper文件中&#xff0c;打上断点 计算分区的代码 这里会对每一个kv进行计算&#xff0c;然后&#…...

就因为没在大屏项目加全屏按钮,早上在地铁挨了领导一顿骂

“嗯嗯”&#xff0c;“嗯嗯”&#xff0c;“那产品也没说加呀”&#xff0c;“按F11不行吗&#xff1f;”&#xff0c;“嗯嗯”&#xff0c;“好的”。 早上在4号线上&#xff0c;我正坐在地铁里&#xff0c;边上站着的妹子&#xff0c;我看他背着双肩包&#xff0c;打着电话…...

STM32学习记录(八)————定时器输出PWM及舵机的控制

文章目录 前言一、PWM1.工作原理2.内部运作机制3. PWM工作模式4.PWM结构体及库函数 二、PWM控制舵机 前言 一个学习STM32的小白~ 有错误评论区或私信指出提示&#xff1a;以下是本篇文章正文内容&#xff0c;下面案例可供参考 一、PWM 1.工作原理 以向上计数为例&#xff0…...

Vue CLI,Vue Router,Vuex

前言 Vue CLI、Vue Router 和 Vuex 都是 Vue.js 生态系统中的重要组成部分&#xff0c;它们在构建 Vue 应用程序时扮演着关键角色。 Vue CLI Vue CLI 介绍 Vue CLI 是 Vue.js 的官方命令行工具&#xff0c;用于快速搭建 Vue.js 项目。它提供了一个图形界面&#xff08;通过…...

互联网广告相关概念

互联网广告概念涉及多个关键指标和定价模式&#xff0c;它们帮助广告主和广告平台衡量广告效果、优化广告投放策略&#xff0c;并计算广告成本。以下是互联网广告中一些核心概念的简要概述&#xff1a; 1.ROI (投资回报率) 衡量广告投资的效益&#xff0c;计算公式为&#xff…...

如何在服务器上部署一个java程序

如何在服务器上部署一个java程序&#xff1f; 一、在服务器上安装jdk环境 1.创建目录用于存放jdk文件 cd /usr/local 2.下载最新版oracle jdk22 wget https://download.oracle.com/java/22/latest/jdk-22_linux-x64_bin.tar.gz 3.解压 tar -zxf jdk-22_linux-x64_bin.ta…...

白酒:中国的酒文化的传承与发扬

中国&#xff0c;一个拥有五千年文明史的国度&#xff0c;其深厚的文化底蕴孕育出了丰富多彩的酒文化。在这片广袤的土地上&#xff0c;酒不仅仅是一种产品&#xff0c;更是一种情感的寄托&#xff0c;一种文化的传承。云仓酒庄的豪迈白酒&#xff0c;正是这一文化脉络中的一颗…...

算法金 | 再见!!!梯度下降(多图)

大侠幸会&#xff0c;在下全网同名「算法金」 0 基础转 AI 上岸&#xff0c;多个算法赛 Top 「日更万日&#xff0c;让更多人享受智能乐趣」 接前天 李沐&#xff1a;用随机梯度下降来优化人生&#xff01; 今天把达叔 6 脉神剑给佩奇了&#xff0c;上 吴恩达&#xff1a;机器…...

python Django安装及怎么检测是否安装成功

一、winr 输入cmd 进入控制台。输入pip install Django5.0.1 二、如果安装过程没有问题。就进行下一步进行检查是否成功安装。 三、 1.在控制台输入python&#xff0c;进入python环境 2.输入 import django 3.继续输入 django.get_version()。显示版本号表示成功安装。...

Swift开发——存储属性与计算属性

Swift语言开发者建议程序设计者多用结构体开发应用程序。在Swift语言中,结构体具有了很多类的特性(除类的与继承相关的特性外),具有属性和方法,且为值类型。所谓的属性是指结构体中的变量或常量,所谓的方法是指结构体中的函数。在结构体中使用属性和方法是因为:①匹别于结…...

如何解决input输入时存在浏览器缓存问题?

浏览器有时会在你输入表单过后缓存你的输入&#xff0c;有时候能提供方便。 但是在某些新建或新页面情况下出现历史的输入信息&#xff0c;用户体验很差。 解决方案 设置 autocomplete关闭 &#xff1a;<input type"text" autocomplete"off">增加…...

Java基础学习-方法

目录 方法基础概念 方法的格式&#xff1a; 案例&#xff1a;最简单方法的定义 案例&#xff1a;带参数的方法调用 案例&#xff1a;求圆的面积 带有返回值的方法&#xff1a; 方法注意点 方法的重载&#xff1a; ​编辑 案例&#xff1a;数组的遍历&#xff1a; 案例…...

Ribbon与Nginx的区别

负载均衡实现的位置不同&#xff1a; Ribbon&#xff1a;负载均衡器位于客户端&#xff0c;不需要单独搭建。Nginx&#xff1a;需要建立一个独立负载均衡服务器&#xff0c;服务端。 负载均衡策略&#xff1a; Ribbon&#xff1a;提供了多种负载均衡策略&#xff0c;如随机策…...

R包开发详细教程

开发一个R包可以帮助你组织和共享代码。以下是一个详细的步骤教程&#xff0c;介绍如何开发一个R包。 步骤 1: 准备工作 确保你已经安装了以下R包&#xff1a; install.packages("devtools") install.packages("roxygen2") install.packages("test…...

图像的高频和低频细节

在图像处理和计算机视觉中&#xff0c;"高频"和"低频"是用来描述图像中不同类型细节的术语。这些术语源自信号处理领域&#xff0c;其中频率的概念用于描述信号随时间变化的&#xff0c;但在图像处理中&#xff0c;它们被用来描述图像随空间变化的&#xf…...

PostgreSQL源码分析——常量表达式化简

常量表达式化简 常量表达式可以进行化简&#xff0c;可降低执行器计算表达式的代价。在逻辑优化阶段&#xff0c;会判断是否可以进行常量表达式化简&#xff0c;如果可以&#xff0c;则在执行器执行之前就预先对常量表达式树进行计算&#xff0c;计算出常量后&#xff0c;以新…...

速卖通自养号测评:安全高效的推广手段

在速卖通平台上&#xff0c;卖家们常常寻求各种方法来提升商品的曝光、转化率和店铺权重。其中&#xff0c;自养号测评作为一种低成本、高回报的推广方式&#xff0c;备受关注。然而&#xff0c;若操作不当&#xff0c;也可能带来风险。以下是如何安全有效地进行自养号测评的指…...

项目监督与控制

1.什么是项目过程度量&#xff1f;其方法有哪些&#xff1f; 项目过程度量是一种对项目执行过程中的活动和性能进行量化测量的方法。它涉及到收集、分析和解释项目数据&#xff0c;以便更好地理解项目的进度、质量和效率。过程度量的目的是提供关于项目健康状况的客观信息&…...

【LeetCode刷题】面试题 17.19. 消失的两个数字

1. 题目链接2. 题目描述3. 解题方法4. 代码 1. 题目链接 面试题 17.19. 消失的两个数字 2. 题目描述 3. 解题方法 例子假设&#xff1a; 数组A元素为 &#xff1a;1 &#xff0c;4&#xff0c;5 缺少的元素为&#xff1a;2&#xff0c; 3 那么所有整数就为1 ~ 5&#xff…...

如何定制Spring的错误json信息

一&#xff0c;前言 相信很多同学都有遇到过这样的spring错误信息。 在我们没有做catch处理时或者做全局的exceptionHandle时&#xff0c;Spring遇到抛出向外的异常时&#xff0c;就会给我们封装返回这么个格式的异常信息。 那么问题来了&#xff0c;我们能否对这个返回增加错…...

【第20章】Vue实战篇之Vue Router(路由)

文章目录 前言一、使用Vue-Router1.安装2. 创建路由器实例3. 注册路由器插件4. 根组件 二、访问路由器1.理论2.使用3. 展示 三、嵌套路由(子路由)1. 准备文件2. 配置路由3. 菜单配置4. 展示 总结 前言 Vue Router 是 Vue.js 的官方路由。它与 Vue.js 核心深度集成&#xff0c;…...

52麻将官方网站做代理/手机优化

导读大家都知道SSH是一种安全的传输协议&#xff0c;用在连接服务器上比较多。不过其实除了这个功能&#xff0c;它的隧道转发功能更是吸引人。如果两个内网之间的linux服务器需要互相登录&#xff0c;或需要互相访问内网某个端口&#xff0c;担忧没有公网IP&#xff0c;可以使…...

网站备案logo/谷歌seo实战教程

Typora是一款非常高效的文件编辑软件。 软件的下载地址&#xff1a; https://typora.io/#windows 升级改造的功能&#xff1a; 1、增加导出word的功能 下载并安装 pandoc-2.11.0.2-windows-x86_64.msi 即可 下载地址&#xff1a;https://pandoc.org/installing.html 2、增加…...

建设项目环保竣工信息公开网站/一键优化清理手机

下面介绍在 Linux 操作系统中重启和关闭相关的命令&#xff1a;shutdown、reboot、init、halt、poweroff、systemctl&#xff0c;你可以根据需要来选择适合的 Linux 命令关闭或重新启动系统。其中 shutdown、halt、poweroff、reboot 命令是用来停机、重启或切断电源&#xff0c…...

皮卡剧网站怎样做/广州网络营销运营

void unset ( mixed $var [, mixed $var [, $... ]] )unset() 销毁指定的变量。注意在 PHP 3 中&#xff0c;unset() 将返回 TRUE&#xff08;实际上是整型值 1&#xff09;&#xff0c;而在 PHP 4 中&#xff0c;unset() 不再是一个真正的函数&#xff1a;它现在是一个语句。这…...

网站如何在百度搜索到/最近三天的新闻大事

题目链接&#xff1a;http://poj.org/problem?id3254 题目大意&#xff1a;Farmer John 放牧cow&#xff0c;有些草地上的草是不能吃的&#xff0c;用0表示&#xff0c;然后规定两头牛不能相邻放牧。问你有多少种放牧方法。 Sample Input 2 3 1 1 1 0 1 0 Sample Output 9 分析…...

模板自助建站网站制作/宁波网络营销推广公司

网站的首页是一个让人头疼的东西。有时它看起来很简单&#xff1a;首页就是网站内容的整合&#xff0c;一个产品经理随便从网站里拿点东西出来&#xff0c;就能堆出一个看上去靠谱的首页。也正因此&#xff0c;它往往非常麻烦&#xff1a;很多人都可以发表自己的见解&#xff0…...