AWSのセキュリティグループを点検していて、nc でMySQLのポートに繋いだら接続が成立しました。「3306がインターネット全体に開いている」と判断して報告したのですが、これは誤りでした。セキュリティグループは特定のIPアドレスだけを許可していて、私はまさにその許可されたIPから叩いていただけです。接続が成功したという事実は、ポートが公開されている証拠にはなりません。この記事では、なぜこの誤診が起きるのかと、正しい確認手順を書きます。
この記事に出てくるIPアドレス・ドメイン・リソースIDはすべてドキュメント用の架空の値です。 IPアドレスは 203.0.113.10 のようにRFC 5737で文書用に予約されている 203.0.113.0/24(TEST-NET-3)を、ドメインは example.com を使っています。どちらも実在のホストには割り当てられないアドレスで、example.com と同じ「サンプル専用」の扱いです。セキュリティグループIDも sg-xxxxxxxx に置き換えています。
接続が成功したので「公開されている」と判断した
最初にやったのは、外部からのポート疎通確認です。
nc -zv -w 5 example.com 3306
返ってきたのはこれです。
Connection to example.com port 3306 [tcp/mysql] succeeded!
succeeded と出ているので、外部からDBのポートに到達できると読みました。Webサーバーのポート(80/443)以外が開いているのは危険なので、警告として報告しました。
この判断の問題点は、「外部から」の定義を自分で確認していなかったことです。私が作業していた回線は、そのセキュリティグループで許可されているIPアドレスでした。つまり正規の接続元から正規の接続をして、正常に成功しただけです。攻撃者の視点を再現したつもりで、実際には管理者の視点をそのまま使っていました。
セキュリティグループのルールを読めば分かった
疎通確認より先にやるべきだったのは、ルールそのものを読むことです。
aws ec2 describe-security-groups --group-ids sg-xxxxxxxx --region ap-northeast-1 \
--query 'SecurityGroups[].IpPermissions[].[IpProtocol,FromPort,ToPort,IpRanges[].CidrIp|join(`,`,@)]' \
--output table
出力はこうなりました。203.0.113.10 は文書用予約アドレスで、実際の値の位置にこれを置いています。
--------------------------------------------------
| tcp | 80 | 80 | 0.0.0.0/0 |
| tcp | 443 | 443 | 0.0.0.0/0 |
| tcp | 22 | 22 | 203.0.113.10/32 | <- 架空のIP
| tcp | 3306 | 3306 | 203.0.113.10/32 | <- 架空のIP
--------------------------------------------------
3306の許可元は 203.0.113.10/32(架空のIP)の1つだけです。/32 は単一のIPアドレスを指す表記なので、このアドレス以外からは接続できません。0.0.0.0/0(全世界)になっているのは80と443だけで、これはWebサーバーとして正常な状態です。
ルールを読んでいれば、疎通確認をする前に結論が出ていました。公開範囲を判断する一次情報はルール定義であって、接続テストの結果ではありません。
許可外のポートは接続そのものが成立しない
比較として、許可していないポートを同じコマンドで叩くと挙動がはっきり違います。
nc -zv -w 5 example.com 8080
nc: connectx to example.com port 8080 (tcp) failed: Operation timed out
セキュリティグループで許可されていないポートは、パケットが破棄されるのでタイムアウトします。「拒否」の応答が返るのではなく無反応になる点が特徴です。
この違いを踏まえると、succeeded が意味するのは「このポートは、いま自分がいる場所からは到達できる」までです。到達できた範囲がどこまで広いかは、まったく別の情報になります。
接続できても認証層で止まる二層構造になっている
もうひとつ見落としていたのが、TCP接続が成立したあとの応答です。-z(ポートスキャンモード)を外して繋ぐと、サーバーからのバナーが読めます。
nc -w 5 example.com 3306 | strings | head -2
Host '203.0.113.10' is not allowed to connect to this MariaDB server
この 203.0.113.10 も架空のIPです。実際には接続元のアドレスがここに入ります。
TCPレベルでは繋がっているのに、MySQL側で接続を拒否しています。MySQLやMariaDBはユーザーごとに接続元ホストを持っていて、許可されていないホストからの接続はここで切られます。実際にリモート接続できるユーザーがいるかは、こう確認できます。
mysql -e "SELECT user, host FROM mysql.user WHERE host NOT IN ('localhost','127.0.0.1','::1');"
この結果が空なら、全ユーザーがローカル接続限定です。つまりセキュリティグループを通過できたとしても、DBには入れません。防御が2層あり、1層目を通過した事実だけでは危険度を判定できないということです。
もっとも、これは「だから公開したままでよい」という話ではありません。層が重なっているおかげで即座に侵入されないだけで、設定ミスで片方が外れれば残りは1層です。不要なポートは閉じるのが前提で、二層構造は保険として理解するのが妥当です。
疎通確認の結果を解釈するときの順序
今回の反省を踏まえて、公開範囲を確認するときは次の順序にしています。
- ルール定義を読む。
describe-security-groupsで許可元CIDRを確認する。0.0.0.0/0かどうかがそのまま答えになる - 自分の接続元IPを確認する。 許可リストに自分が入っているなら、接続成功は当然の結果として扱う
- 疎通確認は裏取りに使う。 ルールで閉じているはずのポートがタイムアウトすることを確認する用途。開いているかどうかの判定には使わない
- バナーまで読む。 接続できた場合、アプリケーション層で拒否されているかを確認する
クラウドのセキュリティグループに限らず、ファイアウォールやアクセス制御リスト全般に同じことが言えます。許可リスト方式の設定を、許可されている場所からテストしても、何も検証できていません。テストの結果を解釈するには、自分がどこから叩いているかを常に条件に含める必要があります。
自分の環境から外して確認する方法
許可外からの到達性をきちんと確かめたい場合は、接続元を変えるしかありません。手軽なのは、テザリングなど別回線に切り替えて同じコマンドを流すことです。許可リストに載っていない経路から叩いて、タイムアウトすれば設定どおりに閉じています。
今回のように「自分は許可された場所にいる」と気づけば、そもそも誤診しません。誤診の原因は技術的な難しさではなく、succeeded という単語を見た時点で確認を止めてしまったことでした。