참조: fail2ban 공식 GitHub · Proper fail2ban configuration (공식 Wiki)

1. 왜 fail2ban을 도입했는가

이 블로그 서버는 인터넷에 공개되어 있어 지속적으로 다양한 자동화 스캐닝 공격에 노출됩니다. 기존에는 문제가 있는 IP를 발견할 때마다 nginx 설정에 deny 지시자를 수동으로 추가하고 있었습니다.

# 기존 방식 — 수동으로 IP를 하나씩 추가해야 했음
location / {
    deny 82.165.66.87;
    deny 20.151.201.236;
    ...
}

이 방식은 다음과 같은 문제가 있었습니다.

  • 반응이 느림: 공격을 발견하고, 로그를 확인하고, 직접 추가해야 함
  • 유지보수 부담: 차단 IP가 늘어날수록 nginx 설정이 지저분해짐
  • 해제 불가: 차단 기간 개념 없이 영구 차단만 가능

fail2ban은 이 과정을 자동화합니다. 로그를 실시간으로 감시하다가 일정 횟수 이상 공격 패턴이 감지되면 방화벽(iptables/nftables) 레벨에서 자동으로 IP를 차단하고, 설정한 시간이 지나면 자동으로 해제합니다.

2. fail2ban이란

“Fail2Ban scans log files like /var/log/auth.log and bans IP addresses conducting too many failed login attempts.” — fail2ban GitHub README

fail2ban은 Python으로 작성된 보안 데몬입니다. 동작 원리는 다음과 같습니다.

로그 파일 감시 → 패턴 매칭(regex) → 임계값 초과 시 방화벽 규칙 추가 → 시간 경과 후 자동 해제

주요 특징:

  • SSH, nginx, Apache 등 어떤 서비스의 로그도 감시 가능
  • iptables, nftables, ufw 등 다양한 방화벽 백엔드 지원
  • 차단 시간(bantime)이 지나면 자동으로 차단 해제
  • Python 3.5 이상 필요, 현재 stable 버전: 1.1.0 (2024년 4월 릴리즈)

nginx deny vs fail2ban 차단의 차이

nginx deny는 nginx 프로세스가 요청을 받은 뒤 거절하는 방식입니다. fail2ban은 iptables/nftables 레벨, 즉 nginx에 도달하기 전 커널 단에서 차단하므로 더 효율적입니다.

3. 핵심 개념 — Filter · Jail · Action

fail2ban은 세 가지 핵심 요소로 구성됩니다.

Filter (필터)

로그에서 공격 패턴을 인식하는 정규표현식 규칙입니다. /etc/fail2ban/filter.d/ 디렉토리에 .conf 파일로 저장합니다.

[Definition]
failregex = ^<HOST> .* "POST /api/auth/login HTTP/[^"]+" 429
  • <HOST>: fail2ban이 자동으로 IP 주소를 캡처하는 특수 토큰
  • failregex: 이 패턴에 매칭되면 "실패 시도 1회"로 카운트

Jail (잠금)

“어떤 로그를, 어떤 필터로, 얼마나 보고, 어떻게 차단할지” 를 정의하는 단위입니다. /etc/fail2ban/jail.d/ 디렉토리에 .conf 파일로 저장합니다.

지시자 의미 예시
enabled jail 활성화 여부 true
filter 사용할 filter 이름 nginx-login-bruteforce
logpath 감시할 로그 파일 경로 /var/log/nginx/hgkimer.me.access.log
maxretry 차단까지 허용하는 실패 횟수 5
findtime 실패 횟수를 카운트하는 시간 창(초) 60
bantime 차단 지속 시간(초) 3600
ignoreip 절대 차단하지 않을 IP 목록 127.0.0.1/8 ::1 <본인IP>

예시: maxretry=5, findtime=60 이면 "60초 이내에 5번 실패하면 차단"을 의미합니다.

Action (액션)

차단이 결정되었을 때 실제로 무엇을 할지 정의합니다. 기본값은 iptables/nftables로 해당 IP의 연결을 차단하는 것이며, 별도 설정이 없으면 jail.localbanaction 기본값을 사용합니다. 이 프로젝트에서는 기본 액션(iptables 차단)을 그대로 사용합니다.

4. 설치

Debian/Ubuntu 기준으로 설명합니다.

