← 아티클 목록

시리즈파이썬으로 만드는 보안 자동화5편 · 공개 8편

파이썬 보안 자동화

요청 한 줄에서 평범하던 열거가 세션 특징표에서는 2등이 됐다

규칙에 걸리지 않는 느린 열거 공격을 섞은 합성 웹 로그에 통계 급증 탐지와 Isolation Forest를 적용하고, 요청 특징표와 IP 세션 특징표에서 결과가 어떻게 달라지는지 측정한 기록.

목차 5

2026-08-09 정정 — 요청 단위와 세션 단위 비교를 집계 단위만 바꾼 통제 실험처럼 설명한 부분을 바로잡았다. 두 실행은 특징 표현도 달랐으므로 결과 차이를 집계 단위 하나의 효과로 볼 수 없으며, 데이터 구성과 입력 건수도 구분해 적었다.

규칙이 못 잡는 공격에 머신러닝을 붙이면 잡히나

시그니처는 아는 문자열만 잡고 빈도는 빠른 것만 잡는다. 그래서 다음 순서로 이상 탐지가 나온다 — 평소 분포에서 벗어난 구간을 찾는 통계, 여러 특징에서 홀로 떨어진 사건을 찾는 Isolation Forest.

이 글은 그 기대를 숫자로 확인한 기록이다. 결과부터 적으면, Isolation Forest를 요청 하나 단위로 돌리는 동안에는 규칙이 놓친 열거 공격을 넓게 잡기 위해 전체 요청의 5~20%를 조사 대상으로 올려야 했다. 같은 합성 로그에서 IP별 요청 관계를 새 특징표로 만들자 그 공격자가 888개 중 2등으로 올라왔다.

두 실행에서 같은 것은 원시 로그 계열과 Isolation Forest이고, 바뀐 것은 행의 단위와 특징 표현 둘 다다. 요청 표에는 경로 길이·엔트로피·파라미터 수 등이, 세션 표에는 고유 경로 비율·오류율·요청 간격 변동계수 등이 들어갔다. 따라서 이 결과는 세션 특징표가 이 합성 열거를 더 위에 올렸다는 관찰이지, 집계 단위 하나의 인과 효과를 잰 통제 실험은 아니다. scikit-learn의 IsolationForest 공식 문서대로 모델은 주어진 특징을 골라 분할하므로, 특징표가 바뀌면 모델이 답하는 문제도 바뀐다.

네 가지 관점이 각각 답하는 질문

여기서 비교한 탐지 방식은 넷이고, 서로 다른 질문을 한다.

관점 묻는 것 못 보는 것
시그니처 아는 공격 문자열인가 문자열이 평범한 공격
빈도 짧은 시간에 너무 많이 왔나 천천히 오는 공격
통계 평소 요청량에서 벗어났나 총량을 안 바꾸는 공격
Isolation Forest 여러 특징에서 홀로 떨어졌나 특징이 평범한 공격

Isolation Forest는 관측치를 무작위로 잘라 나가면서 몇 번 만에 혼자 남는지를 센다. 적게 잘라도 고립되는 점이 이상치다. 정답 라벨은 필요 없고, 결과는 ’이상’이지 ’공격’이 아니다. scikit-learn의 contamination 기본값은 'auto'라 원 논문의 기준으로 점수 임계값을 정할 수도 있다. 이 실습은 조사 대상으로 올리는 비율에 따라 결과가 어떻게 바뀌는지 비교하려고 0.01부터 0.20까지의 실수를 직접 넣었다.

이상 탐지를 재려면 로그가 두 조건을 만족해야 한다

측정에는 지난 글과 같은 방식의 합성 로그를 썼다. 어느 줄이 무슨 공격인지 적힌 정답표가 함께 나오므로 탐지율과 오탐을 숫자로 낼 수 있다. IP는 전부 문서용 대역이고, 코드는 로컬 파일만 읽는다.

그런데 지난 글에 쓴 로그를 그대로 가져다 쓸 수는 없었다. 두 번 고쳐야 했고, 두 번 다 고치기 전에는 측정 자체가 성립하지 않았다.

