この記事に出てくる HITSC 関連の用語、構成名、設計上の呼び方は、執筆時点の検証用基盤に関するものです。実装や設計の進行に伴い、現在は変更されている場合があります。
HITSC / hitto Troubleshooting Contest では、ticketごとにnetwork namespaceを作り、その中でFirecracker microVMを動かしている。
基本方針はこうである。
ticket A namespace:
guest: 10.30.1.2
ticket B namespace:
guest: 10.30.1.2
ticketごとにnamespaceを分けることで、複数ticketで同じguest IPを使い回せる。 これはHITSCの問題環境を複製するうえでかなり重要な設計である。
一方で、operatorがworker上からguestへ入るために、publish.ports という機能も実装した。
MVPではこのpublished portは外部公開というより、worker localhost向けの管理入口である。
publish:
ports:
- name: ssh
node: server
interface: eth0
protocol: tcp
guest_port: 22
published_port: auto
bind: localhost
意図していた境界は次の通り。
worker root namespace:
127.0.0.1:<published_port>
-> victim ticket namespace
-> victim guest:<guest_port>
別ticket namespace:
victimのpublished portへ到達できない
しかし、このpublished port実装に、別ticket namespaceからvictim ticketのpublished portへ到達できる可能性がある、という脆弱性報告が上がった。
これはHITSCの中心的な境界である「ticket間分離」を破る問題なので、かなり重要度が高い。
published portの構成
HITSCでは、root namespaceからguest IPへ直接DNATしない。
理由は、ticketごとに同じguest IPを使い回すからである。
ticket A namespace:
guest: 10.30.1.2
ticket B namespace:
guest: 10.30.1.2
root namespaceから直接 10.30.1.2 へDNATすると、それがどのticketのguestなのか区別できない。
そのため、published portは2段DNATにしている。
worker localhost
127.0.0.1:<published_port>
|
| root namespace DNAT
v
victim ticket namespace uplink
100.64.x.2:<published_port>
|
| ticket namespace DNAT/SNAT
v
victim guest
<guest_ip>:<guest_port>
root namespaceとticket namespaceの間には、HITSCが作るveth pairがある。
root namespace
vr<hash>: 100.64.x.1/30
|
veth
|
ticket namespace hn<hash>
vn<hash>: 100.64.x.2/30
localhost宛通信をvethへDNATするため、root側vethには route_localnet=1 も設定する。
ここまでは設計として必要な処理だった。
問題は、その後のFORWARD/DNAT ruleが「localhostから来た正規の通信か」を十分に確認していなかった点である。
何が壊れていたか
脆弱だった当時のroot namespace側ruleは、概念的にはこうだった。
{"FORWARD",
"-o", rootDev,
"-p", port.Protocol,
"-d", nsIP,
"--dport", published,
"-m", "comment", "--comment", comment,
"-j", "ACCEPT",
}
これは victim ticket namespace側veth IP:<published_port> へ出ていくpacketを許可する。
しかし、このruleには「元の宛先が 127.0.0.1:<published_port> だったか」という条件がない。
ticket namespace側のDNATも、当時は広すぎた。
{"-t", "nat", "PREROUTING",
"-i", nsDev,
"-p", port.Protocol,
"--dport", published,
"-m", "comment", "--comment", comment,
"-j", "DNAT", "--to-destination", target,
}
これは、victim ticket namespaceのuplink deviceに入ってきた published_port 宛通信をguestへDNATする。
しかし、このruleにも「worker root namespace側veth IPから来た正規のpublished port通信か」という条件がない。
つまり、当時の境界はこうなっていた。
problem.yaml:
bind: localhost
root namespace DNAT:
127.0.0.1:<published_port> だけを入口にするつもり
root namespace FORWARD:
victim nsIP:<published_port> 宛なら広く許可
victim namespace DNAT:
victim nsDevに入ってきた published_port 宛なら広くDNAT
設定上はlocalhost-onlyでも、packetがworker root namespace内に入った後の境界確認が甘かった。
攻撃経路
報告された問題の本質は、別ticketがuplink/default routeを持っている場合、worker root namespace経由でvictim ticketのnamespace uplink IPへpacketを送れてしまう、というものだった。
経路としてはこうなる。
attacker guest
-> attacker ticket namespace default route
-> worker root namespace
-> victim ticket root veth
-> victim ticket namespace uplink IP:<published_port>
-> victim namespace PREROUTING DNAT
-> victim guest service
本来、published portはworker localhostからだけ到達できるべきである。
しかし古いruleでは、別ticketから来たpacketでも、最終的にvictim namespace uplink IPとpublished portに向かっていればroot namespaceのFORWARDを通り、victim namespace内のDNATにも引っかかる余地があった。
これは、bind: localhost というproblem.yaml上の制約だけでは守れない。
実際にpacketを転送するiptables rule側で、localhost由来であることを確認する必要があった。
修正方針
修正は大きく2つ。
1つ目は、root namespace側のFORWARD ruleにconntrackのoriginal destination確認を入れること。
現在の internal/network/port_forward.go ではこうしている。
{"FORWARD",
"-o", rootDev,
"-p", port.Protocol,
"-d", nsIP,
"--dport", published,
"-m", "conntrack",
"--ctorigdst", port.BindAddr,
"--ctorigdstport", published,
"-m", "comment", "--comment", comment,
"-j", "ACCEPT",
}
ここで port.BindAddr はMVPでは 127.0.0.1 になる。
つまり、単に victim nsIP:<published_port> へ向かっているだけでは足りない。
conntrack上の元の宛先が 127.0.0.1:<published_port> だったpacketだけをFORWARDする。
2つ目は、ticket namespace側でもroot uplink IP由来のpacketだけをDNAT/FORWARDすること。
現在のnamespace側DNATはこうなっている。
{"-t", "nat", "PREROUTING",
"-i", nsDev,
"-s", rootIP,
"-p", port.Protocol,
"--dport", published,
"-m", "comment", "--comment", comment,
"-j", "DNAT", "--to-destination", target,
}
namespace側FORWARDも同じく -s rootIP を要求する。
{"FORWARD",
"-i", nsDev,
"-s", rootIP,
"-p", port.Protocol,
"-d", port.TargetIP,
"--dport", strconv.Itoa(port.TargetPort),
"-m", "comment", "--comment", comment,
"-j", "ACCEPT",
}
これにより、victim namespaceへ入ってきたpacketであっても、root namespace側veth IPからSNATされた正規のpublished port通信だけをguestへ流す。
legacy ruleも消す
もう一つ重要だったのは、古い広すぎるruleを消すことだった。
HITSCは開発中のworkerで何度もticketを作ったり消したりする。 一度危険なruleが残ると、コードを直してもworker上のiptablesには古いruleが残る可能性がある。
そのため現在の PortForwardManager.Create は、新しいruleを入れる前にlegacy ruleを削除する。
for _, rule := range legacyPortForwardRootRules(port, rootDev, rootIP, nsIP) {
if err := deleteIPTablesRuleIfExists(m.Timeout, "", rule); err != nil {
return fmt.Errorf("delete legacy root port-forward rule for %s: %w", port.Name, err)
}
}
for _, rule := range legacyPortForwardNamespaceRules(port, nsDev, rootIP) {
if err := deleteIPTablesRuleIfExists(m.Timeout, namespace, rule); err != nil {
return fmt.Errorf("delete legacy namespace port-forward rule for %s: %w", port.Name, err)
}
}
legacyPortForwardRootRules / legacyPortForwardNamespaceRules には、脆弱だった当時の広いruleが定義されている。
これは一見すると変に見える。 直したはずの古いruleをコードに残しているからである。
しかし目的は再導入ではなくcleanupである。 過去のworker stateを安全側に寄せるために、古いruleの形を知っておく必要がある。
unit test
今回の修正は、iptablesのrule文字列が少し変わるだけに見える。 だからこそ、unit testでは「生成されるruleに境界条件が入っているか」を直接見る。
root namespace側は、--ctorigdst 127.0.0.1 --ctorigdstport 22022 が入っていることを確認する。
wantForward := []string{
"FORWARD",
"-o", "vrtest",
"-p", "tcp",
"-d", "100.64.0.2",
"--dport", "22022",
"-m", "conntrack",
"--ctorigdst", "127.0.0.1",
"--ctorigdstport", "22022",
"-m", "comment",
"--comment", "hitsc:ticket-1:ssh",
"-j", "ACCEPT",
}
ticket namespace側は、DNAT/FORWARDにroot uplink sourceが入っていることを確認する。
wantDNAT := []string{
"-t", "nat", "PREROUTING",
"-i", "vntest",
"-s", "100.64.0.1",
"-p", "tcp",
"--dport", "22022",
"-m", "comment",
"--comment", "hitsc:ticket-1:ssh",
"-j", "DNAT",
"--to-destination", "172.16.10.2:22",
}
また、legacy cleanup用ruleもtestしている。
これは「古い危険なruleを削除対象として認識できること」を守るためである。
実統合テスト
unit testだけでは、実際のpacketが通るかどうかは分からない。
そこで、root権限が必要な実統合テストも追加した。
sudo tools/test-published-port-isolation.sh
このscriptは次を実行する。
HITSC_RUN_NETNS_INTEGRATION=1 go test ./internal/network \
-run TestPublishedPortRejectsCrossTicketNamespaceTraffic \
-count=1 \
-v
テストの中では、一時的にvictim namespace、attacker namespace、guest namespaceを作る。
root namespace
|
+-- victim ticket namespace
| |
| +-- fake guest namespace
| 10.250.1.2:18080
|
+-- attacker ticket namespace
default routeあり
fake guest側ではPythonの小さなHTTP serverを立て、HITSCOK を返すようにする。
その後、テストは次を確認する。
root namespace:
127.0.0.1:<published_port> に到達できる
attacker namespace:
victim namespace uplink IP:<published_port> に到達できない
さらに、テスト開始時にあえて古い広すぎるlegacy ruleを投入してから PortForwardManager.Create を呼んでいる。
// Seed the exact broad rules that caused the historical cross-ticket bypass.
// PortForwardManager.Create must remove them before installing the narrowed
// localhost-only rules.
これにより、修正後のruleが正しいだけでなく、古いruleが残っていても消せることを確認している。
実際の実行結果はこうだった。
[hitto@hitsc-worker-01 hitsc]$ sudo tools/test-published-port-isolation.sh
=== RUN TestPublishedPortRejectsCrossTicketNamespaceTraffic
READY
--- PASS: TestPublishedPortRejectsCrossTicketNamespaceTraffic (0.71s)
PASS
ok github.com/hitto/hitsc/internal/network 0.713s
今回の教訓
今回のバグは、schema validationだけではnetwork boundaryを守れない、という話だった。
problem.yaml では bind: localhost だけを許可していた。
internal/model/problem.go でも、MVPでは localhost / 127.0.0.1 以外を拒否している。
しかし、実際のpacket pathでは、root namespace、ticket namespace、veth uplink、DNAT、SNAT、FORWARDが絡む。 この場合、設定値がlocalhost-onlyであることと、packetが本当にlocalhost由来であることは別問題である。
今回の修正で重要だったのは、境界を2箇所で確認したことだと思う。
root namespace:
conntrack original destinationが127.0.0.1:<published_port>であること
ticket namespace:
root uplink IPから来たpacketだけをDNAT/FORWARDすること
HITSCのように、network namespaceをまたいでpacketを通す基盤では、「入口で制限したつもり」が途中のFORWARD ruleで崩れることがある。
だから、設計ドキュメント上の境界だけでなく、実際にpacketが通る各地点で境界を再確認する必要がある。
現在仕様
現在の仕様は次の内部ドキュメントにまとめている。
- 外部公開 port forward / DNAT 設計
- security model
現在のMVPでは、published portは次の扱いにしている。
bind:
localhost / 127.0.0.1 のみ
protocol:
tcpのみ
published_port:
auto または 22000-22999 の固定port
cross-ticket:
別ticket namespaceから到達できてはいけない
public internet向け公開、0.0.0.0 bind、LAN bind、UDP、TLS終端、HTTP reverse proxyはMVP対象外である。
残っている課題
今回の修正でlocalhost-only published portのcross-ticket bypassは塞いだ。 ただし、関連する課題はまだある。
nftables / firewalld backend
doctorによるiptables rule診断
cleanup漏れ検出
LAN公開用bind profile
外部client実IPをguestへ見せる構成
VLAN/trunk問題向けの外部公開
また、実統合テストはroot権限とnetwork namespace操作を必要とするため、通常のCIで気軽に回すものではない。
そのため、軽量なunit testでrule形状を守りつつ、worker上では sudo tools/test-published-port-isolation.sh を明示的に実行する方針にしている。
参考
- Linux network namespaces: https://man7.org/linux/man-pages/man7/network_namespaces.7.html
- iptables extensions / conntrack match: https://man7.org/linux/man-pages/man8/iptables-extensions.8.html
- iptables: https://man7.org/linux/man-pages/man8/iptables.8.html
- Linux IP sysctl / route_localnet: https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html
- HITSC current design: 外部公開 port forward / DNAT 設計