# 패키지 설치
sudo apt update
sudo apt install -y fail2ban

설치 후 /etc/fail2ban/ 디렉토리가 생성됩니다.

/etc/fail2ban/
├── fail2ban.conf       # fail2ban 데몬 설정
├── jail.conf           # 기본 jail 설정 (수정 금지)
├── jail.local          # 사용자 jail 설정 (여기에 작성)
├── filter.d/           # 필터 파일 모음
└── jail.d/             # jail 파일 모음 (여기에 작성 권장)

중요: 공식 문서는 .conf 파일을 직접 수정하지 말고 .local 파일을 만들어 덮어쓸 것을 강력히 권장합니다. 패키지 업그레이드 시 .conf 파일은 덮어쓰여지지만 .local 파일은 보존됩니다.

# jail.conf를 직접 수정하지 않고 복사본을 생성
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

# 서비스 활성화 및 시작
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

5. 설정 파일 구조 이해

fail2ban 설정은 INI 파일 형식을 사용하며, [섹션명] 단위로 구분됩니다.

[DEFAULT]           # 모든 jail에 공통 적용되는 기본값
ignoreip = 127.0.0.1/8

[jail-name]         # 개별 jail 설정 (DEFAULT를 덮어씀)
enabled  = true
filter   = my-filter
logpath  = /var/log/myapp.log
maxretry = 5

[DEFAULT] 섹션에 설정한 값은 모든 jail의 기본값이 됩니다. 개별 jail에서 같은 키를 설정하면 [DEFAULT] 값을 덮어씁니다.

6. 이 프로젝트에서의 적용

6.1 nginx 로그 설정

fail2ban이 로그를 읽으려면 nginx가 로그를 명시적인 경로에 기록해야 합니다. 기존 nginx/hgkimer.me.conf에는 access_log 지시자가 없어 nginx 기본 로그(/var/log/nginx/access.log)에만 기록되고 있었습니다. 사이트별로 구분된 로그 파일을 갖도록 명시적으로 경로를 지정했습니다.

# nginx/hgkimer.me.conf
server {
    server_name hgkimer.me www.hgkimer.me;

    access_log /var/log/nginx/hgkimer.me.access.log combined;
    error_log  /var/log/nginx/hgkimer.me.error.log warn;

    ...
}

combined는 nginx의 기본 로그 포맷으로, 한 줄의 형태가 다음과 같습니다.

1.2.3.4 - - [17/Apr/2026:12:00:00 +0900] "POST /api/auth/login HTTP/1.1" 429 162 "-" "curl/7.88"

fail2ban의 <HOST> 토큰은 이 형식에서 맨 앞의 IP(1.2.3.4)를 자동으로 추출합니다.

6.2 Filter 작성

Filter 1 — 로그인 무차별 대입 공격 감지

파일: fail2ban/filter.d/nginx-login-bruteforce.conf

[Definition]
failregex = ^<HOST> .* "POST /api/auth/login HTTP/[^"]+" 429
            ^<HOST> .* "POST /admin/login HTTP/[^"]+" 429
ignoreregex =

이 필터가 필요한 이유:

저는 이미 nginx 설정에서 로그인 경로에 limit_req(rate limiting)가 적용되어 있어, 단시간에 요청이 몰리면 429(Too Many Requests)를 반환하도록 해놓았습니다.

# nginx/hgkimer.me.conf — 이미 적용된 rate limiting
location = /api/auth/login {
    limit_req zone=login_limit burst=10 nodelay;
    limit_req_status 429;
    ...
}

