🛡

AWS WAF の検知結果を Slack 通知して誤検知に対処しよう

2021/11/26に公開

Leaner 開発チームの黒曜(@kokuyouwind)です。

先日会社のポッドキャストでこくぼさんと一緒にお話させていただきました。

ポッドキャストは初体験なのでドキドキしながら収録しましたが、評判が良いようで嬉しいです。

https://anchor.fm/1599/episodes/w-yusuke_kokubo--kokuyouwind-e1a3vrq/a-a6snivm

AWS WAF について

(>ω<)わふー! みなさん AWS WAF は使っているでしょうか?

Web アプリケーションは HTTP ポートをインターネットに露出しているため、 SQL インジェクションやファイル読み出しなど様々な攻撃に晒されてしまいます。

SSH ポートなどはそもそもインターネットから到達できないようセキュリティグループで制限すればよいのですが、 Web アプリケーションは不特定多数から HTTP リクエストを受けるのが仕事なので、アクセスを弾くわけにもいきません。

こうした攻撃を防ぐのが WAF(Web Application Firewall) の役割です。

詳しくは AWS 公式の解説がわかりやすいです。

https://aws.amazon.com/jp/waf/what-is-waf/

Leaner での WAF 設定

WAF を導入する場合、どのようなルールでアクセスを弾くかが問題になります。

幸い AWS WAF では代表的な攻撃パターンが Managed Rule として提供されているため、これを利用すると簡単にルールを設定できます。

Leaner では Core Rule Set, Known Bad Inputs, SQL Database の 3 ルールセットを適用しています。

(後述しますが、これに加えて独自のルールを設定しています)

ルールセットの選び方はWaf Charm のブログ記事を参考にしました。

https://www.wafcharm.com/blog/how-to-choose-aws-managed-rules/

WAF の誤検知問題

AWS Managed Rule を使えば設定なしで WAF を導入できて最高!

…となればいいのですが、実際には以下のような問題が往々にして発生します。

  • WAF を導入したら認証が通らなくなった!
  • WAF を導入したらファイルアップロードが動かなくなった!
  • WAF を導入したら特定のエンドポイントだけ動かなくなった!

本当は攻撃であるアクセスだけをブロックしたいのですが、誤って普通のアクセスを攻撃と判断してブロックしているケースですね。[1]

厄介なことに、こういった誤検知が発生した場合はリクエストが Web アプリケーションまで到達しないため、問題に気づくのがなかなか困難です。

WAF がブロックしたリクエストを Slack に通知する

誤検知問題に気づくためには、そもそもどういったリクエストがブロックされているのかをこまめにチェックする必要があります。

AWS WAF 自体にもブロックされたリクエストをサンプリングする機能がありますが、ある程度間引かれた情報になるうえ実際の攻撃がほとんどなので誤検知に気づくことは難しいです。

そんなわけで、ブロックしたリクエストを Slack 通知するようにしました。

構成は上のような形です。図にする意味があまりない一本道ですが、関係するリソースは意外と多くなりました。[2]

実装にあたりAWSWAFでブロックログのアラートをフィルタリングしつつSlackへ飛ばすを参考にさせていただきましたが、構成は少し変えています。

https://qiita.com/yuta_vamdemic/items/d7fa05f8f3d620cc3bc0

AWS WAF のログ出力設定

まずは AWS WAF のログを出力するよう設定します。

この際、普通に設定するとすべてのアクセスについてログが出てしまい情報量が非常に多くなってしまいます。

今回確認したいのはブロックの情報だけなので、フィルターで Rule Action: BLOCK のみを Keep in logs に設定し、それ以外は Drop from logs としているのがポイントです。

Kinesis Firehose から S3 へ出力する設定

Kinesis Firehose ではログをそのまま S3 へ流すだけなので、大した設定はありません。

AWS WAF 側の設定で注釈が出ますが、ストリーム名を aws-waf-logs-* にしないといけない点に少し注意が必要です。

S3 のイベント通知を Lambda に送る

S3 のイベント通知で Lambda を起動するよう設定します。

これもバケットのプロパティ内にある「イベント通知」を設定するだけです。もしくは、 Lambda 関数を作る際に s3-get-object テンプレートを使うと勝手に設定してくれます。

Lambda から Slack に通知する

Lambda では S3 からファイルを取得し、ログを元に Slack へ通知します。

この際、すべてのブロックを通知すると攻撃によるログが多すぎて誤検知が埋もれてしまうため、よく来る攻撃パターンのルールやパスは通知しないよう除外しています。

// 既知の攻撃パスは通知しない
const known_rules = [
    'UserAgent_BadBots_HEADER',
    'NoUserAgent_HEADER',
]
const known_uris = [
    '/.aws/credentials',
    '/Autodiscover/Autodiscover.xml',
    // etc...
]

