作者:winmt
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送! 投稿郵箱:paper@seebug.org

前言

想來上一次挖洞還在一年前的大一下,然后就一直在忙活寫論文,感覺挺枯燥的(可能是自己不太適合弄學術吧QAQ),所以年初1~2月的時候,有空的時候就又會挖一挖國內外各大知名廠商的設備,拿了幾份思科、小米等大廠商的公開致謝,也分配到了一些CVECNVD編號,之后就沒再挖洞,繼續忙活論文了QAQ。

某捷算是國內挺大的廠商了,我對其某系統進行了漏洞挖掘,并發現了一個可遠程攻擊的未授權命令執行漏洞,可以通殺使用該系統的所有路由器、交換機、中繼器、無線接入點AP以及無線控制器AC等眾多設備,危害還是相當嚴重的。

根據廠商的要求,在修補后的固件未發布前,我對該漏洞細節進行了保密。如今新版本固件都已經發布,在這里給大家分享一下這一次的漏洞挖掘經歷(包括固件解密、仿真模擬、挖掘思路等),希望能給各位師傅帶來些許啟發(大師傅們請繞道QAQ)。

聲明:本文僅供用于安全技術的交流與學習,文中涉及到的敏感內容都進行了刪減或脫敏處理,僅分析了漏洞鏈。若讀者將本文內容用作其他用途,由讀者承擔全部法律及連帶責任,文章作者不承擔任何法律及連帶責任。

時間線

2023-02-27:發現漏洞,并將該漏洞上報給了廠商
2023-02-28:廠商確認了漏洞,并啟動了應急響應,開始修補并內測
2023-03-06:廠商給予了一定的獎勵(不過有點少QAQ)
2023-05-24:修復了所有影響的設備并發布了安全通告及致謝

安全通告:https://www.ruijie.com.cn/gy/xw-aqtg-gw/91389/

CVSS 3.1評分:9.8(嚴重,CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

固件解密

可以從廠商官網下載到最新固件,然而可以發現其中的固件大多都是加密的,用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發送請求的時候,不需要權限驗證

entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false

根據調用的rpc_auth函數,可見此處對應的處理文件是/usr/lib/lua/luci/modules/noauth.lua

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函數過濾了危險字符,故大概率之后會有相關的命令執行點。

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函數進一步處理。

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這個命令分隔符。因此,若之后當真存在命令執行點的話,似乎還是有希望完成命令注入的。

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函數做進一步處理。

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)類字符進行轉義,也就不會被解析成換行符了,不論我們后續再如何傳參,這個疑似的漏洞點已經被封堵住了。

if params.data then
    data = luci.json.encode(params.data)
    _shell = _shell .. " '" .. data .. "'"
end

因此,我們只能將目光聚焦于noauth.lua中最后一個可能的入口merge方法了。這個函數比較簡單,調用了devSta.set函數,其Json參數中的data字段就是傳入的POST報文中params的內容

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不會造成影響。

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函數

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函數。接下來,就是對二進制文件逆向分析并尋找是否存在命令執行點了。

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套接字(分析可知,此處采用的是本地通信的方式)進行數據傳輸

      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總線進行進程間通信)。

$ 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字段可控。

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函數。

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函數進行數據解析。

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類型,八字節)。

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);
    }
_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;
        if ( !v31 )
        {
            v10 = 561LL;
            goto LABEL_11;
        }
    }
}

此外,當async字段為false的時候,偏移7677的位置都為1。但這里的async字段為true,故這兩個偏移處均為初始值0

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,看名字應該是命令執行相關的。

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執行。

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處,也就無法執行到上方的程序片段了

v19 = *((unsigned __int8 *)a1 + 81);
...
if ( !v19 )
{
    v85[19] = *((_QWORD *)a1 + 3);
    goto LABEL_96;
}

