9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amazon EKSをWAFで防御する

Last updated at Posted at 2023-12-13

この記事はNTTコムウェア Advent Calendar 2023 14日目の記事です。

こんにちは。NTTコムウェアの東です。
みなさん、Kubernetes(k8s)使ってますかー?

AWSが提供するマネージドk8sであるAmazon Elastic Kubernetes Service(以降EKSと呼びます)は、AWSの様々なマネージドサービスと連携することができます。AWS WAFもその1つです。
EKSとAWS WAFの連携はけっこう簡単なので、手軽にEKS上のアプリケーションのセキュリティを向上させることができます。

そこで、本稿ではEKS上のAWS Load Balancer Controllerアドオンを用いたIngressの前段にAWS WAFを配置し、Ingressの背後のアプリケーションを保護するための具体的な手順を紹介します。
また、AWS WAFの構築後、その動作確認としてオープンソースのWAFテストツールであるGoTestWAFを用い簡単なテストを行う方法も紹介します。
昨年に引き続きWAFをテーマにしましたが他意はありません。)

注意事項

  • 本内容については、不正アクセスを助長するものではありません。
  • 本ページの内容については、悪用を禁止します。
    • あくまで自身のサイトのテスト目的での利用に限ります。
    • 悪用した場合、不正アクセス禁止法等で訴えられる可能性があります。
  • AWS WAFに限らず、WAF(というかあらゆるセキュリティ製品・手法)は万能ではないので、他の手法との組み合わせも適切に行い総合的にセキュリティを確保することをお勧めします。

AWS WAFの概要

WAFとは、Webアプリへのアクセス内容を解析し、あらかじめ定義されている攻撃パターンにマッチした場合にアクセスを拒否することで攻撃を未然に防ぐソリューションです。
アクセス内容をアプリケーションレベルで解析するため、SQLインジェクションやクロスサイトスクリプティングなどアプリの脆弱性をついた攻撃を(マッチするルールがあれば)防ぐことができます。

AWS WAFはこのWAF機能をAWSのマネージドサービスとして提供するものです。
WAF本体はもちろん、攻撃パターンを定義したルールの集まり(AWS WAFではルールグループと呼ぶ)もマネージドサービスとして提供されているため、手軽にWAFを導入でき、運用の手間も省けます。

AWSが提供のマネージドルールグループはこちらに一覧されています。Core Rule Set(CRS)など基本的なものはもちろん、アプリ固有のものなどたくさん用意されており、ユーザは好きなルールグループを選択し有効化できます。

AWS WAFがルールに基づき検知したアクセスをどう処理するかについては、最も一般的な"ブロック(HTTPコード403を返す)"の他に、ログ記録のみを行う設定(COUNT)や、クライアントにCAPTCHAおよびChallengeを促すなども可能です。

AWS WAFはAmazon CloudFrontやAWS Application Load Balancer(ALB)、Amazon API Gateway REST APIなど、様々なAWSサービスを保護することができます(こちらに一覧)。

AWS WAFは従量課金制のサービスで、通信量に応じて課金されます。またルールグループ1つ1つにWeb ACL容量ユニット(WCU)という数値が定義されており、選択したルールグループのWCUの合計値に応じても基本料金が課金されます。料金について詳細はこちらをご覧ください。

AWS WAFは、以下に挙げる各コンポーネントから構成されています。これら用語は以降でも出てくるので頭の片隅に置いておいてください。

  • Web ACL(ウェブアクセスコントロールリスト)
    • AWS WAFを利用するにあたり、「何を」「何から」「どうやって」守るかについて紐づけを行い、WAFの動作を定義するためのリソースです。
    • 「何を」としてはAmazon CloudFrontやAWS Application Load Balancer(ALB)など守るべきものを指定します。
    • 「何から」には攻撃とみなすアクセスパターンや拒否したいクライアントIPアドレスなどが指定できます。
    • 「どうやって」にはアクセス拒否を行うか、ログ記録のみを行うか、などを指定できます。
    • その他ログ出力先やサンプルリクエストの管理なども行います。
  • ルール
    • どんなアクセスを攻撃とみなすかを正規表現等を駆使してパターンとして定義したものです。
  • ルールグループ
    • ルールの集合です。
    • 特に制限はありませんが、一般に関連のあるルールの集まりとして定義されます。(例:SQLインジェクションのルールの集合=SQL データベースマネージドルールグループ)
    • AWSが作成・管理しユーザに提供するものを「AWSマネージドルール」、AWS以外の事業者が作成・管理しMarketplaceでユーザに販売するものを「AWS Marketplace マネージドルールグループ」と呼びます。
    • ユーザが独自にルールグループを作成することもできます。

前提

本稿は、以下AWS公式ドキュメントなどを参考に、EKSの構築および、AWS Load Balancer Controllerアドオンの導入が済んでいることを前提とします。

