공격 패턴 감지 시 블랙리스트 자동 등록으로 보안 필터 강화
목차
운영 서버 보안 필터를 손본 건 이번이 처음은 아닌데, 이번 작업은 블랙리스트 등록을 수동에서 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
첫 댓글 달아줘.