~17 min read

Pwn2Own Ireland 2024: QNAP Qhora-322

In 2024, we competed as team Neodyme in the Pwn2Own Ireland contest, targeting the "SOHO Smashup" category and all available printers. For our entry, we focused on the QNAP QHora-322 router, successfully exploiting it to pivot into the Canon imageCLASS MF656Cdw printer. This post details the vulnerabilities we discovered, our methodology (from working with a factory-sealed router to achieving remote access via the WAN port without authentication) and how we ultimately compromised the connected printer.
Authored by:

This blog post is part of a series about various exploits that Neodyme presented at the Pwn2Own hacking competition. These posts detail our research methodology and journey in exploiting different devices. See our Pwn2Own Series for more.

TL;DR

In 2024, we participated as team Neodyme at the Pwn2Own Ireland competition. For our “SOHO-Smashup” entry, we exploited the QNAP QHora-322 router from WAN and pivoted to RCE on the Canon imageCLASS MF656Cdw printer.

This post outlines the process from a factory-sealed router to LAN access via the WAN port without further authentication.

Motivation

The bugs we found and exploited are not novel nor in new technologies; however, they provide some learning opportunities or entertainment value. Hence, this article aims to challenge the curious and to be used as a resource to sharpen one’s knife. Throughout the blog post, there will be a few challenges which we invite the initiated to follow along. This will also serve as a base for further security research, no hardware needed. Further, by telling the story of finding these bugs in modern-day hardware, we aim to encourage security research and the responsible disclosure of bugs.

For the SOHO Smashup, the objective is to first compromise a router from the WAN and then pivot from that foothold to compromise a variety of small-office devices. You can find the more detailed rules here. In 2024, the contest included five routers, with the QNAP router appearing for the first time. Because we’d seen similar targets at Pwn2Own, we had a gut feeling it would be worth a closer look, and it turned out we weren’t alone: of the nine SOHO Smashup entries, eight focused on QNAP. 😬 Remarkably, those eight entries did not exploit the same vulnerability; rather, they produced a variety of distinct exploit chains. We were drawn fifth in the running order and overlapped with another team on only one bug, the remainder of our chain was entirely unique.

Retrieving the Firmware and Gaining a Foothold

We started with a factory-new, sealed router package in our hands and the public firmware download page at QNAP at our disposal. Having access to (in this case, encrypted) downloadable firmware helps greatly when assessing vulnerabilities for consumer devices that are well into the triple or quadruple digits, as it allows us to determine the attack surface without having to physically own a device beforehand. So first, let’s check out the firmware download.

The downloaded file is recognized as “data” with an entropy of 7.999 bits per byte, which indicates encryption. There is no obvious header or anything else recognizable. A quick online search reveals that they have their own custom symmetric cipher.

Challenge 1: Firmware Encryption Using the firmware download link above, try putting your online research skills to the test and see if you can track down a decryptor for the firmware update image.

If you do find something, ask yourself:

  • Why doesn’t it work straight away?
  • How might you get around that limitation? Tools like grep.app or Sourcegraph can be especially useful here, as they can help you discover different code variants or at least get a sense of how certain things are structured.

There are public scripts available that implement their simple encryption/decryption algorithm. The algorithm has been shared and is known under the name “PC1”. The only guesswork that remained was guessing a secret key specific to each QNAP product. For the router, this was fairly trivial and allowed us to decrypt the firmware update: Qhora-322. The image turned out to be a compressed tar archive containing several scripts, checksums and two filesystems: a rootfs and a separate usr squashfs. With this, we could analyze the custom binaries and configuration data. With this first success out of the way, one would typically start assessing the typically exposed attack surfaces to get a feeling for the general security posture, before spending 500+€ for the actual hardware. Sometimes, one can already get quite far and safely assume a full bug chain before actually committing to buying the device.

For the sake of this blog post, let’s examine the attack surface given the physical router from a more dynamic point of view.

When setting up the router, we document each step taken and try to keep any custom configuration to the minumum. We make sure to disallow any updates to ensure good research conditions. When we have the physical piece of hardware in our hands, our primary goal is excellent debugging capabilities. Given that we already knew that the router was running Linux, our first goal was to get a root shell.

Challenge 2: Reconnaissance Take, for instance, this video tour of the router’s interface and think about what features you might instinctively use to gain a shell: YouTube. You can also check out the vendor’s website and take a look at the router’s advertised features.