첫째, 규칙으로 안 보이는 공격이 로그에 있어야 한다. 지난 글의 로그에 Isolation Forest를 그대로 돌려 봤다. 셀 것은 탐지 건수가 아니라 시그니처가 이미 잡은 줄과 빈도 탐지에 걸린 IP를 뺀 순증이다. 규칙과 겹치는 탐지는 아무리 많아도 새로 알게 된 것이 없다.

 contam    탐지     참    거짓     정밀도        규칙이 이미 잡음      ML만 잡은 진짜
   0.01   190    79   111   41.6%              190              0
   0.02   379   245   134   64.6%              230              0
   0.05   916   400   516   43.7%              234              0
   0.10  1894   400  1494   21.1%              234              0
   0.20  3787   400  3387   10.6%              281              0

contamination을 스무 배로 올려 3787건을 표시하는 동안 마지막 칸이 계속 0이었다. 원인은 모델이 아니라 로그에 있었다. 그 로그의 공격은 SQLi·XSS 같은 페이로드 공격 아니면 1분에 300건을 던지는 스캐너뿐이고, 앞의 것은 시그니처에 그대로 보이고 뒤의 것은 빈도에 그대로 걸린다. 규칙으로 안 보이는 공격이 애초에 한 건도 없으므로 순증이 0인 것은 당연하고, 이 로그로는 이상 탐지를 평가할 수 없다.

contamination=0.01에서는 「규칙이 이미 잡음」 190건이 ML의 참 탐지 79건보다 많다. 오류가 아니다. 「규칙이 이미 잡음」은 공격 여부와 무관하게 시그니처에 걸린 줄 수라서, 시그니처가 잘못 잡은 정상 요청도 여기 들어간다.

둘째, 공격이 드물어야 한다. 처음 만든 로그는 1892줄 중 공격이 604건, **32%**였다. 이상 탐지는 「이상은 드물다」를 전제로 도는데, Isolation Forest는 정답 라벨 없이 주어진 데이터 전체에서 ’정상’을 배운다. 공격이 3분의 1이면 모델이 배우는 정상에 공격이 섞인다. 통계 쪽은 더 직접적이어서, 기준선이 되는 평균 자체를 스캐너의 300건이 끌어올린다. 정상에서 벗어난 것을 찾겠다고 도입한 방법인데 정상이 오염돼 있으면 앞뒤가 맞지 않는다.

그래서 정상 사용자를 40명에서 800명으로 늘려 다시 만들었다. 위의 첫 표는 느린 열거를 넣기 전 데이터라 공격 400건을 포함한다. 여기에 열거 200건을 추가한 최종 원시 로그가 **19155줄, 공격 600건으로 비율 3.1%**다. 두 표는 정상 트래픽 생성 조건은 같지만 입력 건수와 공격 구성은 같지 않다.

원시 로그 19155줄 가운데 당시 파싱 성공 및 모델 입력으로 기록된 것은 19134건이다. 이 글의 시간 창 평균과 탐지 건수도 19134건을 기준으로 맞고, 같은 로그를 쓴 후속 실시간 글에도 읽은 줄 19155 / 파싱 19134가 남아 있다. 다만 현재 저장소에는 파서와 원본 로그가 없어 제외된 21줄의 구성을 다시 확인할 수 없다. 이전 1692줄 로그에서 빠졌던 21줄과 원인이 같다고 추정하지 않는다.

두 데이터에서 결과도 함께 바뀌었다. 정상 사용자 40명인 공격 비율 32% 데이터에서는 요청 단위 Isolation Forest가 열거 공격을 contamination 0.20에서 20건 잡았고, 정상 사용자 800명인 3.1% 데이터에서는 같은 설정에서 200건을 전부 잡았다. 그러나 이 비교는 공격 비율뿐 아니라 사용자 수, 전체 행 수와 표본으로 뽑힌 정상 분포까지 함께 바뀌었다. 따라서 이 관찰만으로 성능 차이의 원인을 공격 비율 하나로 분리할 수 없다. 비율의 효과를 재려면 같은 정상 표본과 전체 조건을 고정한 비교가 따로 필요하다.

