SSTエンジニアブログ

SSTのエンジニアによるWebセキュリティの技術を中心としたエンジニアブログです。

Dockerのiptablesを有効にしたままSSRF対策をするには

こんにちは、SSTでWeb脆弱性診断用のツール(スキャンツール)開発をしている坂本(Twitter, GitHub)です。

先日、Dockerコンテナのinbound/outbound通信をiptablesで制限する方法について調べたので、簡単に紹介していきたいと思います。

動機としてはタイトルに書いた通りSSRF対策を目的としています。 特に内部ネットワーク宛の攻撃を想定し、Dockerコンテナからの private network 宛のoutboundを禁止したい。 でもDockerホスト側ではメールGWなど一部内部NW上のサーバへのoutboundは許可する必要があるので、Dockerコンテナだけに限定したoutboundルールを設定したい・・・そんな状況に対応するにはどうするか調べてみた次第です。

結論から書くと Docker Engine 公式ドキュメントで解説されているとおり DOCKER-USER chain をカスタム します。 それだけですと記事としてなんの新規性も無いので、公式ドキュメントには無い以下のトピックスに挑戦してみました。

  • RHEL/CentOS系の firewall-cmd コマンドからiptablesのルールを追加してみる。
  • systemd で docker 起動時に DOCKER-USER chain にルールを追加してみる。

検証環境:

  • CentOS Stream 8
    • Docker Engine Community Version 23系
  • Amazon Linux 2
    • Docker Engine Version 20系

1. Docker の iptables と DOCKER-USER chain

まず Docker の iptables と DOCKER-USER chain について簡単に紹介します。

  • Docker はコンテナのネットワーク管理のために iptables を操作しています。
  • Docker は DOCKER-USERDOCKER という2つのカスタム chain を FORWARD chain の一部として追加し、Dockerコンテナのinbound/outboundはこの2つのカスタムchainを通るように設定します。
  • Docker 側で追加する iptables ルールは DOCKER chain に追加されます。
  • ユーザ側でカスタマイズしたい場合は DOCKER-USER chain に iptables ルールを追加します。

詳細は Docker 公式ドキュメントや iptables の資料を参照してください。

2. firewall-cmd コマンドから DOCKER-USER chain にルールを追加

Docker 公式ドキュメントでは iptables コマンドでルールを追加する例が紹介されています。 坂本自身は普段 CentOS Stream 8 を使っており、ディストリビューションとの親和性から firewall-cmd コマンドを使ったやり方を本記事では紹介します。

例として 10.0.0.0/8 (class A private network) 宛のTCP outbound をREJECTするルールを DOCKER-USER chain に追加するには、以下のコマンドを実行します。

$ sudo firewall-cmd --direct \
    --add-rule ipv4 \
    filter DOCKER-USER 10 \
    -m state --state NEW \
    -m tcp -p tcp \
    -d 10.0.0.0/8 \
    -j REJECT

--direct オプションを使うのがポイントです。 RHEL/CentOS系のファイアウォールである firewalld では iptables よりも簡易的かつ分かりやすい形でパケットフィルタ設定を行えるようになっています。 そのトレードオフとして、通常の設定方法では今回のような細かい指定ができません。 それを補うが --direct オプションであり、iptablesのルール設定をほぼそのまま指定することが可能となります。

3. systemd で docker 起動時に DOCKER-USER chain をカスタマイズ

firewall-cmd でoutbound設定を行う例を紹介しましたが、これをシステムが起動する度に手動で実行するのは非効率です。 Linux ディストリビューションにおいては Docker Engine (dockerd) は systemd により起動されるので、DOCKER-USER chain のカスタマイズも systemd で管理してくれると便利です。

これを実現するため試行錯誤してみた結果として、本記事では docker.service の systemd 設定を override.conf でカスタマイズし、 ExecStartPost を使うことで dockerd 起動後に firewall-cmd を自動実行する方法を紹介します。