LABEL_96處開始是一堆字段的提取,存放入v85數組中,還有一些關于v85數組中數據的處理。這里需要關注的是:v85偏移89的位置為a1偏移7776的位置(上文分析過,此時這兩個偏移的值均為0

LOBYTE(v85[1]) = *((_BYTE *)a1 + 77);
BYTE1(v85[1]) = *((_BYTE *)a1 + 76);

既然從LABEL_96開始都是對v85數組進行操作的,那么v85指針肯定會作為參數傳遞給下一個調用的函數,以這個思路,就很容易定位到下面的ufm_handle(v85)了。

v8 = ufm_handle(v85);
pthread_mutex_unlock((pthread_mutex_t *)(v85[23] + 152));
pthread_cleanup_pop(v84, 0LL);

ufm_handle函數中,由于我們是set方式,因此會調用到sub_410140函數。

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

v6 = json_object_object_get(a1[22], "sn");
if ( !v6 )
    goto LABEL_36;

接著,會調用到sub_40DA38函數。

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'(可自行分析得出),很顯然是一個命令,并且單引號內的內容我們可控,所以我們只需要左右分別閉合單引號,中間注入惡意命令,并用分隔符隔開即可完成命令注入。不過,這里還沒到命令執行點,由于不確定之后是否會有過濾,我們需要接著往下看。

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函數,作為第二個參數。

if ( (!v79 || !strcmp(v78, "commit") || (_DWORD)v5) && v7[8] )
{
    ...
}
else
{
    ...
    v13 = ufm_commit_add(0LL, v4, 0LL, a2);
}

然后,繼續進入async_cmd_push_queue函數。

__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的位置。

if ( !a1 )
{
    if ( a2 )
    {
        v23 = strdup(a2);
        v4[6] = v23;
        if ( v23 )
            goto LABEL_28;
        ...
    }

LABEL_28處,注意到最后使用sem_post的原子操作,將信號量加上了1。因此,應該會有其他地方在檢測到信號量發生改變后,對數據進行處理

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的參數執行,且沒有任何過濾。此處采用的是異步執行的方式。

v11 = *((_QWORD *)v5 + 6);
if ( !*((_BYTE *)v5 + 56) )
{
    v10 = ufm_popen(v11, v5 + 24);
    goto LABEL_18;
}

至此,該未授權RCE漏洞的調用鏈分析完畢。

PoC

由于該漏洞影響較大,Poc暫不公開。各位師傅可根據上文分析,自行復現。

暫不公開

真機演示

對某遠程測試靶機攻擊后,無需身份驗證即得到了該設備的最高控制權:

仿真模擬

此部分仿真采用的是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的啟動腳本:

#!/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中依次執行以下命令:

cd rootfs
chmod -R 777 ./
mount --bind /proc proc
mount --bind /dev dev
chroot . /bin/sh

解釋一下,這里chmod給全部文件都賦予所有權限,是為了方便在仿真過程中不用再考慮權限問題的影響了。之后使用mountrootfs中的procdev目錄掛載到/proc/dev系統目錄(顯然仿真系統的這兩個目錄也需要用qemu虛擬機的系統目錄),最后用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函數,判斷是否存在單引號和反引號。若不存在就返回,而返回的內容無法通過單引號閉合,也就無法執行任意命令。

__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;
        }
bool __fastcall is_independ_format(const char *a1)
{
  if ( !a1 )
    return 0LL;
  if ( strchr(a1, '`') )
    return 1LL;
  return strchr(a1, '\'') != 0LL;
}

若是存在單引號,且前面沒有轉義符,則對其Unicode編碼,即\\u0027。反引號也同理。

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反彈的方式。

請求報文:

暫不公開

演示效果:

補丁2

其實,只要在noauth.luamerge函數中對傳入的params加個過濾即可,如下:

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函數過濾了單引號:

function includeXxs(str)
    local ngstr = "[\n`&$;|]"
    return string.match(str, ngstr) ~= nil
end
function includeQuote(str)
    return string.match(str, "(['])") ~= nil
end

可見,在新版本固件中,將換行符\n也過濾了,提高了安全性。

總結

這篇文章是在挖到這個0day挺久之后寫的了。依稀記得當時剛挖到這個漏洞的時候,有著些許興奮,但更多的是感到不易,因為我當時覺得這條調用鏈還挺深的,里面也牽涉到了不少東西。但是,當我如今再梳理這個漏洞的相關細節的時候,我覺得這條調用鏈其實也就那樣吧,整個挖掘思路和利用思路都不算難,拋開影響范圍,并算不上品相多好的洞QAQ。

在挖這個洞的時候,我遇到的最大挑戰就是逆向分析了,我覺得這里的逆向難度還是比較大的(當然我逆向水平也很菜)。在實際逆向分析的過程中,并沒有文章中寫的那么流暢,當時的挖掘思路也不可能是完全按照文章的流程來的,比如需要多考慮一些東西(例如,文章中一直都在找命令注入的洞,但其實也有可能是可控的params字段造成的緩沖區溢出等等,這些在初次挖掘的時候也都需要考慮),當然也走了不少彎路,但好在最終是堅持下來了。

當時,我只知道params字段是可控的,而params內也是Json的格式,于是猜測是其中的某個特定的字段可能會造成命令注入或緩沖區溢出等問題,因此就一路挖到底了。不過如今再看來,其實就這個洞而言,是否采用自動化的方式會更簡單呢(當然就工業界來說,IoT的全自動化漏掃我并沒有看到過實際效果很好的工具,基本都是半自動化提高效率)?

進一步地從宏觀上來看二進制漏洞的挖掘思路,無非就是從危險函數出發和從交互入口出發兩種方式,顯然前者在篩掉明顯無法利用的危險函數點之后,所涉及的支路會更少,挖起來也會更容易,而后者基本是要從交互入口一路挖到中斷點甚至挖到底的。然而,該漏洞卻是采用后者的思路進行挖掘的,當時主要是考慮到只有一個可能的未授權入口,因此很自然地采用了后者的思路。現在想來,這里若是采用前者的思路,可能并不會那么容易地挖到此漏洞。如何更好地結合上述兩種思路,特別是對于自動化漏掃來說,我覺得仍是值得思考的問題。

說了些自己粗淺的理解和感受,就說到這里吧。希望這篇文章能給各位像我一樣剛入門IoT漏洞挖掘的師傅帶來些啟發,也歡迎各位大師傅與我交流。最后,希望我在不久的將來能挖到在挖掘思路和利用手法上都有所創新的高質量0day吧。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/2071/