1. 규칙에 안 걸리는 공격은 어떻게 생겼나

추가한 공격자는 상품 ID를 1번부터 200번까지 차례로 훑는 느린 순차 열거다.

Python
for i in range(1, 201):
    status = 200 if i % 4 else 404
    add(t, fmt(t, ip, "GET", f"/products/{i}", status, ...), "enum", ip, True)
    t += timedelta(seconds=rnd.randint(15, 19))

세 가지를 의도적으로 피했다. 경로에 따옴표도 꺾쇠도 없어 시그니처에 걸릴 문자열이 없고, 1519초 간격이라 분당 34건이어서 빈도 임계값 아래이며, UA는 평범한 크롬이다. 로그만 보면 상품을 열심히 구경하는 사람과 구별되지 않는다.

먼저 통계 급증 탐지를 돌렸다. 시간 창을 바꿔 가며 평균 + 2.5σ를 넘는 구간을 찾는다.

창 1min   구간  68개 / 평균  281.4 / 표준편차  105.1 / 임계값  544.1
    09:41  635건 (공격 304건, 최다 IP 203.0.113.40 300건)

창 5min   구간  14개 / 평균 1366.7 / 표준편차  535.1 / 임계값 2704.3
    급증 없음

창 10min  구간   7개 / 평균 2733.4 / 표준편차 1049.7 / 임계값 5357.6
    급증 없음

열거 공격은 어느 창에서도 안 잡혔다. 당연한데, 이 공격은 급증이 아니기 때문이다. 200건을 57분에 나눠 보내면 분당 요청량 그래프에서 튀는 곳이 없다. 통계 급증 탐지가 답하는 질문은 ’지금 요청이 평소보다 많은가’이고 이 공격의 답은 ’아니오’다.

같은 출력에서 하나 더 보이는 것이 있다. 창을 5분으로 늘리자 1분에 300건을 던진 스캐너까지 놓쳤다. 출력의 1366.7은 14개 구간 전체의 평균이지 스캐너가 들어간 특정 구간의 총량이 아니므로, 이 값으로 스캐너의 비중을 계산할 수는 없다. 여기서 확인되는 것은 스캐너가 포함된 5분 구간도 임계값 2704.3을 넘지 않았다는 데까지다. 이 실행에서는 창을 길게 잡자 짧은 스캐너 활동이 해당 구간의 다른 요청과 합쳐져 급증으로 분류되지 않았다.

2. 요청 하나씩 보면 열거가 이상해 보이나

이제 같은 로그에 Isolation Forest를 돌렸다. 요청 하나가 한 행이다. 특징은 경로 길이, 특수문자 비율, 엔트로피, 상태 코드, 응답 크기, 파라미터 개수, IP별 요청 수 일곱 개다.

 contam    탐지     참    거짓     정밀도        규칙이 이미 잡음      ML만 잡은 진짜
   0.01   192    73   119   38.0%              192              0
   0.02   382   248   134   64.9%              233              2
   0.05   957   450   507   47.0%              234             50
   0.10  1914   450  1464   23.5%              234             50
   0.20  3827   600  3227   15.7%              281            200

열거 200건이 잡히기는 한다. 문제는 어디서 잡히느냐다.

  • contamination 0.02 — 열거 2건. 200건 중 1%
  • 0.05 — 50건. 대신 957건을 표시하고 그중 507건이 정상
  • 0.20 — 200건 전부. 대신 3827건을 표시하고 그중 3227건이 정상 요청

마지막 줄이 이 절의 요점이다. 열거 공격을 다 잡으려면 정상 요청 3227건을 함께 조사해야 한다. contamination 0.20은 정확도 설정이 아니라 전체 요청의 20%를 사람이 들여다보겠다는 선언이다.

규칙 신호(has_sqli·has_xss)를 특징에서 빼고 돌려도 순증은 0.05에서 50건으로 같았다. 모델이 규칙의 답을 커닝해서 나온 결과는 아니라는 뜻이다.

