From b3f45bfc0df3a03634fa76be9dac4b1163ab3a5b Mon Sep 17 00:00:00 2001 From: Hoelee Date: Wed, 9 Sep 2026 09:07:04 +0800 Subject: [PATCH] post: Hardening a Tor Onion Service: What Actually Matters (en + zh) --- .../posts/hardening-a-tor-onion-service.md | 151 ++++++++++++++++++ .../posts/zh/hardening-a-tor-onion-service.md | 151 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 src/content/posts/hardening-a-tor-onion-service.md create mode 100644 src/content/posts/zh/hardening-a-tor-onion-service.md diff --git a/src/content/posts/hardening-a-tor-onion-service.md b/src/content/posts/hardening-a-tor-onion-service.md new file mode 100644 index 0000000..74a0469 --- /dev/null +++ b/src/content/posts/hardening-a-tor-onion-service.md @@ -0,0 +1,151 @@ +--- +title: "Hardening a Tor Onion Service: What Actually Matters" +description: "An audit of a Dockerized onion service, the obfs4 misconception, and the five silent failures that turned out to be the real risk — with fixes and the tests that prove them." +pubDate: 2026-09-09 +category: devops +tags: [tor, docker, security, self-hosting, networking] +--- + +Someone recently told me to add obfs4 to my Dockerized onion service, "for the server side." It's a well-intentioned suggestion and a common mistake. This post is about what I found when I actually audited the stack instead: which of my hardenings had silently rotted, which ones were wrong about how Docker works, and what ended up mattering. + +**TL;DR:** obfs4 does nothing for an onion service's server. The real risks were: an application container with full outbound internet access, a disabled host firewall, a config file that had never been reloaded, and a tor version behind a security release. All fixable with boring tooling. + +## The obfs4 misconception + +obfs4 is a *pluggable transport* — it disguises **client→Tor** traffic so that censored-network users (DPI, blocking regimes) can reach the Tor network at all. It runs on bridges, which are entry relays with disguised traffic. + +An onion service's server has no use for it. The server connects to the Tor network as a client: it builds circuits to guards and registers itself with introduction points. That traffic uses standard Tor link protocol, and there is no "server-side obfs4" mode. Adding an obfs4 bridge container to your stack does exactly zero for your origin IP. (It's also impossible behind CGNAT anyway — a bridge needs a publicly reachable port.) + +What actually protects an onion service origin: + +1. **The protocol itself.** Visitors never learn your IP — they don't connect to you; your tor instance connects out and rendezvous happens inside the network. +2. **Vanguards-lite.** Built into Tor ≥ 0.4.7, this defends against guard-discovery attacks where an attacker forces circuits until they observe your guard. You get it by simply *running a current Tor*. +3. **Not leaking the origin through every other channel.** This is where most home setups actually fail — and it has nothing to do with Tor configuration. + +## The audit + +The stack: a file browser web app served exclusively through a Tor hidden service, both in Docker. tor → app over a private network, no published ports on either container. That part was already textbook. + +Layer by layer, here's what I found: + +### ✅ What was already right + +- **No published ports.** The app and tor were reachable only inside their Docker network. +- **Tor hardening basics.** `cap_drop: ALL`, `no-new-privileges`, non-root user, `SocksPort 0`, no ORPort/exit config. +- **The right app image.** The original file browser project is unmaintained; the stack used its actively-maintained fork ("FileBrowser Quantum"), which is also designed to run as a non-root user. + +### ❌ What was quietly broken + +**1. The app container had full internet egress.** I ran a one-liner inside the container against a public IP echo service and got my home IP back. There had *been* an iptables-based block task, but the rules were gone — the host firewall had flushed them at some point (interface changes, container-manager restarts, platform updates all do this silently). The lesson: **container isolation built on host iptables that nothing re-applies and nothing alarms on is not isolation.** + +**2. The host firewall itself was off.** INPUT/FORWARD policy ACCEPT everywhere, a few dozen ports listening on 0.0.0.0 across the rest of the machine's services. For an onion service, this is the realistic deanonymization path: not exotic traffic analysis, but ordinary compromise of a clearnet-facing service — after which the attacker just reads your onion keys off the disk. Guard the keys' home before you worry about guard discovery. + +**3. The running tor didn't match its config file.** The on-disk torrc said `SocksPort 0`; the process had been up for days and was still listening on 127.0.0.1:9050. Somebody (me) had edited the file and never restarted the container. Low impact here — container-local, nothing else could reach it — but it's a good reminder that *the config file it's running is not the config file on disk*. + +**4. Tor was one security release behind.** Not dramatic on its own, but the newer releases carried fixes around parsing of malformed relay descriptors — my logs had the exact warn-spam signatures, and it disappeared after the upgrade. I treat security releases as mandatory for a box that hosts sensitive keys. + +## The fixes + +### Zero egress by design, not by script + +The core change: put both containers on a Docker network with `internal: true`, and give only the tor container a second NIC for reaching the Tor network. + +```yaml +networks: + service_net: + driver: bridge + internal: true # no gateway, no masquerade, no route out + egress: + external: true # ordinary bridge with internet +``` + +The app now has **no route anywhere** — not to the internet, not to the LAN, not even to the host. `internal: true` removes the gateway entirely, so there's no iptables to flush, no boot task to forget, no silent decay. If the app is compromised, the attacker gets a socket to tor and nothing else. This is enforced by Docker's own networking, which is also why it survives reboots and daemon restarts. + +### Non-root, and why `NET_BIND_SERVICE` didn't save me + +The image's default user is non-root, and I wanted to keep binding port 80. Standard advice: `cap_add: NET_BIND_SERVICE`. It didn't work. The container crash-looped with: + +``` +[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied +``` + +The reason is worth knowing: **Docker grants capabilities to a non-root container only in the *bounding set*, not the effective set.** I verified it with a probe container — `grep Cap /proc/self/status` showed `CapBnd` containing bit 10 (NET_BIND_SERVICE) while `CapEff` was 0. A non-root process executing a binary with no file capabilities gets an empty effective set, and the kernel checks the *effective* set on bind. So instead of fighting it: run on an unprivileged port (8080) internally and map the hidden service to it. + +```yaml + app: + user: "1000:1000" + cap_drop: [ALL] # hands empty; NET_BIND_SERVICE not needed on 8080 + security_opt: [no-new-privileges] + tor: + user: "100:101" + cap_drop: [ALL] + security_opt: [no-new-privileges] +``` + +Hidden service side, one line change: + +``` +HiddenServicePort 80 app:8080 +``` + +Visitors still land on port 80 of the onion address; only the internal port moved. + +### Healthchecks that check the right thing + +The tor image's built-in healthcheck probes the SOCKS port. I'd just turned SOCKS off — so healthy became permanently "unhealthy." Override it with the thing you actually care about: is the process alive? + +```yaml +healthcheck: + test: ["CMD", "pgrep", "-x", "tor"] +``` + +For nginx the trap was subtler: I first used `wget --spider` against the root path. When the site returned 404 (I hadn't uploaded content yet), wget exits non-zero and the container was marked unhealthy — the check was testing the *content*, not the *service*. `nc -z 127.0.0.1 80` tests the port and nothing else. + +Also added `depends_on` (tor waits for the app): tor resolves its `HiddenServicePort` target at startup, and if the app container isn't up yet, tor dies with "Unparseable address in hidden service port configuration" and crash-loops. I'd watched exactly that happen in the old logs — four failed starts, nobody noticed, because nothing was watching. + +### The ownership landmine + +After re-keying the service I copied the key material back through a file-share mount. New containers immediately crash-looped: + +``` +[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied +``` + +Files written through the file share were owned by the share user, not by uid 100 that tor runs as. Fix is one command — run anywhere, including a throwaway container with the volume attached: + +```bash +docker run --rm -v /path/libTor:/var/lib/tor alpine \ + sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor" +``` + +Rule of thumb going forward: **any key file that passed through a file share gets `chown`ed before the next container start.** + +### Version hygiene + +Repulled `osminogin/tor-simple:latest` → tor 0.4.9.11, the current security track. Combined with SocksPort 0 now actually *applied*, the warn-spam stopped and the listener was gone. + +## Verify, don't believe + +Every fix above ends with a test I can run myself: + +```bash +# inside the app: with internal:true, even DNS should fail +wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1 + +# same probe, on tor's egress network (control group) +wget -T 6 -qO- http://ipv4.icanhazip.com # → , exit 0 +``` + +If you can't exec into a container, attach a throwaway probe container to the *same network* — it tests the network's properties, which is the thing you hardened. Final state: all containers healthy, hidden service up with the same onion address (keys on a persistent volume), zero published ports, and an app that literally cannot resolve `ipv4.icanhazip.com`. + +## What I'd do next time + +1. **Audit running state, not config files.** The config file is a wish; `docker inspect`, in-container `netstat`, and live egress probes are the truth. +2. **Prefer mechanisms that can't silently unwind.** `internal: true` beats an iptables boot task, always. +3. **Healthchecks are tiny observability debt payments.** The day they catch something real (mine caught a crash loop within minutes) they've paid for themselves. +4. **The boring host firewall matters more than exotic Tor hardening.** If the rest of the machine is 0.0.0.0-open, the onion's anonymity dies from a pickaxe attack, not a correlation attack. +5. **Skip the plugs, keep the transport.** Run a current Tor (vanguards-lite included), disable what you don't need, isolate egress, and you're ahead of most onion deployments — no obfs4 required. + +--- + +*No IPs, addresses, or infrastructure specifics were harmed in the writing of this post.* \ No newline at end of file diff --git a/src/content/posts/zh/hardening-a-tor-onion-service.md b/src/content/posts/zh/hardening-a-tor-onion-service.md new file mode 100644 index 0000000..163dcd5 --- /dev/null +++ b/src/content/posts/zh/hardening-a-tor-onion-service.md @@ -0,0 +1,151 @@ +--- +title: "加固 Tor 洋葱服务:真正重要的是什么" +description: "一次对 Docker 化洋葱服务的审计、obfs4 的常见误解,以及五个被静默失效的加固项才是真正的风险——附修复方法和能证明它们的测试。" +pubDate: 2026-09-09 +category: devops +tags: [tor, docker, security, self-hosting, networking] +--- + +有人建议我给 Docker 化的洋葱服务"服务器端"加上 obfs4。这是个出于好意的建议,也是个常见误解。这篇文章讲的是我实际审计这套栈之后发现的东西:哪些加固已经悄悄失效、哪些加固误解了 Docker 的机制、以及最后真正起作用的是什么。 + +**太长不看:** obfs4 对洋葱服务的服务器端毫无作用。真正的风险是:应用容器有完整的外网出口、主机防火墙整个没开、一份从未被重新加载的配置文件、以及落后一个安全版本的 Tor。全都是用最普通的工具就能修好的事。 + +## obfs4 的误解 + +obfs4 是一种 *pluggable transport*——它伪装的是**客户端→Tor** 的流量,让被封锁网络里的用户(DPI、审查环境)能接入 Tor 网络。它跑在 bridge 上,bridge 是伪装了流量的入口中继。 + +洋葱服务的服务器端用不上它。服务器是以"客户端"身份接入 Tor 网络的:它自己向 guard 建 circuit、向 introduction point 注册。这段流量走的是标准 Tor link 协议,不存在"服务器端 obfs4"这种模式。往你的栈里加一个 obfs4 bridge 容器,对你隐藏源站 IP 的帮助严格为零。(而且如果你在 CGNAT 后面,它客观上也不可行——bridge 需要一个公网可达端口。) + +真正保护洋葱服务源站的是: + +1. **协议本身。** 访客永远拿不到你的 IP——他们并不连接你;是你的 tor 实例主动外连,会合发生在 Tor 网络内部。 +2. **Vanguards-lite。** Tor ≥ 0.4.7 内置,防御"guard discovery"攻击(攻击者反复制造 circuit 直到观察到你的 guard)。你只要*跑一个当前版本的 Tor* 就白送。 +3. **别让源站信息从其它渠道泄漏。** 大多数自托管部署栽在这条上——而且它跟 Tor 配置毫无关系。 + +## 审计 + +栈的构成:一个文件浏览 Web 应用,只通过 Tor 隐藏服务对外,两者都在 Docker 里。tor → 应用走私有网络,两个容器都没有发布端口。这部分本来就是教科书做法。 + +逐层来看我的发现: + +### ✅ 本来就做对的 + +- **零发布端口。** 应用和 tor 只在 Docker 网络内部可达。 +- **Tor 基础加固。** `cap_drop: ALL`、`no-new-privileges`、非 root 运行、`SocksPort 0`、没有 ORPort/exit 配置。 +- **选对了应用镜像。** 原版文件浏览器项目已停止维护;这套栈用的是仍在活跃维护的 fork("FileBrowser Quantum"),而且它本身就是按非 root 运行设计的。 + +### ❌ 悄悄坏掉的 + +**1. 应用容器有完整外网出口。** 我在容器里跑了一行命令访问公网 IP 回显服务,拿回了我的家庭 IP。之前*确实*有一个基于 iptables 的阻断任务,但规则没了——主机防火墙在某个时刻把链清空了(网络接口变动、容器管理器重启、平台更新都会这样静默地做)。教训:**建立在主机 iptables 上的容器隔离,如果没有任何机制重放、没有任何告警,就不算隔离。** + +**2. 主机防火墙是关的。** INPUT/FORWARD 策略全是 ACCEPT,整台机器几十个端口在 0.0.0.0 上监听。对洋葱服务来说,这才是现实的去匿名化路径:不是花哨的流量关联分析,而是某台 clearnet 服务被普通攻破——之后攻击者直接从硬盘上读走你的洋葱密钥。**先守好密钥的安放处,再担心 guard discovery。** + +**3. 运行中的 tor 和它的配置文件不一致。** 磁盘上的 torrc 写着 `SocksPort 0`;但进程已经跑了几天,还在监听 127.0.0.1:9050。某人(我)改了文件却没重启容器。这里的影响很小——只有容器内部能摸到——但它提醒了一件事:*它正在跑的配置,不等于磁盘上那份配置。* + +**4. Tor 落后一个安全版本。** 单看不严重,但新版带着针对畸形中继描述符解析的修复——我的日志里正好全是那类 warn 刷屏签名,升级后就消失了。对一台存放敏感密钥的机器,安全版本我视为必升。 + +## 修复 + +### 零出网靠设计,不靠脚本 + +核心改动:把两个容器放进 `internal: true` 的 Docker 网络,只给 tor 容器第二块网卡去访问 Tor 网络。 + +```yaml +networks: + service_net: + driver: bridge + internal: true # 没有网关、没有 MASQUERADE、没有出路 + egress: + external: true # 普通的带外网 bridge +``` + +应用从此**哪里都去不了**——到不了公网、到不了局域网、连主机都到不了。`internal: true` 直接去掉网关,所以没有 iptables 可被清空、没有开机任务可被遗忘、没有静默衰退。就算应用被攻破,攻击者能拿到的最多是一个通向 tor 的 socket,仅此而已。它由 Docker 自己的网络机制强制,这也是它能扛过重启和 daemon 重启的原因。 + +### 非 root 化,以及为什么 `NET_BIND_SERVICE` 救不了我 + +镜像默认就是非 root 用户,而我想继续绑 80 端口。标准建议:`cap_add: NET_BIND_SERVICE`。没用。容器崩溃循环,报: + +``` +[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied +``` + +这个原因值得记住:**Docker 给非 root 容器的 capability 只放进 *bounding set*,不会放进 effective set。** 我用探针容器验证过——`grep Cap /proc/self/status` 显示 `CapBnd` 包含 bit 10(NET_BIND_SERVICE),而 `CapEff` 是 0。非 root 进程执行一个没有 file capability 的二进制,得到的是空的 effective set,而内核在 bind 时检查的是 *effective* set。所以别跟它搏斗:内部改用非特权端口(8080),把隐藏服务映射过去。 + +```yaml + app: + user: "1000:1000" + cap_drop: [ALL] # 空手运行;8080 上不需要 NET_BIND_SERVICE + security_opt: [no-new-privileges] + tor: + user: "100:101" + cap_drop: [ALL] + security_opt: [no-new-privileges] +``` + +隐藏服务侧只需改一行: + +``` +HiddenServicePort 80 app:8080 +``` + +访客仍然从洋葱地址的 80 端口进来;变的只是内部端口。 + +### 检查对的东西的 healthcheck + +tor 镜像自带的 healthcheck 探的是 SOCKS 端口。我刚把 SOCKS 关了——于是"healthy"永久变成了"unhealthy"。用你真正关心的东西覆盖它:进程还活着吗? + +```yaml +healthcheck: + test: ["CMD", "pgrep", "-x", "tor"] +``` + +nginx 那边的坑更隐蔽:我先用了 `wget --spider` 探根路径。站点返回 404 时(当时内容还没传),wget 以非零退出,容器被标记为不健康——这个检查测的是*内容*,不是*服务*。`nc -z 127.0.0.1 80` 只测端口,别的什么都不测。 + +另外补了 `depends_on`(tor 等应用先起):tor 在启动时解析 `HiddenServicePort` 的目标地址,如果应用容器还没起来,tor 会报 "Unparseable address in hidden service port configuration" 然后崩溃循环。我在旧日志里就见过这一幕——连续四次启动失败,没人注意到,因为没有任何东西在看着。 + +### 属主带来的地雷 + +换完服务密钥后,我通过文件共享挂载把密钥材料拷了回去。新容器立刻崩溃循环: + +``` +[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied +``` + +经文件共享写入的文件,属主是共享用户,而不是 tor 运行的 uid 100。修复只有一条命令——在哪里跑都行,包括挂上卷的一次性容器: + +```bash +docker run --rm -v /path/libTor:/var/lib/tor alpine \ + sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor" +``` + +此后给自己立的规矩:**任何经过文件共享的密钥文件,在下一次容器启动前必须先 `chown`。** + +### 版本卫生 + +重拉 `osminogin/tor-simple:latest` → tor 0.4.9.11,当前安全线。配合 `SocksPort 0` 这次真正生效,日志刷屏停了,监听也没了。 + +## 验证,而不是相信 + +上面每个修复都有一个我自己能跑的测试收尾: + +```bash +# 在应用里:internal:true 之下,连 DNS 都应该失败 +wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1 + +# 同一个探针,放到 tor 的出网网络上(对照组) +wget -T 6 -qO- http://ipv4.icanhazip.com # → <家庭 IP>, exit 0 +``` + +如果没法 exec 进容器,就挂一个一次性探针容器到*同一个网络*——它测的是网络的属性,而网络正是你加固的对象。最终状态:所有容器 healthy、隐藏服务在线且洋葱地址不变(密钥在持久卷上)、零发布端口、以及一个字面意义上连 `ipv4.icanhazip.com` 都解析不了的应用。 + +## 下次我会怎么做 + +1. **审计运行态,而不是配置文件。** 配置文件是愿望;`docker inspect`、容器内 `netstat`、实际出网探针才是真相。 +2. **优先选择无法静默解体的机制。** `internal: true` 永远胜过 iptables 开机任务。 +3. **healthcheck 是廉价的观测性分期付款。** 它抓到真问题的那天(我的在几分钟内就抓到了崩溃循环),就回本了。 +4. **朴素的主机防火墙比花哨的 Tor 加固重要。** 如果整台机器 0.0.0.0 开放,洋葱服务的匿名性死于撬棍攻击,而不是关联分析。 +5. **跳过插件,保持传输层干净。** 跑当前版 Tor(vanguards-lite 已内含)、关掉不需要的东西、隔离出网——你已经领先大多数洋葱部署了,不需要 obfs4。 + +--- + +*本文写作过程中,没有任何 IP、地址或基础设施细节受到伤害。* \ No newline at end of file