The authenticated web interface includes, for instance, a firmware update function, typical diagnostic functions, but also a feature to connect the router to a network via VPN. We guesstimated that this feature uses a simple openvpn config.ovpn command in the background with our provided config as input. This trivially leads to RCE given the up configuration option, which executes commands once a connection is established.

In case you deal with software or Linux systems you don’t know and do not want to dig deep into each documentation, GTFOBins offers a great collection of resources for nifty primitives in common binaries.

With that, we can upload our specially prepared OpenVPN config and use the up config to spawn a reverse shell. So, here’s the first root shell of this blog post:

bash-3.2# id
id
uid=0(root) gid=0(root)
bash-3.2# hostname
hostname
QHora322854495

This leaves us with all we need to assess the security of the QNAP Qhora-322 router.

Attack Surface

Given the goal of achieving RCE via the WAN interface, one could start by looking for RCE vectors within specific binaries and determine which are reachable. This would be a bottom-up approach, starting with potentially vulnerable sinks and finding the path to attacker-controlled sources. However, given that we are coming from WAN, it makes more sense to look first at what we actually can reach and go from there, since there is usually little exposed.

Naturally, we start by looking at the front door, the firewall, and the services directly exposed on the WAN.

From the running device, we observe an array of services running:

$ netstat -tulpen
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.1.1:53 0.0.0.0:* LISTEN 6874/dnsmasq
tcp 0 0 127.0.0.1:58080 0.0.0.0:* LISTEN 6865/webframework
tcp 0 0 127.0.0.1:3008 0.0.0.0:* LISTEN 5937/light-indicator
tcp 0 0 127.0.0.1:5000 0.0.0.0:* LISTEN 6866/cloudagent
tcp 0 0 127.0.0.1:6666 0.0.0.0:* LISTEN 3649/rapid
tcp 0 0 127.0.0.1:6379 0.0.0.0:* LISTEN 6091/redis-server 1
tcp 0 0 127.0.0.1:7505 0.0.0.0:* LISTEN 6483/openvpn
[..snip..]
tcp6 0 0 :::443 :::* LISTEN 6865/webframework
tcp6 0 0 :::80 :::* LISTEN 6865/webframework
[..snip..]
udp 0 0 255.255.255.255:67 0.0.0.0:* 5012/dm_agent
udp 0 0 0.0.0.0:67 0.0.0.0:* 5427/dhcpd
udp 0 0 0.0.0.0:68 0.0.0.0:* 7745/dhclient
udp 0 0 0.0.0.0:4500 0.0.0.0:* 4917/charon
udp 0 0 0.0.0.0:500 0.0.0.0:* 4917/charon
udp 0 6144 0.0.0.0:5353 0.0.0.0:* 5012/dm_agent
udp 0 0 0.0.0.0:5555 0.0.0.0:* -
udp 0 0 10.31.0.87:7788 0.0.0.0:* 3712/quwan_wmd
[..snip..]
udp6 0 0 :::39298 :::* 5411/dhcpd
udp6 0 0 :::4500 :::* 4917/charon
udp6 0 0 :::500 :::* 4917/charon
udp6 0 0 :::8097 :::* 5566/bcclient
[..snip..]

This observation already shows the webframework binary, which is the HTTP management interface. The netstat output indicates it is listening on IPv6 only; however, the modern way of binding a socket to IPv6 enables IPv4 traffic via IPv4 mapped IPv6 addresses, which essentially “listen” on both IPv4 and IPv6. In Linux, creating an IPv6 socket with IPV6_V6ONLY set to zero (default) leads to this behaviour. The webserver, per default, runs on :http and :https, if not explicitly specified otherwise. This causes it to listen on both IPv6 and IPv4. When we started examining the router, we found the first vulnerability: the firewall did not account for IPv6, allowing pre-authenticated access to the web interface. By reversing the Go binary with Ghidra and the GolangAnalyzerExtension, we quickly found some endpoints that were vulnerable to pre-auth command injection. If you’re interested, try reversing or patch-diffing the webframework binary from firmware revision 2.4.1.634.

These vulnerabilities were fixed before the competition began, so our entry relied on a different firewall bypass.

Below is the relevant iptables output. Try for yourself if you can spot ZDI-25-749, one of the bugs in our Pwn2Own entry.

Spot the Firewall Bug: ZDI-25-749 We only look at the IPv4 Firewall for brevity.

iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
BLOCK_LIST all -- anywhere anywhere mark match 0x0/0xc00
EXTENSION_FIREWALL all -- anywhere anywhere mark match 0x0/0xc00
USER_FIREWALL all -- anywhere anywhere mark match 0x0/0xc00
PARENTAL_CONTROL all -- anywhere anywhere
FIREWALL all -- anywhere anywhere mark match 0x10/0x30
REJECT tcp -- !198.19.0.0/16 anywhere multiport dports 9877,9999 ! match-set rlan src reject-with tcp-reset
Chain FORWARD (policy ACCEPT)
target prot opt source destination
BLOCK_LIST all -- anywhere anywhere mark match 0x0/0xc00
USER_FIREWALL all -- anywhere anywhere mark match 0x0/0xc00
PARENTAL_CONTROL all -- anywhere anywhere
GUESTFORWARD all -- anywhere anywhere mark match 0x0/0xc00
WANFORWARD all -- anywhere anywhere match-set WANIFSET src,src
ATTACK_DEFENSE all -- anywhere anywhere mark match 0x0/0xc00
UPNP all -- anywhere anywhere
FIREWALL all -- anywhere anywhere
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
QUWAN_WHITELIST all -- anywhere anywhere
Chain ACCEPT_WITH_CONNMARK (2 references)
target prot opt source destination
MARK all -- anywhere anywhere MARK xset 0x400/0xc00
FIREWALL all -- anywhere anywhere
Chain ATTACK_DEFENSE (1 references)
target prot opt source destination
Chain BLOCK_LIST (2 references)
target prot opt source destination
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev2_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev3_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev4_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev5_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev6_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev7_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev8_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_swdev9_mac src
DROP_WITH_CONNMARK all -- anywhere anywhere match-set vlan_all_mac src
Chain DROP_WITH_CONNMARK (13 references)
target prot opt source destination
MARK all -- anywhere anywhere MARK xset 0x800/0xc00
FIREWALL all -- anywhere anywhere
Chain EXTENSION_FIREWALL (1 references)
target prot opt source destination
DROP_WITH_CONNMARK tcp -- anywhere anywhere match-set http dst match-set WANIFSET src,src
DROP_WITH_CONNMARK udp -- anywhere anywhere ! match-set LANIFSET src,src multiport dports 8097,8099
ACCEPT_WITH_CONNMARK tcp -- anywhere anywhere match-set lan src match-set https dst
DROP_WITH_CONNMARK tcp -- anywhere anywhere tcp dpts:0:1055 tcp flags:FIN,SYN,RST,ACK/SYN match-set WANIFSET src,src
DROP tcp -- anywhere anywhere tcp flags:FIN,SYN,RST,PSH,ACK,URG/NONE match-set WANIFSET src,src
ACCEPT_WITH_CONNMARK tcp -- anywhere anywhere match-set lan src match-set http dst
DROP_WITH_CONNMARK tcp -- anywhere anywhere match-set https dst match-set WANIFSET src,src
Chain FIREWALL (6 references)
target prot opt source destination
MARK all -- anywhere anywhere mark match 0x0/0xc00 MARK xset 0x400/0xc00
CONNMARK all -- anywhere anywhere CONNMARK save mask 0xc00
ACCEPT all -- anywhere anywhere connmark match 0x400/0xc00
DROP all -- anywhere anywhere connmark match 0x800/0xc00
Chain FORCE_ACCEPT (7 references)
target prot opt source destination
MARK all -- anywhere anywhere MARK xset 0x400/0xc00
FIREWALL all -- anywhere anywhere
Chain GUESTFORWARD (1 references)
target prot opt source destination
Chain PARENTAL_CONTROL (2 references)
target prot opt source destination
Chain QUWAN_WHITELIST (1 references)
target prot opt source destination
FORCE_ACCEPT all -- anywhere anywhere match-set localhost_set dst
FORCE_ACCEPT all -- anywhere anywhere match-set quwan_api dst
FORCE_ACCEPT all -- anywhere anywhere match-set quwan_srv dst
FORCE_ACCEPT udp -- anywhere anywhere match-set localip src match-set RESERVEDUDP dst
FORCE_ACCEPT udp -- anywhere anywhere match-set localip dst match-set RESERVEDUDP dst
FORCE_ACCEPT all -- anywhere anywhere match-set localip src match-set lan dst
FIREWALL all -- anywhere anywhere
Chain UPNP (1 references)
target prot opt source destination
Chain USER_FIREWALL (2 references)
target prot opt source destination
Chain WANFORWARD (1 references)
target prot opt source destination
DROP all -- anywhere anywhere ! ctstate DNAT ctdir ORIGINAL
ipset -L
<..snip..>
Name: quwan_api
Type: hash:net
Revision: 7
Header: family inet hashsize 1024 maxelem 65536 timeout 60 bucketsize 12 initval 0x641ec6f8
Size in memory: 456
References: 3
Number of entries: 0
Members:
Name: quwan_srv
Type: hash:net
Revision: 7
Header: family inet hashsize 1024 maxelem 65536 timeout 60 bucketsize 12 initval 0x32f247c2
Size in memory: 456
References: 2
Number of entries: 0
Members:
Name: lan
Type: hash:net
Revision: 7
Header: family inet hashsize 1024 maxelem 65536 bucketsize 12 initval 0xb7a90752
Size in memory: 840
References: 19
Number of entries: 8
Members:
192.168.106.0/24
192.168.100.0/24
192.168.103.0/24
192.168.101.0/24
192.168.102.0/24
192.168.104.0/24
192.168.105.0/24
192.168.107.0/24
Name: localip
Type: hash:ip
Revision: 5
Header: family inet hashsize 1024 maxelem 65536 bucketsize 12 initval 0x21bc1041
Size in memory: 560
References: 12
Number of entries: 9
Members:
192.168.103.1
192.168.100.1
10.31.0.87
192.168.102.1
192.168.107.1
192.168.105.1
192.168.101.1
192.168.106.1
192.168.104.1
Name: dport
Type: bitmap:port
Revision: 3
Header: range 0-65535
Size in memory: 8264
References: 0
Number of entries: 0
Members:
Name: WANIFSET
Type: hash:net,iface
Revision: 8
Header: family inet hashsize 1024 maxelem 65536 bucketsize 12 initval 0x6f18938a
Size in memory: 576
References: 14
Number of entries: 1
Members:
0.0.0.0/0,swdev1
Name: LANIFSET
Type: hash:net,iface
Revision: 8
Header: family inet hashsize 1024 maxelem 65536 bucketsize 12 initval 0xd2b563f9
Size in memory: 1192
References: 2
Number of entries: 9
Members:
192.168.103.0/24,swdev5
192.168.101.0/24,swdev3
192.168.100.0/24,swdev2
192.168.104.0/24,swdev6
192.168.105.0/24,swdev7
192.168.106.0/24,swdev8
192.168.102.0/24,swdev4
192.168.107.0/24,swdev9
Name: http
Type: bitmap:port
Revision: 3
Header: range 0-65535
Size in memory: 8264
References: 6
Number of entries: 1
Members:
80
Name: https
Type: bitmap:port
Revision: 3
Header: range 0-65535
Size in memory: 8264
References: 5
Number of entries: 1
Members:
443
Name: RESERVEDUDP
Type: bitmap:port
Revision: 3
Header: range 0-65535
Size in memory: 8264
References: 7
Number of entries: 5
Members:
67
68
1194
5555
7788
Name: RESERVEDTCP
Type: bitmap:port
Revision: 3
Header: range 0-65535
Size in memory: 8264
References: 1
Number of entries: 2
Members:
7788
Name: localhost_set
Type: hash:ip
Revision: 5
Header: family inet hashsize 1024 maxelem 65536 bucketsize 12 initval 0xaaf2a893
Size in memory: 280
References: 2
Number of entries: 2
Members:
127.0.1.1
127.0.0.1
<..snip..>

