从零开始复现CVE-2023-34644

发布时间 2023-09-27 10:58:23作者: 何思泊河

从零开始复现CVE-2023-34644

说实话复现这个漏洞光调试我就调了一个星期,主要是逆向很难

仿真启动脚本

tar czf rootfs.tar.gz ./rootfs
scp rootfs.tar.gz root@192.168.192.135:/root/rootfs
cd rootfs
chmod -R 777 ./
mount -bind /proc proc
mount -bind /dev dev
chroot . /bin/sh
/sbin/init
mkdir /var/run/lighttpd.pid
/etc/init.d/lighttpd start
/sbin/ubusd &


mkdir /tmp/coredump
mkdir /tmp/rg_device
cp /sbin/hw/60010081/rg_device.json  /tmp/rg_device/rg_device.json
/usr/sbin/unifyframe-sgi.elf

配置文件漏洞分析

简单说一下JSON中的一些字段

method 用于指定调用远程的方法

params 用于指定方法的参数

首先这是一个未授权漏洞那就从认证部分开始,所以思路就是从api接口寻找,这个路由器是使用lua来完成前端服务路径是/usr/libc/lua/luci/controller/eweb,下面有一个api.lua

这里的_tbl是四种方法分别是login,singleLogin,merge,checkNet

路径usr/lib/lua/luci/controller/eweb/api.lua

认证模块

-- 认证模块
function rpc_auth()
    --导入模块
    local jsonrpc = require "luci.utils.jsonrpc"    --应对JSON-RPC 的请求和响应
    local http = require "luci.http"
    local ltn12 = require "luci.ltn12"
    local _tbl = require "luci.modules.noauth"
    if tonumber(http.getenv("HTTP_CONTENT_LENGTH") or 0) > 1000 then  --长度检查
        http.prepare_content("text/plain")
        -- http.write({code = "1", err = "too long data"})
        return "too long data"
    end
    --设置http的响应类型
    http.prepare_content("application/json")
    --调用`jsonrpc.handle`处理JSON-RPC请求
    ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
end

handle

handle函数的主要功能就是根据接收json中的method字段选择方法和params做为参数(这也是为什么后面构造的poc只有methodparams这两个字段)

路径usr/lib/lua/luci/utils/jsonrpc.lua

function handle(tbl, rawsource, ...)
    local decoder = luci.json.Decoder() --c创建一个json解码器
    local stat, err = luci.ltn12.pump.all(rawsource, decoder:sink())
    local json = decoder:get() --获得解码后的数据
    local response
    local success = false

    if stat then --判断是否解码成功
        if type(json.method) == "string" then --查看json中是否存在method字段并且是一个字符串
            local method = resolve(tbl, json.method) --调用`resolve`方法
            if method then
                response = reply(json.jsonrpc, json.id, proxy(method, json.params or {})) --调用 reply和proxy函数 (params 字段是用来传递参数给远程过程调用(RPC)方法的部分)
            else
                ...
end

resolve这个函数的功能就是为method中字段选择方法

function resolve(mod, method) --第一个参数是是四种方法分别是`login`,`singleLogin`,`merge`,`checkNet` 第二个参数是JSON中的method字段
    local path = luci.util.split(method, ".") -- 根据.来分割字符

    for j = 1, #path - 1 do
        if not type(mod) == "table" then -- 这段代码是检查导入的"luci.modules.noauth" 模块是否成功
            break
        end
        mod = rawget(mod, path[j]) --从 Lua 表格 mod 中获取键为 path[j] 的元素或字段的值,并将该值存储在变量 mod 中
        if not mod then
            break
        end
    end
    mod = type(mod) == "table" and rawget(mod, path[#path]) or nil
    if type(mod) == "function" then
        return mod
    end
end

reply函数根据传入的参数创建一个符合JSON-RPC规范的响应对象

function reply(jsonrpc, id, res, err)
    require "luci.json"
    id = id or luci.json.null

    -- 1.0 compatibility
    if jsonrpc ~= "2.0" then
        jsonrpc = nil
        res = res or luci.json.null
        err = err or luci.json.null
    end
    -- if type(res) == "string" then
    --     res = luci.json.decode(res) or res
    -- end
    return {id = id, data = res, error = err, jsonrpc = jsonrpc, code = 0}
end

proxy

function proxy(method, ...)
    local tool = require "luci.utils.tool"
    local res = {luci.util.copcall(method, ...)}--在这里又调用了copcall
    ...
end

copcall

function copcall(f, ...)
	return coxpcall(f, copcall_id, ...)
end

coxpcall

function coxpcall(f, err, ...)
	local res, co = oldpcall(coroutine.create, f) --在这里利用coroutine.create创建一个新的进程f,也就是我们选择的method
    ...

四种metho

下面就是重点了,分析四种metho了,根据上面的分析这四种方法在luci.modules.noauth

singleLogin

没有看见可以控制的参数

-- 单点登录
function singleLogin()
    local sauth = luci.sauth
    local fs = require "nixio.fs"
    local config = require("luci.config")
    config.sauth = config.sauth or {}
    local sessionpath = config.sauth.sessionpath
    if sauth.sane() then
        local id
        for id in fs.dir(sessionpath) do
            sauth.kill(id)
        end
    end
end

login

调用includeXxs过滤危险字符

调用checkPasswd,checkPasswd里面有调用了cmd.devSta.getcmd.devSta.get又调用了doParams会对于未检查到的特殊字符进行进一步过滤

故这个也没有可以利用的漏洞

function login(params)
    local disp = require("luci.dispatcher")
    local common = require("luci.modules.common")
    local tool = require("luci.utils.tool")
    if params.password and tool.includeXxs(params.password) then --检查输入的密码是否为空,并调用includeXxs函数(就是检查密码中是否含有[`&$;|]这些特殊字符)
        tool.eweblog("INVALID DATA", "LOGIN FAILED")
        return
    end
    local authOk
    local ua = os.getenv("HTTP_USER_AGENT") or "unknown brower (ua is nil)"
    tool.eweblog(ua, "LOGIN UA")
    local checkStat = {   -- 创造一个结构题,但我们只能控制password字段
        password = params.password,
        username = "admin", -- params.username,
        encry = params.encry,
        limit = params.limit
    }
    local authres, reason = tool.checkPasswd(checkStat)--调用checkPasswd
    ...

checkNet

检查了host的合法性,并拼接了一下字符

ction checkNet(params)
    if params.host then
        local tool = require("luci.utils.tool")
        if string.len(params.host) > 50 or not tool.checkIp(params.host) then --过滤"^[\.%d:%a]+$"
            return {connect = false, msg = "host illegal"}
        end
        local json = require "luci.json"
        local _curl =
            'curl -s -k -X POST \'http://%s/cgi-bin/luci/api/auth\' -H content-type:application/json -d \'{"method":"checkNet"}\'' %
            params.host  --拼接字符
        ...

merge

调用devSta.set并将params当成data传入,没有看到有什么过滤,下面就是查找命令执行

function merge(params)
    local cmd = require "luci.modules.cmd"
    return cmd.devSta.set({device = "pc", module = "networkId_merge", data = params, async = true})
end
devSta.set
    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)
    end
fetch
local function fetch(fn, shell, params, ...)
    require "luci.json"
    local tool = require "luci.utils.tool"
    local _start = os.time()
    local _res = fn(...) --调用fn函数,也就是model.fetch
   ...

model.fetch

最终将参数都传递给了/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 ctype

...
    local stat = uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi)
    return stat
end

下面就是找一下client_call的定义

首先就是在libuflua.so找一下client_call的具体名字是uf_client_call

image-20230926183350022

先将传入的data等字段转为Json格式的数据,作为param字段的内容。然后将Json数据通过uf_socket_msg_writesocket套接字(分析可知,此处采用的是本地通信的方式)进行数据传输

  if ( !a3 )                                    // 判断是否有调用了方法
  {
	...
  }
 v4 = json_object_new_object();//创建一个JSON对象用于存放键对值
   ...
  switch ( *(_DWORD *)a1 )//这里的a1是ctype在dev_sta.lua文件中赋值为2
  {
	...
    case 2:
      v7 = ((int (*)(void))strlen)() + 8;
      v8 = calloc(v7, 1);
      v9 = 433;
      if ( !v8 )
        goto LABEL_20;
      v10 = v8;
      v11 = v7;
      v12 = "devSta.%s";
      goto LABEL_22;
   	  ...
  }
	...
LABEL_22:                                       // 开始为各种字段设置相应字段
      ...
      v18 = json_object_new_string(v8);     //创建一个新的JSON字符串对象
      free(v8);
      if ( !v18 )
      {
        ...
      }
      json_object_object_add(v4, "method", v18);//向JSON对象中存放一个键对值,其中键是字符串 "method",值是v18(v18一般是一个结构体的地址)
      v19 = json_object_new_object();
      if ( !v19 )
      {
        ...
      }
      v20 = json_object_new_string(*(_DWORD *)(a1 + 8));
      if ( !v20 )
      {
        ...
      }
      json_object_object_add(v19, "module", v20);
      v21 = *(_DWORD *)(a1 + 20);
      if ( !v21 )
        goto LABEL_34;
      v22 = json_object_new_string(v21);
      if ( !v22 )
        goto LABEL_40;
      json_object_object_add(v19, "remoteIp", v22);
LABEL_34:
      v23 = *(_DWORD *)(a1 + 24);
      if ( v23 )
      {
        ...
        json_object_object_add(v19, "remotePwd", v24);
      }
      if ( *(_DWORD *)(a1 + 36) )
      {
        v25 = json_object_new_int();
        ...
        json_object_object_add(v19, "buf", v25);
      }
      if ( *(_DWORD *)a1 )
      {
        ...
      }
      else
      {
        ...
      }
      v26 = *(unsigned __int8 *)(a1 + 45);
LABEL_56:
      if ( v26 )
      {
        v31 = json_object_new_boolean(1);
        if ( v31 )
          json_object_object_add(v19, "from_url", v31);
      }
      if ( *(_BYTE *)(a1 + 47) )
      {
        v32 = json_object_new_boolean(1);
        if ( v32 )
          json_object_object_add(v19, "from_file", v32);
      }
      if ( *(_BYTE *)(a1 + 48) )
      {
        v33 = json_object_new_boolean(1);
        if ( v33 )
          json_object_object_add(v19, "multi", v33);
      }
      if ( *(_BYTE *)(a1 + 46) )
      {
        v34 = json_object_new_boolean(1);
        if ( v34 )
          json_object_object_add(v19, "not_commit", v34);
      }
      ...
      v36 = *(_BYTE **)(a1 + 12);
      if ( !v36 || !*v36 )
        goto LABEL_75;
      v37 = json_object_new_string(v36);
      if ( !v37 )
        goto LABEL_78;
      json_object_object_add(v19, "data", v37);
LABEL_75:
      v38 = *(_BYTE **)(a1 + 16);
      if ( v38 && *v38 )
      {
        v39 = json_object_new_string(v38);
        if ( !v39 )
        {
			...
        }
        json_object_object_add(v19, "device", v39);
      }
      json_object_object_add(v4, "params", v19);
      v40 = json_object_to_json_string(v4);
      if ( !v40 )
      {
        ...
      }
      v41 = uf_socket_client_init(0);
      if ( v41 <= 0 )
      {
        ...
      }
      v46 = strlen(v40);
      uf_socket_msg_write(v41, v40, v46);//通过uf_socket_msg_write使用socket进行数据传输
      json_object_put(v4);
      ...
  while ( 1 )
  {
    ...
  }
  else
  {
    ...
  }
}

下面就是找一下接收的地方

image-20230926183729971

二进制文件流程分析

说一下这个程序的流程,因为代码量有点大就不再一一说明,主要就是调试,只要掌握了调试的技巧其实是很快的,在调试的时候从分支三开始调试,前两个分支只可以下断点到被阻塞之后的函数,因为前面的函数都是初始化的函数执行的比较早,想看具体细节的可以看我上传的附件里面也有解密后的固件

链接:https://pan.baidu.com/s/1rGHH2FCBWMptIdgd3rF6WQ?pwd=ubm1
提取码:ubm1

分支一

main首先进入ufm_init这个函数中,接下来的流程如下

  • ufm_init
  • ufm_thd_init
  • sub_41AFC8 会执行 sem_wait(&unk_4360A8); 阻塞当前进程,直到在分之二中执行async_cmd_push_queue被唤醒
  • sub_41ADF0
  • ufm_popen触发漏洞

分支二

  • uf_cmd_task_init()
  • deal_remote_config_handle
  • uf_task_remote_pop_queue进入这个函数会阻塞当前进程(sem_wait(&unk_435E90);)需要触发在分之二中的sub_40b0b0去唤醒然后才会执行下面的函数
  • uf_cmd_call
  • ufm_handle
  • sub_40FD5C
  • sub_40CEACv72 += snprintf(&v66[v70], v68, ``" '%s'"``, v71);//这里存在了命令注入,data字段的值为我们可控,造成了任意命令拼接到原本的字符串上(第243行)
  • ufm_commit_add (第264行)
  • async_cmd_push_queue: 会执行sem_post(&unk_4360A8);就是唤醒分支一的sub_41AFC8进程

分支三

main会执行下面三个函数

  • uf_socket_msg_read:接收json字符串

  • parse_content :在这个函数中会执行两个函数1、parse_obj2_cmd作用就是解析字段,将接收的json字符串解析为json数组,2、pkg_add_cmd它的核心作用就是在 a1 这个数据结构中记录了 v16 的指针,使得后续操作通过 a1 访问到刚刚解析出来的各个字段

  • add_pkg_cmd2_task

    在这个函数中会有下面一个调用链

    • sub_40B304 94行
    • sub_40B0B0 32行 在这个函数中会执行sem_post(&unk_435E90);唤醒分支二的uf_task_remote_pop_queue

json的几种格式

  • 1 表示 JSON 对象(json_type_object
  • 2 表示 JSON 数组(json_type_array
  • 3 表示 JSON 字符串(json_type_string
  • 4 表示 JSON 整数(json_type_int
  • 5 表示 JSON 双精度浮点数(json_type_double
  • 6 表示 JSON 布尔值(json_type_boolean
  • 7 表示 JSON 空值(json_type_null

uf_socket_msg_read接受的数据

image-20230921144157053

调试

这个就是顺便提一下,必须是在系统级下进行调试,还是老样子
传进入一个 [gdbserver ](gdb-static-cross/prebuilt/gdbserver-7.7.1-mipsel-ii-v1 at master · stayliv3/gdb-static-cross · GitHub)

gdbserver 0.0.0.0:9999 --attach PID

在外边启动gdb-multiarch输入target remote 192.168.195.153:9999

参考

[原创]站在巨人肩膀上复现CVE-2023-34644-智能设备-看雪-安全社区|安全招聘|kanxue.com

[原创] 记一次全设备通杀未授权RCE的挖掘经历-智能设备-看雪-安全社区|安全招聘|kanxue.com