Tor Deep Dive Part 5: What a Hostile Exit Can Do to an SSH Connection, Tested in an Isolated Lab

The October 2026 incident (Part 1) came down to one capability: a relay that carries your port 22 traffic can send it somewhere else. Part 4 showed the host key warning that stopped one victim. This part builds the redirect in an isolated lab so each outcome can be seen on its own: when the client is warned, when it is not, and when the attacker gets nothing.

The lab reproduces only the redirect and the SSH client and server behaviour. There is no Tor in it. The Tor side of the incident, the edited torrc and the changed exit policy, is covered in Part 1. I also did not build a relay that forwards the captured login on to the real server. That is the part that makes a real attack invisible, and it adds nothing to the defensive picture, so I left it out.

The lab

Four network namespaces on one Debian 13 VM, joined by virtual links:

client 10.9.0.2 --- exit 10.9.0.1 / 10.9.1.1 / 10.9.2.1 ---+--- real  10.9.1.10  (sshd, host key A)
                                                            +--- evil  10.9.2.66  (sshd, host key B)

The exit namespace is a router with forwarding on. It stands in for the relay. real is the server the user means to reach. evil is a second stock sshd with a different host key, standing in for an interception server. Both run unmodified OpenSSH.

Creating the namespaces and links:

for n in client exit real evil; do sudo ip netns add $n; sudo ip -n $n link set lo up; done
sudo ip link add c-e type veth peer name e-c; sudo ip link set c-e netns client; sudo ip link set e-c netns exit
sudo ip link add e-r type veth peer name r-e; sudo ip link set e-r netns exit;   sudo ip link set r-e netns real
sudo ip link add e-v type veth peer name v-e; sudo ip link set e-v netns exit;   sudo ip link set v-e netns evil
sudo ip -n client addr add 10.9.0.2/24 dev c-e;  sudo ip -n exit addr add 10.9.0.1/24 dev e-c
sudo ip -n exit   addr add 10.9.1.1/24 dev e-r;  sudo ip -n real addr add 10.9.1.10/24 dev r-e
sudo ip -n exit   addr add 10.9.2.1/24 dev e-v;  sudo ip -n evil addr add 10.9.2.66/24 dev v-e
for p in "client c-e" "exit e-c" "exit e-r" "real r-e" "exit e-v" "evil v-e"; do set -- $p; sudo ip -n $1 link set $2 up; done
sudo ip -n client route add default via 10.9.0.1
sudo ip -n real   route add default via 10.9.1.1
sudo ip -n evil   route add default via 10.9.2.1
sudo ip netns exec exit sysctl -qw net.ipv4.ip_forward=1

Each sshd runs in its own namespace from its own config with its own host key. The two host key fingerprints:

real: SHA256:WLrRQQ8fD+pDMTtgu5KHr+iH+a1Uh3SA06eOAvkLnVc
evil: SHA256:mmPA3D+Uqp3dWJx0CSKSEqJGWjBgOPpbFxNg/28ixps

Two test accounts exist on both: labkey, who logs in with a key, and labvictim, who logs in with a password. The real server accepts both methods. The interceptor accepts the password but has an empty authorised keys file. That asymmetry is the whole point. An attacker’s server can accept a password you hand it. It cannot accept a key it has never been given.

Baseline

With no redirect in place, both logins reach the real server and record its host key:

logged in as labkey; SSH_CONNECTION=10.9.0.2 54470 10.9.1.10 22
recorded host key: SHA256:WLrRQQ8fD+pDMTtgu5KHr+iH+a1Uh3SA06eOAvkLnVc
logged in as labvictim
recorded host key: SHA256:WLrRQQ8fD+pDMTtgu5KHr+iH+a1Uh3SA06eOAvkLnVc

The redirect

One rule on the exit namespace rewrites the destination of anything headed for the real server’s port 22:

sudo ip netns exec exit nft add table ip nat
sudo ip netns exec exit nft 'add chain ip nat prerouting { type nat hook prerouting priority -100 ; }'
sudo ip netns exec exit nft add rule ip nat prerouting ip daddr 10.9.1.10 tcp dport 22 dnat to 10.9.2.66

