Part II · Protocol & Services · Chapter 3

Tor From the Inside

Commands only. What each step does, why it is built this way, and the judgment behind it are in the book.

LAB 3.1

The control port as an instrument

Step 1, Speak the protocol by hand

workstation terminal
# the control port is a line protocol; authenticate, then ask
$ printf 'AUTHENTICATE "%s"\r\nGETINFO version\r\nGETINFO uptime\r\nQUIT\r\n' \
    "$LAB_CONTROL_PW" | nc 10.152.152.10 9051
Expected
250 OK
250-version=0.4.8.13
250 OK
250-uptime=4211
250 OK
250 closing connection

Step 2, Use stem for anything structured

workstation terminal
$ python3 - <<'PY'
import os
from stem.control import Controller
with Controller.from_port(address="10.152.152.10", port=9051) as c:
    c.authenticate(password=os.environ["LAB_CONTROL_PW"])
    print("tor version :", c.get_version())
    print("dir info    :", c.get_info("status/enough-dir-info"))
    print("live circuits:", len(c.get_circuits()))
PY
Expected
tor version : 0.4.8.13
dir info    : 1
live circuits: 6
Verify in the Docker host
Ubuntu
$ ./lab check 3.1
  • The control port authenticates with $LAB_CONTROL_PW
  • GETINFO version returns a running Tor version
  • stem is installed and can connect to the gateway
LAB 3.2

The consensus: Tor's shared map

Step 1, Confirm you have a live consensus

workstation terminal
$ printf 'AUTHENTICATE "%s"\r\nGETINFO consensus/valid-after\r\nGETINFO status/enough-dir-info\r\nQUIT\r\n' \
    "$LAB_CONTROL_PW" | nc 10.152.152.10 9051
Expected
250-consensus/valid-after=2026-07-30 05:00:00
250-status/enough-dir-info=1
250 OK

Step 2, Read the map with stem

workstation terminal
$ python3 - <<'PY'
import os, collections
from stem.control import Controller
with Controller.from_port(address="10.152.152.10", port=9051) as c:
    c.authenticate(password=os.environ["LAB_CONTROL_PW"])
    relays = list(c.get_network_statuses())
    flags = collections.Counter(f for r in relays for f in r.flags)
    print("relays in consensus:", len(relays))
    for f in ("Guard", "Exit", "Fast", "Stable", "Running"):
        print(f"  {f:8}: {flags[f]}")
PY
Expected, figures vary
relays in consensus: 7314
  Guard   : 3902
  Exit    : 1985
  Fast    : 7001
  Stable  : 6640
  Running : 7314
Verify in the Docker host
Ubuntu
$ ./lab check 3.2
  • The gateway holds a current consensus with a valid-after time
  • It reports enough-dir-info to build circuits
  • The consensus lists a non-trivial number of relays
LAB 3.3

Guards: your fixed door into the network

Step 1, Find your guard

workstation terminal
$ printf 'AUTHENTICATE "%s"\r\nGETINFO entry-guards\r\nQUIT\r\n' \
    "$LAB_CONTROL_PW" | nc 10.152.152.10 9051
Expected; one primary guard
250+entry-guards=
$A1B2C3D4E5F6...9F up
.
250 OK

Step 2, Confirm the guard is persisted

Ubuntu
# the guard choice lives in Tor's state file, on the persistent volume
$ docker exec darkweb-gateway grep -c '^Guard' /var/lib/tor/state
$ docker volume ls --format '{{.Name}}' | grep darkweb_tor_data
Expected
1
darkweb_tor_data
Verify in the Docker host
Ubuntu
$ ./lab check 3.3
  • Tor reports at least one entry guard
  • The guard is recorded in the persistent state file
  • The darkweb_tor_data volume exists so the guard survives a reset
LAB 3.4

Building a circuit: three hops, layered encryption

Step 1, List live circuits

workstation terminal
$ python3 - <<'PY'
import os
from stem.control import Controller
with Controller.from_port(address="10.152.152.10", port=9051) as c:
    c.authenticate(password=os.environ["LAB_CONTROL_PW"])
    for circ in c.get_circuits():
        if circ.status != "BUILT":
            continue
        hops = " -> ".join(nick or fp[:8] for fp, nick in circ.path)
        print(f"circuit {circ.id} [{circ.purpose}]: {hops}")
