자동화 slecs

공격 패턴 감지 시 블랙리스트 자동 등록으로 보안 필터 강화

목차

운영 서버 보안 필터를 손본 건 이번이 처음은 아닌데, 이번 작업은 블랙리스트 등록을 수동에서 DB 자동화로 전환하는 게 핵심이었다. 규모 자체는 크지 않았지만, 그동안 "누가 공격 패턴으로 걸렸을 때 누군가가 직접 IP를 등록해줘야 한다"는 구조가 계속 마음에 걸렸던 터라 정리할 필요가 있었다.

왜 지금이었나

블랙리스트 수동 등록 구조는 소규모일 때는 별 문제가 없다. 공격 시도가 산발적이면 그냥 로그 보고 등록하면 그만이다. 문제는 스캐너 봇들이 /admin, /.env, /wp-login.php 같은 경로를 조직적으로 훑기 시작하면 대응 속도가 따라가질 않는다는 거다. 로그에 같은 IP 패턴이 수십 번 찍혀도 사람이 보기 전까지 필터는 그냥 통과시킨다.

Rate Limit 임계값도 한동안 완화 상태로 뒀던 게 이번에 같이 조여졌다. 예전에 정상 사용자 트래픽이 많은 구간에 임계값을 낮게 잡았다가 정상 요청이 튕기는 문제가 있었고, 그 이후로 느슨하게 유지해왔는데 지금 트래픽 패턴을 다시 보니 충분히 강화해도 될 여유가 생긴 상황이었다.

처리 흐름과 변경 내용

필터 전체 흐름은 아래와 같다.

요청 수신
  → 화이트리스트 IP 확인
  → 봇 UA / 의심 경로 감지
  → Rate Limit 체크 (Redis 슬라이딩 윈도우)
  → 차단 / 블랙리스트 등록 / 통과

단계별로 보면 화이트리스트 확인이 제일 앞에 있는 게 중요하다. 이후 단계에서 모니터링 IP나 내부 서비스 IP가 잘못 걸리면 운영 전체에 영향이 생기기 때문에, 신뢰할 수 있는 IP는 아무것도 체크하지 않고 먼저 통과시킨다.

이번 변경을 요약하면:

항목 기존 변경 후
Rate Limit 임계값 완화 상태 강화 (1분 기준 하향)
공격 패턴 감지 기본 UA/경로 의심 경로 + 페이로드 확장
블랙리스트 등록 수동 감지 즉시 DB 자동 등록
AJAX 요청 Rate Limit 카운트 포함 카운트 제외 옵션 추가

블랙리스트 자동 등록은 감지 즉시 DB에 쓰고, 이후 요청에서 화이트리스트 확인 다음에 바로 블랙리스트 조회가 붙는 구조다. Redis 캐싱 레이어를 사이에 두면 DB 직접 조회 없이 처리할 수 있어서 성능 부담도 거의 없다.

의심 경로/페이로드 감지 쪽은 정규식 기반으로 관리하는 게 유지보수가 편하다. 패턴을 코드에 하드코딩하면 추가할 때마다 배포가 필요하지만, DB나 설정 파일로 빼두면 재시작 없이 패턴을 추가할 수 있다. 이번에 그 방향으로 구조를 가져갔다.

# 의심 경로 패턴 예시 (정규식 기반)
SUSPICIOUS_PATHS = [
    r"^/\.env",
    r"^/wp-(?:login|admin)",
    r"^/admin(?:/|$)",
    r"\.php$",
    r"/\.\./",       # path traversal
]

PAYLOAD_PATTERNS = [
    r"(?:union\s+select|select\s+.*from)",   # SQLi
    r"<script[\s>]",                          # XSS
    r"(?:eval|exec|system)\s*\(",             # RCE
]

패턴이 많아질수록 정규식 컴파일 비용이 누적되니, 서버 시작 시점에 한 번 컴파일해서 캐싱해두는 게 기본이다.

예외 경로와 AJAX 처리

예외 경로 처리에서 실수가 나기 쉬운 부분이 있다. 헬스체크 엔드포인트(/health, /ping 류)나 메트릭 수집 경로를 필터에 넣으면 모니터링 알림이 오발되거나, 로드밸런서의 헬스체크가 실패해서 서버가 트래픽에서 빠지는 일이 생긴다. 당연한 얘기처럼 들리지만, Rate Limit 강화할 때 이 경로들을 빼먹는 경우가 의외로 많다. 화이트리스트 IP보다 경로 기반 예외가 더 관리하기 불편한데, 경로는 인증 없이 접근 가능한 상태로 두는 거라 범위를 좁게 잡아야 한다.

AJAX 요청을 Rate Limit 카운트에서 제외한 건 트레이드오프가 있다. 페이지 로드 시 AJAX가 여러 번 나가는 구조에서 동일 임계값을 적용하면 정상 사용자가 조금만 빠르게 클릭해도 차단될 수 있다. 반면 AJAX를 무조건 제외하면 AJAX 엔드포인트만 집중 공격하는 패턴이 뚫릴 수 있다. 이번에는 AJAX를 카운트에서 제외하되, 의심 경로/페이로드 감지는 동일하게 적용하는 방식으로 타협했다.

Redis 슬라이딩 윈도우

Rate Limit 구현에서 슬라이딩 윈도우를 쓰는 이유는 고정 윈도우의 경계 구간 문제 때문이다. 고정 윈도우는 창이 리셋되는 경계 직전/직후에 요청을 몰아 보내면 실제로는 2배 가까운 요청을 허용하는 구간이 생긴다. 슬라이딩 윈도우는 이 문제가 없다.

다만 Redis를 Rate Limit 단일 의존성으로 쓸 때 챙겨야 할 게 있다. Redis 연결이 끊기거나 응답이 느려질 때 어떻게 할지 정해야 한다. "fail open"(Redis 죽으면 그냥 통과)이냐 "fail closed"(Redis 죽으면 전부 차단)냐의 선택인데, 보안 레이어를 운영 중단보다 우선시할 거라면 fail open으로 가면서 Redis 이중화를 챙기는 게 현실적이다. 연결 풀 크기와 타임아웃을 타이트하게 잡아두지 않으면 Redis 레이턴시 스파이크가 앱 전체 레이턴시를 끌어올리는 일도 생긴다.


작업 자체는 반나절 분량이었지만, 이번에 정리해두지 않으면 다음에 공격 패턴이 들어왔을 때 또 수동으로 대응하는 상황이 반복됐을 거다. 블랙리스트 자동화 하나로 대응 루프에서 사람이 빠지는 구조가 됐고, 그게 이번 작업의 실질적인 가치였다.

댓글 0

첫 댓글 달아줘.