Tor Deep Dive Part 7: Finding Bad Exit Relays with a Python Scanner

The hijacked exits in Part 1 were not found by an alarm. They were found by a person who read a host key warning, then wrote a scanner. In this part I build a small version of that scanner so the idea is concrete: pick an exit, force a circuit through it, connect to a server whose identity you already know, and check the identity you get back.

The tool kaba used is exitmap, a framework by Philipp Winter for running modules over every exit relay. What follows is not exitmap. It is about 150 lines of Python that does one job, and it is useful mostly because you can read all of it.

The idea

A hostile exit can only lie to you about things it can see and rewrite. For SSH that means the host key. For TLS it means the certificate. So the test is simple:

  1. Choose a target where you know the right answer. For SSH, that is the host key fingerprint. For TLS, it is the certificate fingerprint seen from a clean network path.
  2. Build a circuit that ends at the exit under test.
  3. Connect to the target through that circuit and record what you get.
  4. If it differs from the known answer, try again through a different middle relay to rule out a one-off.

The same machine does the direct baseline and the Tor probes, so there is no second system to keep in step.

How it works

The script starts its own private tor process with a separate data directory, so it never touches the system tor you may be running. Two options matter:

  • __LeaveStreamsUnattached 1 stops tor attaching streams to circuits by itself. The script attaches each new stream to the circuit it built.
  • UseEntryGuards 0 lets the script choose the first hop. The path is a random middle relay, then the exit under test.

Using stem, the script reads the consensus, picks relays with the Exit flag and without BadExit, builds the two-hop path, and listens for stream events to attach each connection. For SSH it uses paramiko to complete only the key exchange and read the host key, with no login attempt. For TLS it captures the certificate and compares its SHA-256 fingerprint to a baseline fetched directly three times at the start.

Exits whose policy refuses the port show up as errors. Those do not count as results, which matters for the numbers below.

The code

#!/usr/bin/env python3
"""exitscan: check whether Tor exit relays tamper with SSH host keys or TLS certificates.

Only point this at servers you own or at well-known public ones with a published
fingerprint. It starts its own private tor process and never touches the system tor.
"""
import argparse, base64, hashlib, json, random, shutil, socket, ssl, sys, tempfile, time

import paramiko
import socks
import stem
import stem.control
from stem import Flag, StreamStatus
from stem.process import launch_tor_with_config

SOCKS_PORT, CONTROL_PORT = 19050, 19051


def tor_socket(host, port):
    s = socks.socksocket()
    s.set_proxy(socks.SOCKS5, "127.0.0.1", SOCKS_PORT, rdns=True)
    s.settimeout(30)
    s.connect((host, port))
    return s


def ssh_fingerprint(sock):
    t = paramiko.Transport(sock)
    try:
        t.start_client(timeout=30)
        key = t.get_remote_server_key()
        return "SHA256:" + base64.b64encode(hashlib.sha256(key.asbytes()).digest()).decode().rstrip("=")
    finally:
        t.close()


def tls_fingerprint(sock, host):
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE          # we compare fingerprints, not trust chains
    with ctx.wrap_socket(sock, server_hostname=host) as w:
        return hashlib.sha256(w.getpeercert(True)).hexdigest()


def direct_tls(host, port):
    return tls_fingerprint(socket.create_connection((host, port), timeout=20), host)