上記手順実施後、以下コマンドで、AWS Load Balancer Controllerアドオンの稼働および、イングレスクラス「alb」が存在することを確認します。

$ kubectl get deployment -n kube-system aws-load-balancer-controller
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
aws-load-balancer-controller   2/2     2            2           84d

$ kubectl get ingressclass
NAME    CONTROLLER             PARAMETERS   AGE
alb     ingress.k8s.aws/alb    <none>       84d

以降、この環境でIngress(により作成されるALB)の前段にAWS WAFを配置する手順を紹介します。構成を図にするとこんな感じです1

eks-waf01.png

ルールとWeb ACLの構築

まず、これから作成するAWS WAFに適用するルールを定義します。
冒頭で紹介した通り、AWS WAFにはAWSが管理するマネージドルールグループがあるため、ここではそのうちの以下を選択し使用することとします。カッコ内の数字は各ルールグループのWCU値です。
選択に際してはWCU値の合計が1500を超えないようにしました(WCUが1500を超えると追加料金が必要になります。またWCUの上限は5000です。詳細はこちらをご覧ください。)。

上記ルールグループの選択についてこれという根拠はありませんが、1500WCU以内で幅広く有用そうなもの+WorkerノードのLinux環境へのローカルファイルインクルージョン攻撃等やDBサーバへのSQLインジェクション攻撃を防御する、というイメージで選択しました。なお、"Linux オペレーティングシステム"は"POSIX オペレーティングシステム"と組み合わせて使用する必要があるそうです。
他にもPHPやWordPressなどアプリに特化したものや、アカウント乗っ取りやBotによるアクセスに特化したものなど、いろいろあるので、ご自身の用途に応じ適宜選択してください。

なお、AWSが管理するマネージドルールグループ以外にも、AWS Marketplace マネージドルールグループと呼ばれるAWS以外の販売者により管理されたものもあります。こちらはAWS WAFの料金に加え各ベンダにサブスクリプション費用を支払うことで使用できるルールグループです。
また、ルールを個別に自作し定義することもできます。が、これらについては本稿では触れません。

上記ルールグループの使用を定義する設定ファイルを以下のように作成します。
なお、このファイルはこちらを参考に筆者が作成したものです。

AWS WAFv2のAPI仕様に従い、各マネージドルールグループが1つのRuleオブジェクトに対応する配列になっています。

$ cat << EOF > my-acl-rules-count.yaml
[
    {
        "Name": "AWS-AWSManagedRulesAmazonIpReputationList",
        "Priority": 0,
        "Statement": {
            "ManagedRuleGroupStatement": {
                "VendorName": "AWS",
                "Name": "AWSManagedRulesAmazonIpReputationList"
            }
        },
        "OverrideAction": {
            "Count": {}
        },
        "VisibilityConfig": {
            "SampledRequestsEnabled": true,
            "CloudWatchMetricsEnabled": true,
            "MetricName": "AWS-AWSManagedRulesAmazonIpReputationList"
        }
    },
    {
        "Name": "AWS-AWSManagedRulesAnonymousIpList",
        "Priority": 1,
        "Statement": {
            "ManagedRuleGroupStatement": {
                "VendorName": "AWS",
                "Name": "AWSManagedRulesAnonymousIpList"
            }
        },
        "OverrideAction": {
            "Count": {}
        },
        "VisibilityConfig": {
            "SampledRequestsEnabled": true,
            "CloudWatchMetricsEnabled": true,
            "MetricName": "AWS-AWSManagedRulesAnonymousIpList"
        }
    },
    {
        "Name": "AWS-AWSManagedRulesKnownBadInputsRuleSet",
        "Priority": 2,
        "Statement": {
            "ManagedRuleGroupStatement": {
                "VendorName": "AWS",
                "Name": "AWSManagedRulesKnownBadInputsRuleSet"
            }
        },
        "OverrideAction": {
            "Count": {}
        },
        "VisibilityConfig": {
            "SampledRequestsEnabled": true,
            "CloudWatchMetricsEnabled": true,
            "MetricName": "AWS-AWSManagedRulesKnownBadInputsRuleSet"
        }
    },
    {
        "Name": "AWS-AWSManagedRulesUnixRuleSet",
        "Priority": 3,
        "Statement": {
            "ManagedRuleGroupStatement": {
                "VendorName": "AWS",
                "Name": "AWSManagedRulesUnixRuleSet"
            }
        },
        "OverrideAction": {
            "Count": {}
        },
        "VisibilityConfig": {
            "SampledRequestsEnabled": true,
            "CloudWatchMetricsEnabled": true,
            "MetricName": "AWS-AWSManagedRulesUnixRuleSet"
        }
    },
    {
        "Name": "AWS-AWSManagedRulesLinuxRuleSet",
        "Priority": 4,
        "Statement": {
            "ManagedRuleGroupStatement": {
                "VendorName": "AWS",
                "Name": "AWSManagedRulesLinuxRuleSet"
            }
        },
        "OverrideAction": {
            "Count": {}
        },
        "VisibilityConfig": {
            "SampledRequestsEnabled": true,
            "CloudWatchMetricsEnabled": true,
            "MetricName": "AWS-AWSManagedRulesLinuxRuleSet"
        }
    },
    {
        "Name": "AWS-AWSManagedRulesSQLiRuleSet",
        "Priority": 5,
        "Statement": {
            "ManagedRuleGroupStatement": {
                "VendorName": "AWS",
                "Name": "AWSManagedRulesSQLiRuleSet"
            }
        },
        "OverrideAction": {
            "Count": {}
        },
        "VisibilityConfig": {
            "SampledRequestsEnabled": true,
            "CloudWatchMetricsEnabled": true,
            "MetricName": "AWS-AWSManagedRulesSQLiRuleSet"
        }
    },
    {
        "Name": "AWS-AWSManagedRulesCommonRuleSet",
        "Priority": 6,
        "Statement": {
            "ManagedRuleGroupStatement": {
                "VendorName": "AWS",
                "Name": "AWSManagedRulesCommonRuleSet"
            }
        },
        "OverrideAction": {
            "Count": {}
        },
        "VisibilityConfig": {
            "SampledRequestsEnabled": true,
            "CloudWatchMetricsEnabled": true,
            "MetricName": "AWS-AWSManagedRulesCommonRuleSet"
        }
    }
]
EOF

