Tor Deep Dive Part 8: Running a Tor Relay Securely on Debian

The relays in Part 1 were not broken Tor. They were ordinary Debian servers whose operators lost control of the host. If you run a relay, or are thinking about it, the lesson is that the relay software is the smaller part of the job. The host around it is what got attacked.

Everything below was tested on a throwaway Debian 13 VM. I did not join the live network as a relay, so where I say a relay is configured a certain way, I validated the configuration and the unit behaviour, not months of uptime.

Pick the least risky kind of relay

Tor has three relay roles, described in Part 2. A guard or middle relay only talks to other relays. An exit relay talks to the open internet on behalf of strangers, which brings abuse complaints and legal attention. If your aim is to help the network, a non-exit relay is the sensible start.

A minimal non-exit torrc:

Nickname labrelay
ContactInfo anon@example.invalid
ORPort 9001
ExitRelay 0
SocksPort 0

ExitRelay 0 says never to exit, and SocksPort 0 turns off the local client port, since the machine is not meant to be used as a client. ContactInfo should be a real address on a real relay so the Tor Project can reach you. I checked it without starting the relay:

tor --verify-config -f /tmp/relay-torrc
Configuration was valid

(The scratch copy also set DataDirectory /tmp/relay-test-data so the check did not touch the system tor.)

A non-exit gives an attacker less to work with than an exit does: the traffic it carries is encrypted onion layers, not your users’ cleartext. But it is not immune. In Part 1 the attackers edited the torrc on relays so that non-exits became exits. A non-exit is a configuration choice, not a property of the machine, and whoever controls the host controls that choice. That is why the published-state check later in this part matters.

The Debian tor.service trap

On Debian, the tor package installs a unit called tor.service, and it is not the Tor process. It is a master unit that starts the instances. Checking on the test VM:

systemctl show tor.service -p Type -p ActiveState -p RemainAfterExit
Type=oneshot
RemainAfterExit=yes
ActiveState=active

The process that actually runs is tor@default. That has a consequence people trip over: stopping the wrong unit does not stop Tor.

sudo systemctl disable tor.service
systemctl is-active tor@default
active

Disabling tor.service removed its wants symlink, but tor@default was still running. What does block an instance is masking it. I tested that on a second instance named lab2:

sudo systemctl mask tor@lab2
Created symlink '/etc/systemd/system/tor@lab2.service' -> '/dev/null'.
sudo systemctl start tor@lab2
Failed to start tor@lab2.service: Unit tor@lab2.service is masked.

Then sudo systemctl unmask tor@lab2 to put it back. The practical rule: when you want a relay off, deal with tor@default (or the instance name you use), not the master unit.

This matters for one more reason from Part 1. Zimmer’s post says the 1 October edit went into a file the relays never used, because Debian runs relays as tor@ instances. Whichever file you edit has to be the one the instance actually reads, and you should confirm it from the running process, not from the file you expected.

Constrain the process with systemd

The packaged unit already includes some hardening. These are the lines I looked for in the instance unit:

NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=full
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_NET_BIND_SERVICE CAP_DAC_READ_SEARCH

systemd-analyze security tor@default scored it 6.6 MEDIUM on the test VM. That is a reasonable result for a network daemon, and I would not chase a lower number at the cost of a relay that no longer starts. The score tells you the service is constrained. It does not tell you the host is safe, and the Part 1 compromise did not go through the Tor process at all.

The host is the real target

The relay operator in the incident lost the machines through a reused password on SSH. So the baseline is the same advice as Part 6 and the earlier SSH series:

  • Key-only SSH, with PasswordAuthentication no, and no password you also use anywhere else. See SSH Hardening Beyond the Basics.
  • Management off the public internet. A relay needs its ORPort reachable. It does not need SSH reachable from everywhere.
  • Logs sent off the host, so a quiet sshd on the box is visible elsewhere. Host-Based Detection covers Wazuh for this.
  • Integrity checks that do not trust the package database alone.

File integrity with AIDE

Part 6 showed that dpkg -V stays silent on the Ebury layout. A file integrity tool is the answer, and I tested AIDE 0.19.1 against exactly that layout. I used a scratch configuration that watches only the two library families involved, with a rule set that records permissions, owner, inode, name, size and SHA-256:

Lib = p+u+g+i+n+s+sha256
/usr/lib/x86_64-linux-gnu/libkeyutils Lib
/usr/lib/x86_64-linux-gnu/libkrb5 Lib

After initialising a database, a check on the clean host reported:

AIDE found NO differences between database and filesystem. Looks okay!!

Then I recreated the Part 6 layout (a copied libkeyutils.so.1.10.2 with the setuid bit, and the symlink repointed) and ran the check again:

Added entries:
f++++++++++++++++++: /usr/lib/x86_64-linux-gnu/libkeyutils.so.1.10.2