Dynamically probing the firewall from the WAN port using our favorite port scanner revealed no open TCP ports. Hence, it makes sense to look at the firewall statically by closely reviewing the deployed rule sets to find any holes. Any reachable service is attack surface we should examine.

The implemented iptable rules make heavy use of the ipset and mark extensions. This design is elegant in the sense that it is highly generic and user-supplied changes to the configured IPs of the WAN or LAN ports only affect the relevant ipsets and not the iptable rules themselves. However, it also adds complexity, a jungle that might be difficult to navigate and maintain.

Firewall Bypass

The main entry point of incoming packets is handled by the INPUT chain and is matched into sub-chains:

Chain INPUT (policy ACCEPT)
target prot opt source destination
BLOCK_LIST all -- anywhere anywhere mark match 0x0/0xc00
EXTENSION_FIREWALL all -- anywhere anywhere mark match 0x0/0xc00
[...]

Looking at the EXTENSION_FIREWALL, you can see that it is intended to drop or accept certain very specific packets.

Chain EXTENSION_FIREWALL (1 references)
target prot opt source destination
[1] DROP_WITH_CONNMARK tcp -- anywhere anywhere match-set http dst match-set WANIFSET src,src
[2] DROP_WITH_CONNMARK udp -- anywhere anywhere ! match-set LANIFSET src,src multiport dports 8097,8099
[3] ACCEPT_WITH_CONNMARK tcp -- anywhere anywhere match-set lan src match-set https dst
[4] DROP_WITH_CONNMARK tcp -- anywhere anywhere tcp dpts:0:1055 tcp flags:FIN,SYN,RST,ACK/SYN match-set WANIFSET src,src
[5] DROP tcp -- anywhere anywhere tcp flags:FIN,SYN,RST,PSH,ACK,URG/NONE match-set WANIFSET src,src
ACCEPT_WITH_CONNMARK tcp -- anywhere anywhere match-set lan src match-set http dst
[6] DROP_WITH_CONNMARK tcp -- anywhere anywhere match-set https dst match-set WANIFSET src,src

