← 아티클 목록

시리즈AWS 실습 계정에서 흔적을 남기고 되짚기4편 · 공개 4편

Cloud · DevSecOps

GuardDuty를 켜자마자 내가 잡혔다

실습 계정에 GuardDuty를 켜고 며칠을 기다릴 생각이었는데, 몇 분 만에 진짜 Finding이 하나 올라왔다. 대상은 나였다. 심각도가 무엇을 뜻하고, Finding을 어디까지 믿을 수 있는지 확인한 기록이다.

목차 10

2026-08-09 업데이트 — 샘플 Finding의 원시 JSON과 콘솔 표시를 구분하고, High 필터가 모든 Policy: 유형을 제외한다는 설명을 바로잡았다. Root 자격 증명 Finding과 집계 통계의 해석 범위도 보완했다.

탐지를 켰다는 것은 무엇을 켠 것인가

앞의 세 글은 내가 만든 흔적을 내가 찾는 이야기였다. S3 공개 설정을 바꾸고, 권한을 좁히고, 그 호출을 로그에서 되짚었다.

이 글은 방향이 반대다. 도구가 나에게 말해 오는 것을 어떻게 읽는가.

GuardDuty를 켜고 며칠 기다릴 생각이었다. 정상적으로 쓰는 계정에서는 아무 일도 안 일어날 테니까. 그런데 몇 분 만에 Finding이 하나 올라왔고, 대상은 나였다.

GuardDuty가 보는 것

GuardDuty의 기본 데이터 원본은 CloudTrail 관리 이벤트·VPC Flow Logs·Route 53 Resolver DNS 쿼리 로그다. GuardDuty를 켜면 이 원본의 독립된 데이터 스트림을 직접 소비하므로, 계정에서 Trail이나 VPC Flow Logs를 별도로 만들어야 기본 탐지가 시작되는 것은 아니다. 여기에 보호 계획별 데이터 원본을 더해 위협 패턴을 찾아 Finding을 만든다.

CloudTrail GuardDuty
하는 일 호출을 기록한다 기록을 해석해 Finding을 만든다
결과물 이벤트 Finding (유형·심각도·리소스)
판단 하지 않는다 한다

켜면 어떤 데이터 원본을 보는지 조회할 수 있다.

bash
aws guardduty get-detector --detector-id <탐지기 ID> \
  --query 'Features[].[Name,Status]' --output table
|  CLOUD_TRAIL            |  ENABLED   |
|  DNS_LOGS               |  ENABLED   |
|  FLOW_LOGS              |  ENABLED   |
|  S3_DATA_EVENTS         |  ENABLED   |
|  EKS_AUDIT_LOGS         |  ENABLED   |
|  EBS_MALWARE_PROTECTION |  ENABLED   |
|  RDS_LOGIN_EVENTS       |  ENABLED   |
|  RUNTIME_MONITORING     |  DISABLED  |

기본으로 켜지는 것과 그렇지 않은 것이 나뉜다. RUNTIME_MONITORING처럼 꺼진 항목이 있다는 것은, 켰다고 해서 전부 보는 것이 아니라는 뜻이다.

1. 켜지 않은 리전은 조용하다

처음 켰을 때 탐지기가 한 리전에만 생겼다.

bash
aws guardduty list-detectors --region ap-northeast-2   # 없음
aws guardduty list-detectors --region us-east-1        # 있음

GuardDuty는 리전별 서비스다. 서울 리전에 실습 버킷도 다른 워크로드도 있는데, 그쪽은 아무도 보고 있지 않았다.

여기서 문제는 화면이 조용하다는 것이다. Finding 목록이 비어 있는 것은 「위협이 없다」로 읽히지, 「이 리전은 아예 안 보고 있다」로 읽히지 않는다.

앞 글에서 CloudTrail을 조사할 때 리전을 잘못 골라 「기록이 없다」는 결과를 받았다. 같은 성질의 함정이 이번엔 탐지 쪽에서 나왔다.