例として class A/B/C および 169.254.0.0/16 (RFC3927, link-local address) への TCP outbound を REJECT するルールを追加してみます。

  1. /etc/systemd/system/docker.service.d/override.conf を作成して以下の内容を保存します。
    [Service]
    ExecStartPost=/usr/bin/firewall-cmd --direct --add-rule ipv4 filter DOCKER-USER 20 -m state --state NEW -m tcp -p tcp -d 10.0.0.0/8 -j REJECT
    ExecStartPost=/usr/bin/firewall-cmd --direct --add-rule ipv4 filter DOCKER-USER 21 -m state --state NEW -m tcp -p tcp -d 172.16.0.0/12 -j REJECT
    ExecStartPost=/usr/bin/firewall-cmd --direct --add-rule ipv4 filter DOCKER-USER 22 -m state --state NEW -m tcp -p tcp -d 192.168.0.0/16 -j REJECT
    ExecStartPost=/usr/bin/firewall-cmd --direct --add-rule ipv4 filter DOCKER-USER 23 -m state --state NEW -m tcp -p tcp -d 169.254.0.0/16 -j REJECT
    
  2. システムを再起動します。

RHEL/CentOS系では docker.service という名前で dockerd の systemd サービスが登録されます。 systemd では /etc/systemd/system/<service名>.d/(任意のファイル名).conf という設定ファイルを作ることで、元の service 設定にマージする仕組みが提供されています。1 その仕組みを使って、 docker.service が起動したあと (= ExecStartPost) に firewall-cmd を使って DOCKER-USER chain をカスタマイズするコマンドを実行するというのが、上記の例の中身になります。

余談: firewalld 側で DOCKER-USER chain のカスタマイズを永続化できないか?というのも試してみたのですが、どうも DOCKER-USER chain 自体は永続化されておらず、dockerd起動時に毎回作成してるようです。そしてサービスの起動順序としては firewalld のほうが先なので・・・ firewalld 側で DOCKER-USER chain のカスタマイズを永続設定にしてしまうと、「まだ dockerd が起動して無くて DOCKER-USER chain が存在しない状況でそれを操作しようとする」状況となるため、設定が無視されてしまう現象を確認しました。 このため、DOCKER-USER chain をカスタムするなら docker が起動した後が確実なので、上記のように systemd の override 機能を使って ExecStartPost を使ってみた次第です。 と、ここまで書いてて気づいたのですが、 firewall-cmd コマンドを単発実行するだけの service を作成して、docker サービスの起動後に実行するよう設定するアプローチもありかもしれません。 さらに厳密を期するなら、dockerが起動して DOCKER-USER chain が追加されたところまで確認してから firewall-cmd コマンドを実行するよう health check の仕組みを導入する必要もありそうです。 興味ある方はお試しください。

4. outboundを制限したい private/local network いろいろ

「SSRF対策として private network へのoutboundを制限したい」と聞いて、すぐ思いつくIPアドレスとしては class A/B/C でしょうか。最近ではそれに加えて AWS EC2 メタデータURL2が含まれる 169.254.0.0/16 も必須と思われます。

最近見かけた記事では、以下のURLを参考資料として他にもいろいろなIP範囲を制限していて勉強になりました。 https://github.com/letsencrypt/boulder/blob/d6cd589795735d876ad62e40b6adf19655e8f7f0/bdns/dns.go#L32-L124

ここに挙げられている全ての範囲が本当に outbound 制限が必要かどうかまでは、本記事ではスコープ外とします。 SSRF対策を目的とした outbound 制限対象の private network については、ベストプラクティスとなるような一覧があると便利かと思いますので、機会があれば調べてみたいと思います。

5. bridge network 内の通信と outbound 制限の衝突について

Docker ではコンテナを配置するネットワークを複数種類サポートしています。3 ここでは DOCKER-USER chain の outbound 制限と、bridge network のIP範囲が衝突したため、同じ bridge network に配置したコンテナ間で通信ができなくなった例を紹介します。

