User-Agent による振り分けの問題点
AWS で Application Load Balancer(以下 ALB)を使っていると Apache ログに下記のアクセスが記録される。デフォルトではチェック間隔が 30 秒になっているので 86400/30 で一日あたり約 2880 レコードが記録される。
10.0.0.128 - - [13/Jan/2020:00:00:00 +0900] "GET /healthcheck.html HTTP/1.1" 200 - "-" "ELB-HealthChecker/2.0"
で、このログを振り分けるための方法として下記のようなものを見かけた。(正規表現の部分については多少違いはある)
SetEnvIf User-Agent "ELB-HealthChecker.*" nolog CustomLog "logs/access_log" combined env=!nolog
上記の設定は次のように動く。
User-Agent
がSetEnvIf
の正規表現ELB-HealthChecker.*
にマッチした場合、nolog
環境変数を設定する。CustomLog
でnolog
が設定されていなければ(つまり!nolog
ならば)access_log
に記録する。
どこかからコピペしてきたのだと思ったら案の定、同様の情報が次々と出てきた。
- ELB配下のEC2アクセスログについてあれこれ | Developers.IO
- ELBのヘルスチェックをログに残したくない(Apache版) - Qiita
- ELBの生死監視(ヘルスチェック)ログを停止 - Qiita
- ELBのヘルスチェックをログに記載しない (Apache) | technote
- Apache Httpd アクセスログ設定メモ - Qiita
しかしこれには欠点があって、User-Agent を ELB-HealthChecker
等に偽装してしまえば隠れて活動が可能となる。例えば、下記のようにアクセスすれば Apache のログには記録されない。(env=nolog
を別のログに出力していればそちらに出力される)
$ curl --user-agent "ELB-HealthChecker" localhost
この設定が横行してしまうと AWS で運用しているシステムは軒並み不正アクセスされてしまうのではないかなと思う(そもそもログに残らないので不正アクセスされたことすら気づかないかもしれない)。例えば「ログは動いてないのに接続数がものすごく多い」などの状態になるかもしれない。
Qiita はコピペ記事もあったりするんだろうけど、Classmethod さんはもう少し危険性について説明や補足しておいた方がいいのではないかと思う。
Apache の公式ドキュメントを読んでいる人ならわかると思うが、SetEnvIf
の第3引数は regex なので ELB-HealthChecker.*
と書く必要は無い。そう書いている人はコピペなのだろう。
SetEnvIf User-Agent ELB-HealthChecker nolog
ベストプラクティスを考える
とりあえずどんな状況でも安易に User-Agent だけで判別するのはよろしく無いと思う(iOS や Android の振り分けとかならいいと思うが)。なので User-Agent 以外の項目を条件に加える。
SetEnvIf User-Agent
と BrowserMatch
、および SetEnvIfNoCase User-Agent
と BrowserMatchNoCase
はそれぞれ同じ動作なのでここではすべて SetEnvIf User-Agent
と SetEnvIfNoCase User-Agent
とする。
ELB ヘルスチェッカーからのアクセスでわかっていることは下記の通り。
Remote_Host
はサブネットで付与される IP アドレスRequest_Method
はGET
Request_URI
はコンソールで設定したもの(デフォルトは/
)Request_Protocol
はHTTP/1.1
User-Agent
はELB-HealthChecker/1.0
やELB-HealthChecker/2.0
等
Apache の公式ドキュメントを見ると SetEnvIf
の第一引数に指定出来るものには Remote_Host
やリクエストヘッダなどがある。(Request-Line
も使えるのだろうか?)
mod_setenvif - Apache HTTP サーバ バージョン 2.4
Remote_Addr
を使えばより正確に ELB ヘルスチェッカーからのアクセスだと見分けることが出来るだろうが、これは環境によって変わるのでメンテナンス性や移植性に欠ける。となると User-Agent
以外に使えそうなものは Remote_URI
である。
では下記のように SetEnvIf
に条件を二つ以上設定出来るか?と言うと出来ない。シンタックスエラーにはならないが、下記の例で言うと一番最初の attribute regex 以降は env-variable として見られていると思われる。
SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ && Remote_URI ^/healthcheck.html$ nolog
ちょっと話が逸れるが ALB に設定するパスの話をしておく。ALB の初期設定では /
に設定されているが、チェックの解釈によって手法が分かれると思う。
- トップページ(
/
や/index.html
)が 200 を返すことを確認する - どのパスでもいいので Apache が 200 を返すことを確認する
私は大抵後者を選択する。トップページが 403 だろうがなんだろうがとりあえずインスタンスに接続してもらわなければ Apache の状態を確認することが出来ない。後者の場合、/
や index.html
等を返すのはコストの無駄なので空のファイルを置いている。
$ touch /var/www/html/aws/healthcheck.html
というわけで以降で説明するパスは /aws/healthcheck.html
というファイルが存在する前提で進める。
このパスを User-Agent
と組み合わせることで UA 偽装の判別精度が向上するが、UA 偽装を抽出したいわけではなく、「ELB ヘルスチェッカー」と「それ以外」を切り分けたいだけである。それは下記の理由。
- UA 偽装で正規のパスにアクセスされたとしても 0 バイトのファイルを返すだけなので特に問題にはならない。更に ALB を使っている場合はパス一致でブロックしてしまえば正規のパスへのアクセスは ELB の内側からしか行えないように出来る
- UA 偽装で正規のパス以外にアクセスされた場合は
access_log
に記録されるので UA 偽装だと判断出来る。これは CloudWatch Logs でaccess_log
に対して文字列フィルタでメトリクス化しておけば良い
話を戻して User-Agent
と Request_URI
で ELB ヘルスチェッカーを判別したい場合に Apache で使えそうな機能を集める。
<Location>
ディレクティブを使うSetEnvIf
を複数組み合わせる<IF>
ディレクティブを使う(Apache 2.2 では使えない)
Location ディレクティブを使う
<Location>
ディレクティブを使うことで Request_URI = /healthcheck.html
を達成することが出来て、その中に SetEnvIf User-Agent
を書けば AND 条件となる。
<Location "/aws/healthcheck.html"> SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb </Location> CustomLog "logs/access_log" combined env=!nolog CustomLog "logs/access_elb_log" combined env=access_elb
例えば Collectd からのアクセスが増えたとしても <Location>
ディレクティブが増えるだけで簡単に設定することが出来る。(Apache を信用するのであれば)Require all denied
と Require local
によって SetEnvIf Remote_Host localhost
を条件に追加していることと同等と考えられる。
<Location "/aws/healthcheck.html"> SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb </Location> <Location "/server-status"> SetHandler server-status Require local SetEnvIf User-Agent ^collectd/\d+\.\d+\.\d+$ nolog access_collectd </Location> CustomLog "logs/access_log" combined env=!nolog CustomLog "logs/access_elb_log" combined env=access_elb CustomLog "logs/access_collectd_log" combined env=access_collectd
SetEnvIf で複数条件を組み合わせて AND 条件を作る
SetEnvIf による AND 条件の組み方。別の要件で見たのだけどなんともトリッキーな方法だなと思う。❶ と ❷ は見ればわかるのだけど、❸ がポイント。
❸ で is_elb
がセットされていれば Apache のデフォルト設定で 1
が入っているので正規表現 ^$
にマッチせず to_healthcheck
はキャンセルされないので ❹ が通る。
❸ で is_elb
がセットされていなければ to_healthcheck
がキャンセルされるので ❹ は通らない。
❶ SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ is_elb ❷ SetEnvIf Request_URI ^/aws/healthcheck.html$ to_healthcheck ❸ SetEnvIf is_elb ^$ !to_healtchcheck ❹ SetEnvIf to_healthcheck 1 nolog access_elb CustomLog "logs/access_log" combined env=!nolog CustomLog "logs/access_elb_log" combined env=access_elb
考えるのも読み解くのも面倒なので Request_URI
を使うなら <Location>
ディレクティブを使った方がいいと思う。
IF ディレクティブを使う
Apache 2.4 では <If>
ディレクティブが使えるので複雑な条件も作ることが出来る。
<If "%{REQUEST_METHOD} == 'GET' && %{REQUEST_URI} == '/aws/healthcheck.html' && %{HTTP_USER_AGENT} =~ m|^ELB-HealthChecker/\d+\.\d+$|"> SetEnvIf _ .* nolog access_elb </If> CustomLog "logs/access_log" combined env=!nolog CustomLog "logs/access_elb_log" combined env=access_elb
<If>
ディレクティブ内で SetEnvIf
する必要は無いので上記例では SetEnv
を使用している。regex に /
(スラッシュ)が含まれる場合はバックスラッシュによるエスケープは無効なようなのでデリミタに代替文字を使う必要がある。
m#^ELB-HealthChecker/\d+\.\d+$#
ALB で UA 偽装を弾いてみる
ALB にはパスや HTTP ヘッダーで任意のレスポンスを返すことが出来る機能が備わっている。ただしパスはワイルドカードが使えるだけで正規表現には対応していないので SetEnvIf
のようにはいかない。
ELB-HealthChecker の UA 偽装を弾くには下記のように登録する。レスポンスはお好みだが 403 や 404 よりは 503 を返しておいたほうがクローラーなどに効果がある気がする。文字列については大小文字を区別するため下記の場合 elb-healthchecker
には効果が無い。
項目 | 値 |
---|---|
HTTP ヘッダー | User-Agent = ELB-HealthChecker* |
レスポンスコード | 503 |
Content-Type | text/plain |
レスポンス本文 | 省略 |
実際に ELB ヘルスチェッカーを装ってくるパターンにどんなものがあるかわからないけど全ブロックしてヘルスチェックファイルだけ許可するのもいいかもしれない。
SetEnvIfNoCase User-Agent ELB-HealthChecker like_elb <Location "/"> Order allow,deny Allow from all Deny from env=like_elb </Location> <Location "/aws/healthcheck.php"> Allow from env=like_elb SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb </Location> CustomLog "logs/access_log" combined env=!nolog CustomLog "logs/access_elb_log" combined env=access_elb
SetEnvIfNoCase User-Agent ELB-HealthChecker like_elb <Location "/"> <RequireAll> Require all granted Require not env like_elb </RequireAll> </Location> <Location "/aws/healthcheck.php"> Require env like_elb SetEnvIf User-Agent ^ELB-HealthChecker/\d+\.\d+$ nolog access_elb </Location> CustomLog "logs/access_log" combined env=!nolog CustomLog "logs/access_elb_log" combined env=access_elb
動作は下記のようになる。アクセス可能な領域に違いは無いが、UA 偽装の場合は ELB 用のログファイルに記録されないないので見分けが付く。
ELB-HealthChecker/2.0 | elb-healthchecker/2.0 | |
---|---|---|
/aws/healthcheck.html へのアクセス |
allow | allow |
/aws/healthcheck.html 以外へのアクセス |
deny | deny |
記録されるログファイル | access_elb_log |
access_log |