[アップデート] Amazon GuardDuty の Custom Detection Rules を試してみた
はじめに
2026 年 9 月、Amazon GuardDuty に Custom Detection Rules が追加されました。
これは、AMI の外部共有や VPC フローログの削除など、環境によっては日常的でも別の環境では侵害の兆候になるような操作を検知するルールのプリセットです。
有効化したルールは、Finding を作成する「ライブモード」か、CloudWatch メトリクスだけを出力する「ドライランモード」のどちらかで動かします。ドライランモードは、ライブで有効化する前に「このアカウントでどれくらい一致するか」を測るためのモードです[1]。
この記事では、CloudTrail 管理イベントを対象にした 3 つのルールをドライランで有効化し、実際に操作してメトリクスに出るかを確かめます。
東京リージョンで使えるルール
ListCustomDetectionRules で一覧を取得したところ、東京リージョンのルールは 88 個で、CloudTrail 管理イベントが対象のものが 33 個、Runtime Monitoring のイベントが対象のものが 55 個でした。
us-east-1 には、東京リージョンにない organization-leave-attempt と organization-policy-disabled があります。AWS Organizations などを対象にしたルールは、そのサービスが動作するリージョンでのみ提供されるためだと思われます[2]。
各ルールの検知条件は、GetCustomDetectionRule の Definition.Expression に SQL で記載されています。今回試すルールは次の 3 つです。
| ルール ID | 重大度 | 検知条件の要点 |
|---|---|---|
vpc-flow-logs-deleted |
HIGH | DeleteFlowLogs が成功した |
ec2-user-data-modified |
HIGH | ModifyInstanceAttribute が成功し、requestParameters['userData'] がある |
ec2-security-group-public-ssh |
MEDIUM | AuthorizeSecurityGroupIngress が成功し、toPort が 22 で送信元が /0 |
ec2-security-group-public-ssh の検知条件の全文
eventSource = 'ec2.amazonaws.com'
AND eventName = 'AuthorizeSecurityGroupIngress'
AND errorCode IS NULL
AND userIdentity.type != 'AWSService'
AND NOT contains(sourceIPAddress, 'amazonaws.com')
AND (
(
requestParameters['toPort'] = '22'
AND (
requestParameters['cidrIp'] LIKE '%/0'
OR requestParameters['cidrIpv6'] LIKE '%/0'
)
)
OR (
requestParameters['ipPermissions'] LIKE '%"toPort":22%'
AND (
requestParameters['ipPermissions'] LIKE '%"cidrIp":"%/0"%'
OR requestParameters['ipPermissions'] LIKE '%"cidrIpv6":"%/0"%'
)
)
)
検証環境を作る
検証用のリソースは CloudFormation で作り、操作には AWS CLI を使います。Custom Detection Rules のコマンドは AWS CLI v2.36.37 以降で使えます[3]。
CloudFormation テンプレート(cdr-dryrun.yaml)
AWSTemplateFormatVersion: "2010-09-09"
Description: GuardDuty Custom Detection Rules dry run verification
Parameters:
AmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
Resources:
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.99.0.0/24
Tags:
- Key: Name
Value: cdr-dryrun
Subnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.99.0.0/26
Tags:
- Key: Name
Value: cdr-dryrun
# SSH ルールの追加を試すためのセキュリティグループ
TestSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: cdr-dryrun-test
GroupDescription: cdr-dryrun test
VpcId: !Ref Vpc
# ユーザーデータの変更を試すためのインスタンス
Instance:
Type: AWS::EC2::Instance
Properties:
ImageId: !Ref AmiId
InstanceType: t4g.nano
SubnetId: !Ref Subnet
SecurityGroupIds:
- !Ref TestSecurityGroup
Tags:
- Key: Name
Value: cdr-dryrun
FlowLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /cdr-dryrun/flowlogs
RetentionInDays: 1
FlowLogRole:
Type: AWS::IAM::Role
Properties:
RoleName: cdr-dryrun-flowlogs
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: vpc-flow-logs.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: flowlogs
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
- logs:DescribeLogGroups
- logs:DescribeLogStreams
Resource: "*"
Outputs:
VpcId:
Value: !Ref Vpc
SecurityGroupId:
Value: !Ref TestSecurityGroup
InstanceId:
Value: !Ref Instance
FlowLogGroupName:
Value: !Ref FlowLogGroup
FlowLogRoleArn:
Value: !GetAtt FlowLogRole.Arn
ユーザーデータは停止中のインスタンスでしか変更できないため、スタックを作成したらインスタンスを停止しておきます。
aws cloudformation create-stack --stack-name cdr-dryrun \
--template-body file://cdr-dryrun.yaml --capabilities CAPABILITY_NAMED_IAM
aws cloudformation wait stack-create-complete --stack-name cdr-dryrun
out() {
aws cloudformation describe-stacks --stack-name cdr-dryrun \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text
}
VPC_ID=$(out VpcId)
SG_ID=$(out SecurityGroupId)
INSTANCE_ID=$(out InstanceId)
FLOW_LOG_ROLE_ARN=$(out FlowLogRoleArn)
aws ec2 stop-instances --instance-ids $INSTANCE_ID
aws ec2 wait instance-stopped --instance-ids $INSTANCE_ID
ドライランで挙動をチェックしてみる
ルールをアカウントに適用するための関連付けを作ります。
for RULE in vpc-flow-logs-deleted ec2-user-data-modified ec2-security-group-public-ssh; do
aws guardduty create-custom-detection-rule-association --rule-id $RULE --mode DRY_RUN
done
応答から一部の項目を抜き出すと次のとおりで、ExpiresAt には作成からちょうど 14 日後の日時が入っていました。期限が切れると、ルールは自動で無効に戻ります。
{
"RuleAssociation": {
"RuleId": "vpc-flow-logs-deleted",
"Mode": "DRY_RUN",
"ExpiresAt": "2026-11-12T11:07:08+09:00"
}
}
続いて、3 つのルールに引っかかりそうな操作を実行してみます。
# フローログを作成してすぐに削除する
FLOW_LOG_ID=$(aws ec2 create-flow-logs --resource-type VPC --resource-ids $VPC_ID \
--traffic-type ALL --log-destination-type cloud-watch-logs \
--log-group-name /cdr-dryrun/flowlogs --deliver-logs-permission-arn $FLOW_LOG_ROLE_ARN \
--query "FlowLogIds[0]" --output text)
aws ec2 delete-flow-logs --flow-log-ids $FLOW_LOG_ID
# 停止中のインスタンスのユーザーデータを変更する
printf '#!/bin/bash\necho hello\n' > userdata.sh
aws ec2 modify-instance-attribute --instance-id $INSTANCE_ID \
--user-data Value=$(base64 < userdata.sh | tr -d '\n')
# 0.0.0.0/0 からの SSH を許可し、すぐに取り消す
aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
aws ec2 revoke-security-group-ingress --group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
3 分後、AWS/GuardDuty 名前空間に CustomRuleDryRunSignalsMatched というメトリクスが 3 つ増えました!
aws cloudwatch list-metrics --namespace AWS/GuardDuty \
--metric-name CustomRuleDryRunSignalsMatched
ディメンションは AccountId、Tactic、Technique、RuleName の 4 つで、単位は Count です。ルールごとに別のメトリクスになるため、ルール単位でグラフやアラームを作れます。
{
"Namespace": "AWS/GuardDuty",
"MetricName": "CustomRuleDryRunSignalsMatched",
"Dimensions": [
{"Name": "AccountId", "Value": "123456789012"},
{"Name": "Tactic", "Value": "Defense Impairment"},
{"Name": "Technique", "Value": "T1685.002 - Disable or Modify Tools: Disable or Modify Cloud Log"},
{"Name": "RuleName", "Value": "VPCFlowLogsDeleted"}
]
}
1 分間隔の合計値をグラフにすると次のようになりました。01:01 の山は 3 つのルールの線が重なったもので、01:14 の山は後の節で対照として再実行したユーザーデータの変更です。