이유는 특징표를 보면 분명하다. /products/137이라는 요청 하나는 경로가 짧고 특수문자가 없고 엔트로피도 낮고 파라미터가 0개다. 정상 사용자가 보내는 /products/34와 숫자만 다르다. 요청 표에는 IP별 총요청 수도 있어서 반복량 신호가 아예 없는 것은 아니지만, 어떤 경로를 어떤 순서와 간격으로 훑었는지는 담기지 않았다.

이 합성 공격에서 더 구분력 있는 것은 요청 내용 하나가 아니라 서로 다른 상품 경로를 일정한 간격으로 200번 훑었다는 관계였다. 그 관계를 요청 특징으로 만들지 않은 표에서는 모델도 직접 쓸 수 없다.

3. 같은 데이터를 IP 한 줄로 접으면

그래서 같은 원시 로그를 IP별로 다시 접어 별도의 세션 특징표를 만들었다. Isolation Forest 알고리즘은 같지만 특징 계산 함수는 요청 단위 실행과 다르다. 아래 블록은 당시 전체 코드에서 핵심 열만 옮긴 발췌다. 요청 시각 차이의 IP별 평균과 표준편차를 avg_gap·gap_std로 만드는 앞 단계는 현재 저장소에 남아 있지 않아 생략했다.

Python
sessions = pd.DataFrame({
    "req_count":    g.size(),                   # 요청 수
    "unique_paths": g["path_norm"].nunique(),    # 고유 경로 수
    "path_ratio":   g["path_norm"].nunique() / g.size(),
    "err_rate":     g["status"].apply(lambda s: float((s >= 400).mean())),
    "mean_size":    g["size"].mean(),
})
# 발췌에서 생략한 앞 단계가 sessions["avg_gap"]과 sessions["gap_std"]을 만든 뒤 계산했다.
sessions["gap_cv"] = sessions["gap_std"] / sessions["avg_gap"]

마지막 gap_cv는 요청 간격의 변동계수(표준편차 ÷ 평균)다. 이 합성 로그에서는 정상 사용자의 요청 간격은 넓게 흔들고 열거 요청은 15~19초로 만들었기 때문에 둘을 가르는 특징이 된다. 실제 사람과 자동화가 항상 이 분포를 따른다는 뜻은 아니다.

여기서는 contamination으로 자르는 대신 이상 점수 순으로 줄을 세웠다. 임계값을 정하는 것 자체가 앞 절에서 문제였으므로, 순위로 보면 그 선택 없이 비교할 수 있다.

IP 888개 (공격자 6개) — 이상한 순으로 상위 12개
 순위 ip                  요청    고유경로    경로비    오류율     평균간격     간격변동  정체
  1 203.0.113.40       300     300   1.00   1.00      0.2     2.24  scan/공격/규칙O
  2 203.0.113.80       200     200   1.00   0.25     17.1     0.08  enum/공격/규칙X
  3 198.51.100.200     360       1   0.00   0.00     10.0     0.00  bot/정상/규칙X
  4 203.0.113.10        40       5   0.12   0.25     11.5     0.47  sqli/공격/규칙O
  5 198.51.100.201      94       8   0.09   0.00     38.6     0.32  bot/정상/규칙X
  6 203.0.113.30        20       6   0.30   0.80     16.2     0.55  traversal/공격/규칙O
  7 198.51.100.35        4       2   0.50   0.00    117.0     0.02  benign_lookalike/정상/규칙O
  ...
 12 203.0.113.20        25       4   0.16   0.00     12.1     0.54  xss/공격/규칙O

규칙에 안 걸리는 열거 공격자가 세션 특징표에서 888개 중 2등이다. 요청 특징표에서는 정상 3227건을 함께 올려야 열거 200건이 모두 포함됐지만, IP별 관계를 명시한 표에서는 위에서 두 번째 줄에 있다. 다만 두 표의 특징이 달라 이 차이를 집계 하나의 효과로만 해석할 수는 없다.