各Ruleオブジェクトの要素を少し補足しておきます。

  • Name
    • このRuleオブジェクトの名前を指定します。
  • Priority
    • このRuleオブジェクト(≒ルールグループ)を検査する優先順位を指定します。数字が小さいほど先に検査されます。
  • Statement.ManagedRuleGroupStatement
    • このRuleオブジェクトが使用するルールグループを指定します。
  • OverrideAction
    • ルールで検出されたアクセス(攻撃)をどうするか(=アクション)について、各ルールグループ内で規定されたアクションのオーバライド(書き換え)を指定します。
    • ここでは、すべてのルールグループのアクションを"Count": {}に書き換えることで、「ブロックはせずログ出力するだけ」にしています。
  • VisibilityConfig
    • Amazon CloudWatch メトリクスとリクエストのサンプル収集の有無およびメトリクス名を指定します。
    • ここでは、全ルールグループでメトリクスおよびサンプル収集を有効化しています。

なお、上記設定ファイルは全ルールグループでOverrideActionを"Count": {}としているため、適用してもアクセスのブロックは実施されず、検知内容がログ出力されるのみになります。

というのは、WAFはその性質上、ときとして正常なリクエストさえもブロックしてしまいアプリ動作に支障をきたす場合があります。そのためWAFの導入時はまずログ出力だけを有効にし、十分なアクセステストを行い正常なアクセスがブロックされないことをログ上で確認するのがセオリーです。
本稿ではそれに倣い、まずはログ出力のみを行うかたちでWAFを導入し、後にブロックを有効化する、という手順を紹介します。

さきほど作成したルールグループの定義ファイルを使用してAWS WAFを構築します。
まずは、構築際し必要となる各パラメータを環境変数として定義しておきます。これらはこの後の手順でも利用します。

EKS_REGION=ap-northeast-1
EKS_ACCOUNT=$(aws sts get-caller-identity --query 'Account' --output text)
EKS_WAF_NAME=my-test-waf
EKS_LOG_NAME=aws-waf-logs-${EKS_WAF_NAME}
  • EKS_REGION
    • AWS WAFを構築するリージョンを指定します。
  • EKS_ACCOUNT
    • AWSのアカウントIDを指定します。
    • aws sts get-caller-identity --query 'Account' --output textというコマンドで取得することができます。
  • EKS_WAF_NAME
    • これから作成するWAF(のWeb ACL)の名前を指定します。
  • EKS_LOG_NAME

以下コマンドで、Web ACLを作成します。

$ aws wafv2 create-web-acl \
  --name ${EKS_WAF_NAME} \
  --region ${EKS_REGION} \
  --scope REGIONAL \
  --default-action Allow={} \
  --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=${EKS_WAF_NAME} \
  --rules file://my-acl-rules-count.yaml

{
    "Summary": {
        "Name": "my-test-waf",
        "Id": "0f67a454-1521-4786-9946-8a629103645b",
        "Description": "",
        "LockToken": "f04a03af-f67a-424a-91dc-9cb6ed4bb91e",
        "ARN": "arn:aws:wafv2:ap-northeast-1:<AWSアカウントID>:regional/webacl/my-test-waf/0f67a454-1521-4786-9946-8a629103645b"
    }
}

