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
# 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
$ 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
$ ./lab check 3.1
- The control port authenticates with
$LAB_CONTROL_PW GETINFO versionreturns 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
$ 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
$ 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
$ ./lab check 3.2
- The gateway holds a current consensus with a
valid-aftertime - It reports
enough-dir-infoto 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
$ 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
# 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
$ ./lab check 3.3
- Tor reports at least one entry guard
- The guard is recorded in the persistent state file
- The
darkweb_tor_datavolume exists so the guard survives a reset
LAB 3.4
Building a circuit: three hops, layered encryption
Step 1, List live circuits
$ 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
$ 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
$ ./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
# 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
$ ./lab check 3.5
stream-statusis 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
# 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
$ ./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
$ 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
$ 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
$ ./lab check 3.7
SIGNAL NEWNYMis accepted over the control portMaxCircuitDirtinessis readable- The guard is unchanged after a
NEWNYM, only the exit rotates