Tor Deep Dive Part 4: SSH Over Tor, Host Keys, and Why an Onion Service Beats an Exit

The October 2026 exit relay incident (Part 1) worked because SSH over a Tor exit passes through a relay that someone else controls. This part sets up SSH over Tor properly on the client built in Part 3, and shows what the host key warning looks like when it fires. It also shows the case where it does not fire at all.

I tested everything here against an onion service on a lab VM, with the SSH client running on the same machine and reaching the server through Tor. I did not send a login through a random clearnet exit. The mechanics are identical apart from the destination, and an unnecessary password across a public relay is exactly what the incident was about.

Why an onion service is the better answer

When you connect to an ordinary server over Tor, the last hop is an exit relay, which then opens a normal connection to the server. The exit can see the destination, and it can interfere with anything that is not protected by something it cannot forge.

An onion service has no exit. Your circuit and the server’s circuit meet at a rendezvous point inside the network, and the address you connect to is derived from the service’s own public key. A relay cannot present you with a different server under that address, because it cannot produce the matching key. The SSH host key is a second layer on top.

It is not free. Both ends need a running Tor client, connections are slower than a direct path, and a new service takes a while to become reachable, as the timeouts below show. For reaching your own servers I think that is a fair price, and Part 10 compares it with the alternatives.

Creating an onion service for SSH

On the server, add two lines to /etc/tor/torrc:

sudo tee -a /etc/tor/torrc >/dev/null <<'EOF'

HiddenServiceDir /var/lib/tor/ssh_onion/
HiddenServicePort 22 127.0.0.1:22
EOF
sudo tor --verify-config -f /etc/tor/torrc --defaults-torrc /usr/share/tor/tor-service-defaults-torrc
sudo systemctl reload tor@default

--verify-config reported Configuration was valid. Tor created the directory with the right ownership and permissions straight away:

drwx--S--- 3 debian-tor debian-tor 4096 Oct 21 12:08 /var/lib/tor/ssh_onion
authorized_clients
hostname
hs_ed25519_public_key
hs_ed25519_secret_key

The hostname file holds the onion address, and hs_ed25519_secret_key is the service’s identity. Treat that secret key like a private SSH key, because whoever has it can impersonate the service. The authorized_clients directory is for client authorisation, which restricts who can even look the service up. I did not test that.

ONION=$(sudo cat /var/lib/tor/ssh_onion/hostname)

The address is a 56-character v3 name ending in .onion. Note that the SSH service itself is still listening on all interfaces. If the onion service is meant to be the only way in, bind sshd to loopback and firewall port 22.

Connecting

Plain OpenSSH has no idea what Tor is, so it needs a proxy. The simplest form uses ProxyCommand with the OpenBSD netcat, which understands SOCKS5 (sudo apt install netcat-openbsd):

ssh -o ProxyCommand="nc -X 5 -x 127.0.0.1:9050 %h %p" anon@$ONION

My first attempts timed out. A new onion service publishes its descriptor to the network over a minute or more, and until that has spread, clients cannot find it. A retry loop connected on the second attempt:

attempt 1: Connection to UNKNOWN port 65535 timed out
attempt 2 OK
connected-via-onion
tor-lab

Two other forms also worked, both tested:

torsocks ssh anon@$ONION

and a config block in ~/.ssh/config, which also sets the safe defaults for anything on Tor:

Host *.onion
    ProxyCommand nc -X 5 -x 127.0.0.1:9050 %h %p
    PreferredAuthentications publickey
    PasswordAuthentication no
    ForwardAgent no
    IdentitiesOnly yes

With that block, ssh anon@$ONION connected and ran a command with no extra flags.

Host keys: the check that did the catching

The first connection records the server’s host key in known_hosts. The fingerprint the client recorded over Tor was identical to the one on the server:

client saw:  256 SHA256:ospIBjYcbsVnmnCVScxHxrMqJ0HxnUYU213jk5hzae8
server key:  256 SHA256:ospIBjYcbsVnmnCVScxHxrMqJ0HxnUYU213jk5hzae8

I got the server-side value with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub. Comparing the two over a channel an attacker does not control is the step that makes first contact safe.

Next I reproduced what the researcher in the incident saw. I put a different key for the same host name into known_hosts and connected:

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
The fingerprint for the ED25519 key sent by the remote host is
SHA256:ospIBjYcbsVnmnCVScxHxrMqJ0HxnUYU213jk5hzae8.
Please contact your system administrator.
Add correct host key in /tmp/kh2 to get rid of this message.
Offending ED25519 key in /tmp/kh2:1
Host key for <onion> has changed and you have requested strict checking.
Host key verification failed.

The exit status was 255 and the command never ran. This is the screen that stopped the password being typed in the incident. It only appears because the client already had a key on record.

The case with no warning

With an empty known_hosts, the same connection from a machine that has never seen the server succeeds silently under StrictHostKeyChecking=accept-new:

Warning: Permanently added '<onion>' (ED25519) to the list of known hosts.
first-connect-succeeded-silently

There is nothing to compare against, so there is nothing to warn about. In the incident, anyone connecting for the first time from a new laptop, a fresh VM or a reinstalled machine would have been shown the attacker’s key and recorded it. Your defences are, in order of strength:

  1. Use key-based login. A man in the middle cannot relay public-key authentication, because the client’s signature covers a session identifier that differs on each leg. Even if you accept a wrong host key, the real server rejects the forwarded login, and no password exists to steal. Part 5 tests this.
  2. Disable password authentication on the server and in the client config, as in the block above.
  3. Pre-seed known_hosts with the fingerprint you obtained out of band, and use StrictHostKeyChecking=yes so a first-contact mismatch fails instead of being learned.
  4. Keep ForwardAgent off for hosts you have not verified, since a hostile server holding a forwarded agent can ask it to sign.
  5. Use an onion service where you control both ends, so there is no exit at all.

My SSH hardening post covers host certificates, which solve the first-contact problem without a manual fingerprint check. The SSH Deep Dive covers the forwarding and jump-host options.

What I did not test

  • A login through a real clearnet exit. The incident makes the risk clear enough.
  • Onion client authorisation.
  • Whether sshd should be bound to loopback only. That is a server hardening choice, and Part 8 covers the host side.

Part 5 looks at how the interception itself works, in an isolated lab, so the warning above has something concrete to protect against.