Changed entries:
l > ... i. : /usr/lib/x86_64-linux-gnu/libkeyutils.so.1
  Link size: 19 | 21
  Inode    : 6415 | 9897

Both the new file and the repointed symlink were reported, which is what dpkg -V missed. After restoring the host a fresh check was clean again. Two caveats. A full AIDE run over a whole system is much noisier than this scoped demo. And the database has to live somewhere an attacker on the host cannot rewrite, or the check is only as honest as the box.

Checking what the network says about your relay

Everything so far looks at the host from inside. The hijacked relays also changed what they told the network: they began advertising exit policies they had not had, and the directory authorities later voted BadExit and MiddleOnly for them. You can watch that from outside with Onionoo, the Tor Project’s relay data service. This script compares what the network publishes about one relay to what you intended:

#!/usr/bin/env python3
"""relaycheck: compare what the Tor network publishes about YOUR relay with what you intended.

usage: relaycheck.py FINGERPRINT non-exit|exit   (exit status 2 if the published state disagrees)
"""
import json, sys, urllib.request

fp, expect = sys.argv[1].upper(), sys.argv[2]
url = f"https://onionoo.torproject.org/details?lookup={fp}&fields=nickname,flags,exit_policy_summary,last_restarted"
relays = json.load(urllib.request.urlopen(url, timeout=20))["relays"]
if not relays:
    sys.exit("relay not found in Onionoo (offline, or not yet listed)")
r = relays[0]
policy = r["exit_policy_summary"]
accepts_something = "accept" in policy and policy["accept"] != []
is_exit = "Exit" in r["flags"] or accepts_something
print(f"{r['nickname']}: flags={r['flags']} exit_policy_summary={json.dumps(policy)[:120]} last_restarted={r.get('last_restarted')}")
for bad in ("BadExit", "MiddleOnly"):
    if bad in r["flags"]:
        print(f"WARNING: {bad} flag present")
if expect == "non-exit" and is_exit:
    print("MISMATCH: you expected a non-exit but the network publishes an exit policy or the Exit flag")
    sys.exit(2)
if expect == "exit" and not is_exit:
    print("MISMATCH: you expected an exit but the network shows none")
    sys.exit(2)
print("matches intent")

I ran it against two real relays. A guard I picked, chali2na, checked as a non-exit:

chali2na: flags=['Fast', 'Guard', 'Running', 'Stable', 'V2Dir', 'Valid'] exit_policy_summary={"reject": ["1-65535"]} last_restarted=...
matches intent

And the exit Digitalcourage4ipha from Part 7, deliberately checked as if it should be a non-exit:

Digitalcourage4ipha: flags=['Exit', 'Fast', 'Guard', ...] exit_policy_summary={"accept": ["20-22", ...]} ...
MISMATCH: you expected a non-exit but the network publishes an exit policy or the Exit flag

The exit status was 2. Put that in cron hourly against your own fingerprint, with an alert on a non-zero exit, and a silent policy change shows up within the hour instead of when someone files a bug. It cannot tell you why the state changed, only that it did. Note the MiddleOnly and BadExit warnings: if either appears on a relay you run, the network has already decided something is wrong.

A maintained version of this script is on GitHub at MichealGarner/tor-checks, with an MIT licence, tests and a README. It adds --json output, checks the IPv6 exit policy as well, and uses distinct exit codes: 0 for a match, 1 for a lookup error, 2 for a mismatch, and 3 when the state matches but the BadExit or MiddleOnly flag is present. It also ships a systemd timer example. The listing above is the version I ran for this post.

If you think a relay was compromised

The Tor Project advisory (the forum post by hiro on 5 October) and Zimmer’s own write-up give a consistent checklist. Condensed:

  1. Compare the relay’s entry on Tor Metrics with what you configured.
  2. Read the torrc and every file it %includes, and check what the running instance reads.
  3. Look for traffic redirection: sudo nft list ruleset and sudo iptables -t nat -S. The attackers redirected port 22 to interception servers.
  4. Look at established connections: sudo ss -tnp.
  5. Verify the Tor package: sudo debsums tor (and remember the Part 6 limits).
  6. Check for new users, changes to authorized_keys, cron jobs and systemd units. Zimmer found a fake daemon with an uppercase CRON name.
  7. Take the machine offline and preserve the disk if you want an investigation.
  8. Reinstall, generate a new relay identity, rotate every credential the machine ever saw, and report it to bad-relays@lists.torproject.org.

Step 8 is the one people resist. The reason to reinstall rather than clean is in Part 6: you cannot trust the tools on the box to tell you it is clean.

What to take from this

Run a non-exit relay if you want to help, keep SSH closed to the world, send logs elsewhere, watch the library directories with an integrity tool, and check from outside what the network believes about your relay. Part 9 steps back from relays to the other ways Tor can fail the people using it.