(1) 本記事で紹介したように /etc/systemd/system/docker.service.d/override.conf で以下のように 172.16.0.0/12 へのTCP outbound のREJECTルールを DOCKER-USER chain に追加。

[Service]
(...)
ExecStartPost=/usr/bin/firewall-cmd --direct --add-rule ipv4 filter DOCKER-USER 21 -m state --state NEW -m tcp -p tcp -d 172.16.0.0/12 -j REJECT
(...)

(2) 以下のように 172.16.0.0/12 レンジで独自に bridge nework の mytestbr0 を作成。

$ docker network create \
   --driver=bridge \
   --subnet=172.30.230.0/24 \
   --ip-range=172.30.230.0/27 \
   --gateway=172.30.230.30 \
   mytestbr0

(3) mytestbr0 に nginx コンテナを2つ配置して起動。

$ docker run -d --rm -p 8080:80 --network mytestbr0 --name nginx8080 nginx:1.23-alpine
$ docker run -d --rm -p 8081:80 --network mytestbr0 --name nginx8081 nginx:1.23-alpine

(4) Docker ホストからコンテナへの疎通確認 → 正常

$ curl http://localhost:8080/
$ curl http://localhost:8081/

(5) それぞれのコンテナに入って、お互いに通信できるか確認 → 失敗

$ docker exec -it nginx8080 /bin/sh
/ # curl http://nginx8081/
curl: (7) Failed to connect to nginx8081 port 80 after 1003 ms: Couldn't connect to server
/ # exit

$ docker exec -it nginx8081 /bin/sh
/ # curl http://nginx8080/
curl: (7) Failed to connect to nginx8080 port 80 after 1001 ms: Couldn't connect to server
/ # exit

原因としては単純で、DOCKER-USER chain で 172.16.0.0/12 宛のTCP outboundをREJECTしてます。 mytestbr0 の設定上、各コンテナにもその範囲のIPが割り当てられますので、お互いにTCP通信をしようとすれば当然 172.16.0.0/12 宛のTCP outbound となるのでREJECTされてしまった・・・というオチになります。

このような状況を避けるため、自分としては以下のように対処してみました。

  1. mytestbr0172.16.0.0/12 レンジまるごとカバーする形で作成する。
  2. その代わり DOCKER-USER chain からは 172.16.0.0/12 レンジのルールを削除する。

mytestbr0 に配置されたコンテナからすれば、 172.16.0.0/12 宛の通信は全て同じネットワーク上の他のコンテナ宛となるはずで、外には出ていかない・・・と思います。なので、DOCKER-USER chain のルールからも外してしまって支障はない、という判断での対処になります。(この部分の理解や前提が間違ってたらご指摘ください)

なお Docker ホスト側が所属するIPと被る状況だとどうなるか、自分も理解しきれておりません。そのため、Dockerホストが所属するIPどは別のレンジで bridge network を作成し、そのレンジを除外しておくなど細かい設定が必要となるかと思います。4

6. うまく動かなくなったときは

これは自分の体験談ですが、docker network コマンドでネットワークの作成/削除を繰り返していたり、iptablesのカスタム設定に試行錯誤しているうちに、どう頑張っても設定した通りの挙動にならないときがありました。

その時どうしたか・・・ OSを再起動するとアッサリ解消しました。

docker自体も iptables をいろいろ操作してるので、何かの拍子に設定がずれたり、壊れたりする状況があるのだと思われます。 ただ docker の良いところというか、たまたま今現在の仕様がそうなってるので助かってるところとして、iptablesに対する操作が iptables のレイヤーで永続化されておらず、dockerが起動してから設定されるところです。 もしも docker 関連でiptablesの設定が壊れてしまっても、OSを再起動すれば壊れた設定がクリアされ、きれいな状態で再挑戦できます。

みなさんがもし docker network コマンドやiptablesで試行錯誤をしているうちに「あれ?」と思うような現象に遭遇したら、ダメ元でもいいのでOS再起動を検討してみてください。

