ICTSC2018 本戦 問題解説: それはアクセスできないようにしたはずなのに……

本問題は、 iptables の設定を間違えたように見せかけて、運用者が Docker、ないし docker-compose に詳しくなかったためにハマってしまう、というシナリオで出題した問題です。

問題文

あなたの会社では、最近会社のブログを Docker へ移行した。
ブログには WordPress を使っており、DBは MariaDB、そしてその管理用に phpMyAdmin を 8080 番ポートで動作させている。
phpMyAdmin は外部からのアクセスを防ぐため、ローカルホストからのみアクセスを許可するようにしたつもりであったが、どうやら Docker に移行した際にうまく設定が適用されなくなってしまったらしい。

上記の問題を解決してください。

アクセスに必要な情報

IPアドレス: 192.168.20.1 ユーザー: admin パスワード: bloom-into-docker

  • WordPress の管理者ID / パスワード: admin / admin
  • データベースの ID / パスワード: wordpress / wordpress

問題のゴール状態

問題文に示した意図を達成すること

 

解説

情報として与えられたサーバへログインすると、ホームディレクトリに docker-compose.yml が置かれており、問題文で説明されていたアプリケーションが Docker のコンテナオーケストレーションを行う docker-compose で設定されていたことが分かります。
また、ブラウザから 192.168.20.1 にアクセスすると WordPress で構築されたブログが表示され、8080番ポートへアクセスすると phpMyAdmin が表示されるようになっていました。

Docker の動作中コンテナを確認するコマンドである docker ps を叩くと以下のような結果が得られます。 (コンテナIDは異なる場合があります)

# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3424d4d54bae wordpress:5.0.3-php7.1-apache "docker-entrypoint.s…" 5 minutes ago Up 5 minutes 0.0.0.0:80->80/tcp wordpress
f195b424d1cf phpmyadmin/phpmyadmin:4.8 "/run.sh supervisord…" 5 minutes ago Up 5 minutes 9000/tcp, 0.0.0.0:8080->80/tcp phpmyadmin
b9c12504b150 mariadb:10.2.22 "docker-entrypoint.s…" 5 minutes ago Up 5 minutes 0.0.0.0:3306->3306/tcp database

この問題のゴール状態は「問題文に示した意図を達成すること」とありますが、意図とはなんでしょうか? それは問題文に示されています。
問題文には「外部からのアクセスを防ぐため、ローカルホストからのみアクセスを許可するようにしたつもりであったが」と書かれています。
意図をエスパーして iptables -L  で iptables のルールを確認してみると、それらしいルールが登録されていることが分かります。 (dpt:http-alt に関連する2行がそうです)

# iptables -L
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
ACCEPT     tcp  --  anywhere             anywhere             tcp dpt:http
ACCEPT     tcp  --  localhost            anywhere             tcp dpt:http-alt
DROP       tcp  --  anywhere             anywhere             tcp dpt:http-alt
ACCEPT     tcp  --  10.0.0.0/8           anywhere             tcp dpt:ssh
ACCEPT     tcp  --  172.16.0.0/12        anywhere             tcp dpt:ssh
ACCEPT     tcp  --  192.168.0.0/16       anywhere             tcp dpt:ssh
DROP       tcp  --  anywhere             anywhere             tcp dpt:ssh

つまり、TCPの8080ポートに対するアクセスは、送信元がlocalhostからのものを許可し、それ以外は拒否するようになっています。
「どうやら Docker に移行した際にうまく設定が適用されなくなってしまった」と問題文にあるとおり、おそらく普通に Apache や nginx のような HTTPサーバをローカルで実行していればこのルールは正しく適用されます。

このルールが意図の通りに動作しないのは Docker 上のコンテナで HTTPサーバが動いていることによるものです。
詳しい説明は参考で示したリンク先を参照して頂きたいのですが、 Docker でコンテナのポートを外部に公開した場合、外部からのアクセスは iptables のDOCKERチェインへジャンプするルールがPREROUTINGチェインに追加されます。
そのため、上記のINPUTチェインに追加された 8080ポート (http-alt) に対するルールは処理されません。

さて、問題の背景が分かったところでこの問題に対処する必要がありますが、実は本問題の解答にあたり、 iptables と格闘する必要はありません。この問題のジャンルが軽量コンテナであることもヒントになっています。