各引数の意味は以下の通りです。

  • --name
    • 作成するWeb ACLの名前を指定します。
  • --region
    • Web ACLを作成するリージョンを指定します。
  • --scope
    • AWS WAFで保護するリソースがリージョンをまたぐかどうかを指定します。
    • 具体的にはAmazon CloudFrontを保護する場合はリージョンをまたぐためそれを示すCLOUDFRONTを、それ以外の場合REGIONALを指定します。
  • --default-action
    • Web ACLに含まれるいずれのルールにもマッチしなかった場合の動作を指定します。
    • ここでは、ルールにマッチする=攻撃とみなし、マッチしなかったもののみを通すためAllowを指定しています2
    • {}の部分には任意のヘッダを追加するカスタムヘッダ設定を記述することもできますが、ここでは指定しないため空のJSONオブジェクト({})を指定しています。
  • --visibility-config
    • Amazon CloudWatch メトリクスとリクエストのサンプル収集の有無およびメトリクス名を指定します。
    • ここでは、メトリクスおよびサンプル収集を有効化し、メトリクス名はWeb ACL名と同じにしています。
  • --rules
    • このWeb ACLに紐づけるルールを定義したファイルを指定します。
    • 先ほど作成の「my-acl-rules-count.yaml」を指定しています。

ログ出力設定

続いて、先ほど作成したWeb ACLの動作ログの出力先を設定します。

まず、ログ出力先となるAWS CloudWatchのロググループを作成します。

$ aws logs create-log-group --log-group-name ${EKS_LOG_NAME}

Web ACLとロググループのARNを使用するため、環境変数に格納しておきます。

EKS_WAF_ACL_ARN=$(aws wafv2 list-web-acls \
    --region $EKS_REGION \
    --scope REGIONAL \
    --query "WebACLs[?Name=='${EKS_WAF_NAME}'].ARN" \
    --output text )

EKS_LOG_ARN=$(aws logs describe-log-groups \
  --log-group-name-prefix ${EKS_LOG_NAME} \
  --query "logGroups[0].arn" \
  --output text )

以下コマンドで、Web ACLにログ設定を行います。

$ aws wafv2 put-logging-configuration \
  --logging-configuration ResourceArn=${EKS_WAF_ACL_ARN},LogDestinationConfigs=${EKS_LOG_ARN}

Ingress(ALB)との連携設定

AWS WAFをEKS上のIngressリソースにより作成されるALBと紐づけます。

ここではまず、サンプルアプリとして、echo-serverというアクセスされるとURLやヘッダをそのまま表示するだけのアプリをデプロイします。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo-server
  template:
    metadata:
      labels:
        app: echo-server
    spec:
      containers:
        - name: echo-server
          image: jmalloc/echo-server:latest
          ports:
            - name: http-port
              containerPort: 8080
          env:
          - name: LOG_HTTP_HEADERS
            value: "true"
          - name: LOG_HTTP_BODY
            value: "true"
---
apiVersion: v1
kind: Service
metadata:
  name: echo-service
spec:
  type: ClusterIP
  ports:
    - name: http-port
      port: 80
      targetPort: http-port
      protocol: TCP
  selector:
    app: echo-server
$ kubectl apply -f echo-server.yaml

サンプルアプリへのIngressリソースを作成します。
このとき、annotations:に「alb.ingress.kubernetes.io/wafv2-acl-arn: <Web ACLのARN>」を付与することで、このIngress(ALB)とAWS WAF(Web ACL)を紐づけることができます(★箇所)。

$ cat << EOF > echo-server-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: echo
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/wafv2-acl-arn: ${EKS_WAF_ACL_ARN} #★
spec:
  ingressClassName: alb
  rules:
  - host: echo.example.com
    http:
      paths:
      - pathType: Prefix
        path: "/"
        backend:
          service:
            name: echo-service
            port:
              number: 80
EOF

Ingressおよびそれに紐づくALBが作成されサービスが全世界に公開されます。それを意図しない場合(検証用途等)、外部LB側でのアクセス元IPアドレス制限やFirewall等をご検討ください。

$ kubectl apply -f echo-server-ingress.yaml

デプロイ後、PodやService、Ingressが正常にデプロイされたことを確認します。

$ kubectl get pod,svc,ing
NAME                                   READY   STATUS    RESTARTS   AGE
pod/echo-deployment-57ffdd9fc4-hcdx8   1/1     Running   0          5d19h

NAME                     TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
service/echo-service     ClusterIP   10.100.36.95     <none>        80/TCP         6d19h
service/kubernetes       ClusterIP   10.100.0.1       <none>        443/TCP        88d

NAME                               CLASS   HOSTS              ADDRESS                                                                  PORTS   AGE
ingress.networking.k8s.io/echo     alb     echo.example.com   k8s-default-echo-xxxxxxxxxx-yyyyyyyyy.ap-northeast-1.elb.amazonaws.com   80      6d19h