다만 한쪽이 더 나쁘다. 조회는 리전을 바꿔 다시 물어보면 그만이지만, 탐지는 켜지 않은 동안의 일이 나중에 소급되지 않는다. 서울 리전에 탐지기를 만든 시점 이전의 활동에 대해서는, 지금 화면이 비어 있다는 사실이 아무것도 말해 주지 않는다.

2. 켜자마자 올라온 진짜 Finding

서울에도 켜고 나서 목록을 다시 봤다. 버지니아 북부에 샘플이 아닌 Finding이 하나 있었다.

bash
aws guardduty get-findings --detector-id <탐지기 ID> --region us-east-1 \
  --finding-ids <Finding ID>
JSON
{
  "Type": "Policy:IAMUser/RootCredentialUsage",
  "Severity": 2.0,
  "Title": "The API GetFindingsStatistics was invoked using root credentials.",
  "Service": {
    "Count": 17,
    "Archived": false,
    "Action": {
      "ActionType": "AWS_API_CALL",
      "AwsApiCallAction": {
        "Api": "ListManagedNotificationEvents",
        "ServiceName": "notifications.amazonaws.com"
      }
    }
  },
  "Resource": {
    "ResourceType": "AccessKey",
    "AccessKeyDetails": { "UserName": "Root", "UserType": "Root" }
  }
}

root로 로그인한 콘솔 세션에서 API가 호출된 것이 잡혔다. 앞 글의 실습을 하면서 root로 로그인해 둔 상태였지만, 이 Finding의 트리거는 로그인 이벤트 자체가 아니라 root 로그인 자격 증명을 사용한 API 호출이다.

세 가지가 읽힌다.

Count: 17 — 이 Finding ID에 같은 보안 이슈로 판단된 활동이 17번 집계됐다. 어떤 활동을 하나로 묶는 기준은 Finding 유형마다 다르므로, 목록의 Finding 수나 Count를 그대로 사건 수로 읽을 수 없다.

Title과 Action.Api가 다르다. 제목은 GetFindingsStatistics인데 액션에는 ListManagedNotificationEvents가 들어 있다. GuardDuty는 같은 이슈의 새 활동을 기존 Finding에 합치고 최신 발생 정보로 세부 내용을 갱신한다. 이 한 스냅샷만으로 17번의 호출과 두 필드가 각각 어떻게 대응하는지 복원할 수 없다. 제목 하나를 전체 호출 목록으로 읽지 말고 CloudTrail에서 개별 이벤트를 확인해야 한다.

Resource.ResourceType이 AccessKey다. EC2도 S3도 아니고 자격 증명 쪽 활동이 대상이다. 대응도 인스턴스 격리부터 시작하는 것이 아니라 root 세션과 호출 내역을 확인하고, 승인되지 않은 사용이라면 계정의 root 자격 증명과 활성 세션을 보호·무효화하는 쪽에서 시작한다.

CloudTrail 글에서 같은 사실을 다른 각도로 봤다. 거기서는 userIdentity.type: Root라는 필드 하나였고, 여기서는 판단이 붙어서 올라온다. 로그는 기록만 하고 GuardDuty는 「이건 정책 위반이다」까지 말한다. 두 도구의 차이가 이 한 건에 다 있다.

3. 심각도 2.0을 어떻게 읽어야 하는가

root 사용이 잡혔는데 심각도가 2.0이다. 문서상 1.0~3.9는 Low다.

수준 값 문서상 설명
Critical 9.0–10.0 공격이 진행 중이거나 방금 일어났을 수 있음
High 7.0–8.9 리소스가 이미 침해되어 사용되고 있음
Medium 4.0–6.9 평소와 다른 의심스러운 활동
Low 1.0–3.9 침해에 이르지 못한 시도

이 유형은 로그인이 일어났다는 뜻이 아니라 root 자격 증명으로 API가 호출됐음을 알린다. Policy:IAMUser/RootCredentialUsage의 기본 심각도는 Low이고, 이 관찰만으로 자격 증명이 침해됐다고 판정할 수는 없다. GuardDuty는 「누가 남의 계정에 들어왔다」를 확정하는 게 아니라 「이 계정에서 root 자격 증명 사용이 관측됐다」고 말한다.