そもそもなぜ外部から TCPの8080ポートへアクセスできるのかといえば、それは外部のインタフェースからのアクセスを受け付けているからに他なりません。

docker ps の結果における PORTS の列に表示されているように、 0.0.0.0:8080->80/tcp というのは、ホストが全てのIPからTCPの8080ポートでアクセスを受け付けることを意味しています。
これを拒否するようにするためには、コンテナが受け付けるIPアドレスを制限すれば良いのです。

docker-compose のコンフィグファイルのドキュメントを参照すれば、 docker-compose.yml"8080:80""127.0.0.1:8080:80" に変更することで問題が解決できることが分かります。

反映は docker-compose up -dで可能ですが、とりあえずホストを再起動しても反映されます。困ったら再起動しましょう。

また、WordPressやデータベースの認証情報はただの罠です。特に意味はありません。

別解

これは意表を突かれたのですが、 phpMyAdmin の Docker イメージは内部で nginx を使っているようで、その nginx のコンフィグを書き換えることで対処してきたチームが1チームいました。
Docker のコンテナのライフタイムや設定の柔軟性を考えるとあまり望ましくはありませんが、大会における条件は満たしていたため満点としました。

また、解決できることは分かっていましたが望ましくない解答として、 iptables と格闘することによる解答方法があります。このときは、以下のDNATのルールに着目する必要があります。

iptables -L DOCKER -t nat
Chain DOCKER (2 references)
target     prot opt source               destination
RETURN     all  --  anywhere             anywhere
RETURN     all  --  anywhere             anywhere
DNAT       tcp  --  anywhere             anywhere             tcp dpt:mysql to:172.18.0.2:3306
DNAT       tcp  --  anywhere             anywhere             tcp dpt:http-alt to:172.18.0.3:80
DNAT       tcp  --  anywhere             anywhere             tcp dpt:http to:172.18.0.4:80

内部的には、このルールが Docker 内のコンテナへの NAT を実現しているため、このルールが適用される送信元IPをローカルホストへ絞るようにすることで本来望んでいた動作を得ることができます。
具体的な操作は述べませんが、上記の設定を修正した上で、これを永続化するようにすることができれば目的を達成できます。
しかし、 Dockerチェインのiptables のルールの操作は Docker が責任を追っており、これをユーザが弄ることは望ましい操作ではありません。事実、このルールを変更しても再起動後にはDockerのデーモンによりルールが上書きされてしまいます。
大会中の解答では dockerd が iptables を操作しないよう修正し、なんとか再起動後もルールが維持されるようにしたチームが2チームいました。
しかし、これは他のコンテナ等が存在する場合には管理が大変煩雑になるためあまり望ましくありません。

本来であればこのような解答を行うことが望ましくないと分かるよう誘導するべきであり、これは作問を行った私のミスだと考えています。

講評

採点基準として、解法の良し悪しに関係なく、ホストの再起動後も期待した動作を維持したチームには満点の200点、再起動後は期待した動作を維持できなかったチームには100点を与えました。

本戦においては、12チームが想定解法で満点となりました。また、2チームが別解により満点、1チームが別解により100点となりました。

本問題は想定していたよりも正答率がかなり高く、最近の学生は Docker が使えるのだな、と個人的には少し感動しました。
まだまだ Docker や Kubernetes は登場したてではありますが、アンケート等の結果も見ると興味を持って触っている方が多いように思いました。

この講評を書きながら、そういえば私は ICTSC5において  FreeBSD における軽量コンテナシステムである Jail の問題を出題したことを思い出しました。Dockerがここまで流行るとは思ってなかった……
皆さんも FreeBSD、そしてJail を使ってみてください。

参考

パブリックIPを持つサーバでDockerを起動するとportが全開放される問題の対処法 – grep Tips *

記事中の問題とは関係がありませんが、 Docker が iptables へ設定するルール等が詳しく説明されています。

また、出題した環境で用いられていた docker-compose.yml および永続化されていた iptables コンフィグ (/etc/iptables/rules.v4) はこちらです。 https://gist.github.com/kyontan/d96a6afbaae615b1b156844a1be261a1
問題環境を再現するためには Ubuntu Server 18.04 において netfilter-persistent および iptables-persistent を導入し、上記の iptables のルールを読み込んだ上で docker-compose up -d でコンテナを起動してもらえればと思います。