スマートなタイトルが思いつきませんでした。X(クロス) イノベーション 本部 ソフトウェアデザインセンター セキュリティグループの耿です。 インターネットにWebアプリケーションを公開すると、さまざまなHTTPリク エス トを受けることになります。中にはアプリケーションを利用する目的ではない、 情報収集や 脆弱性 を突いて攻撃を狙うリク エス トも多数含まれています。攻撃への根本対策はWebアプリケーションレベルで行うべきですが、対策漏れや ゼロデイ攻撃 に対してはWAFの利用も効果的です。 AWS WAFには AWSマネージドルール が多数用意されており、簡単に導入し多層防御を実現できます。この記事では AWS WAFによるブロックをより厳しくする方法として、WAFのルールがブロックしたリク エス トの IPアドレス を、悪意のあるリク エス トを送信してくる可能性がある IPアドレス とみなして、一定期間 IPアドレス レベルでブロックし続ける仕組みをご紹介します。一度WAFのWeb ACL でブロックに成功しても、攻撃者はパターンを変えて何度も攻撃を試みる場合があり、IPレベルで一定期間ブロックすることで他のWAFルールが対応していないパターンで攻撃が成立するリスクを減らすことを期待しています。 インフラは AWS CDK で実装します。 どんな Web アプリが適しているか アーキテクチャ 1.サブスクリプションフィルターでWAFのログからLambdaを起動 2.DynamoDBのTTLで一定期間後にブロックを解除 3.CDKデプロイ用にSSM Parameter Storeを利用 4.WAF IPセットの楽観ロックで同時更新に対応 サンプルコード 事前準備 (CDK) IPアドレスリストの取得 (CDK) WAFの作成 (CDK) WAFのログを出力 (CDK) DynamoDBテーブルの作成 (CDK) ブロック対象を追加するLambda関数 (CDK) サブスクリプションフィルター (CDK) EventBridgeでIPアドレスリストを同期 (Lambda) パッケージのインポートとSDKクライアントの作成 (Lambda) ハンドラー (Lambda) ログの抽出 (Lambda) IPアドレスをDynamoDBテーブルに追加 (Lambda) DynamoDBからIPアドレスリストを取得 (Lambda) DynamoDBのIPアドレスリストを同期する関数 どんな Web アプリが適しているか 不特定多数の利用者を想定した一般公開のアプリには、これから紹介する仕組みは向かないと思います。WAFの誤検知によってリク エス トをブロックしてしまった場合の影響が大きいからです。社内用アプリなど、利用ユーザーは特定できるがインターネットからアクセスできるアプリがこの仕組みに適していると思います。 アーキテクチャ 1. サブスクリプション フィルターでWAFのログからLambdaを起動 AWS WAFで IPアドレス レンジレベルでリク エス トを許可・ブロックするためには、 IPセット を定義します。WAFのブロック結果からこのIPセットを動的に変更すれば良いので、WAFのブロックログをCloudWatch Logsに出力し、 サブスクリプションフィルター で特定のログをトリガーにLambda関数を起動します。 2.DynamoDBの TTL で一定期間後にブロックを解除 IPアドレス とユーザーの関連は変わり得るので、一定期間経過後は IPアドレス レベルのブロックを自動で解除し、ブロック対象の IPアドレス リストの長さが増え続けないようにします。誤動作で特定の IPアドレス がブロックされてしまった場合の影響を小さくします。 DynamoDBの TTL を利用します。ブロック対象となる IPアドレス をDynamoDBテーブルのレコードとして保持し、 TTL を設定します。EventBridgeで定期的にLambda関数を起動し、DynamoDBテーブルのアイテムのうち、有効期限内の IPアドレス のみでWAFのIPセットを更新します。 3.CDKデプロイ用にSSM Parameter Storeを利用 今回はCDKでインフラを構築する前提なので、IPセットもCDKコードに記載することになります。ただしIPセットの中身は、CDKデプロイ時に先祖返りしないように SSM Parameter Storeパラメータから取得 するようにします。Lambda関数でDynamoDBテーブルから有効な IPアドレス を取得した後は、このパラメータも更新するようにします。 最終的な アーキテクチャ は次の図の通りになります。ブロック対象の IPアドレス 情報はWAFのIPセット、DynamoDBテーブル、Parameter Storeパラメータと3箇所に分散していますが、DynamoDBテーブルのデータを正とし、IPセットとParameter StoreパラメータのデータはDynamoDBテーブルから結果整合的に更新されるようになっています。 4.WAF IPセットの楽観ロックで同時更新に対応 同じIPセットを更新する箇所が複数あり、また複数のWAFブロックログが同時に出力された場合はブロック対象 IPアドレス の追加が同時に実行される可能性があります。この複数の更新に対する 排他制御 の仕組みが、IPセットの API にあります。 WAF IPセットを取得する GetIPSet API のレスポンスで LockToken を取得し、IPセットを更新する UpdateIPSet API を呼びだす時のリク エス トに付加することで、楽観ロックを容易に実現できます。 WAF IPセットを更新するUpdateIPSet API は、個別の IPアドレス レンジを追加、削除するのではなく、更新時に IPアドレス レンジの全体を置き換えるような API 仕様になっているため、このような楽観ロックの仕組みが用意されているのだと思います。 サンプルコード 以上で説明した仕組みを実現するためのサンプルコードをご紹介します。 事前準備 ブロック対象の IPアドレス リストを保持するParameter Storeパラメータを手動で作成しておきます。ここでは MALICIOUS_IP_LIST というパラメータ名とします。空文字では登録できないので初期値は半角スペース 1 つとし、Lambda関数で動的に更新していきます。 1.1.1.1/32;2.2.2.2/32 のように、複数のIP CIDRを セミ コロンで区切るフォーマットを想定します。 (CDK) IPアドレス リストの取得 Parameter Storeパラメータより IPアドレス リストを取得し、文字列型の配列に変換します。それを利用してWAFのIPセットを定義します。 const ipListParamName = "MALICIOUS_IP_LIST" ; const maliciousIpSet = ssm.StringParameter.valueFromLookup ( this , ipListParamName ) .split ( ";" ) .map (( ip ) => ip.trim ()) .filter (( ip ) => ip ); const wafIpSetName = "MaliciousIpSet" ; const wafIpSet = new wafv2.CfnIPSet ( this , "MaliciousIpSet" , { name: wafIpSetName , ipAddressVersion: "IPV4" , scope: "REGIONAL" , // CloudFront用のWAFの場合は "CLOUDFRONT" addresses: maliciousIpSet , } ); またCDKデプロイする際には、 コンテキスト をリセットして常にParameter Storeから最新のパラメータを取得するように、 cdk deploy の前に cdk context --clear を実行するようにします。 cdk context --clear cdk deploy MyStack --require-approval never (CDK) WAFの作成 WAFを作成します。最初に評価されるルールに IpConstraintStatement を作成し、先ほど作成した IPセットをブロックします。他のルールは AWSManagedRulesCommonRuleSet など、自由に追加します。 WAFのALBやCloudFrontなどへの関連付けは省略します。 const wafWebAcl = new wafv2.CfnWebACL ( this , "WafV2WebAcl" , { defaultAction: { allow: {} } , scope: "REGIONAL" , // CloudFront用のWAFの場合は "CLOUDFRONT" visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "WafV2WebAcl" , } , rules: [ { name: "IpConstraintStatement" , priority: 10 , statement: { ipSetReferenceStatement: { arn: wafIpSet.attrArn , } , } , action: { block: {} } , visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "IpConstraintStatement" , } , } , { name: "AWSManagedRulesCommonRuleSet" , priority: 20 , statement: { managedRuleGroupStatement: { vendorName: "AWS" , name: "AWSManagedRulesCommonRuleSet" , } , } , overrideAction: { none: {} } , visibilityConfig: { cloudWatchMetricsEnabled: true , sampledRequestsEnabled: true , metricName: "AWSManagedRulesCommonRuleSet" , } , } , // 他のルールは省略 ] , } ); (CDK) WAFのログを出力 WAFのBLOCKログとCOUNTログをCloudWatch Logsに出力します。 const wafLogGroup = new logs.LogGroup ( this , "WafLogGroup" , { logGroupName: "waf-logs" , } ); const logConfig = new wafv2.CfnLoggingConfiguration ( this , "WafV2LoggingConfiguration" , { logDestinationConfigs: [ `arn:aws:logs: ${ region } : ${ accountId } :log-group: ${ wafLogGroup.logGroupName } ` ] , resourceArn: wafWebAcl.attrArn , loggingFilter: { DefaultBehavior: "DROP" , Filters: [ { Behavior: "KEEP" , Conditions: [{ ActionCondition: { Action: "BLOCK" } } , { ActionCondition: { Action: "COUNT" } }] , Requirement: "MEETS_ANY" , } , ] , } , } ); logConfig.addDependsOn ( wafWebAcl ); (CDK) DynamoDBテーブルの作成 ブロック対象 IPアドレス 用のDynamoDBテーブルを作成します。 パーティション キーはIP CIDRで、 ip_range というキー名にします。 TTL は expires という名前の属性にしています。 const ipTable = new dynamodb.Table ( this , "MaliciousIpTable" , { tableName: "malicious-ip" , partitionKey: { name: "ip_range" , type : dynamodb.AttributeType.STRING } , billingMode: dynamodb.BillingMode.PAY_PER_REQUEST , timeToLiveAttribute: "expires" , } ); (CDK) ブロック対象を追加するLambda関数 ブロック対象の IPアドレス を追加するLambda関数を作成します。関数内で利用するための 環境変数 をいくつか設定し、必要な API コールをするための権限を渡します。 const autoBlockMaliciousIpFunction = new lambdaNodejs.NodejsFunction ( this , "AutoBlockMaliciousIpFunction" , { entry: "functions/auto-block-malicious-ip.ts" , runtime: Runtime.NODEJS_18_X , timeout: Duration.minutes ( 3 ), environment: { TABLE_NAME: ipTable.tableName , // DynamoDBテーブル名 IP_LIST_PARAMETER_NAME: ipListParamName , // Parameter Storeパラメータ名 IP_SET_NAME: wafIpSetName , // IPセット名 IP_SET_ID: wafIpSet.attrId , // IPセットID IP_SET_SCOPE: "REGIONAL" , // IPセットスコープ。CloudFront用のWAFの場合は "CLOUDFRONT" } , } ); // DynamoDBテーブルの参照・更新権限 ipTable.grantReadWriteData ( autoBlockMaliciousIpFunction ); // IPセットの参照・更新権限 autoBlockMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ wafIpSet.attrArn ] , actions: [ "wafv2:GetIpSet" , "wafv2:UpdateIPSet" ] , effect: iam.Effect.ALLOW , } ) ); // パラメータの更新権限 autoBlockMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ `arn:aws:ssm: ${ region } : ${ accountId } :parameter/ ${ ipListParamName } ` ] , actions: [ "ssm:PutParameter" ] , effect: iam.Effect.ALLOW , } ) ); (CDK) サブスクリプション フィルター ブロックログに対して サブスクリプション フィルターでLambda関数を起動させます。ただし IpConstraintStatement ルール自体でブロックされた場合はLambda関数を起動しないようにします。他にも地理的一致条件や、他の IPアドレス 条件でブロックされた場合、対象の IPアドレス はどちらにせよブロックされるので、Lambda関数の起動条件から除外しても良いでしょう。さらに会社の IPアドレス の場合はブロック対象外とするなど、フィルターパターンは要件に応じていろいろとカスタマイズできるでしょう。 const filter = wafLogGroup.addSubscriptionFilter ( "WafLogFilter" , { destination: new destinations.LambdaDestination ( autoBlockMaliciousIpFunction ), filterPattern: logs.FilterPattern.literal ( '{ $.action = "BLOCK" && $.terminatingRuleId != "IpConstraintStatement" }' ), } ); // https://github.com/aws/aws-cdk/issues/23177 へのワークアラウンド // これがないと初回デプロイで失敗する ( filter.node.defaultChild as logs.CfnSubscriptionFilter ) .addDependency ( filter.node.findChild ( "CanInvokeLambda" ) as CfnPermission ); (CDK) EventBridgeで IPアドレス リストを同期 定期的にEventBridgeで起動し、DynamoDBの IPアドレス リストをParameter StoreパラメータとIPセットに同期するLambda関数を作成します。EventBridgeルールは、ここでは1時間に1度起動するようにしています。 const autoUpdateMaliciousIpFunction = new lambdaNodejs.NodejsFunction ( this , "AutoUpdateMaliciousIpFunction" , { entry: "functions/auto-update-malicious-ip.ts" , runtime: Runtime.NODEJS_18_X , timeout: Duration.minutes ( 1 ), environment: { TABLE_NAME: ipTable.tableName , IP_LIST_PARAMETER_NAME: ipListParamName , IP_SET_NAME: wafIpSetName , IP_SET_ID: wafIpSet.attrId , IP_SET_SCOPE: "REGIONAL" , } , } ); // DynamoDBテーブルの参照権限のみ ipTable.grantReadData ( autoUpdateMaliciousIpFunction ); autoUpdateMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ wafIpSet.attrArn ] , actions: [ "wafv2:GetIpSet" , "wafv2:UpdateIPSet" ] , effect: iam.Effect.ALLOW , } ) ); autoUpdateMaliciousIpFunction.addToRolePolicy ( new iam.PolicyStatement ( { resources: [ `arn:aws:ssm: ${ region } : ${ accountId } :parameter/ ${ ipListParamName } ` ] , actions: [ "ssm:PutParameter" ] , effect: iam.Effect.ALLOW , } ) ); new events.Rule ( this , "AutoUpdateWafIpListEventRule" , { schedule: events.Schedule.cron ( { minute: "0" } ), targets: [ new eventTargets.LambdaFunction ( autoUpdateMaliciousIpFunction ) ] , } ); (Lambda) パッケージのインポートと SDK クライアントの作成 ここからはブロック対象の IPアドレス を追加するLambda関数のコードを紹介します。まず必要なパッケージのインポートと、使用する各サービスの SDK クライアントを作成する部分です。 // functions/auto-block-malicious-ip.ts import * as zlib from "zlib"; import { DynamoDBClient, ScanCommand, ScanCommandInput, AttributeValue, PutItemCommand, } from "@aws-sdk/client-dynamodb"; import { SSMClient, PutParameterCommand } from "@aws-sdk/client-ssm"; import { WAFV2Client, UpdateIPSetCommand, GetIPSetCommand } from "@aws-sdk/client-wafv2"; import { Handler, CloudWatchLogsEvent, CloudWatchLogsLogEvent, CloudWatchLogsDecodedData } from "aws-lambda"; const dynamodbClient = new DynamoDBClient({ region: process.env.AWS_REGION }); const ssmClient = new SSMClient({ region: process.env.AWS_REGION }); const wafClient = new WAFV2Client({ region: process.env.AWS_REGION }); (Lambda) ハンドラー サブスクリプション フィルターを受けて起動する関数なので、引数のイベントは CloudWatchLogsEvent 型になります。全体の処理の流れはこの通りです。 1. ログイベントを抽出 2. IPアドレスをDynamoDBテーブルに追加 3. WAF IPセットのロックトークンを取得(楽観ロック開始) 4. DynamoDBからIPアドレスリストを取得 5. Parameter Storeパラメータを更新 6. WAF IPセットを更新。失敗したら3からやり直す export const handler: Handler = async ( input: CloudWatchLogsEvent ) => { if ( ! process .env.IP_SET_ID || ! process .env.IP_SET_NAME || ! process .env.TABLE_NAME || ! process .env.IP_LIST_PARAMETER_NAME || ! process .env.IP_SET_SCOPE || ! [ "REGIONAL" , "CLOUDFRONT" ] .includes ( process .env.IP_SET_SCOPE ) ) { return; } // 1. ログイベントを抽出(関数の中身は後述) const logEvents = await extractAwsLogEvents ( input ); // 各イベントに対してループ処理 for ( const event of logEvents ) { type LogMessageType = { httpRequest?: { clientIp?: string ; } ; } ; const message = JSON .parse ( event.message ) as LogMessageType ; if ( ! message.httpRequest || ! message.httpRequest.clientIp ) continue; // 2. IPアドレスをDynamoDBテーブルに追加(関数の中身は後述) await addDynamodbItem ( { tableName: process .env.TABLE_NAME , ipAddress: message.httpRequest.clientIp } ); let retryCount = 10 ; while ( retryCount > 0 ) { // 3. WAF IPセットのロックトークンを取得(楽観ロック開始) const { LockToken: lockToken } = await wafClient.send ( new GetIPSetCommand ( { Name: process .env.IP_SET_NAME , Scope: process .env.IP_SET_SCOPE , Id: process .env.IP_SET_ID , } ) ); // 4. DynamoDBからIPアドレスリストを取得(関数の中身は後述) const ipRanges: string [] = await getAllIpsFromDynamodb ( { tableName: process .env.TABLE_NAME } ); const ipRangeParam = ipRanges.length > 0 ? ipRanges.join ( ";" ) : " " ; // 5. Parameter Storeパラメータを更新 await ssmClient.send ( new PutParameterCommand ( { Name: process .env.IP_LIST_PARAMETER_NAME , Value: ipRangeParam , Overwrite: true } ) ); try { // 6. WAF IPセットを更新 // lockToken発行後に更新されているとエラーがThrowされる await wafClient.send ( new UpdateIPSetCommand ( { Name: process .env.IP_SET_NAME , Id: process .env.IP_SET_ID , Scope: process .env.IP_SET_SCOPE , Addresses: ipRanges , LockToken: lockToken , } ) ); retryCount = 0 ; } catch { // 楽観ロックで失敗したら一定回数リトライ retryCount -= 1 ; } } } } ; (Lambda) ログの抽出 ハンドラーの引数からログイベントの中身を抽出する関数は こちら を参考にして実装します。 const extractAwsLogEvents = async ( input: CloudWatchLogsEvent ) : Promise < CloudWatchLogsLogEvent [] > => { const payload = Buffer . from( input.awslogs.data , "base64" ); const result = await new Promise < string >(( resolve , reject ) => { zlib.gunzip ( payload , ( e , result ) => { return e ? reject ( e ) : resolve ( result.toString ( "ascii" )); } ); } ); return ( JSON .parse ( result ) as CloudWatchLogsDecodedData ) .logEvents ; } ; (Lambda) IPアドレス をDynamoDBテーブルに追加 ログに記録された IPアドレス をDynamoDBテーブルにアイテムとして追加する部分は次のようになります。 TTL のフォーマットは Unix エポック時間形式の秒単位 であり、現在時刻より1時間後としています。 const addDynamodbItem = async ( props: { tableName: string ; ipAddress: string } ) : Promise < void > => { // 1時間ブロックする const expires = Math .floor (new Date () .getTime () / 1000 ) + 3600 ; const command = new PutItemCommand ( { TableName: props.tableName , Item: { ip_range: { S: ` ${ props.ipAddress } /32` } , expires: { N: ` ${ expires } ` } , } , } ); await dynamodbClient.send ( command ); } ; (Lambda) DynamoDBから IPアドレス リストを取得 Parameter StoreパラメータとIPセットを更新するために、DynamoDBテーブルをスキャンし全 IPアドレス を取得する部分です。DynamoDBの TTL で有効期限が切れてもすぐにアイテムが削除される保証はなく、最大で48時間テーブルに残るため、スキャン後に フィルター で TTL が有効なアイテムをフィルタリングします。また読み取りは 強力な整合性 とし、直前で追加した IPアドレス が確実に結果に含まれるようにします。(結果整合性よりも多くのキャパシティユニットを消費することにご留意ください) const getAllIpsFromDynamodb = async ( props: { tableName: string } ) : Promise < string [] > => { let startKey: Record < string , AttributeValue > | undefined = undefined ; let shouldScanNext = true ; const ipRanges: string [] = [] ; const currentTime = Math .floor (new Date () .getTime () / 1000 ); while ( shouldScanNext ) { const params: ScanCommandInput = { TableName: props.tableName , ConsistentRead: true , ExpressionAttributeNames: { "#e" : "expires" } , ExpressionAttributeValues: { ":1" : { N: ` ${ currentTime } ` } } , FilterExpression: "#e >= :1" , ExclusiveStartKey: startKey , } ; const { Items , LastEvaluatedKey } = await dynamodbClient.send (new ScanCommand ( params )); Items?.forEach (( item ) => { if ( item [ "ip_range" ] .S ) ipRanges.push ( item [ "ip_range" ] .S ); } ); if ( LastEvaluatedKey ) { startKey = LastEvaluatedKey ; } else { shouldScanNext = false ; } } return ipRanges ; } ; これで、ブロック対象の IPアドレス をIPセットに追加するLambda関数を作成できました。 (Lambda) DynamoDBの IPアドレス リストを同期する関数 EventBridgeを受けて定期的に起動し、DynamoDBの IPアドレス リストをParameter StoreパラメータとIPセットに同期するLambda関数は、先ほどのLambda関数の 3 ~ 6 の処理を利用すれば作成できます。 3. WAF IPセットのロックトークンを取得(楽観ロック開始) 4. DynamoDBからIPアドレスリストを取得 5. Parameter Storeパラメータを更新 6. WAF IPセットを更新。失敗したら3からやり直す 以上で、 AWS WAFでブロックしたリク エス トの IPアドレス を、一定期間 IPアドレス レベルでブロックし続ける仕組みを実現できました。人手を介さずに完全に自動で動き、ブロック対象の IPアドレス は一定期間後に自動消去されるので増え続けることもなく、CDKのデプロイに対してデータが上書きされることもありません。 お読みいただいてありがとうございました! 私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。 セキュリティエンジニア(セキュリティ設計) 執筆: @kou.kinyo 、レビュー: 寺山 輝 (@terayama.akira) ( Shodo で執筆されました )