表題の通りです。
ある日、面識のない海外のセキュリティ研究者を名乗る人物から「あなたのサブドメインが乗っ取れる状態になっている」というメールが届きました。
結論から書くと指摘自体は事実で、Route53に消し忘れたAレコードが1本残っていました。
自分の場合は運良く実害の出ない構成でしたが、備忘録として対応を残しておきます。
届いたメール
要約すると下記の内容でした。
-
code.tomohiko.ioが使われていないGCPのIP (35.212.164.96) を向いている - これは Subdomain Takeover の脆弱性で CVSS 8.2 の High
- 該当のIPは自分が確保していたが既に解放した
- 参考になったら PayPal で寄付してほしい
最後の一行で大体察しがつきますが、それはそれとして指摘が本当かどうかは別の話なので確認します。
とりあえず自分で確認する
まずDNSを引きます。
$ dig +short code.tomohiko.io A
35.212.164.96
普通に生きていました。
そのIPが誰のものかを見ます。
$ whois 35.212.164.96 | grep -iE "netname|orgname"
NetName: GOOGLE-CLOUD
OrgName: Google LLC
GCPでした。
自分はこのドメインでGCPを使ったことがあり、その時のVMをとっくに消していました。
そして疎通を見ると応答がありません。
$ curl -m 8 -o /dev/null -w "%{http_code}\n" https://code.tomohiko.io/
000
※000 はHTTPステータスではなく「応答を1つも受け取れなかった」という意味です。接続拒否・TLSエラー・タイムアウトのどれでもこの値になるので、これだけで「IPが未使用」と断定はできません。80/443がどちらも閉じていることは別途確認しました。
つまり「自分は解放済み、でもDNSだけがそこを指し続けている」という状態です。
Route53を見たらAレコードが素で残っていました。
$ aws route53 list-resource-record-sets --hosted-zone-id Z15E5E5L3IKIT8 \
--query "ResourceRecordSets[?Name=='code.tomohiko.io.']"
[
{
"Name": "code.tomohiko.io.",
"Type": "A",
"TTL": 300,
"ResourceRecords": [ { "Value": "35.212.164.96" } ]
}
]
指摘は事実でした。
なぜこれが「乗っ取り」になるのか
GCPの外部IPにはエフェメラル(一時的)と静的の2種類があります。
エフェメラルの方はVMを消すとプールに戻り、他の人に再割り当てされる仕様です。
詳細は下記のドキュメントにあります。
External IP addresses | Compute Engine Documentation
なので理屈の上では、同じリージョン(us-west1)でVMの作成と削除を回し続けて 35.212.164.96 を引き当てた人が、そのまま code.tomohiko.io の中身になれてしまいます。
自分では試していませんが、メールの人はこれを引いたと主張していました。
※ただし「特定のIPを狙って引き直せる」とは公式には書かれていません。あくまでプールからのランダムな割り当てなので、成功率がどの程度なのかは自分には分かりませんでした。
正直これ単体だと当たりを引く確率が低いので、CVSS 8.2 は盛りすぎだと思います。
サービス名を指定して確実に奪える CNAME 型(<好きな名前>.herokuapp.com のようなパターン)の方がよほど危ないです。
ただし確率が低いだけで、成立したときに何が起きるかは別問題でした。
本題: 親ドメインのクッキーまで持っていかれるパターン
ここが今回一番怖いと思った点です。
Cookieはオリジン単位ではなくドメイン単位で管理されていて、ポートやスキームでは分離されません。
なので tomohiko.io 側が下記のようにCookieを発行していたとします。
Set-Cookie: session=xxxxx; Domain=tomohiko.io; Path=/; Secure; HttpOnly
Domain を明示すると、そのCookieは指定ドメインだけでなく配下の全サブドメインに送信されます。
つまり乗っ取られた code.tomohiko.io にも同じセッションCookieが飛んでいきます。
※Path を省略した場合は発行時のURLから default-path が計算されるので、送信範囲が想定より狭くなることがあります。ここでは / を明示しています。
そしてこの状況で普段の対策がほぼ効きません。
-
HttpOnlyは効かない。JSから読めなくするだけで、攻撃者は自分のサーバーのアクセスログでリクエストヘッダをそのまま受け取れる -
Secureも効かない。乗っ取ったサブドメインでLet's Encryptの証明書を取ればHTTPSになる -
SameSiteも効かない。同じ登録可能ドメインかつ同じスキーム(どちらもHTTPS)なので same-site 扱いになる
ログイン中のユーザーを https://code.tomohiko.io/ に踏ませれば持っていかれます。
※SameSite=Strict だけは少し事情が違って、外部サイトからのトップレベル遷移では送信されません。ただし遷移先は同じ site なので、着地したページから自分自身にもう1回リクエストを投げれば送られてきます。一手増えるだけで、防げるわけではないという理解です。
逆向きも成立します。
サブドメイン側から Domain=tomohiko.io でCookieを焼き込めるので、本体側に自分の用意したCookieを送り込めます。
Cookies do not provide integrity guarantees for sibling domains (and their subdomains).
... The foo.example.com server can set a cookie with a Domain attribute of "example.com" (possibly overwriting an existing "example.com" cookie set by bar.example.com), and the user agent will include that cookie in HTTP requests to bar.example.com.
RFC 6265 - 8.6. Weak Integrity
つまりサブドメインから送り込まれたCookieを、受け取った側は自分が発行したCookieと区別できないとのこと。
※厳密に言うと、常に上書きされるわけではありません。ブラウザはCookieを名前・Domain・Path・host-only かどうかの組で識別するので、本体が Domain 無し(host-only)で発行していれば別物として両方が保存されます。ただしその場合は同じ名前のCookieが2つ並んで送られてきて、サーバー側はどちらが本物か判断できません。結局アプリ次第で危ないのは変わらないという感じです。
ここからセッションフィクセーションや cookie bomb につながります。
- セッションフィクセーション: 攻撃者が用意したセッションIDを焼き込む。ただしアプリ側がそのIDを受理し、かつログイン時にセッションIDを再生成していないことが条件
- cookie bomb: 巨大なCookieを大量に焼いてリクエストヘッダを膨らませ、本体を応答不能にする。返るのは
400や431などサーバー実装によります
対策としては下記になります。
- Cookieに
Domainを付けない(host-only にする。そのホストにしか送られなくなる) -
__Host-プレフィックスを使う。Domain無し・Secure・Path=/をブラウザ側で強制してくれる
サブドメインを気軽に生やす個人開発ほど注意が必要だと思います。
example.com にログイン機能を置いて、stg.example.com や demo.example.com を作っては消して、を繰り返している人は要注意です。
自分の場合はどうだったか
自分のドメインについては確認したところ成立しませんでした。
Cookieを発行しているものが無かったので、そもそも奪われるものが無かったという話です。
対処
Aレコードを消すだけです。
バックアップを取ってから消しました。
$ aws route53 list-resource-record-sets --hosted-zone-id Z15E5E5L3IKIT8 > backup.json
$ aws route53 change-resource-record-sets --hosted-zone-id Z15E5E5L3IKIT8 \
--change-batch file://delete-batch.json
{
"Changes": [
{
"Action": "DELETE",
"ResourceRecordSet": {
"Name": "code.tomohiko.io.",
"Type": "A",
"TTL": 300,
"ResourceRecords": [ { "Value": "35.212.164.96" } ]
}
}
]
}
※DELETEは既存レコードと完全に一致していないと通りません。TTLも値も一字一句合わせる必要があります。
change-resource-record-sets の応答は PENDING で返ってくるので、全権威サーバーへの反映は下記で待ちます。
$ aws route53 wait resource-record-sets-changed --id /change/C06622632P2INCERI0L3R
これが返ってきた時点で INSYNC です。
そのうえで権威サーバーに直接聞いて確認します。
$ dig code.tomohiko.io @ns-31.awsdns-03.com | grep status
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 14048
NXDOMAINになりました。
※dig を1台に投げただけでは伝播の証明にはならないので、判断は上の wait(INSYNC)の方を根拠にしています。
最後に
サブドメインを作って消してを繰り返していると、DNSレコードだけ残ります。
自分の場合はGCPのVMを消したときにRoute53のことを完全に忘れていました。
Cookieの話は今回たまたま成立しませんでしたが、成立する構成の方がむしろ普通だと思います。
サブドメインが1本乗っ取られると、本体のセッションまで巻き込まれる可能性があるというのは覚えておいて損はなさそうです。