なお、今現在ALBとWeb ACLがちゃんと紐づいているかどうかは、以下手順で確認できます。

まず、Ingressに紐づくALBのIDを確認します。
ALBのIDは「kubectl get ingress」で表示されるADDRESS欄の4オクテットまでです。
上記例の場合k8s-default-echo-xxxxxxxxxxです。

これを使用し、ALBのARNを環境変数に格納します。(EKS_LB_NAMEの値は適宜書き換えてください。)

EKS_LB_NAME=k8s-default-echo-xxxxxxxxxx
EKS_LB_ARN=$( aws elbv2 describe-load-balancers \
  --region ${EKS_REGION} \
  --name ${EKS_LB_NAME} \
  --query "LoadBalancers[0].LoadBalancerArn" \
  --output text )

以下コマンドで、ALBに紐づくWeb ACL名を取得できます。
冒頭で作成したWeb ACL名が得られれば、ちゃんと紐づいていることが確認できます。

$ aws wafv2 get-web-acl-for-resource \
  --region ${EKS_REGION} \
  --resource-arn ${EKS_LB_ARN} \
  --query "WebACL.Name" \
  --output text

my-test-waf

WAFの動作確認

デプロイしたサンプルアプリ(のIngress)に攻撃っぽいアクセスをし、WAFが機能しているかどうか確認します。

$ curl -X POST -d "cmd=<script>" http://echo.example.com/
Request served by echo-deployment-57ffdd9fc4-hcdx8

POST / HTTP/1.1

Host: echo.example.com
Accept: */*
Content-Length: 12
Content-Type: application/x-www-form-urlencoded
User-Agent: curl/8.0.1
X-Amzn-Trace-Id: Root=1-65604896-0b7e9e903689ebe573ead759
X-Forwarded-For: xx.xx.xx.xx
X-Forwarded-Port: 80
X-Forwarded-Proto: http

cmd=<script>

特にブロックされることなく正常にアクセスできました。(echo-serverなのでアクセス情報がそのまま表示されています。)

これは、冒頭で作成したルールファイル(my-acl-rules-count.yaml)の中でOverrideActionCountを指定しているためで、異常ではありません。

確認のため、ログを見てみます。
(引数の--since 5mは直近5分のログを表示するという意味です。アクセス~ログを参照するタイミングに応じ適宜書き換えてください。)

$ aws logs tail --since 5m --format json --no-paginate ${EKS_LOG_NAME}
ログ(長いので折りたたんでいます)
2023-11-24T06:54:14.015000+00:00 ap-northeast-1_my-test-waf_23 
{
    "timestamp": 1700808854015,
    "formatVersion": 1,
    "webaclId": "arn:aws:wafv2:ap-northeast-1:<AWSアカウントID>:regional/webacl/my-test-waf/0f67a454-1521-4786-9946-8a629103645b",
    "terminatingRuleId": "Default_Action",
    "terminatingRuleType": "REGULAR",
    "action": "ALLOW",
    "terminatingRuleMatchDetails": [],
    "httpSourceName": "ALB",
    "httpSourceId": "<AWSアカウントID>-app/k8s-default-echo-xxxxxxxxxx/25d2826749446396",
    "ruleGroupList": [
        {
            "ruleGroupId": "AWS#AWSManagedRulesAmazonIpReputationList",
            "terminatingRule": null,
            "nonTerminatingMatchingRules": [],
            "excludedRules": null,
            "customerConfig": null
        },
        {
            "ruleGroupId": "AWS#AWSManagedRulesKnownBadInputsRuleSet",
            "terminatingRule": null,
            "nonTerminatingMatchingRules": [],
            "excludedRules": null,
            "customerConfig": null
        },
        {
            "ruleGroupId": "AWS#AWSManagedRulesLinuxRuleSet",
            "terminatingRule": null,
            "nonTerminatingMatchingRules": [],
            "excludedRules": null,
            "customerConfig": null
        },
        {
            "ruleGroupId": "AWS#AWSManagedRulesPHPRuleSet",
            "terminatingRule": null,
            "nonTerminatingMatchingRules": [],
            "excludedRules": null,
            "customerConfig": null
        },
        {
            "ruleGroupId": "AWS#AWSManagedRulesSQLiRuleSet",
            "terminatingRule": null,
            "nonTerminatingMatchingRules": [],
            "excludedRules": null,
            "customerConfig": null
        },
        {
            "ruleGroupId": "AWS#AWSManagedRulesCommonRuleSet",
            "terminatingRule": {
                "ruleId": "CrossSiteScripting_BODY",
                "action": "BLOCK",
                "ruleMatchDetails": null
            },
            "nonTerminatingMatchingRules": [
                {
                    "ruleId": "CrossSiteScripting_BODY_RC_COUNT",
                    "action": "COUNT",
                    "ruleMatchDetails": [
                        {
                            "conditionType": "XSS",
                            "location": "BODY",
                            "matchedData": [
                                "<",
                                "script"
                            ],
                            "matchedFieldName": ""
                        }
                    ]
                }
            ],
            "excludedRules": null,
            "customerConfig": null
        }
    ],
    "rateBasedRuleList": [],
    "nonTerminatingMatchingRules": [
        {
            "ruleId": "AWS-AWSManagedRulesCommonRuleSet",
            "action": "COUNT",
            "ruleMatchDetails": [
                {
                    "conditionType": "XSS",
                    "location": "BODY",
                    "matchedData": [
                        "<",
                        "script"
                    ],
                    "matchedFieldName": ""
                }
            ]
        }
    ],
    "requestHeadersInserted": null,
    "responseCodeSent": null,
    "httpRequest": {
        "clientIp": "xx.xx.xx.xx",
        "country": "JP",
        "headers": [
            {
                "name": "Host",
                "value": "echo.example.com"
            },
            {
                "name": "User-Agent",
                "value": "curl/8.0.1"
            },
            {
                "name": "Accept",
                "value": "*/*"
            },
            {
                "name": "Content-Length",
                "value": "12"
            },
            {
                "name": "Content-Type",
                "value": "application/x-www-form-urlencoded"
            }
        ],
        "uri": "/",
        "args": "",
        "httpVersion": "HTTP/1.1",
        "httpMethod": "POST",
        "requestId": "1-65604896-0b7e9e903689ebe573ead759"
    },
    "labels": [
        {
            "name": "awswaf:managed:aws:core-rule-set:CrossSiteScripting_Body_RC_COUNT"
        },
        {
            "name": "awswaf:managed:aws:core-rule-set:CrossSiteScripting_Body"
        }
    ],
    "requestBodySize": 12,
    "requestBodySizeInspectedByWAF": 12
}

