電通総研 テックブログ

電通総研が運営する技術ブログ

AWS WAFがリクエストをブロックした時に、自動的にIPアドレスレベルで一定期間ブロックし続ける方法

スマートなタイトルが思いつきませんでした。X(クロス)イノベーション本部 ソフトウェアデザインセンター セキュリティグループの耿です。

インターネットにWebアプリケーションを公開すると、さまざまなHTTPリクエストを受けることになります。中にはアプリケーションを利用する目的ではない、 情報収集や脆弱性を突いて攻撃を狙うリクエストも多数含まれています。攻撃への根本対策はWebアプリケーションレベルで行うべきですが、対策漏れやゼロデイ攻撃に対してはWAFの利用も効果的です。

AWS WAFにはAWSマネージドルールが多数用意されており、簡単に導入し多層防御を実現できます。この記事ではAWS WAFによるブロックをより厳しくする方法として、WAFのルールがブロックしたリクエストのIPアドレスを、悪意のあるリクエストを送信してくる可能性があるIPアドレスとみなして、一定期間IPアドレスレベルでブロックし続ける仕組みをご紹介します。一度WAFのWeb ACLでブロックに成功しても、攻撃者はパターンを変えて何度も攻撃を試みる場合があり、IPレベルで一定期間ブロックすることで他のWAFルールが対応していないパターンで攻撃が成立するリスクを減らすことを期待しています。

インフラはAWS CDKで実装します。

どんな Web アプリが適しているか

不特定多数の利用者を想定した一般公開のアプリには、これから紹介する仕組みは向かないと思います。WAFの誤検知によってリクエストをブロックしてしまった場合の影響が大きいからです。社内用アプリなど、利用ユーザーは特定できるがインターネットからアクセスできるアプリがこの仕組みに適していると思います。

アーキテクチャ

1.サブスクリプションフィルターでWAFのログからLambdaを起動

AWS WAFでIPアドレスレンジレベルでリクエストを許可・ブロックするためには、IPセットを定義します。WAFのブロック結果からこのIPセットを動的に変更すれば良いので、WAFのブロックログをCloudWatch Logsに出力し、サブスクリプションフィルターで特定のログをトリガーにLambda関数を起動します。

サブスクリプションフィルターでWAFのログからLambdaを起動

2.DynamoDBのTTLで一定期間後にブロックを解除

IPアドレスとユーザーの関連は変わり得るので、一定期間経過後はIPアドレスレベルのブロックを自動で解除し、ブロック対象のIPアドレスリストの長さが増え続けないようにします。誤動作で特定のIPアドレスがブロックされてしまった場合の影響を小さくします。
DynamoDBのTTLを利用します。ブロック対象となるIPアドレスをDynamoDBテーブルのレコードとして保持し、TTLを設定します。EventBridgeで定期的にLambda関数を起動し、DynamoDBテーブルのアイテムのうち、有効期限内のIPアドレスのみでWAFのIPセットを更新します。

DynamoDBのTTLで一定期間後ブロックを解除

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 というキー名にします。TTLexpires という名前の属性にしています。

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で執筆されました