gap_cv 열이 이유를 보여 준다. 요청 20건 이상인 정상 IP 481개의 이 값은 중앙값이 1.59, 사분위 범위가 1.50~1.67이다. 생성기는 정상 사용자가 5초 만에 다음 링크를 누르기도 하고 2분을 머무르기도 하도록 만든 반면, 열거 공격은 약 17초 간격으로 만들었다. 그래서 열거 공격자의 gap_cv는 0.08이고, 정상 IP 가운데 이보다 낮은 값은 1개뿐이었다. path_ratio가 1.00인 것도 같은 방향이다 — 200번 요청하는 동안 같은 경로를 한 번도 다시 보지 않도록 생성했다.

다만 세션 단위가 전부를 잡은 것은 아니다. 이중 인코딩 공격자(203.0.113.50)는 57등이었다. 요청이 15건뿐이라 세션 지표가 거의 서지 않는다. 이 공격자는 시그니처가 이미 잡고 있으므로 실질적인 손해는 아니지만, 세션 단위가 요청 단위를 대체하는 것이 아니라는 근거로는 남는다.

4. 요청이 몇 건뿐인 IP를 그대로 세면

위 표의 7등부터가 걸린다. 요청이 2~5건인 정상 사용자들이 상위권에 올라와 있다.

원인은 gap_cv다. 요청이 3건이면 간격은 2개뿐이고, 그 2개로 계산한 표준편차는 통계라고 부르기 어렵다. 우연히 두 번 비슷한 간격으로 눌렀다는 이유로 ’지나치게 규칙적인 자동화’와 같은 자리에 놓인다.

요청 20건 이상인 IP만 남기고 다시 돌렸다.

IP 486개 (공격자 5개) — 이상한 순으로 상위 12개
  1 203.0.113.40       300     300   1.00   1.00      0.2     2.24  scan/공격/규칙O
  2 203.0.113.80       200     200   1.00   0.25     17.1     0.08  enum/공격/규칙X
  3 198.51.100.200     360       1   0.00   0.00     10.0     0.00  bot/정상/규칙X
  4 203.0.113.30        20       6   0.30   0.80     16.2     0.55  traversal/공격/규칙O
  5 198.51.100.201      94       8   0.09   0.00     38.6     0.32  bot/정상/규칙X
  6 203.0.113.10        40       5   0.12   0.25     11.5     0.47  sqli/공격/규칙O
  7 203.0.113.20        25       4   0.16   0.00     12.1     0.54  xss/공격/규칙O

상위 7개 안에 공격자 5개가 전부 들어왔고, 나머지 2개는 헬스체크와 크롤러다. 조사 대상 7건 중 5건이 진짜다.

남은 오탐 2건은 성격이 다르다. 10초마다 같은 경로를 360번 부르는 헬스체크가 ’이상하다’는 판정은 맞다. 정상인 이유는 데이터가 아니라 그것이 우리 것임을 우리가 알기 때문이고, 이 정보는 로그 안에 없다. 이런 오탐은 모델을 조절해서 줄이는 것보다 알려진 주체를 목록으로 빼는 쪽이 맞다.

무엇을 특징에 담을 것인가

네 번의 측정을 한 표로 놓으면 이렇다. 대상은 규칙에 안 걸리는 열거 공격 200건이다.

방식 단위 열거 공격을 잡으려면
통계 급증 (평균+2.5σ) 시간 창 어떤 창에서도 안 잡힌다. 총량을 안 바꾸는 공격이다
Isolation Forest 요청 전체의 20%(3827건)를 조사해야 200건이 다 나온다
Isolation Forest IP 세션 888개 중 2등
Isolation Forest IP 세션 (요청 20건 이상) 486개 중 2등, 상위 7개 중 5개가 공격자

뒤의 셋은 Isolation Forest를 썼다는 점은 같지만 요청 표와 세션 표의 특징 계산은 다르다. 확인된 것은 이 합성 공격의 관계 정보가 세션 특징표에 더 직접적으로 표현됐을 때 순위가 올라갔다는 데까지다. 그래서 이상 탐지를 붙일 때 알고리즘과 함께 물어야 할 것은 이쪽이었다.

찾으려는 이상이 현재 특징표에 담기는가.