Here, it uses the LANIFSET and WANIFSET ipsets to map IP addresses and their respective interface (hence the IF in the names) to match rules. You can think of the interface as the physical Ethernet port the packet is coming from. Firewall rules can always be read “top to bottom”, and as soon as a rule matches, it is processed. The bug here is twofold: A weird ordering of rules and an oversight. Rule [3] matches incoming packets based on the source IP being in the lan ipset; however, it does not check the interface. The oversight is using lan instead of LANIFSET. This means we can craft a packet from the WAN interface that, as long as the source IP matches the lan ipset, is immediately accepted and further rule processing is stopped. Secondly, Rules [4] and [6] would clearly disallow incoming packets from the WAN interface to the https port; however, they are placed following Rule [3], which allows a firewall bypass on port 443.

Other Vulns

Now that we have pre-authenticated access to the web interface, we need to chain it together with other vulnerabilities to achieve full RCE. The VPN functionality of the router allowed remote access via four different VPN standards: WireGuard, OpenVPN, L2TP/IPsec, and QNAP’s proprietary QBelt. Examining the authorisation of each protocol using a statically compiled process scanning tool (i.e some pspy port), we found usage of the underlying qvpn_cli binary with user-provided credentials in all cases. A quick analysis of the binary revealed trivial SQL injections in both the username and password fields. Another catch here: while the three other protocols require pre-shared keys for authentication, OpenVPN relies on client certificates but the server, as configured, does not verify those certificates. This created another reachable attack vector from the WAN.

The underlying DBMS was sqlite3, and the injection point allowed for stacked queries. We also found that the webframework contained “engineer” APIs that enable a development mode which starts an SSH server and adjusts the firewall to make it globally reachable. The cherry on the cake was that the user credentials in another sqlite3 database were stored as encrypted plaintext instead of password hashes.

Initially, we mentioned that eight other teams targeted the QNAP Qhora-322 router as part of their SOHO Smashup. The published advisories (searching for “Qhora-322”) reveal some additional attack surface. Most notably the lionic_dpi kernel module (RCE in deep packet inspection) and another IPv6 misconfiguration (link-local ipv6 not firewalled). Take this list as additional inspiration.

Exploit Chain

Putting everything together, we:

  • Use the SQLi to read from the system.db and write the users to a new database in a directory that is served by the webserver.
  • Use the firewall bypass to access the DB and decrypt the admin user’s password.
  • Log in via web interface and set the router into the “dev” mode to spawn the SSH server (dropbear).
  • Log in via SSH and previously gathered credentials.

Executing the chain onstage greeted us with this beautiful screenshot, including a likely rare insight into the competition:

Running the exploit live on stage.

Running the exploit live on stage.

From there, we can move on and exploit the printer connected to one of the LAN interfaces for Part 2 of our SOHO chain.

While investigating the iptables, we found the tooling around visualisation and analysis of iptable rules to be in a desolate space. Some legacy tools exist, but collectively they do not help assess the security of production-grade iptables. If you read this and feel called to write a tool that can visualise iptable rules and is ready to answer questions like “What kind of traffic is allowed?”, please do, you’d be filling a real need. Be warned, the path will not be easy; however, this is an area that can be vastly improved and could lead to a product or service that would be extremely helpful.

Conclusion

We hope you enjoyed reading along, learned something, or were entertained by the state of security in (then) 2024. For readers who took the challenge and followed along, we hope to have sparked some joy, curiosity, and interest in security research. We encourage you to follow that path and tackle further real-world targets with the experience you gained. If you liked this style of blog posts, feel free to let us know. Lastly, we want to reiterate the classic: Complexity leads to vulnerability.

We at Neodyme thrive on complex challenges and are always eager to dive deep into custom protocols and architectures, providing expert-level insights to our clients. Get in touch if you need research-driven assessments tailored to your needs. We are happy to take on those challenges!

Share: