HTB on Qubes: an OpenVPN Gateway and the Reverse Shell Problem
Five minutes on Kali, five hours on Qubes. The default route that kills your internet, where the DNAT rule belongs, and the mistakes I made getting there.
On a Kali laptop, HTB takes five minutes to set up: download the .ovpn, start openvpn, done. On Qubes OS the same thing cost me five hours.
Not because Qubes is difficult. Because things that run together on a single machine here have to work across domain boundaries. The outbound path worked immediately. The way back, meaning reverse shells, took the rest of the time.
What came out of it is a structure worth having, and a few traps that aren’t in any guide.
The chain
Four qubes, split by job:
Internet → sys-net → sys-firewall → htb-vpn → HTB
sys-net holds the hardware, sys-firewall routes, htb-vpn builds the OpenVPN tunnel to HTB, and HTB is where I work. That last one has no VPN and no tun interface of its own. It sends its traffic through htb-vpn whether it wants to or not.
One thing matters here: the HTB VPN runs separately from my everyday VPN. Browsing and general use go through a Mullvad qube, HTB through this one. No stacked VPNs, no shared path. What applies to HTB applies only to HTB.
Setting up the gateway
Most guides online date from the 4.1 era and use hand-written iptables scripts (qubes-vpn-handler.sh and relatives). Those break under 4.3, and the Qubes maintainers now advise against them. The current route is NetworkManager.
Create the ProxyVM, in dom0:
qvm-create --template debian-13-xfce --label orange --prop provides_network=true htb-vpn
qvm-prefs htb-vpn netvm sys-firewall
Add the network-manager service in the qube’s Services tab. Install openvpn and network-manager-openvpn in the template.
Then copy the HTB profile into the qube with qvm-copy and import it:
nmcli connection import type openvpn file ~/QubesIncoming/HTB/machines_eu-dedivip-2.ovpn
nmcli connection up machines_eu-dedivip-2
Finally, hang the working qube behind it:
qvm-prefs HTB netvm htb-vpn
That much matches what the current guides say. The next two points don’t appear in them.
Trap 1: HTB takes your internet away
The moment the VPN came up, the HTB qube had nothing. No DNS, no browsing. Without the VPN everything worked.
The routing table explains it:
default via 10.10.14.1 dev tun0 metric 50
default via 10.138.9.216 dev eth0 metric 100
The HTB profile pushes a default route over tun0, and it wins on the lower metric. So all traffic goes into the tunnel, including requests for ordinary websites. HTB only routes its lab networks. Everything else disappears.
On a single laptop this barely registers, because during an HTB session you’re on HTB anyway. In a ProxyVM it hits every qube behind it.
The fix is one line, and it works because HTB already routes the lab networks explicitly (10.10.8.0/22, 10.129.0.0/16 and a few more). The default route isn’t needed at all:
nmcli connection modify machines_eu-dedivip-2 ipv4.never-default yes
nmcli connection down machines_eu-dedivip-2
nmcli connection up machines_eu-dedivip-2
After that a default route over eth0 remains while the HTB networks keep going through the tunnel, and both work at the same time.
Trap 2: the way back
Outbound worked straight away. nmap against the box, exploit out, fine. A shell just never came back.
That’s down to where the interfaces live. The listener runs in HTB, but the tun0 interface sits in htb-vpn. When the box connects back, it arrives at the HTB address of htb-vpn, and that’s where the path ends. The connection would have to be handed on, and nothing does that by itself.
What’s needed is a DNAT rule in htb-vpn: forward incoming connections on certain ports to the working qube’s internal address.
Where you put it matters more than it sounds. Qubes ships a dnat-dns chain that redirects DNS queries, and your own rules have no business in there. A separate chain, running right after it:
sudo nft add chain qubes htb-dnat '{ type nat hook prerouting priority -95 ; }'
sudo nft add rule qubes htb-dnat iifname "tun0" tcp dport { 80, 4443, 8000, 9001 } dnat to 10.137.0.26
The 10.137.0.26 is my HTB qube’s internal address (ip -4 -br a shows it). Priority -95 lets Qubes’ DNS redirect at -100 run first.
No forward rule is needed. The forward chain already contains:
iifname != "vif*" accept
Traffic from the tunnel doesn’t come from an AppVM, so it’s accepted regardless. I set a redundant rule there at first.
Then let the working qube accept the ports:
sudo nft add rule qubes custom-input tcp dport { 80, 4443, 8000, 9001 } accept
And the test:
$ nc -lvnp 9001
Listening on 0.0.0.0 9001
Connection received on 10.129.x.x 52018
The callback address you hand the target is the tun0 address of htb-vpn. It changes with every new VPN connection, so check it first:
ip -4 -br a | grep tun0What I got wrong
The route there was longer than it needed to be, and the detours teach more than the solution.
I wrote into dnat-dns. Obvious choice, since the chain is already there and already does DNAT. It’s Qubes’ DNS redirect, and putting your own rules in it is a bad idea.
I used ping against the DNS address as a test. ping 10.139.1.1 fails even when DNS works perfectly. The resolver answers on port 53, not to ICMP. I spent time debugging a symptom that wasn’t one. nslookup or getent hosts are the right tests.
The VPN qube sat without a NetVM for a while. No eth0, no uplink, and OpenVPN reports Could not find source connection. The error points at the VPN configuration; the cause was a layer below. Before troubleshooting a VPN, check ip -4 -br a: is eth0 even there?
I set rules without looking at the existing ruleset. An nft list ruleset in the VPN qube would have shown immediately that Qubes keeps a chain per attached AppVM (qbs-10-137-0-26) and that forward traffic from the tunnel is already permitted. Two of the rules I added were redundant.
Making it persistent
nftables rules don’t survive a reboot. In htb-vpn, into /rw/config/qubes-firewall-user-script:
nft add chain qubes htb-dnat '{ type nat hook prerouting priority -95 ; }'
nft add rule qubes htb-dnat iifname "tun0" tcp dport { 80, 4443, 8000, 9001 } dnat to 10.137.0.26
In HTB, into /rw/config/rc.local:
nft add rule qubes custom-input tcp dport { 80, 4443, 8000, 9001 } accept
Make both executable. The internal qube address is hard-coded here. It stays stable across reboots, but changes if you recreate the qube or attach it to a different NetVM.
The same applies to bug bounty
None of this is HTB-specific. Once the structure exists it transfers: swap htb-vpn for a qube running whatever VPN you need, swap the HTB qube for a working qube with Burp, swap nc for a proxy listener. The chain stays, the target changes.
The pattern underneath is the interesting part. A network layer the working qube cannot bypass, because the separation lives in the routing rather than in an application setting. A misconfigured application can’t leak around it.
Is it worth it
Five hours for something that takes five minutes elsewhere sounds like a bad trade. For me it isn’t, and the reason is isolation.
With HTB it doesn’t matter yet. It’s a lab, the targets are built to be attacked, and whatever malicious code or exploit you handle there does its damage, at worst, inside a throwaway environment. The point is different: if you only build the separation when you need it, you build it under pressure. On real targets, with someone else’s data and credentials you’ve been trusted with, you don’t want to start reasoning about network layers.
So you build it in the lab. There it’s allowed to cost five hours, there you can break your own networking three times over, and there you find out where the traps are. When it counts, the structure is already standing and you just work with it.
The cost is also one-off. The rules now live in two files, the gateway starts with everything else, and the next session costs nothing more than a look at the current tunnel address.