X-Forwarded-For(XFF) 헤더에 루프백(127.x · ::1 · localhost) 또는 미지정 주소(0.0.0.0)를 넣어, 애플리케이션의 IP 기반 접근 제어(관리자 페이지 허용 IP · 로그인 시도 제한 · 속도 제한)를 우회하려는 요청을 CloudFront 접속 로그로 탐지합니다.
인터넷에서 CloudFront로 들어온 요청의 XFF에 루프백 · 미지정 주소가 들어갈 정상 경로는 없으므로 1건부터 탐지합니다. AWS WAF 관리형 규칙은 이 값을 검사하지 않으므로, WAF만으로는 걸러지지 않습니다.
개요
| 중요도 | 중 |
|---|---|
| 유형 | 배치 탐지 |
| 실행 주기 | 매달/매일/매시/40분 40 * * * * |
| 메시지 | $rule_result |
| 출력 필드 순서 | _time, _site, _device_name, src_ip, host_header, cnt, xff_kinds, xff_ips, sens_cnt, sens_paths, xff_none, xff_allow, xff_count, xff_block, xff_other, waf_rules, codes, path_cnt, paths, ev_cnt, evs, ua_cnt, user_agents, first_seen, last_seen, rule_result |
| MITRE 기술 | T1190 |
| MITRE 전술 | TA0001 Initial Access |
| 출처 | 사용자 제안 |
쿼리
1table from=$("from") to=$("to") *:AWS_CF*
2
3|# ================================================================
4|# AWS - CF, WAF : XFF 헤더 내부 IP 사칭 (접근 제어 우회 시도)
5|# 목적 X-Forwarded-For 헤더에 루프백 · 미지정 주소를 넣어 IP 기반 접근 제어(관리자 페이지 · 인증 · 속도 제한)를 우회하려는 요청을 탐지
6|# 판정 XFF 첫 값이 실제 접속 IP 와 다르고, 루프백(127.x · ::1 · localhost) 또는 미지정 주소(0.0.0.0) 인 요청 (1건부터, 출발지 × 도메인 단위)
7|# 근거 인터넷에서 CloudFront 로 들어온 요청의 XFF 에 루프백 · 미지정 주소가 들어갈 정상 경로는 없으며, AWS WAF 관리형 규칙은 이 값을 검사하지 않음
8|# ================================================================
9
10|# [1] CloudFront 파싱 필드
11| eval cip = replace(str(src_ip), "/", "")
12| eval host_header = str(host_header)
13| eval uri = str(uri)
14| eval st = str(status)
15| eval result_type = str(result_type)
16| eval request_id = str(request_id)
17| eval method = str(method)
18| eval ua = urldecode(replace(str(user_agent), "+", "%2B"))
19| search isnotnull(src_ip)
20
21|# [2] XFF 첫 값 추출 · 대괄호 · 포트 제거 (IPv4 · IPv6 · 호스트명 모두)
22| eval xff = trim(str(forwarded_for))
23| search isnotnull(forwarded_for) and xff != "-" and xff != ""
24| rex field=xff "^ *(?<x1>[^, ]+)"
25| rex field=x1 "^\[(?<x1b>[^\]]+)\]"
26| rex field=x1 "^(?<x1p>[^:\[\]]+):[0-9]+$"
27| eval x1n = nvl(x1b, nvl(x1p, x1))
28| search isnotnull(x1n) and x1n != cip
29| eval x1l = lower(x1n)
30
31|# [3] XFF 가 루프백 · 미지정 주소인지 (사설 IP 는 정상 프록시 · 사내망 경유와 구분되지 않아 제외)
32| eval xff_kind = case(
33 x1l == "localhost" or x1l == "::1" or match(x1l, "^(::ffff:)?127[.]") or match(x1l, "^(0:){7}1$"), "루프백",
34 x1l == "0.0.0.0" or x1l == "::" or x1l == "::ffff:0.0.0.0" or match(x1l, "^(0:){7}0$"), "미지정 주소",
35 true, null)
36| search isnotnull(xff_kind)
37
38|# [4] 인증 · 관리 경로 여부 (URL 2중 해제)
39| eval u = lower(urldecode(urldecode(uri)))
40| eval sens = if(match(u, "(/admin|/manager|/login|/signin|/auth|/actuator|/console|/wp-admin|/wp-login|/phpmyadmin|/jenkins|/artifactory|/api/admin)"), 1, 0)
41| eval sens_path = if(sens == 1, uri, null)
42
43|# [5] XFF 요청의 WAF 처리 (요청 ID 대조)
44|# 하위 쿼리에는 from · to 가 전달되지 않아 실행 시점 기준 3시간을 조회한다
45| join type=left request_id [
46 table duration=3h *:AWS_WAF*
47 | eval request_id = str(req_id), waf_act = upper(str(action))
48 | eval waf_cnt = if(waf_act == "COUNT" or (waf_act == "ALLOW" and match(concat(str(non_match_rules), str(rule_groups)), "action.{1,4}COUNT")), 1, 0)
49 | eval waf_rule = nvl(str(sub_signature), str(signature))
50 | fields request_id, waf_act, waf_cnt, waf_rule ]
51| eval w_none = if(isnull(waf_act) and isnull(waf_cnt), 1, 0)
52| eval w_block = if(waf_act == "BLOCK", 1, 0)
53| eval w_count = if(waf_cnt == 1, 1, 0)
54| eval w_allow = if(waf_act == "ALLOW" and waf_cnt == 0, 1, 0)
55| eval w_other = if(w_none == 0 and w_block == 0 and w_count == 0 and w_allow == 0, 1, 0)
56| eval hit_rule = if(w_block == 1 or w_count == 1, waf_rule, null)
57
58|# [6] 출발지 × 도메인 집계
59| eval ev = concat(st, " ", result_type, " ", method, " ", u)
60| stats count as cnt,
61 values(xff_kind) as xff_kinds,
62 values(x1) as xff_ips,
63 sum(sens) as sens_cnt,
64 values(sens_path) as sens_paths,
65 sum(w_none) as xff_none,
66 sum(w_allow) as xff_allow,
67 sum(w_count) as xff_count,
68 sum(w_block) as xff_block,
69 sum(w_other) as xff_other,
70 values(hit_rule) as waf_rules,
71 values(st) as codes,
72 dc(uri) as path_cnt,
73 values(uri) as path_all,
74 dc(ev) as ev_cnt,
75 values(ev) as ev_all,
76 dc(ua) as ua_cnt,
77 values(ua) as ua_all,
78 min(_time) as first_seen,
79 max(_time) as last_seen
80 by cip, host_header
81
82|# [7] 출력 (목록은 앞 20개만)
83| eval src_ip = ip(cip)
84| eval paths = subarray(path_all, 0, 20), evs = subarray(ev_all, 0, 20), user_agents = subarray(ua_all, 0, 20)
85| eval _time = last_seen
86| eval _site = host_header
87| eval _device_name = "AWS_CF"
88| eval sp_txt = replace(replace(replace(str(subarray(sens_paths, 0, 3)), "[", ""), "]", ""), "\"", "")
89| eval rule_txt = replace(replace(replace(str(subarray(waf_rules, 0, 3)), "[", ""), "]", ""), "\"", "")
90| eval rule_result = concat(
91 "XFF 헤더 내부 IP 사칭 : ", cip, " → ", host_header, " / ", str(cnt), "건",
92 " / 위장 IP ", replace(replace(replace(str(xff_ips), "[", ""), "]", ""), "\"", ""),
93 " / 인증 · 관리 경로 ", str(sens_cnt), "건", if(sens_cnt > 0, concat(" (", sp_txt, ")"), ""),
94 " / WAF 로그 미대조 ", str(xff_none), "건 · 허용 ", str(xff_allow), "건 · COUNT ", str(xff_count), "건 · 차단 ", str(xff_block), "건",
95 if(xff_other > 0, concat(" · 기타 ", str(xff_other), "건"), ""),
96 if(isnotnull(rule_txt) and rule_txt != "", concat(" (", rule_txt, ")"), ""),
97 " / 경로 ", str(path_cnt), "종 · 접속 도구 ", str(ua_cnt), "종")
98
99| fields _time, _site, _device_name, src_ip, host_header, cnt, xff_kinds, xff_ips, sens_cnt, sens_paths, xff_none, xff_allow, xff_count, xff_block, xff_other, waf_rules, codes, path_cnt, paths, ev_cnt, evs, ua_cnt, user_agents, first_seen, last_seen, rule_result
- CloudFront 로그에서
forwarded_for값이 있는(-가 아닌) 요청만 대상으로 합니다. - XFF 첫 값이 실제 접속 IP(
src_ip)와 다르고, 루프백 또는 미지정 주소인 요청을 남깁니다.- 사설 IP(10.x · 172.16~31.x · 192.168.x)는 정상 프록시 · 사내망 경유 요청과 구분되지 않아 제외합니다.
- 요청 경로가 인증 · 관리 경로(/admin · /login · /wp-login · /phpmyadmin · /actuator 등)인지 표기합니다.
- 요청 ID로 WAF 로그를 대조해 각 요청의 WAF 처리(미대조 · 허용 · COUNT · 차단 · 기타)를 확인합니다.
- 1시간 동안 출발지 × 대상 도메인 단위로 집계하고, 같은 출발지 · 같은 1시간의 WAF 처리 건수와 규칙을 함께 표기합니다.
- 「WAF 로그 미대조」는 요청 ID로 WAF 로그를 찾지 못한 건수입니다. WAF 로깅 필터로 규칙 미매칭 요청을 남기지 않는 환경, WAF 로그 수집이 CloudFront보다 늦은 경우에도 미대조로 집계되므로 「WAF가 검사하지 않았다」는 근거로 단정하지 마십시오.
- 권장 실행 : 매시 40분, 분석 기간 1시간 (CloudFront 로그 수집 지연 대응)
- 하위 쿼리의
*:AWS_WAF*는 사용 환경의 WAF 테이블 이름으로 변경하십시오.
위협 분석
XFF 헤더는 요청을 보내는 쪽이 임의로 넣을 수 있습니다. 서버가 XFF 첫 값을 사용자 IP로 믿는 경우 다음과 같은 우회가 가능합니다.
- 내부 IP만 허용하는 관리자 페이지 접근
- IP 기준 로그인 실패 횟수 제한 · 속도 제한 회피
- 접속 기록 · 감사 로그의 출발지 위조
자동화 도구는 짧은 시간에 요청마다 XFF 값(localhost · ::1 · 127.0.0.1 · 0.0.0.0 · 사설 IP)과 접속 도구를 바꿔 가며 우회 가능 여부를 확인하는 형태가 많습니다.
오탐 유형
- 내부 점검 도구 · 개발자가 XFF 헤더를 직접 넣어 테스트하는 경우 → 점검 출발지를 제외 대역으로 등록
- 오리진 앞단에 별도 프록시를 두고 XFF를 127.0.0.1로 덮어쓰는 구성 → CloudFront 이전 구간에서는 발생하지 않으나, 구성 확인 필요
대응 방안
- WAF 사용자 정의 규칙으로
x-forwarded-for헤더 첫 값이 루프백 · 미지정 주소인 요청을 차단합니다. 초기에는 COUNT로 적용해 정상 요청 영향이 없음을 확인한 뒤 차단으로 전환합니다. - 애플리케이션이 XFF 첫 값으로 접근 제어를 판단하는지 점검하고, CloudFront가 기록한 실제 접속 IP(XFF의 마지막 값 또는 CloudFront-Viewer-Address 헤더)를 기준으로 판단하도록 변경합니다.
- 인증 · 관리 경로 요청이 포함된 경우, 같은 시간대 해당 경로의 서버 응답과 로그인 기록을 확인합니다.
- 반복 출발지는 IP 차단 목록에 추가합니다 (보조 조치).