それでも思ったように動いてくれないときは、 iptables -L -n などで実際にiptablesの設定状況をダンプしてみて、どういう設定になっているか目視で解析してみるのを推奨します。

7. Docker の iptables 機能を無効化するアプローチについて

最近見かけた記事で、そもそも docker の daemon.jsoniptables キーに false を設定してdockerによるiptables操作を無効化し、マニュアルでiptablesのrouting設定を構築して outbound 制限を組み込む記事5がありました。

こちらの記事も読み応え十分で、iptablesの勉強になります。 また outbound で制限したいIPアドレスとして class A/B/C 以外にも様々なものがあることをこちらの記事で知り、大変勉強になりました。

一方で、Docker の iptables 操作を無効化することの副作用について注意が必要な印象も受けました。 公式ドキュメントでは Prevent Docker from manipulating iptables で次のように説明しています。

Setting iptables to false will more than likely break container networking for the Docker engine.

iptablesfalse に設定すると、高い確率で docker のコンテナネットワークを壊す可能性がある、とのことです。

非常に副作用が大きい設定変更と思われますので、こちらのアプローチを採用する際は本当にそれが適切か、入念な事前検証やメリット/デメリットの検討が必要と思われます。

8. その他 docker network におけるセキュリティの話題について

最近見かけた記事で、Docker Desktop の port mapping が Docker ホストのファイアウォールのルールも書き換えていて、公衆WiFi上で動かしたら外部からアクセスされてしまった事例がありました。

コンテナサービスが提供するネットワーク機能は便利な点もありますが、裏側でどういう仕組で動いているのか、どういう副作用があるのか公式ドキュメントなどを通じてきちんと仕組みを理解する必要性を改めて感じました。

9. まとめ

  1. docker は独自に iptables のルールを設定している。
  2. コンテナに対する iptables のルールをカスタマイズしたいときは DOCKER-USER chain に設定する。
  3. システム(docker)起動時に DOCKER-USER chain へのルール設定を行いたい場合は、systemd によるカスタマイズ機能を使う。
    • 本記事の例では /etc/systemd/system/docker.service.d/override.conf ファイルの ExecStartPost から firewall-cmd --direct で詳細なiptablesルールを設定。
  4. class A/B/C の他にも特殊なIPアドレスレンジがいろいろある。SSRF対策として outbound を制限したいIPアドレスについて、それらも含めてRFCなど調べて検討すると良さそう。
  5. outbound 制限をするときは、 bridge network などコンテナ自身が所属するIPレンジとの衝突に注意。
  6. うまく動かなくなったときはシステム再起動。 iptables -L -n などで実際の設定状況の確認も忘れずに。
  7. Docker の daemon.jsoniptables キーを false にするアプローチは、副作用が大きい設定変更。入念な事前検証やメリット/デメリットの検討を。
  8. コンテナ環境での便利なネットワーク機能について、裏側の仕組みや公式ドキュメントの確認を忘れずに。(セキュリティ関連の記載は特に要チェック)

SSRF対策に限らず、コンテナネットワークの routing や filtering 設定をカスタマイズしたい、調べてみたいユースケースはたくさんあると思います。本記事の内容がそのようなときにお役に立てれば幸いです。


  1. https://www.freedesktop.org/software/systemd/man/systemd.unit.html
  2. https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
  3. https://docs.docker.com/network/drivers/
  4. 自分の場合、VirtualBoxによる仮想VM環境に CentOS Stream 8 を入れてその中で docker を動かしています。VirtualBox のVMにデフォルトで設定されるNATネットワークなので、VMのIPレンジは 10.0.0.0/8 となることが確定しているので、それ以外のレンジで bridge network を作成しています。もし Docker Desktop などで物理PC上に直接Dockerを導入していて、なおかつ WiFi などでIPレンジが時と場所によって変わるような使い方ですと、これについてはより詳細に検証する必要があるかと思います。
  5. https://developers.prtimes.jp/2023/06/13/prevent-from-ssrf-with-docker-and-firewalld/