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

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.

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.

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.

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.

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

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

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.

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.

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.

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>

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.

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.

Modified Configuration File

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.