3 つの操作は 2 つの分にまたがって実行しましたが、データ点はすべて同じ 1 分に入りました。データ点の時刻は操作の時刻ではなく、GuardDuty がまとめて評価した時刻なのだと思われます。
検知条件から外れる操作を試してみる
ドキュメントによると、同じ変更を API の別のパラメーターでも指定できる場合など、操作の仕方によってはルールを回避できてしまうと書かれています[4]。
ルールが検知しようとしている変更を、検知条件から外れる方法で 2 つ試してみます。
--attribute と --value でユーザーデータを変更する
modify-instance-attribute では、ユーザーデータを --user-data で渡す以外に、--attribute userData --value でも渡せます。試しに以下で実行してみました。
aws ec2 modify-instance-attribute --instance-id $INSTANCE_ID \
--attribute userData --value $(base64 < userdata.sh | tr -d '\n')
しかし、いくら待っても EC2UserDataModified のデータ点は増えませんでした。--user-data で変更し直すとデータ点が増えたので、後者の渡し方だけが一致しなかったことになります。
CloudTrail の requestParameters を比べると、--attribute と --value で渡した場合には userData キーがありませんでした。
| 渡し方 | requestParameters |
|---|---|
--user-data |
{"instanceId": "i-0123456789abcdef0", "userData": "<sensitiveDataRemoved>"} |
--attribute userData --value |
{"instanceId": "i-0123456789abcdef0", "attribute": "userData", "value": "<sensitiveDataRemoved>"} |
ルールの条件は requestParameters['userData'] IS NOT NULL なので、キーの名前の違いで一致しなかったのだと考えられます。
全ポートを開放するルールを追加する
ec2-security-group-public-ssh の条件は toPort が 22 であることなので、景気良く 0〜65535 番のポートを 0.0.0.0/0 に開放してみました。
aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 0-65535 --cidr 0.0.0.0/0
aws ec2 revoke-security-group-ingress --group-id $SG_ID --protocol tcp --port 0-65535 --cidr 0.0.0.0/0
SSH も外部から到達できる設定ですが、CloudTrail には "toPort": 65535 と記録され、メトリクスは増えませんでした。このルールが検知するのは 22 番ポートを指定した開放操作で、SSH が外部から到達できる状態ではないということですね。
状態そのものを評価したい場合は、AWS Config や Security Hub CSPM のコントロールなど、構成を評価するサービスと組み合わせることになりそうです。
ライブモードに切り替える
最後に、vpc-flow-logs-deleted の関連付けをライブモードに更新してみます。
ASSOCIATION_ID=$(aws guardduty list-custom-detection-rule-associations \
--rule-id vpc-flow-logs-deleted --query "RuleAssociations[0].AssociationId" --output text)
aws guardduty update-custom-detection-rule-association \
--rule-id vpc-flow-logs-deleted --association-id $ASSOCIATION_ID --mode LIVE
aws guardduty get-custom-detection-rule-association \
--rule-id vpc-flow-logs-deleted --association-id $ASSOCIATION_ID
更新後の関連付けは ID と CreatedAt がそのままで、ExpiresAt がなくなっていました。ドライランの関連付けを更新すれば、14 日の期限を気にせずそのままライブに移れます。
切り替えた後にフローログの作成と削除を 2 回繰り返すと、3 分ほどで次の Finding が 1 件作成されました。
| 項目 | 値 |
|---|---|
| Type | DefenseImpairment:EC2/DisableOrModifyTools |
| Severity | 8.0 |
| FeatureName | CustomDetectionRule - CloudTrailManagementEvent |
| Count | 2 |
2 回の削除が 1 件にまとめられ、Count が 2 になっています。ドキュメントのとおり、同じプリンシパルによる同じ戦術・サービス・手法のシグナルは 1 件に集約されるようです[5]。
発表時点で、35 個のルールに対して Finding タイプは 26 種類しかなく、Type だけではどのルールに一致したかを特定できない場合があります。Service.Detection.Sequence.Signals にはルール名の VPCFlowLogsDeleted が入っていたので、通知にはこの値も含めると良さそうです。
なお、ライブモード中の削除は CustomRuleDryRunSignalsMatched に現れず、ドライラン中に一致した操作からも Finding は作成されていませんでした。
おわりに
Custom Detection Rules のドライランでは、一致した件数だけがルールごとに CloudWatch メトリクスとして出力されます。どの操作が一致したかが知りたければ CloudTrail で調べることになります。
ユーザーデータの書き換えで試してみた通り、CloudTrail の特定のキーや値を条件にするルールは、その気になれば回避できてしまう可能性があります。ドライランは誤検知の量を測るだけでなく、検知してほしい操作が本当に検知されるかを確かめる場としても使えそうですね。