def probe(ctl, state, mid, exit_fp, ssh_targets, tls_targets, tls_baseline):
    """Build mid -> exit, send every probe through it, return {target: result}."""
    out = {}
    try:
        circ = ctl.new_circuit(path=[mid, exit_fp], await_build=True, timeout=60)
    except (stem.CircuitExtensionFailed, stem.Timeout, stem.InvalidRequest, stem.ControllerError) as e:
        return {"circuit": f"failed: {type(e).__name__}"}
    state["circ"] = circ
    try:
        for hostport, expected in ssh_targets:
            host, port = hostport.rsplit(":", 1)
            try:
                seen = ssh_fingerprint(tor_socket(host, int(port)))
                out[f"ssh:{hostport}"] = "ok" if seen == expected else f"MISMATCH seen={seen}"
            except Exception as e:
                out[f"ssh:{hostport}"] = f"error: {type(e).__name__}"
        for hostport in tls_targets:
            host, port = hostport.rsplit(":", 1)
            try:
                seen = tls_fingerprint(tor_socket(host, int(port)), host)
                out[f"tls:{hostport}"] = "ok" if seen in tls_baseline[hostport] else f"MISMATCH seen={seen}"
            except Exception as e:
                out[f"tls:{hostport}"] = f"error: {type(e).__name__}"
    finally:
        state["circ"] = None
        try:
            ctl.close_circuit(circ)
        except stem.ControllerError:
            pass
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--ssh", action="append", default=[], metavar="HOST:PORT=SHA256:FP")
    ap.add_argument("--tls", action="append", default=[], metavar="HOST:PORT")
    ap.add_argument("--count", type=int, default=20, help="exits to test successfully")
    ap.add_argument("--max-tries", type=int, default=60)
    ap.add_argument("--seed", type=int)
    ap.add_argument("--delay", type=float, default=2.0, help="seconds between exits")
    ap.add_argument("--out", default="exitscan.jsonl")
    a = ap.parse_args()
    rnd = random.Random(a.seed)

    ssh_targets = [tuple(x.split("=", 1)) for x in a.ssh]
    tls_baseline = {t: {direct_tls(*(t.rsplit(":", 1)[0], int(t.rsplit(":", 1)[1]))) for _ in range(3)} for t in a.tls}

    datadir = tempfile.mkdtemp(prefix="exitscan-")
    proc = launch_tor_with_config(
        config={"SocksPort": str(SOCKS_PORT), "ControlPort": str(CONTROL_PORT), "DataDirectory": datadir,
                "UseEntryGuards": "0", "__LeaveStreamsUnattached": "1", "Log": "NOTICE stderr"},
        init_msg_handler=lambda l: print("tor:", l) if "Bootstrapped" in l and ("100" in l or "%" in l and int(l.split("Bootstrapped ")[1].split("%")[0]) % 25 == 0) else None,
        timeout=180)
    state = {"circ": None}
    bad = tested = 0
    try:
        with stem.control.Controller.from_port(port=CONTROL_PORT) as ctl:
            ctl.authenticate()

            def attach(ev):
                if ev.status == StreamStatus.NEW and state["circ"]:
                    try:
                        ctl.attach_stream(ev.id, state["circ"])
                    except stem.ControllerError:
                        pass
            ctl.add_event_listener(attach, stem.control.EventType.STREAM)

            relays = [r for r in ctl.get_network_statuses() if Flag.RUNNING in r.flags]
            exits = [r for r in relays if Flag.EXIT in r.flags and Flag.BADEXIT not in r.flags]
            middles = [r for r in relays if Flag.FAST in r.flags and Flag.STABLE in r.flags and Flag.EXIT not in r.flags]
            rnd.shuffle(exits)
            print(f"{len(exits)} exits in consensus, {len(middles)} usable middles")

            with open(a.out, "w") as fh:
                for r in exits[:a.max_tries]:
                    if tested >= a.count:
                        break
                    res = probe(ctl, state, rnd.choice(middles).fingerprint, r.fingerprint, ssh_targets, a.tls, tls_baseline)
                    if "circuit" in res or all(v.startswith("error") for v in res.values()):
                        continue                                  # exit policy refused or unreachable: not a result
                    tested += 1
                    if any(v.startswith("MISMATCH") for v in res.values()):
                        for _ in range(3):                           # retry if the second circuit cannot be built
                            again = probe(ctl, state, rnd.choice(middles).fingerprint, r.fingerprint, ssh_targets, a.tls, tls_baseline)
                            if "circuit" not in again:
                                break
                        res["recheck"] = again
                        if any(v.startswith("MISMATCH") for v in again.values()):
                            bad += 1
                    rec = {"fp": r.fingerprint, "nick": r.nickname, "addr": r.address, "results": res}
                    fh.write(json.dumps(rec) + "\n"); fh.flush()
                    flag = "!!" if "recheck" in res else "ok"
                    print(f"{flag} {r.nickname:<20} {r.address:<16} {json.dumps({k: v[:22] for k, v in res.items() if k != 'recheck'})}")
                    time.sleep(a.delay)
    finally:
        proc.terminate()
        shutil.rmtree(datadir, ignore_errors=True)
    print(f"tested={tested} confirmed_mismatch={bad}")
    sys.exit(1 if bad else 0)


if __name__ == "__main__":
    main()

A mismatch is only counted as confirmed if it repeats on a second circuit through the same exit with a different middle relay. A single odd result can come from a flaky path, a server that changed its key, or a middlebox, so one hit is a lead and not a finding.

A maintained version on GitHub