429 응답이 발생했다는 것은 rate limit을 초과한 공격 시도가 있었다는 의미입니다. 이 패턴을 감지해 IP를 차단합니다.

  • ^<HOST>: 로그 라인 시작의 IP 주소 캡처
  • HTTP/[^"]+: HTTP 버전(1.0, 1.1, 2.0 등)을 유연하게 매칭
  • 429: rate limit 초과 응답 코드

Filter 2 — 악성 URI 스캐닝 감지

파일: fail2ban/filter.d/nginx-bad-uri.conf

[Definition]
failregex = ^<HOST> .* ".*" 444
ignoreregex =

이 필터가 필요한 이유:

기존에 제 서버에는 nginx/bad_uri.conf에는 WordPress 공격, 환경설정 파일 탐색, Log4j 취약점 시도 등 수십 가지 악성 URI 패턴이 정의하여 include 해 놓았습니다.

# nginx/bad_uri.conf — 악성 URI 패턴 정의 (일부)
map $request_uri $bad_word {
    ~*(\.php|wp-includes|wp-login|wp-admin) 1;
    ~*(\.env|\.git|DS_Store)               1;
    ~*(ldap|jndi|dns)                      1;  # Log4j
    ...
}

이 패턴에 매칭된 요청은 nginx가 444(연결 즉시 종료)를 반환합니다.

# nginx/hgkimer.me.conf
if ($bad_word) {
    return 444;
}

444 응답은 명백한 악의적 스캐닝을 의미하므로, 단 3회만 탐지되어도 24시간 차단합니다.

6.3 Jail 설정

파일: fail2ban/jail.d/nginx-hgkimer.conf

[DEFAULT]
# 자기 차단 방지 — 본인 공인 IP 반드시 추가
ignoreip = 127.0.0.1/8 ::1 <본인_공인IP>

[nginx-login-bruteforce]
enabled  = true
filter   = nginx-login-bruteforce
logpath  = /var/log/nginx/hgkimer.me.access.log
maxretry = 5
findtime = 60
bantime  = 3600

[nginx-bad-uri]
enabled  = true
filter   = nginx-bad-uri
logpath  = /var/log/nginx/hgkimer.me.access.log
maxretry = 3
findtime = 60
bantime  = 86400

설계 의도:

Jail maxretry findtime bantime 이유
nginx-login-bruteforce 5회 60초 1시간 실수로 비밀번호를 틀릴 수 있어 여유를 둠
nginx-bad-uri 3회 60초 24시간 악성 스캐닝은 정상 사용자가 발생시킬 수 없음

ignoreip를 반드시 설정해야 하는 이유:

관리자가 잘못된 비밀번호를 여러 번 입력하거나, 브라우저 캐시 등으로 인해 본인 IP가 차단될 수 있습니다. 차단되면 서버에 접속 자체가 불가능해지므로, ignoreip에 본인 공인 IP를 반드시 추가해야 합니다.

7. 서버 적용 순서

# 1. fail2ban 설치
sudo apt update && sudo apt install -y fail2ban
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

# 2. filter/jail 파일 복사 (레포 루트에서 실행)
sudo cp fail2ban/filter.d/nginx-login-bruteforce.conf /etc/fail2ban/filter.d/
sudo cp fail2ban/filter.d/nginx-bad-uri.conf          /etc/fail2ban/filter.d/
sudo cp fail2ban/jail.d/nginx-hgkimer.conf            /etc/fail2ban/jail.d/

# 3. jail.d 파일에서 본인 IP 교체
sudo nano /etc/fail2ban/jail.d/nginx-hgkimer.conf
# <본인_공인IP> → 실제 공인 IP로 수정

# 4. nginx 설정 업데이트 및 재로드
sudo cp nginx/hgkimer.me.conf /etc/nginx/sites-available/hgkimer.me
sudo nginx -t && sudo systemctl reload nginx

# 5. fail2ban 시작
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

8. 동작 확인

# 전체 jail 목록 및 상태 확인
sudo fail2ban-client status

# 개별 jail 상태 확인 (차단된 IP 목록 포함)
sudo fail2ban-client status nginx-login-bruteforce
sudo fail2ban-client status nginx-bad-uri

# filter 정규표현식이 로그를 올바르게 파싱하는지 테스트
sudo fail2ban-regex /var/log/nginx/hgkimer.me.access.log \
  /etc/fail2ban/filter.d/nginx-login-bruteforce.conf

sudo fail2ban-regex /var/log/nginx/hgkimer.me.access.log \
  /etc/fail2ban/filter.d/nginx-bad-uri.conf

# 특정 IP를 수동으로 차단 해제
sudo fail2ban-client set nginx-bad-uri unbanip <IP주소>

# fail2ban 설정 재로드 (서비스 재시작 없이)
sudo fail2ban-client reload

fail2ban-regex 명령의 출력 마지막에 Lines: N matched, M missed 형태로 결과가 나옵니다.

실제 로그파일에 우리가 설정한 조건에 맞는 접속 로그가 있는 상태에서 matched 수가 0이 아니면 필터가 로그를 정상적으로 인식하는 것입니다.