시리즈파이썬으로 만드는 보안 자동화4편 · 공개 8편
탐지율을 바꾼 것은 규칙이 아니라 입력이었다
정답을 아는 합성 웹 로그 1692줄에 정규식 파서와 시그니처·빈도 탐지를 돌려, 탐지 규칙을 그대로 둔 채 입력 처리만 바꿨을 때 탐지율과 오탐이 어떻게 움직이는지 측정한 기록.
목차 5
탐지 규칙을 고쳐도 탐지율이 안 오를 때 어디를 봐야 하나
정규식과 시그니처를 검색과 AI 도움을 받아 여러 형태로 바꿔 봐도 탐지율이 크게 오르지 않았다. 규칙을 더 만지기 전에 원인이 정말 규칙에 있는지부터 확인해 보기로 했다.
웹 로그에서 공격을 찾는 코드는 대개 정규식 하나에서 시작한다. 로그 한 줄을 필드로 쪼개고, 경로에 union select나 <script가 있는지 보고, 같은 IP가 1분에 몇 번 왔는지 센다. 탐지가 안 되면 시그니처를 늘리고 임계값을 낮춘다.
이 글은 그 순서를 한 번 반대로 해 본 기록이다. 규칙은 한 글자도 고치지 않고 입력 처리만 세 번 바꿔서, 탐지율과 오탐이 각각 얼마나 움직이는지 숫자로 쟀다.
먼저 답부터 적으면 이렇다. 파서가 조용히 버린 줄에 SQLi 4건이 들어 있었고, 이중 인코딩된 페이로드 8건은 디코딩 한 번을 더 하자 그냥 잡혔고, 정상 사용자를 25명이나 잡던 빈도 임계값은 임계값을 바꾸지 않고 정적 리소스를 걷어내는 것만으로 오탐이 0이 됐다. 세 번 다 규칙은 그대로였다.
로그 한 줄이 이벤트가 되기까지
측정한 것을 이해할 만큼만 짚는다.
access.log 한 줄
→ 정규표현식 파싱 (필드로 쪼갠다)
→ 이벤트 구조화 (자료형·시각 변환)
→ URL 디코딩 (인코딩된 페이로드 복원)
→ 시그니처·빈도 탐지
→ 경보파싱이 만드는 필드는 여섯 개고, 각각이 탐지에서 맡는 역할이 다르다.
| 필드 | 탐지에서의 의미 |
|---|---|
ip |
주체별 집계와 빈도 |
time |
요청 순서와 시간 창 |
method |
요청 방식 |
path |
페이로드 시그니처 |
status |
오류·차단 반응 |
size |
평소와 다른 응답 크기 |
여기서 중요한 것은 이 파이프라인이 한 방향이라는 점이다. 앞 단계에서 빠진 줄은 뒤 단계의 어떤 규칙으로도 복구되지 않는다. 탐지기가 보는 것은 로그가 아니라 파서가 넘겨준 표다.
정답을 아는 로그를 만들어 놓고 시작했다
탐지율과 오탐을 숫자로 내려면 어느 줄이 공격인지 미리 알아야 한다. 그래서 실제 로그를 구하는 대신 어느 줄이 무엇인지 기록해 둔 합성 로그를 만들었다.
gen_log.py가 access.log(1692줄)와 정답표 truth.csv를 함께 쓴다. 시드를 고정했으므로 다시 돌리면 같은 로그가 나온다.
lines=1692 malicious=404 benign=1288
benign_lookalike 26
bot 454
cmdi 9
malformed_empty 4
malformed_tls 5
normal 264
normal_ipv6 8
scan 300
sqli 40
sqli_double 10
sqli_ipv6 4
static 527
traversal 11
xss 25
xss_double 5구성은 한 시간짜리 트래픽이다. 정상 사용자 40명이 페이지를 열고 CSS·JS·이미지를 함께 받고(normal·static), 헬스체크와 크롤러가 계속 돌고(bot), 그 사이에 다섯 종류의 공격자가 있다. 여기에 실제 로그에 늘 있지만 예제 로그에는 잘 안 들어가는 것 세 가지를 일부러 섞었다.
- IPv6 클라이언트 12줄 (
2001:db8::/32) — 그중 4줄은 진짜 SQLi다 - 이중 인코딩 페이로드 15줄 —
%27이 아니라%2527로 들어온다 - 시그니처와 겹치는 정상 요청 26줄 —
/search?q=union select 방어,/posts/xss-onerror-필터-우회같은 것
세 번째는 오탐을 재려고 넣었다. 보안 주제를 다루는 사이트라면 정상 검색어와 정상 문서 주소가 공격 패턴과 같은 문자열을 포함한다. 이런 줄이 없는 로그에서는 오탐률이 항상 0으로 나오고, 그 0은 규칙이 정확하다는 뜻이 아니라 로그가 깨끗하다는 뜻일 뿐이다.
이 글의 숫자는 전부 내가 설계한 로그의 성질이다. 뒤에 나오는 탐지율 100%는 규칙이 좋다는 증거가 아니라 내가 심어 둔 패턴을 내가 찾았다는 뜻이다. 이 측정으로 비교할 수 있는 것은 절대적인 성능이 아니라 같은 로그에 같은 규칙을 두고 입력 처리만 바꿨을 때의 차이뿐이다.
로그는 합성이고 IP는 전부 문서용 대역(RFC 5737 198.51.100.0/24·203.0.113.0/24, RFC 3849 2001:db8::/32)이라 실제 주소가 들어갈 자리가 없다. 이 글의 모든 코드는 로컬 파일만 읽는다. 바깥으로 요청을 보내는 코드는 없다.
1. 정규식은 몇 줄을 버렸을까
그때까지 쓰던 파싱 정규식을 한 글자도 고치지 않고 그대로 돌렸다. 로그 파싱 예제에서 흔히 보는 형태다.
LOG_PATTERN = re.compile(
r'(?P<ip>\d+\.\d+\.\d+\.\d+)'
r'.*?\[(?P<time>[^\]]+)\]'
r'\s+"(?P<method>\w+)\s+'
r'(?P<path>[^\s"]+)'
r'[^"]*"\s+(?P<status>\d+)'
r'\s+(?P<size>\d+|-)'
)알고 싶었던 것은 몇 줄이 매치되는가가 아니라 매치되지 않은 줄에 무엇이 들어 있었는가였다. 그래서 실패한 줄을 버리지 않고 정답표와 대조했다.
전체 1692줄 / 파싱 성공 1671줄 / 실패 21줄 (1.2%)
버려진 21줄의 정체:
normal_ipv6 8
malformed_tls 5
sqli_ipv6 4
malformed_empty 4
버려진 줄 중 실제 공격: 4건
버려진 줄 예시:
L1531 [sqli_ipv6] 2001:db8::1 - - [30/Jul/2026:09:52:00 +0900] "GET /products?id=1%27%20OR%20%271%27=%271 HTTP/1.1" 200 1200 "-"(?P<ip>\d+\.\d+\.\d+\.\d+)는 점으로 구분된 십진수 네 덩어리만 받는다. 2001:db8::1은 이 모양이 아니므로 줄 전체가 매치되지 않는다. 나머지 9줄은 요청줄이 요청줄이 아닌 경우다 — HTTP 포트로 들어온 TLS 핸드셰이크("\x16\x03\x01\x02\x00\x01")와 빈 요청("-")은 "(?P<method>\w+)\s+ 를 만족하지 못한다.
문제는 비율이 아니라 이 실패가 아무 소리도 내지 않는다는 점이다. re.search()가 None을 돌려주면 보통은 continue를 쓴다. 예외도 로그도 남지 않고 데이터프레임의 행 수만 조용히 줄어든다. 그 뒤에 붙는 탐지기가 아무리 정확해도, 이 SQLi 4건은 ’탐지 실패’가 아니라 처음부터 존재하지 않는 이벤트가 된다.
클라이언트 주소 형식 하나 때문에 줄이 통째로 빠지는 것은 이 실습에서 처음 봤다.
사고 사례 대신 이 설정이 어긋났을 때 번지는 경로를 적어 둔다. 파서 정규식이 형식을 못 받음 → 해당 줄 누락 → 탐지 대상 자체가 없음 → 경보 없음 → 대시보드는 정상. 마지막 칸이 ’이상 없음’으로 보이는 것이 이 경로의 특징이다.
그래서 파서에 넣은 것은 정규식 수정이 아니라 실패 건수 집계였다. 매치 실패를 세고 샘플을 남기면, 정규식을 고치지 않아도 최소한 ’내가 못 보고 있는 줄이 21개 있다’는 사실은 화면에 뜬다.
2. 디코딩을 한 번 더 하면 무엇이 달라질까
파싱에 성공한 1671줄에 시그니처 탐지를 돌렸다. 규칙은 SQLi·XSS·Traversal·CmdInjection 네 개고, 바꾼 것은 unquote()를 몇 번 돌리느냐 하나다.
def deep_unquote(value: str, rounds: int) -> str:
for _ in range(rounds):
decoded = unquote(value)
if decoded == value:
break
value = decoded
return value| 디코딩 | 탐지 | 참 | 거짓 | 페이로드 탐지율 |
|---|---|---|---|---|
| 1회 | 108 | 92 | 16 | 92.0% |
| 2회 | 116 | 100 | 16 | 100.0% |
=== 디코딩 1회 ===
탐지 108건 (참 92 / 거짓 16)
페이로드 공격 100건 중 놓친 것 8건 → 탐지율 92.0%
놓친 줄의 정체: {'sqli_double': 6, 'xss_double': 2}
오탐 16건의 정체: {'benign_lookalike': 16}
오탐 예시:
L102 [SQLi] /search?q=or 1=1 우회 사례
L177 [XSS] /search?q=<script> 이스케이프 처리
=== 디코딩 2회 ===
탐지 116건 (참 100 / 거짓 16)
페이로드 공격 100건 중 놓친 것 0건 → 탐지율 100.0%
오탐 16건의 정체: {'benign_lookalike': 16}놓친 8건은 전부 이중 인코딩이었다. %2527을 한 번 디코딩하면 %27이 되는데, 이 상태의 문자열은 여전히 or\s+'?1'?\s*=\s*'?1 와 맞지 않는다. 한 번 더 돌리면 '가 되어 규칙에 걸린다. 시그니처를 하나도 추가하지 않고 미탐 8건이 사라졌다.
같은 표에서 더 눈여겨볼 것은 오른쪽 두 칸이다. 오탐 16건은 두 경우가 완전히 같다. 정체도 같고 건수도 같다. 디코딩 횟수는 미탐에만 작용하고 오탐에는 아무 영향이 없다 — 오탐의 원인이 인코딩이 아니라 규칙과 정상 문자열의 겹침이기 때문이다. /search?q=or 1=1 우회 사례는 몇 번을 디코딩해도 or 1=1을 포함한다.
이것이 디코딩을 무한히 반복해도 된다는 뜻은 아니다. 반복 횟수를 고정하지 않으면 정상 입력까지 원래 없던 모양으로 바뀔 수 있어서 deep_unquote에 rounds 인자를 뒀다. 다만 이 로그에서 잰 범위 안에서는, 2회로 늘려서 치른 대가가 0이었다.
3. 빈도 임계값은 왜 정상 사용자를 잡을까
세 번째는 빈도 탐지다. IP별로 1분 창에서 요청 수를 세고 임계값을 넘으면 표시한다.
for ip, group in d.groupby("ip"):
per_ip[ip] = group.resample("1min").size().max()임계값을 5건/분부터 올려 가며 돌렸다.
정상 IP 중 분당 요청이 많은 순:
198.51.100.31 최대 10건/분 [static]
198.51.100.41 최대 9건/분 [static]
198.51.100.49 최대 8건/분 [static]
임계값 5건/분 → IP 28개 탐지, 그중 공격자 3개
203.0.113.40 최대 300건/분 [scan] 공격
198.51.100.31 최대 10건/분 [static] 정상
198.51.100.41 최대 9건/분 [static] 정상
...
임계값 10건/분 → IP 1개 탐지, 그중 공격자 1개
203.0.113.40 최대 300건/분 [scan] 공격임계값을 어디에 두든 결과가 둘 중 하나였다. 5건/분이면 IP 28개를 잡고 그중 25개가 정상 사용자다. 10건/분으로 올리면 오탐은 사라지지만 잡히는 것이 디렉터리 스캐너 하나뿐이다. 7건/분의 SQLi 공격자와 6건/분의 XSS 공격자는 5건/분 기준에서는 잡히지만, 10건/분으로 올리면 놓친다. 원본 로그에서는 이들을 잡는 낮은 임계값에 정상 사용자도 함께 걸린다.
여기서 임계값을 더 만지는 대신 정상 사용자의 10건/분이 무엇으로 채워져 있는지를 봤다. 대부분 정적 리소스였다. 페이지 하나를 열면 CSS·JS·이미지가 함께 따라오고, 그 요청들이 사람 한 명의 ’분당 요청 수’를 서너 배로 부풀린다.
경로 확장자와 헬스체크 주소로 노이즈를 분류해 뺐다.
STATIC_PATTERN = re.compile(r"\.(css|js|png|jpe?g|gif|webp|svg|ico|woff2?)(\?|$)", re.I)
HEALTH_PATTERN = re.compile(r"^/health$")노이즈로 분류해 제외한 요청: 887건 (정적 527, 헬스체크 360)
=== 빈도 탐지 (1분 창, 정적·헬스체크 제외) ===
정상 IP 중 분당 요청이 많은 순:
198.51.100.41 최대 3건/분 [normal]
198.51.100.40 최대 3건/분 [normal]
198.51.100.31 최대 3건/분 [normal]
임계값 5건/분 → IP 3개 탐지, 그중 공격자 3개
203.0.113.40 최대 300건/분 [scan] 공격
203.0.113.10 최대 7건/분 [sqli] 공격
203.0.113.20 최대 6건/분 [xss] 공격| 1분당 임계값 5건 | 탐지 IP | 공격자 | 오탐 |
|---|---|---|---|
| 원본 로그 | 28개 | 3 | 25 |
| 정적·헬스체크 제외 | 3개 | 3 | 0 |
임계값은 5로 그대로다. 887건을 걷어내자 정상 사용자 상한이 10건/분에서 3건/분으로 내려갔고, 7건/분과 6건/분인 두 공격자의 요청 빈도는 그대로여도 정상 사용자와 구분할 수 있게 됐다. 원본 로그에서는 존재하지 않던 ’정상과 공격 사이의 간격’이 생긴 것이다.
정적 리소스를 지운 게 아니라 빈도 계산에서만 뺐다는 점은 적어 둘 만하다. 그 요청들은 다른 용도로는 여전히 필요하다. 제외한 건수 887건을 출력에 남긴 것도 같은 이유다 — 이 숫자가 갑자기 변하면 사이트 구조나 로그 형식이 바뀌었다는 신호이므로, 무엇을 얼마나 뺐는지는 결과와 함께 보여야 한다.
입력을 먼저 보는 순서
세 번의 측정이 같은 모양이었다. 규칙은 그대로 두고 입력 처리만 바꿨는데 결과가 움직였다.
| 바꾼 것 | 규칙 변경 | 결과 |
|---|---|---|
| 파싱 실패 건수 집계 | 없음 | 정답표 대조로 누락된 SQLi 4건 확인 |
| 디코딩 1회 → 2회 | 없음 | 미탐 8건 사라짐, 오탐 변화 없음 |
| 정적·헬스체크 제외 | 없음 (임계값도 그대로) | 오탐 25건 → 0건 |
그래서 탐지가 기대만큼 안 될 때 확인하는 순서를 이렇게 잡았다.
- 파서가 몇 줄을 버렸는지 센다. 매치 실패를
continue로 넘기지 말고 건수와 샘플을 남긴다. 버려진 줄이 0이 아니면 거기부터 본다. - 디코딩 상태를 확인한다. 규칙이 보는 문자열이 실제로 어떤 모양인지 한 건이라도 출력해 본다. 원본
path는 남기고 정규화 값은 별도 컬럼에 둔다. - 빈도를 재기 전에 무엇을 세고 있는지 본다. 사람 한 명의 분당 요청 수가 정적 리소스로 부풀어 있으면 임계값은 어디에 둬도 맞지 않는다.
- 그다음에 시그니처를 늘린다.
1~3번을 건너뛰고 4번부터 하면, 규칙이 늘어나는 만큼 오탐도 같이 늘고 미탐은 그대로 남는다.
재확인 방법
아래는 당시 실습에 사용한 명령이다. 현재 저장소에는 gen_log.py, parse.py, detect.py가 포함돼 있지 않아 이 명령만으로 표의 수치를 재현할 수는 없다.
python gen_log.py # access.log(1692줄) + truth.csv 생성. 시드 고정
python parse.py # 위 정규식으로 파싱, 실패 21줄을 정답표와 대조
python detect.py # 시그니처(디코딩 1회/2회) + 빈도(정제 전후)한글 출력이 깨지면 PYTHONIOENCODING=utf-8을 앞에 붙인다. 측정에 쓴 환경은 Python 3.14.6 / pandas 3.0.3이다.
다시 실험한다면 로그 생성기와 탐지 코드를 복원한 뒤 시드와 공격자의 요청 간격을 바꿔 비교할 수 있다.
이 글에서 확인하지 못한 것은 두 가지다. 첫째, 여기 숫자는 전부 내가 만든 로그의 성질이라 실제 트래픽에서 같은 비율이 나온다는 보장이 없다 — 특히 정상 사용자 상한 3건/분은 로그 구성에 그대로 의존한다. 둘째, IPv6를 받도록 정규식을 고치는 것까지는 하지 않았다. 파서를 고치는 대신 실패를 세는 데서 멈췄고, 형식이 몇 가지나 더 있는지는 확인하지 않았다.
2026-09-25 정정: 분당 5건 기준에서 SQLi·XSS도 탐지됐다는 표에 맞춰 설명을 고쳤다. 당시 실행 파일이 현재 저장소에 없어 재실행할 수 없다는 점도 반영했다.