The listing above is the version I ran for this post. A maintained copy lives at MichealGarner/tor-checks, with an MIT licence, tests and a README. It adds a repeatable --exit FINGERPRINT option, so you can test specific exits, such as one you run, instead of a random sample. It also retries when Tor has not yet loaded a named relay’s descriptor. Its behaviour is otherwise the same, and everything in this post applies to it.

Running it

On Debian 13 the dependencies are all in the archive:

sudo apt install -y tor python3-stem python3-socks python3-paramiko

Save the script as exitscan.py, then test against GitHub, whose SSH host key fingerprint is published and which I can check independently:

ssh-keyscan -t ed25519 github.com 2>/dev/null | ssh-keygen -lf -
256 SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU github.com (ED25519)

That matches the fingerprint GitHub publishes for its ED25519 key. Now the scan itself, 12 exits, SSH and TLS:

python3 exitscan.py --ssh github.com:22=SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU --tls github.com:443 --count 12 --seed 7 --out scan1.jsonl

The start of the run, from the Debian 13 test VM:

tor: Bootstrapped 25% (requesting_status): Asking for networkstatus consensus
tor: Bootstrapped 50% (loading_descriptors): Loading relay descriptors
tor: Bootstrapped 75% (enough_dirinfo): Loaded enough directory info to build circuits
tor: Bootstrapped 100% (done): Done
2690 exits in consensus, 4528 usable middles

And six of the twelve result lines:

ok Digitalcourage4ipha  185.220.101.103  {"ssh:github.com:22": "ok", "tls:github.com:443": "ok"}
ok UnredactedGun        23.191.200.44    {"ssh:github.com:22": "error: GeneralProxyErr", "tls:github.com:443": "ok"}
ok eo208                23.129.64.208    {"ssh:github.com:22": "ok", "tls:github.com:443": "ok"}
ok NTH94R7              192.42.116.94    {"ssh:github.com:22": "ok", "tls:github.com:443": "ok"}
ok marcuse9             178.20.55.182    {"ssh:github.com:22": "ok", "tls:github.com:443": "ok"}
ok delfin               109.70.100.11    {"ssh:github.com:22": "error: GeneralProxyErr", "tls:github.com:443": "ok"}
tested=12 confirmed_mismatch=0

Across all twelve exits, 12 of 12 TLS probes matched the baseline, 6 of 12 SSH probes returned exactly the published host key, and the other 6 SSH probes got a SOCKS error. I did not check each of those relays’ exit policies, so I will not claim the reason, but the pattern fits exits that do not allow port 22. For comparison, Onionoo shows Digitalcourage4ipha, one of the exits above, accepting ports 20-22 in its published policy summary, and it returned the right key. It is also why exit policy changes matter so much in this incident: a relay that blocks port 22 never sees the SSH traffic in the first place.

Checking the checker

A scanner that has never reported a hit proves nothing, because I do not know that it would. So I ran a negative control: the same SSH test, but with a deliberately wrong expected fingerprint (SHA256:AAAA...). Every exit that completed the connection should be flagged.

python3 exitscan.py --ssh github.com:22=SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA --count 2 --seed 3 --out scan3.jsonl
!! forest01             94.156.152.8     {"ssh:github.com:22": "MISMATCH seen=SHA256:+"}

The recheck through a second circuit repeated the mismatch and reported seen=SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU, which is GitHub’s real key. The exit was behaving, the expectation was wrong, and the scanner said so, which is the behaviour I wanted.

What this does not tell you

  • It is a sample, not a scan. Twelve exits out of 2,690 says nothing about the rest. A full run takes hours and puts load on the network, and it should point at a server you own, not at someone else’s.
  • A hostile exit can hide from scanners. exitmap-style tools are known, and an attacker can choose to leave scanner-looking traffic alone or interfere only with specific destinations. kaba’s scan worked because the attackers redirected port 22 for everyone.
  • It checks what it probes. SSH host keys and TLS certificates only. A relay that tampers with unencrypted HTTP, or does something subtle to DNS, is not caught.
  • A match is not a clean bill of health. It means the exit did not alter these connections at that moment.

Using it for your own servers

The practical version is to run this against servers that matter to you, which means passing your own host key as the expected value. If you already use SSH over Tor, Part 4 explains why an onion service sidesteps the exit entirely and is the better fix. A scan like this is a way to notice that the exit path is hostile before you depend on it.

Next is the other side: running a relay without becoming the problem, and what the Debian packaging does that surprises people.