JSON形式でなにやらずらずら出ました。
上記のうち以下のあたりから、先ほどの攻撃っぽいアクセスのcmd=<script>という部分を検知したこと、およびActionがCountであったのでブロックはしなかったことなどが読み取れます。

            "ruleGroupId": "AWS#AWSManagedRulesCommonRuleSet",
            ・・・
            "nonTerminatingMatchingRules": [
                {
                    "ruleId": "CrossSiteScripting_BODY_RC_COUNT",
                    "action": "COUNT",
                    "ruleMatchDetails": [
                        {
                            "conditionType": "XSS",
                            "location": "BODY",
                            "matchedData": [
                                "<",
                                "script"
                            ],
                            "matchedFieldName": ""
                        }
                    ]
                }
            ],

AWS WAFによるブロック動作の有効化

先ほどはログのみ取得するするモード(Count)でしたが、次はこれを実際にブロックするモードに切り替えます。

まず、冒頭で作成したルールグループの設定ファイル(my-acl-rules-count.yaml)の"Count": {}の箇所を"None": {}に書き換えたものを新たに作成します。

$ cat my-acl-rules-count.yaml | sed 's/"Count": {}/"None": {}/g' > my-acl-rules-none.yaml

次に、Web ACLのIDをEKS_WAF_IDという環境変数に格納しておきます。

EKS_WAF_ID=$(aws wafv2 list-web-acls \
    --region $EKS_REGION \
    --scope REGIONAL \
    --query "WebACLs[?Name=='${EKS_WAF_NAME}'].Id" \
    --output text )

次に、Web ACLのLock Tokenという値を取得し、環境変数に格納します。
Web ACLには、複数人が同時に更新してしまい意図しない状態になってしまわないよう楽観的ロックによる排他制御がかけられています。あらかじめLock Tokenを取得し、更新時にそのTokenを添えることで、更新対象を明確にします。(更新時のLock Tokenと、更新コマンドに添えられたTokenが一致しない場合、更新コマンドはエラーとなります。)

EKS_WAF_LOCK=$(aws wafv2 list-web-acls \
    --region $EKS_REGION \
    --scope REGIONAL \
    --query "WebACLs[?Name=='${EKS_WAF_NAME}'].LockToken" \
    --output text )

最後に以下コマンドでWeb ACLを更新します。
--idは更新対象のWeb ACLのID、--lock-tokenは先ほど取得したLock Tokenを指定します。
--rulesには、先ほど"None": {}に書き換えたルールグループ設定ファイル(my-acl-rules-none.yaml)を指定します。
それ以外の引数はWeb ACL作成時のコマンド(aws wafv2 create-web-acl)と同じです。

$ aws wafv2 update-web-acl \
  --name ${EKS_WAF_NAME} \
  --region ${EKS_REGION} \
  --scope REGIONAL \
  --default-action Allow={} \
  --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=${EKS_WAF_NAME} \
  --id ${EKS_WAF_ID} \
  --lock-token ${EKS_WAF_LOCK} \
  --rules file://my-acl-rules-none.yaml