문제는 그다음이다. 심각도가 낮다고 대응 우선순위까지 자동으로 낮아지는 것은 아니다. root 자격 증명이 유출됐다면 계정에 매우 큰 영향을 줄 수 있다. 심각도는 GuardDuty가 관측한 활동의 성격을 매긴 값이고, 그 자격 증명이 내 손에 있는지 남의 손에 있는지는 모른다.

문서에도 「같은 유형이라도 맥락에 따라 심각도가 달라질 수 있다」고 적혀 있다. 유형마다 기본값이 있고 고정된 상수는 아니라는 뜻이다.

샘플 Finding을 생성한 뒤 조회한 당시 계정 전체 Finding 409건의 심각도 분포는 이렇게 나왔다. 아래 명령에는 샘플 여부 조건이 없으므로, 앞에서 확인한 실제 Finding도 섞일 수 있다. 따라서 이 수치를 샘플만의 분포로 읽지는 않는다.

bash
aws guardduty get-findings-statistics --detector-id <탐지기 ID> \
  --finding-statistic-types COUNT_BY_SEVERITY
JSON
{ "2.0": 66, "5.0": 166, "6.0": 4, "8.0": 162, "9.0": 11 }

이 스냅샷에서는 값이 몇 개에 몰렸다. GuardDuty 유형에는 기본 심각도가 있고 맥락에 따라 달라질 수 있으므로, 이를 1.0~10.0 사이의 모든 점수가 고르게 나타나는 연속 측정값처럼 읽지는 않는다.

4. Finding이 가리키는 리소스가 실재하는가

샘플 Finding을 만들어 구조를 봤다. 유형별로 하나씩 생성되므로 실제 공격을 흉내 낼 필요가 없다.

JSON
{
  "Type": "Trojan:Runtime/DriveBySourceTraffic!DNS",
  "Severity": 8.0,
  "Title": "A drive-by source domain name was queried by EC2 instance i-99999999.",
  "Resource": { "ResourceType": "KubernetesCluster" }
}

이 계정에는 EC2도 EKS도 없다. 인스턴스 ID i-99999999도, 쿠버네티스 클러스터도 실재하지 않는다. 샘플이라 그렇다.

원시 Finding JSON에서 샘플 여부를 확인하는 자리는 Service.AdditionalInfo다.

bash
aws guardduty get-findings --detector-id <ID> --finding-ids <Finding ID> \
  --query 'Findings[0].Service.AdditionalInfo'
JSON
{
  "Value": "{\"threatListName\":\"GeneratedFindingThreatListName\",\"sample\":true,...}",
  "Type": "default"
}

Value가 문자열로 감싸인 JSON이고 그 안에 sample: true가 있다. 위 get-findings 출력의 원시 Title에는 접두어가 없지만, GuardDuty 콘솔의 Current findings 목록은 샘플 제목 앞에 [SAMPLE]을 표시한다. 화면 표시와 API 값을 섞어 「목록에서 구분되지 않는다」고 말하면 안 된다. 자동화에서는 화면용 제목 문자열보다 sample: true를 확인하는 편이 명확하다.

여기서 나오는 습관 하나가 이 글의 실무적 결론이다. Finding을 받으면 그것이 가리키는 리소스가 실재하는지부터 확인한다. 없는 리소스를 가리키는 Finding은 샘플이거나, 이미 삭제된 리소스에 대한 것이거나, 다른 계정의 일이다. 셋 다 대응이 달라진다.

샘플을 다 보고 나서 보관 처리(archive)했는데, 여기서 하나 더 나왔다. 보관한 뒤에도 list-findings는 같은 개수를 돌려준다.

bash
# 보관 여부를 명시하지 않으면 보관된 것까지 함께 돌아온다
aws guardduty list-findings --detector-id <ID> --max-items 50   # 50건

# 활성만 보려면 조건을 준다
aws guardduty list-findings --detector-id <ID> \
  --finding-criteria '{"Criterion":{"service.archived":{"Eq":["false"]}}}'   # 0건

콘솔은 보관된 것을 숨기고 CLI는 숨기지 않는다. 같은 질문에 두 도구가 다르게 답한다. 「보관했으니 목록이 비었을 것」이라는 기대로 CLI 결과를 읽으면 아직 남아 있는 것으로 보인다.

