2026년 10월 29일 목요일
미단재

AI개발 스캐너 IP 자동 차단: 404 3회 일배치와 즉시 차단, 오차단 방어까지

작성자 웅파 작성일 2026-10-29 15:44:13 조회수 6
시리즈 AI 시대 보안 점검 현재 위치 6 / 6
  1. "깃헙에 시크릿이 노출됐습니다" 메일을 받았다 — 진짜 위험은 따로 있었다
  2. 사진 동선 앱에서 남의 사진이 보였다 — 위치 데이터를 다룰 때 놓치기 쉬운 것
  3. 멀티테넌트 API에서 company_id를 요청이 아니라 JWT에서 뽑는 이유 — AI 보안 리뷰로 재처리 API 뜯어보기
  4. 남의 사이트에 붙는 채팅 위젯 설계: 보안 경계는 iframe 하나뿐이다 (설계 편)
  5. AI가 실제로 반복한 실수 모음 — 진짜 사고 편
  6. 스캐너 IP 자동 차단: 404 3회 일배치와 즉시 차단, 오차단 방어까지

시리즈 소개 보기

시리즈 블로그 기능 추가 현재 위치 5 / 9
  1. 블로그 기능을 하나씩 직접 만들기 ① 시리즈 내비게이션
  2. 블로그 글 목차(TOC)를 JS 70줄로 직접 만들기
  3. 블로그 기능을 하나씩 직접 만들기 3 — CodeIgniter 4로 RSS 피드 만들기
  4. 글마다 다른 OG 썸네일을 GD로 자동 생성하기 (CodeIgniter 4)
  5. 스캐너 IP 자동 차단: 404 3회 일배치와 즉시 차단, 오차단 방어까지
  6. 워드프레스 이관 후 깨진 이미지, 태그째 정리하기 (CodeIgniter 4)
  7. Groq로 글 태그·시리즈 자동 추천: 환각 필터와 호출 제한
  8. 테마 폴백으로 애드센스용 블로그 테마 4종과 자동광고 head 삽입 만들기
  9. 라이브러리 없이 마크다운 렌더러를 만들다 만난 XSS — 엔티티로 인코딩된 태그가 되살아났다

시리즈 소개 보기

요약: 공개 사이트에는 wp-login.php, .env 같은 경로를 훑는 스캐너가 계속 들어온다. 방문 로그의 404 기록으로 1시간 3회 이상 두드린 IP는 크론으로 30일 차단하고, 전형적인 스캐너 경로는 첫 요청에서 바로 차단하게 했다. Cloudflare 뒤에서 실제 IP를 받는 법과, 엣지 IP·방문자 브라우저·내 IP를 차단해 버리는 사고를 막는 장치도 함께 정리한다.

문제: 없는 주소만 골라 두드리는 요청

CodeIgniter 4로 만든 기업 사이트(CMS)에 방문 로그를 쌓고 있었다. 로그를 보면 이 사이트에 없는 wp-login.php, xmlrpc.php, .env, .git/config 같은 주소를 연달아 요청하는 IP가 있다. 취약한 WordPress나 노출된 비밀 파일을 찾는 스캐너다.

404로 끝나니 당장 피해는 없다. 하지만 요청마다 앱이 부팅되고 방문 로그가 쌓이며, 통계가 오염되고, 언젠가 진짜 취약점에 걸릴 수 있다. 그래서 반복해서 두드리는 IP는 아예 문을 닫기로 했다.

설계: 두 겹으로 막는다

404 반복은 일배치로, 스캐너 전형 경로는 즉시 차단해 blocked_ips에 쌓고 BlockedIpFilter가 403으로 막는 흐름

  • 일배치(느린 길): php spark ip:auto-block을 1시간마다 크론으로 돌린다. 최근 3시간 방문 로그에서 1시간 안에 404를 3회 이상 낸 IP를 30일 차단하고, 만료된 차단을 정리한다.
  • 즉시 차단(빠른 길): 404인데 경로가 스캐너 전형이면 크론을 기다리지 않고 그 요청에서 바로 차단한다.
  • 차단 집행: 전역 before 필터 BlockedIpFilter가 blocked_ips에 있는 IP의 모든 요청을 403으로 돌려보낸다.