ちゃんとブロックされるようになったか、確認します。
先ほどの攻撃っぽいアクセスをしてみます。

$ curl -X POST -d "cmd=<script>" http://echo.example.com/
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
</body>
</html>

403 Forbiddenが返り、ブロックされていることが確認できました。

先ほどと同様にログを確認すると、以下の通り、AWSManagedRulesCommonRuleSet(コアルールセット (CRS) マネージドルールグループ)により、Cross Site Scriptingが検出されブロックされたことがわかります。

$ aws logs tail --since 5m --format json --no-paginate ${EKS_LOG_NAME}

・・・略・・・
            "ruleGroupId": "AWS#AWSManagedRulesCommonRuleSet",
            "terminatingRule": {
                "ruleId": "CrossSiteScripting_BODY",
                "action": "BLOCK",
                "ruleMatchDetails": null
            },
・・・略・・・

WAFテストツールの実行

最後に仕上げとして、WAFをテストするツールを用いここまでで構築したWAFが本当に攻撃を検知し防いでくれるのか、ツールを使用し確認してみます。

WAFをテストするツールとしてGoTestWAFというものを使ってみます。GoTestWAFはwallarmという、APIセキュリティ製品を販売する会社が作ったツールです。オープンソースで開発されており、ライセンスはMIT licenseです。コンテナイメージも公開されているため、ホストを汚すことなく簡単に実行することができます。

GoTestWAFはデフォルトでたくさんの疑似攻撃パターン(GoTestWAFではテストケースと呼ぶ)を定義済みです。
例えば、こちらはSQLインジェクションを模擬したテストケースですが、1ファイルに13通りのペイロード(送信データ)が定義されており、同時に2つのエンコード方式(encoder)と6つの格納場所(placeholder)が定義されています。この1つのファイルだけで、ペイロード(13)×エンコード(2)×格納場所(6)=156通りのアクセスが生成されます。
GoTestWAFにはこうしたテストケースファイルが42ファイル同梱されており、全部で2045通り3の疑似攻撃をテストしてくれます。

GoTestWAFを前段にWAFを配置したecho-serverに対して実行してみます。DockerまたはPodmanが構築済みの環境で、以下のコマンドを実行します。引数の意味はそれぞれ以下の通りです。

  • -v ${PWD}/reports:/app/reports:z
    • reportsというレポート出力用のディレクトリを作成し、コンテナ内の/app/reportsにボリュームアタッチしています。
    • 末尾の:zは、SELinuxが有効なホストでもコンテナ内から読み書きができるよう適切なラベルを付与するオプションです。
  • --network="host"
    • ホスト側のNWをコンテナにアタッチし、k8sクラスタ(Ingress)へのアクセスを可能にしています。
  • --url
    • 試験対象のWebサーバ(ここではecho-serverへのIngressのアクセス先)を指定します。
  • --noEmailReport
    • GoTestWAFが結果をEmailで送信しようとするのを抑制しています。
# mkdir reports
# docker run --rm \
  -v ${PWD}/reports:/app/reports:z \
  --network="host" \
  wallarm/gotestwaf --url=http://echo.example.com/ --noEmailReport

GoTestWAFにより攻撃っぽいアクセスが2000件以上(秒間10件程度)実行されます。
必ず自身で管理のサイトにのみ実行し、他者サイトへの実行はおやめください。

実行中は、以下のような進捗を示すプログレスバーが表示されます。

time="2023-12-04T01:54:44Z" level=info msg="GoTestWAF started" version=v0.4.7
time="2023-12-04T01:54:44Z" level=info msg="Test cases loading started"
time="2023-12-04T01:54:44Z" level=info msg="Test cases loading finished"
time="2023-12-04T01:54:44Z" level=info msg="Test cases fingerprint" fp=71e159d25b0e204eb5e4b7dbd174b790
time="2023-12-04T01:54:44Z" level=info msg="Try to identify WAF solution"
time="2023-12-04T01:54:44Z" level=info msg="WAF was not identified"
time="2023-12-04T01:54:44Z" level=info msg="WAF pre-check" url="http://echo.example.com/"
time="2023-12-04T01:54:44Z" level=info msg="WAF pre-check" blocked=true code=403 status=done
time="2023-12-04T01:54:44Z" level=info msg="WebSocket pre-check" status=started url="ws://echo.example.com"
time="2023-12-04T01:54:45Z" level=info msg="WebSocket pre-check" blocked=true connection=available status=done
time="2023-12-04T01:54:45Z" level=info msg="gRPC pre-check" status=started
time="2023-12-04T01:54:45Z" level=info msg="gRPC pre-check" connection="not available" status=done
time="2023-12-04T01:54:45Z" level=info msg="Scanning started" url="http://echo.example.com/"
Sending requests...   6% [=>                                ] (142/2045) 

