← logs

HITSCのlocalhost-only published port分離バグを直した

2026年6月7日

HITSC の localhost-only published port で別 ticket namespace から到達できる余地があった問題を、conntrack original destination と root uplink source の確認で塞いだ記録

tags: HITSC, Network, Security, iptables, Troubleshooting

この記事に出てくる 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が通る各地点で境界を再確認する必要がある。

現在仕様

現在の仕様は次の内部ドキュメントにまとめている。

現在の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 を明示的に実行する方針にしている。

参考

関連ログ