if (known_rules.includes(picked.rule) || known_uris.includes(picked.uri)) {
    return;
}
return sendSlack(picked);

特に UserAgent_BadBots_HEADERNoUserAgent_HEADER は大量に来るうえ、普通にアプリケーションを利用している限りでは発生しないはずなので、通知から除外するのがオススメです。

一方で攻撃が来るパスについては多岐に渡るため、通知を確認しながら都度潰していく必要があります。このあたりはもう少し楽にしたいところです。

結果

以下のように Slack へ通知が来るようになりました![3]

このチャンネルを確認しつつ、アプリケーションパスっぽいものがあれば誤ブロックでないか確認し、明らかに攻撃であれば Lambda を編集して除外設定に足しています。

誤検知だった場合の対応

明らかに誤検知だった場合は、 WAF がブロックを行わないよう設定が必要です。

このあたりはクラスメソッドさんの記事で詳しくまとまっています。

https://dev.classmethod.jp/articles/aws-waf-false-positive/

Leaner では「検知してしまうパターンを明示的に許可」を利用して、誤検知が発生したパスは明示的に検査を除外するようにしています。

まとめ

AWS WAF は便利な機能ですが、誤検知が発生するとアプリケーションに影響が出てしまうため、運用には気を使う部分があります。

Slack 通知を仕込むことでこのあたりの安心感がかなり増したので、今後も運用を改善していきます。

宣伝

Leaner Technologies では AWS やセキュリティに興味のあるエンジニアを募集しています!

https://careers.leaner.co.jp/

付録: Lambda 全コード(一部編集済)

同じように通知を設定したい人向けに、 Lambda の全コードを載せておきます。

HTTP リクエストが存外大変だったため、 request-promise を Lambda レイヤーで設定して利用しています。

const rp = require('request-promise');
const aws = require('aws-sdk');
const s3 = new aws.S3({ apiVersion: '2006-03-01' });

const sendSlack = (record) => {
    const url = ''; // Slack Webhook URL
    const data = {
        attachments: [{
            text: 'WAF BLOCKを検知しましたよ。わふ〜!',
            color: 'good',
            fields: [{
                    title: 'Rule',
                    value: record.rule,
                    short: false
                },
                {
                    title: 'URI',
                    value: record.uri,
                    short: true
                },
                {
                    title: 'Query',
                    value: record.args,
                    short: true
                }
            ]
        }]


    };
    const postOptions = {
        hostname: 'hooks.slack.com',
        path: url,
        method: 'POST',
    };
    let option = {
        'url': url,
        'header': {
            'Content-Type': 'application/json'
        },
        'method': 'POST',
        'json': true,
        'body': data
    };
    return rp(option).promise()
}

exports.handler = async(event, context) => {
    // Get the object from the event and show its content type
    const bucket = event.Records[0].s3.bucket.name;
    const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, ' '));
    const params = {
        Bucket: bucket,
        Key: key,
    };
    try {
        const { Body } = await s3.getObject(params).promise();
        const lines = Body.toString().split('\n');
        await Promise.all(lines.map((line) => {
            if (!line) { return; }
            const record = JSON.parse(line);
            const rule = record.ruleGroupList[0].terminatingRule.ruleId
            
            const picked = {
                rule: rule,
                host: record.httpRequest.host,
                uri: record.httpRequest.uri,
                args: record.httpRequest.args
            }

            // 既知の攻撃パスは通知しない
            const knownRules = [
                'UserAgent_BadBots_HEADER',
                'NoUserAgent_HEADER',
            ]
            const knownUris = [
                '/.aws/credentials',
                '/Autodiscover/Autodiscover.xml',
                // etc...
            ]

            if (knownRules.includes(picked.rule) || knownUris.includes(picked.uri)) {
                return;
            }

            return sendSlack(picked);
        }))
        return 'success';
    }
    catch (err) {
        console.log(err);
        const message = `Error getting object ${key} from bucket ${bucket}. Make sure they exist and your bucket is in the same region as this function.`;
        console.log(message);
        throw new Error(message);
    }
};
脚注
  1. こういった判定の誤りを正式な用語で 偽陽性 (False Positive) といいます。偽陽性という用語自体は、 WAF に限らず臨床検査などでも使われる用語です。また、逆に検査対象を見逃してしまうケースは 偽陰性 (False Negative) といいます。 ↩︎

  2. AWS WAF のログは Firehose を経由せずに S3 出力できるようなので、そちらで設定するとよりシンプルになりそうです。今回は参考にした記事が Firehose 経由だったためこの形になっています。 ↩︎

  3. アイコンと Bot 名は個人的な趣味で設定しています。なおアイコンについては著作権的にアレなのでモザイクをかけています。 ↩︎

リーナーテックブログ

Discussion