PY
Expected
circuit 5 [GENERAL]: gabelmoo -> relay8842 -> tortexit01
circuit 6 [GENERAL]: gabelmoo -> munich3  -> exitnode77

Step 2, Who knows what

Step 3, Build one by hand

workstation terminal
$ python3 - <<'PY'
import os
from stem.control import Controller
with Controller.from_port(address="10.152.152.10", port=9051) as c:
    c.authenticate(password=os.environ["LAB_CONTROL_PW"])
    cid = c.new_circuit(await_build=True)          # let Tor pick the path
    circ = c.get_circuit(cid)
    print("built circuit", cid)
    for fp, nick in circ.path:
        print("  ", nick or fp[:16])
PY
Expected
built circuit 11
   gabelmoo
   quintex44
   fastExit9
Verify in the Docker host
Ubuntu
$ ./lab check 3.4
  • At least one BUILT, general-purpose circuit exists
  • It has exactly three hops
  • Its first hop is the same guard reported in Lab 3.3
LAB 3.5

Streams: how a connection rides a circuit

Step 1, Watch streams attach to circuits

workstation terminal
# generate a little traffic, then look at streams and their circuits
$ curl -s https://check.torproject.org/api/ip >/dev/null &
$ printf 'AUTHENTICATE "%s"\r\nGETINFO stream-status\r\nGETINFO circuit-status\r\nQUIT\r\n' \
    "$LAB_CONTROL_PW" | nc 10.152.152.10 9051
Expected
250+stream-status=
41 SUCCEEDED 6 check.torproject.org:443
.
250+circuit-status=
6 BUILT gabelmoo,munich3,exitnode77 PURPOSE=GENERAL
.
250 OK

Step 2, Keep destinations on separate circuits

Verify in the Docker host
Ubuntu
$ ./lab check 3.5
  • stream-status is queryable and streams reference a circuit id
  • You can explain the difference between a circuit and a stream
  • You can state what stream isolation prevents, and what it does not
LAB 3.6

Rendezvous, from the outside in

Step 1, Reach an onion and look at the circuits

workstation terminal
# fetch a known onion through the gateway, then read the circuit purposes
$ curl -s --socks5-hostname 10.152.152.10:9050 \
    https://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion/ >/dev/null

$ python3 - <<'PY'
import os, collections
from stem.control import Controller
with Controller.from_port(address="10.152.152.10", port=9051) as c:
    c.authenticate(password=os.environ["LAB_CONTROL_PW"])
    p = collections.Counter(circ.purpose for circ in c.get_circuits())
    for purpose, n in p.items():
        print(f"{purpose:22} {n}")
PY
Expected, onion-specific purposes appear
GENERAL                4
HS_CLIENT_INTRO        1
HS_CLIENT_REND         1

Step 2, Why one machine can't chat with itself

Verify in the Docker host
Ubuntu
$ ./lab check 3.6
  • A known onion is reachable through the gateway (rendezvous completes)
  • You can describe the descriptor / intro-point / rendezvous sequence
  • You can explain the single-daemon chat limit in terms of rendezvous
LAB 3.7

New identity and circuit hygiene

Step 1, Note your exit, rotate, note it again

workstation terminal
$ curl -s https://check.torproject.org/api/ip          # exit before
$ printf 'AUTHENTICATE "%s"\r\nSIGNAL NEWNYM\r\nQUIT\r\n' "$LAB_CONTROL_PW" \
    | nc 10.152.152.10 9051
$ sleep 8
$ curl -s https://check.torproject.org/api/ip          # exit after
Expected, the exit IP changes
{"IsTor":true,"IP":"185.220.101.34"}
250 OK
{"IsTor":true,"IP":"23.129.64.210"}

Step 2, Circuits age out on their own

workstation terminal
$ printf 'AUTHENTICATE "%s"\r\nGETCONF MaxCircuitDirtiness\r\nQUIT\r\n' \
    "$LAB_CONTROL_PW" | nc 10.152.152.10 9051
Expected
250 MaxCircuitDirtiness=600
Verify in the Docker host
Ubuntu
$ ./lab check 3.7
  • SIGNAL NEWNYM is accepted over the control port
  • MaxCircuitDirtiness is readable
  • The guard is unchanged after a NEWNYM, only the exit rotates