일배치의 핵심은 임계값 세 개뿐이다.

private const LOOKBACK_HOURS = 3;     // 로그를 거슬러 볼 범위
private const THRESHOLD      = 3;     // 404 횟수
private const WINDOW_SECONDS = 3600;  // 이 시간 안에 THRESHOLD 이상이면 차단
# 서버 crontab: 1시간마다
0 * * * * cd /path/to/app && php spark ip:auto-block

처음에는 이 일배치만 있었다. 그런데 스캐너는 wp-login.php를 한 번만 찔러 보고 가는 경우도 있어서, 3회 조건에는 걸리지 않고 크론 주기만큼 늦기도 했다. 그래서 즉시 차단을 추가했다.

즉시 차단: 정규식 하나와 안전장치 셋

404 처리 지점에서 경로를 검사한다. 대상은 이 사이트에 존재하지 않는 서버 스크립트, WordPress, 비밀 파일 경로다.

private const SCANNER_PATH_PATTERN =
    '~(\.(php\d?|phtml|phar|asp|aspx|jsp|cgi)$|(^|/)(wp-admin|wp-login|xmlrpc|\.env|\.git)(/|\.|$))~i';

이 사이트는 PHP 파일을 URL로 직접 노출하지 않으므로 *.php 요청은 전부 정상 사용자의 것이 아니다. 다만 "스캐너처럼 보이는 경로"가 곧 "스캐너"는 아니라서 오차단을 막는 조건을 붙였다.

  1. 브라우저 요청은 제외한다. 다른 사이트가 <img src="https://내사이트/x.php">를 심어 두면 그 사이트 방문자의 브라우저가 우리에게 404를 낸다. 즉시 차단이 이걸 그대로 믿으면 무고한 방문자가 차단된다. 브라우저 요청에는 Referer 또는 Sec-Fetch-Site 헤더가 붙고 스캐너 도구는 보통 보내지 않으므로, 둘 중 하나라도 있으면 즉시 차단하지 않고 일배치(3회 조건)에 맡긴다.
  2. **admin/*은 제외한다.** 운영자가 관리자 경로를 잘못 입력해도 본인이 차단되면 안 된다.
  3. 정적 파일은 패턴에 넣지 않는다. wp-content/uploads/*.jpg 같은 주소는 WordPress에서 옮겨 온 옛 글의 깨진 링크일 수 있다.
private static function shouldBlock(IncomingRequest $request): bool
{
    $path = $request->getPath();

    return self::isScannerPath($path)
        && ! fnmatch('admin/*', trim($path, '/'))
        && $request->getHeaderLine('Referer') === ''
        && $request->getHeaderLine('Sec-Fetch-Site') === '';
}

한 가지 함정이 있었다. CI4에서 404는 라우터가 던진 예외로 처리되어 일반 after 필터가 돌지 않고, 라우트에 매칭되지 않는 주소는 before 필터도 돌지 않는다. 그래서 필터가 아니라 예외 핸들러(NotFoundVisitExceptionHandler)를 감싸는 방식으로 404를 가로채 차단과 방문 로그 표시를 함께 한다. 기존 핸들러에는 그대로 위임한다.

Cloudflare 뒤에서 진짜 IP 받기

IP로 차단하려면 IP가 정확해야 한다. 운영 서버는 Cloudflare 뒤에 있어서 REMOTE_ADDR가 접속자가 아니라 Cloudflare 엣지 IP다. 이걸 그대로 차단하면 같은 엣지를 거치는 모든 방문자가 막힌다.

CI4의 proxyIPs는 "이 대역에서 온 요청에 한해 지정한 헤더를 실제 IP로 신뢰한다"는 설정이다. Cloudflare 공개 대역을 CF-Connecting-IP에 매핑해 Config\App 생성자에서 등록했다.

// 운영 서버는 Cloudflare 뒤에 있다. 엣지 IP에서 온 요청만 CF-Connecting-IP를 신뢰한다.
// .env 등으로 proxyIPs를 직접 지정했으면 그 값을 우선한다.
if ($this->proxyIPs === []) {
    $this->proxyIPs = CloudflareIps::proxyMap();
}

Cloudflare 엣지 대역에서 온 요청만 CF-Connecting-IP를 접속자 IP로 신뢰하고, 그 밖의 곳에서 온 요청의 헤더는 무시한다

중요한 점은 대역 검사다. 아무 요청의 CF-Connecting-IP를 믿으면 공격자가 헤더에 임의의 IP를 적어 남을 차단시키거나 자기 차단을 회피할 수 있다. 엣지 대역에서 온 요청일 때만 헤더를 믿고, 그 밖에서 온 요청의 헤더는 무시된다. Cloudflare 대역은 바뀔 수 있으므로 CloudflareIps::RANGES를 갱신해야 한다.

차단하면 안 되는 IP

차단 로직을 한 곳(BlockedIpModel::block())에 모으고, 그 안에서 두 종류를 걸러 false를 돌려준다.

  • Cloudflare 엣지 IP: 프록시 설정이 어긋나 엣지 IP가 방문 로그에 찍히는 경우에도, 그 IP를 차단하면 정상 방문자가 줄줄이 막힌다. inet_pton으로 IPv4·IPv6를 모두 비트 단위 prefix로 비교한다.
  • 화이트리스트: .env의 security.ipWhitelist에 쉼표로 나열한 IP는 차단하지 않고, 기존 차단 행이 있어도 isBlocked()가 항상 false를 돌려준다. 사무실이나 운영자 IP가 실수로 막혀도 풀어 둘 필요가 없다.
public function block(string $ipAddress, string $reason): bool
{
    if (CloudflareIps::contains($ipAddress) || self::isWhitelisted($ipAddress)) {
        return false;
    }
    // ... 신규는 insert, 기존은 blocked_until만 30일 뒤로 연장
}

일배치 명령은 block()이 false를 주면 "건너뜀"으로 출력만 하고 넘어간다. 화이트리스트 IP 자체는 공개 저장소에 커밋하지 않고 서버 .env에만 둔다.

관리자 화면에는 일별 방문 기록의 IP별 수동 차단 버튼과, 차단 IP 목록·검색·해제 화면을 같이 만들었다. 자동 규칙이 틀렸을 때 사람이 바로 풀 수 있어야 한다.

배운 점

  • 차단 규칙의 설계는 오차단 방어가 절반이다. 막는 조건보다 "누구를 막으면 안 되는가"(엣지 IP, 브라우저 경유 요청, 관리자, 내 IP)를 먼저 적었다.
  • 즉시 차단은 일배치의 보완이다. 확실한 신호(스캐너 전형 경로)만 즉시 처리하고, 애매한 신호는 횟수 임계값으로 판단하게 나누니 규칙이 단순해졌다.
  • IP 신뢰는 헤더가 아니라 출처 대역으로 정한다. CF-Connecting-IP는 엣지에서 온 요청에서만 믿는다.
  • 프레임워크의 필터 실행 순서를 확인한다. 404는 after 필터가, 라우트 미매칭은 before 필터가 돌지 않아 예외 핸들러에서 처리해야 했다.
  • 차단 기간은 30일로 두고, 같은 IP가 다시 걸리면 기간만 연장한다. 만료된 행은 일배치가 정리한다.

댓글 1개

웅파
2026-10-29 15:44

하루에 30개 정도 아이피가 차단 됨