5. 심각도로 거르면 무엇이 걸러지는가

Finding을 사람이 콘솔에서 들여다보는 것으로는 운영이 안 된다. EventBridge로 흘려보내고, 대개 심각도로 거른다.

규칙을 만들기 전에 패턴이 실제로 무엇을 잡는지 시험해 볼 수 있다. 규칙을 생성하지 않고 평가만 하는 명령이다.

bash
aws events test-event-pattern \
  --event-pattern file://pattern.json \
  --event file://finding-event.json

pattern.json — 흔히 쓰는 「High 이상만」 필터다.

JSON
{
  "source": ["aws.guardduty"],
  "detail-type": ["GuardDuty Finding"],
  "detail": { "severity": [{ "numeric": [">=", 7] }] }
}

두 가지 이벤트로 시험했다.

severity 8.0 (Trojan:Runtime/...)          -> True
severity 2.0 (Policy:IAMUser/RootCredentialUsage) -> False

이 계정에서 유일하게 진짜였던 Finding이 걸러진다. 샘플로 만든 가짜 8.0은 통과하고, 실제로 일어난 root 사용은 통과하지 못한다.

이 시험으로 확인한 것은 High 이상 필터가 이 계정의 Low Policy:IAMUser/RootCredentialUsage를 제외한다는 점이다. 이를 모든 Policy: 유형이 사라진다고 일반화할 수는 없다. 예를 들어 AWS 문서에서 Policy:S3/BucketPublicAccessGranted의 기본 심각도는 High다. 접두어가 아니라 실제 detail.severity 값이 통과 여부를 결정한다.

「High 이상만 알림」은 낮은 심각도 알림량을 줄이지만, 이번 root 사용처럼 중요하게 보는 Low Finding도 버릴 수 있다. 운영 기준에 포함해야 하는 Low·Medium 유형은 유형 조건을 별도로 추가하거나 다른 경로로 검토해야 한다.

이번엔 규칙을 실제로 만들어 대상(SNS·Lambda)까지 연결하지는 않았다. test-event-pattern은 패턴이 무엇을 잡는지만 확인해 주고, 알림이 실제로 도착하는지는 확인해 주지 않는다.

켜고 나서 보는 순서

  1. 리전이 맞는가 — 리소스가 있는 모든 리전에 켜져 있는지. 안 켠 기간은 소급되지 않는다
  2. 어떤 데이터 원본이 ENABLED인가. 꺼진 항목이 있으면 그 영역은 안 본다
  3. Finding이 가리키는 리소스가 실재하는가
  4. Count가 몇인가 — 한 건이 아니라 묶음일 수 있다
  5. 심각도 필터가 무엇을 버리고 있는가. 특히 RootCredentialUsage처럼 조직에서 중요하게 보는 Low 유형
  6. 알림이 실제로 도착하는가 — 규칙을 만든 것과 알림이 오는 것은 다르다

다시 확인하는 방법

bash
# 1. 리전별로 켜져 있는지
aws guardduty list-detectors --region <리전>

# 2. 데이터 원본 상태
aws guardduty get-detector --detector-id <ID> --query 'Features[].[Name,Status]' --output table

# 3. 심각도 분포
aws guardduty get-findings-statistics --detector-id <ID> \
  --finding-statistic-types COUNT_BY_SEVERITY

# 4. 필터가 무엇을 잡는지 (규칙을 만들지 않고)
aws events test-event-pattern --event-pattern file://pattern.json --event file://event.json

이번 실습에서 진짜 Finding은 한 건뿐이었고 나머지는 샘플이다. 그래서 실제 침해 Finding이 어떤 값으로 오는지는 확인하지 못했다. 알림 경로도 패턴 평가까지만 했고 SNS·Lambda로 실제 전달되는 것까지는 확인하지 않았다. 억제 규칙(suppression rule)과 다중 계정 위임 관리자 구성도 단일 계정이라 확인 대상이 아니었다.

참고 자료


출력의 계정 ID·탐지기 ID·Finding ID는 예시 값으로 치환했다.