The client keeps connecting to 10.9.1.10. Nothing on the client changes.

Three outcomes

1. The client already knows the real host key. This is the researcher’s case in the incident:

@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
The fingerprint for the ED25519 key sent by the remote host is
SHA256:mmPA3D+Uqp3dWJx0CSKSEqJGWjBgOPpbFxNg/28ixps.
Host key verification failed.
exit status: 255

The connection stops before any credential is sent.

2. A password user on a machine that has never seen the server. The client has no key on record, so there is nothing to compare against:

logged in as labvictim; SSH_CONNECTION=10.9.0.2 54506 10.9.2.66 22
recorded host key: SHA256:mmPA3D+Uqp3dWJx0CSKSEqJGWjBgOPpbFxNg/28ixps

The login worked and no warning appeared. The only evidence is in the details a user does not normally look at: SSH_CONNECTION shows the session ended on 10.9.2.66, and the recorded host key is the interceptor’s. In a real attack the interceptor then passes the session on, so even that goes away. The password has already been handed to the wrong machine.

3. A key user on a machine that has never seen the server:

labkey@10.9.1.10: Permission denied (publickey,password,keyboard-interactive).
exit status: 255

The client accepted the wrong host key, as in case 2, but there is no login and no password. The interceptor could not complete public-key authentication, because the client’s signature covers a session identifier that is tied to that one connection, and the attacker has no authorised key to match it. The real server never saw the connection, and the attacker obtained nothing usable.

Client stateAuthenticationResult
Real host key on recordKey or passwordStopped by the warning, no credential sent
No host key on recordPasswordLogged in to the wrong server, password captured
No host key on recordKeyDenied at the interceptor, nothing captured

What a defender can see

From the client, comparing the host key you are offered with the one you expect catches the redirect without a login:

ssh-keyscan -t ed25519 10.9.1.10 2>/dev/null | ssh-keygen -lf - | cut -d' ' -f2
SHA256:mmPA3D+Uqp3dWJx0CSKSEqJGWjBgOPpbFxNg/28ixps

That is the interceptor’s key, not WLrRQ.... If the answer does not match what you recorded out of band, do not log in.

From the relay host, the rule is plain to see. The Tor Project’s advisory to relay operators lists looking for NAT rules that redirect port 22, with nft list ruleset and iptables -t nat -S. On a host you can trust:

sudo nft list ruleset | grep -n 'dport 22'
sudo iptables-save -t nat 2>/dev/null | grep -- '--dport 22'
sudo conntrack -L 2>/dev/null | grep 'dport=22'

In the lab, the first command returned the one rule:

4:		ip daddr 10.9.1.10 tcp dport 22 dnat to 10.9.2.66

and conntrack showed the rewritten destination on each flow, with the original dst=10.9.1.10 on the way in and src=10.9.2.66 on the reply:

tcp 6 119 TIME_WAIT src=10.9.0.2 dst=10.9.1.10 sport=53470 dport=22 src=10.9.2.66 dst=10.9.0.2 sport=22 dport=53470 [ASSURED]

One caveat applies to checking a compromised host. Part 6 explains that Ebury runs inside the processes it hooks, so a relay that has been taken over can lie to its own tools. Run these checks from a clean boot or an offline image, and compare the published exit policy against what you deployed from outside the host, which Part 8 automates.

What this changes

  1. Key-based login removes the payoff. A redirected key user gets nothing. Disabling password authentication on the server and in the client config is the strongest single change.
  2. A host key warning is only as good as your known_hosts. A fresh machine, a rebuilt VM or a new laptop has no history, so seed it from a source you trust before connecting over any untrusted path.
  3. First contact needs an out-of-band check. Compare the fingerprint with one you obtained another way, or use SSH host certificates so the question does not arise (see the SSH hardening post).
  4. An onion service has no exit to redirect. That is the main reason Part 4 recommends one where you control both ends.

Cleaning up

sudo kill $(cat /tmp/lab/real.pid) $(cat /tmp/lab/evil.pid)
for n in client exit real evil; do sudo ip netns del $n; done
sudo userdel -r labkey; sudo userdel -r labvictim

The next part looks at Ebury, the malware on the relay hosts, and at how to check your own Debian machines for it.