담기면 요청 단위로 충분하다. 페이로드가 이상하게 긴 요청, 응답 크기가 튀는 요청은 한 줄에 다 적혀 있다. 이 실습의 요청 특징표에도 IP별 총요청 수는 있어 반복량 자체는 들어갔다. 다만 ‘고유 경로를 얼마나 순서대로 훑었는가’, ‘간격이 얼마나 일정한가’ 같은 관계는 별도 특징으로 만들지 않아 보이지 않았다. 세션 특징표는 그 관계를 unique_paths, path_ratio, gap_cv로 명시했다.

순서로 정리하면 이렇게 된다.

  1. 규칙으로 먼저 훑는다. 시그니처와 빈도가 잡는 것은 설명도 쉽고 비용도 싸다.
  2. 남은 것이 무엇인지 본다. 규칙이 다 잡고 있으면 이상 탐지를 붙여도 순증이 0이다.
  3. 기준선이 될 데이터에 공격이 얼마나 섞였는지 확인한다. 이상 탐지는 이상이 드물다는 전제 위에서 돈다.
  4. 찾으려는 이상을 어떤 특징으로 표현할지 정한다. 한 줄의 내용이면 요청 특징으로, 반복·순서·간격이면 여러 줄을 집계한 관계 특징으로 만든다.
  5. 집계 단위를 정했으면 표본이 부족한 행을 뺀다. 요청 3건짜리 세션의 간격 통계는 통계가 아니다.

3번과 5번은 이 실습에서 두 번 다시 만들게 만든 항목이다. 둘 다 모델을 건드리는 일이 아니라 모델에 넣기 전에 끝내는 일이다.

재확인 방법

아래 명령은 당시 로컬 실습 패키지에서 사용한 실행 기록이다. 이 블로그 저장소와 Git 이력에는 gen_log.py, parse.py, ml_detect.py가 포함되어 있지 않으므로 공개 저장소만으로 숫자를 다시 만들 수는 없다.

bash
python gen_log.py --users 800            # 열거 없음, 공격 400건
python gen_log.py --users 800 --enum     # 열거 추가: 원시 19155줄, 공격 600건(3.1%)
python parse.py   --users 800 --enum
python ml_detect.py --users 800 --enum   # 당시 파싱 성공 19134건으로 통계·요청/세션 IF

--enum 없이 돌리면 순증이 모든 contamination에서 0으로 나오는 첫 번째 표가 재현된다. --users 40 --enum으로 낮추면 공격 비율이 32%인 로그가 되고, 요청 단위 탐지가 눈에 띄게 나빠지는 것을 볼 수 있다. 측정 환경은 Python 3.14.6 / pandas 3.0.3 / scikit-learn 1.9.0이고 random_state=42를 고정했다.

숫자를 흔들어 보려면 열거 공격의 간격 randint(15, 19)를 넓히면 된다. 간격이 불규칙해질수록 gap_cv가 올라가고, 어느 지점부터 세션 단위에서도 사라진다.

직접 실행해 확인한 범위는 당시 로컬 패키지에서 합성 로그를 만들고 통계 급증 탐지와 두 특징표의 Isolation Forest 결과를 출력한 데까지다. 2026-08-09 정정에서는 그 패키지가 현재 저장소에 없어 수치를 다시 실행하지 못했고, 본문에 남은 표와 실행 기록 사이의 내부 일관성만 대조했다.

이 글에서 확인하지 못한 것은 세 가지다. 첫째, 여기 숫자는 전부 내가 설계한 로그의 성질이다 — 특히 열거 공격을 ‘규칙에 안 걸리게’ 만든 것이 나이므로 실제 공격이 이만큼 깨끗하게 숨는다는 뜻은 아니고, 공격 비율 3.1%도 현실의 어떤 사이트를 재현한 값이 아니다. 둘째, 세션을 IP 하나로만 잘랐다. 같은 사람이 IP를 바꾸거나 여러 사람이 한 IP를 쓰는 경우는 다루지 않았다. 셋째, 요청 표와 세션 표에서 같은 특징을 유지한 통제 실험을 하지 않았고, 규칙 결과를 임시 라벨로 삼는 방식도 시도하지 않았다. 정답표가 없는 환경에서 같은 결론이 나오는지는 확인하지 못했다.