最後に、結果詳細やサマリ、ログファイル名などが出力され試験(コンテナ)が終了します。

・・・略・・・

Summary:
+-----------------------------+-----------------------------+-----------------------------+-----------------------------+
|            TYPE             | TRUE-NEGATIVE TESTS BLOCKED | TRUE-POSITIVE TESTS PASSED  |           AVERAGE           |
+-----------------------------+-----------------------------+-----------------------------+-----------------------------+
| API Security                | 42.11%                      | n/a                         | 42.11%                      |
| Application Security        | 36.21%                      | 100.00%                     | 68.11%                      |
+-----------------------------+-----------------------------+-----------------------------+-----------------------------+
|                                                                        SCORE            |           55.11%            |
+-----------------------------+-----------------------------+-----------------------------+-----------------------------+

time="2023-12-04T01:58:54Z" level=info msg="Export full report" filename=reports/waf-evaluation-report-2023-December-04-01-58-52.pdf

結果は上記コンソールに加え、先ほどコンテナの/app/reportsにアタッチしたフォルダにCSVとPDFの2つの形式でも出力されます。

以下は本稿手順で構築のAWS WAF + echo-serverに対する試験結果です。
Overall grade(全体評価)はF、スコアは100点満点中55.1でした。

gotestwaf-result01.png

その下の表は、以下2つの観点で各テストケースを分類しそれぞれの結果(Gradeおよびスコア)を示しています。

表の行は「API Security」と「Application Security」に分かれています。これは、各テストケースを大まかに分類したものです。具体的にはテストセット名(=testcases/ 直下のディレクトリ名)に"api"という文字列が含まれるかどうか、で決まるようです。

表の列は「True-negative tests blocked」と「True-positive tests passed」に分かれています。これもテストケースを分類しており、前者は攻撃であるため防ぐべきもの、後者は一見攻撃のようだが攻撃ではないため防ぐべきではないものです。
(GoTestWAFに同梱のテストケースにAPI Securityに分類されるTrue-positiveの項目は無いため"N/A"となります。)

スコア55.1と聞くと半分そこそこかといまいちに感じるかもしれませんが、WAFというものが本質的に過剰検知を回避しつつ、攻撃だけを防ぐ、という相反する問題に対応するものである以上、妥当な結果であるようです。というのは、PDFレポートの2ページ目に他製品(ModSecurity)での結果が参考として掲載されていますが、55.1はModSecurityの"PARANOIA=2"に近いスコアであり、ModSecurityのデフォルト(PARANOIA=1)を上回っています
PARANOIAとはModSecurityにおける感度設定で、大きいほど感度は高くなりますが、攻撃ではないアクセスもブロックしてしまうため、デフォルトは1となっています。)

gotestwaf-result02.png

また、True-positiveの項目が100%(100点満点)なのは特筆すべき素晴らしい結果だと思います。これは攻撃ではない正規アクセスを誤ってブロックしてしまうことが無かったことを示しています。筆者の経験ではModSecurityにおいてPARANOIAを2にするとTrue-positiveが2~3割発生してしまいがちですが、AWSのマネージドルールグループはこのあたりのチューニングがしっかりとされているようです。

なお、さらに言うと、GoTestWAFの実行は筆者のIPアドレスから実施していますが、これは冒頭で紹介のAWSマネージドルールグループのうち以下にはあたらないものなので、これらは今回威力を発揮していません。ですが実際の攻撃は世界中のIPアドレスから来るため、これらがブロックしてくれるものもあるはずです。

  • Amazon IP 評価リストマネージドルールグループ
  • 匿名 IP リストマネージドルールグループ

さらにAWSマネージドルールグループにはBotやアカウント乗っ取りに特化したものや、有償のもの、それ以外にも独自のルール追加もできるため、アプリの特性を踏まえさらに強化することも可能です。

さいごに

というわけで、無事にEKS上のIngressの前段にAWS WAFを配置しアプリを保護することができました。
有償ではあるものの従量課金で手軽にここまで防御してくれるなら、導入しない手は無いですね。テストツールによる結果で正規アクセスのブロックが無かったのも魅力です。

皆様も、是非AWS WAFを活用し、よりセキュアにEKSを運用し、心安らかにクリスマス&年末をお過ごしください。

※本稿に記載されている製品名、サービス名は、各団体の商標または登録商標です。

  1. 図中※の箇所は正確にはクライアントはALBに付与されたパブリックIPへアクセスし、ALBがAWS WAFに当該アクセスの受信可否を問う形ですが、図はWAFが前段にいる雰囲気を示しています。

  2. ルールをホワイトリストとみなし、ルールにマッチしないものをブロックする場合Blockを設定します。

  3. v0.4.7時点

9
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?