집 서버에 WAF/IPS를 직접 짓다 — nginx + CrowdSec 4계층 방어 구축 리서치

2026-05-22·1분 읽기·

집 서버에 WAF/IPS를 직접 짓다 — nginx + CrowdSec 4계층 방어 구축 리서치

zino.kr은 집에 있는 미니 PC 한 대다. 8일치 nginx 로그를 까보니 16만 요청 중 2만 건 넘게 공격이었다. CrowdSec(IPS) + AppSec(WAF) + AbuseIPDB(평판)로 4계층 심층 방어를 직접 구축하고, 실제 공격 로그에서 커스텀 룰을 뽑고, 8일치 로그를 리플레이해 검증하고, 관제 대시보드까지 만든 전 과정 기록.

1. 서론 — 내 서버는 매일 공격받고 있다

zino.kr은 거창한 인프라가 아니다.

집에 있는 미니 PC 한 대. 그 위에서 nginx가 리버스 프록시로 돌고, 그 뒤에 Next.js 앱이 pm2로 떠 있다. 그게 전부다.

그런데 이 작은 서버도 인터넷에 노출된 이상 24시간 두들겨 맞는다.

막연히 알고는 있었다. 하지만 정확히 얼마나, 그리고 어떻게 맞는지는 제대로 본 적이 없었다. 그래서 먼저 8일치 nginx 액세스 로그를 통째로 분석했다.

결과는 명확했다.

zino.kr 가상호스트로 들어온 요청이 153,638건. 거기에 도메인도 없이 IP로 직접 찔러본 스캔이 10,101건. 그중 공격 시그니처에 걸리는 요청이 2만 건을 넘었다.

일자별 공격 시그니처 탐지 추이 — 8일 내내 하루도 빠짐없이 공격이 들어온다
8일 중 단 하루도 공격이 0인 날이 없다. 5월 14일은 3,675건까지 치솟았다.

기존에 방어가 아예 없던 건 아니다.

  • AbuseIPDB IP 블로커 — 15분마다 cron이 nginx 로그를 분석해, 짧은 시간에 많이 때린 IP를 AbuseIPDB로 조회한다. 신뢰도 70점 이상이면 nginx geo 모듈로 403 처리한다. (예전에 만들어 둔 것이고, playground에 모니터링 페이지도 있다.)
  • fail2ban — SSH 무차별 대입만 막는다.

문제는 이 구성이 "이미 평판이 나쁜 IP""SSH 브루트포스" 만 막는다는 것이다.

정작 웹으로 들어오는 공격 — SQL 인젝션, 경로 순회, .env 수집, 알려진 CVE 정찰 — 은 그냥 통과했다. 요청 내용을 들여다보는 계층이 아예 없었기 때문이다.

다행히 Next.js 앱이라 PHP 익스플로잇이 먹히진 않았다. 하지만 "안 맞아서 다행"과 "막아서 안전함"은 다르다.

이 글은 그래서 WAF(웹 방화벽)와 IPS(침입 차단 시스템)를 집 서버에 직접 구축한 기록이다.

남이 만든 매니지드 서비스를 붙이는 게 아니다. 오픈소스로 self-hosted 구성을 짜고, 내 실제 공격 로그에서 룰을 뽑고, 그게 진짜 막는지 검증하고, 마지막으로 무엇이 잡혔는지 한눈에 보는 관제 대시보드까지 만들었다.


2. 공격 지형 분석 — 로그가 먼저 말하게 한다

룰을 만들기 전에 데이터를 봐야 한다.

막연한 "보안 모범사례"가 아니라, 내 서버에 실제로 들어온 공격이 룰의 근거가 되어야 한다.

nginx 액세스 로그는 combined 포맷이다. 하지만 공격 트래픽은 요청 줄에 따옴표·제어문자·raw 바이트를 욱여넣기 때문에 단순 awk로는 파싱이 깨진다. 그래서 Python으로 견고한 파서를 짰다.

LOG_RE = re.compile(
    r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
    r'"(?P<req>(?:[^"\\]|\\.)*)" (?P<status>\d{3}) (?P<size>\d+) '
    r'"(?P<ref>(?:[^"\\]|\\.)*)" "(?P<ua>(?:[^"\\]|\\.)*)"'
)

8일치 로그를 이 파서로 돌리고, 공격 유형별 시그니처 정규식으로 분류했다.

zino.kr 공격 유형 분포 — 관리자 페이지 정찰 9,727건이 1위
관리자 정찰·WordPress 공격·비밀파일 수집이 압도적. SQL 인젝션은 30개 IP가 1,720번 시도했다.

상태 코드 분포도 의미심장했다.

정상 200이 10만 건. 그런데 404가 19,746건, 400(형식 오류·프로토콜 공격)이 6,919건이었다. 400이 저렇게 많다는 건 정상 브라우저가 아닌 무언가가 쏘고 있다는 뜻이다.

HTTP 메서드를 까보니 답이 나왔다.

GET       145,194
POST        2,272
PROPFIND      228   ← WebDAV 스캔
CONNECT       132   ← 오픈 프록시 악용 시도
\x05\x02\x00\x02  78   ← raw SOCKS 프록시 프로브
\x03\x00\x00/*\xE0...Cookie:  29   ← RDP/프로토콜 혼동 공격

PROPFIND, CONNECT, 그리고 아예 HTTP도 아닌 raw 바이트열. 이런 건 정상 사용자가 절대 보내지 않는다.

기억에 남는 공격자들

숫자만큼이나 누가 때리는지도 봤다.

  • 122.136.188.132 — 8일간 3,121회. 이미 nginx config에 수동으로 deny 박아둔 IP였다. 손으로 막은 걸 자동화로 넘길 때가 됐다는 신호다.
  • 185.215.167.53 — XSS·경로순회·SQLi를 한 IP가 전부 시도한 멀티벡터 퍼징 스캐너. ?message=%3Cscript%3Ealert(document.domain)%3C/script%3E 같은 걸 수백 개 경로에 뿌렸다.
  • 93.123.109.10.git/config/, /admin/, /backup/, /app/, /public/ … 수십 개 디렉터리 프리픽스에 붙여가며 긁었다.
  • 208.84.100.117/device.rsp?opt=sys&cmd=___S_O_S_T_R_E_A_MAX___... — Hikvision DVR 원격코드실행. 봇넷이 IoT 기기를 모집하는 전형적 패턴이다.
  • 154.19.37.43 — WordPress 플러그인 대상 시간 기반 블라인드 SQLi. ?id=1+AND+(SELECT+1+FROM+(SELECT(SLEEP(6)))A) 를 알려진 취약 플러그인 엔드포인트에 정밀 타격했다.

이 분석에서 나온 핵심 통찰

가장 중요한 발견은 따로 있었다.

zino.kr은 Next.js 앱이다. PHP를 단 한 줄도 서빙하지 않는다. Java 백엔드도, 노출된 .git 디렉터리도 없다.

그렇다면 /admin.php, /.git/config, /actuator/heapdump, /boaform/... 같은 요청은 — 정상일 가능성이 0% 다.

일반적인 WAF 룰은 "SQLi처럼 보이는 패턴"을 휴리스틱으로 잡느라 오탐과 싸운다. 하지만 "이 사이트엔 절대 존재하지 않는 것"의 목록은 그 자체로 오탐 없는 완벽한 룰이 된다.

이 통찰이 6장 커스텀 룰의 토대가 된다.

한 가지 주의할 함정도 있었다. /wp-content/uploads/2022/09/*.png 같은 경로가 로그에 잔뜩 찍힌다. 이건 공격이 아니라 — 이 사이트가 예전에 WordPress였던 시절의 정상 이미지 URL이고, SEO 크롤러가 아직도 인덱스를 따라 들어오는 것이다. "wp-content가 보이면 차단" 같은 거친 룰을 짰다면 멀쩡한 크롤러를 막을 뻔했다. 룰은 .php 확장자를 노려야지, wp-content 경로를 노리면 안 된다.


3. 설계 — 4계층 심층 방어

공격 지형을 알았으니 방어를 설계한다.

핵심 원칙은 심층 방어(defense in depth) 다. 하나의 만능 방패가 아니라, 서로 다른 원리로 동작하는 여러 계층을 겹쳐서 한 계층이 놓쳐도 다음이 잡게 한다.

zino.kr 4계층 방어 아키텍처 — nginx 4관문과 CrowdSec 엔진
들어오는 요청은 nginx 안에서 4개의 관문을 통과한다. 오른쪽 CrowdSec 엔진이 '무엇을 막을지' 결정한다.
계층기술막는 것원리
L1 엣지nginx비정상 메서드·과도한 요청형식 검사
L2 IP 평판AbuseIPDB이미 평판이 나쁜 IP평판 DB
L3 IPSCrowdSec행동으로 들킨 IP행위 분석
L4 WAFCrowdSec AppSec요청 내용이 공격인 것시그니처

네 계층은 던지는 질문이 다르다.

L1은 "형식이 맞나?", L2는 "이 IP, 전과 있나?", L3은 "이 IP, 지금 수상하게 구나?", L4는 "이 요청 내용 자체가 공격인가?"를 묻는다.

그래서 한 계층을 우회해도 다른 계층의 질문에는 걸린다.

왜 CrowdSec인가

L3와 L4를 무엇으로 구현할지가 핵심 결정이었다.

후보는 ModSecurity(전통적 WAF), SafeLine(독립형 WAF), CrowdSec였다. CrowdSec 단일 스택을 골랐다.

  • 이미 있는 nginx 로그를 그대로 먹는다. 엣지 구조를 갈아엎을 필요가 없다. (SafeLine은 80/443을 가로채는 프록시라 프로덕션 엣지 수술이 필요했다.)
  • AppSec 컴포넌트로 IPS와 WAF를 한 시스템에서 처리한다.
  • 커뮤니티 인텔리전스(CAPI) — 전 세계 CrowdSec 사용자가 공유하는 악성 IP 블록리스트를 받는다. 남이 당한 공격자를 내가 미리 막는 집단 면역이다.
  • cscli로 데이터가 깔끔하게 빠져서 관제 대시보드 만들기 좋다.

⚠️ 한 가지 미리 못 박아둘 것. 호스트 단 WAF/IPS는 볼류메트릭 DDoS는 못 막는다. 회선 자체가 포화되면 nginx 앞단에서 할 수 있는 게 없다. 그건 Cloudflare 같은 업스트림의 영역이고, 이 글의 범위 밖이다. 여기서 막는 건 "지능형 공격"이지 "물량 공세"가 아니다.


4. L3 구축 — CrowdSec IPS

설치: 버전 함정

Ubuntu 25.10 universe에도 crowdsec가 있다.

하지만 1.4.6 — AppSec(WAF) 기능이 들어오기 버전이다. WAF까지 쓰려면 1.5+ 가 필요하므로 CrowdSec 공식 저장소를 추가해 1.7.8을 받았다.

curl -s https://install.crowdsec.net | sudo sh
sudo apt-get install -y crowdsec

포트 충돌

설치하자마자 첫 번째 벽에 부딪혔다.

CrowdSec LAPI(Local API)의 기본 포트는 127.0.0.1:8080 인데, 이미 Docker 컨테이너 하나가 docker-proxy로 8080을 점유하고 있었다.

Not attempting to start crowdsec, port 8080 is already used

설정 두 파일에서 LAPI 포트를 비어 있는 8459로 옮겼다.

sudo sed -i 's|listen_uri: 127.0.0.1:8080|listen_uri: 127.0.0.1:8459|' \
  /etc/crowdsec/config.yaml
sudo sed -i 's|url: http://127.0.0.1:8080|url: http://127.0.0.1:8459|' \
  /etc/crowdsec/local_api_credentials.yaml

차단 집행 — firewall bouncer

CrowdSec 엔진은 판단만 한다. 실제 차단은 "바운서(bouncer)"라는 별도 컴포넌트가 한다.

이 역할 분리가 CrowdSec 설계의 핵심이다. nftables 바운서를 설치했다.

sudo apt-get install -y crowdsec-firewall-bouncer-nftables

여기서 두 번째 함정.

바운서 설치 스크립트가 중간에 실패하면서 설정 파일에 API 키가 api_key: ${API_KEY} — 치환 안 된 문자 그대로 남았다. 당연히 인증이 깨졌다.

바운서를 다시 등록하고 키를 직접 주입해서 해결했다.

KEY=$(sudo cscli bouncers add cs-firewall-bouncer -o raw)
sudo sed -i "s|api_key: .*|api_key: ${KEY}|" \
  /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml

기존 방화벽은 UFW(nftables ip filter 테이블)를 쓰고 있었다. 다행히 CrowdSec 바운서는 자기 전용 테이블(crowdsec)을 따로 만들기 때문에 충돌 없이 공존했다.

자기 자신을 차단하지 않기

IPS를 켜기 전 반드시 해야 할 일 — 화이트리스트.

crowdsecurity/whitelists 파서가 사설 IP 대역(127.0.0.1, 172.16.0.0/12 등)을 탐지 단계에서 제외한다. 이게 없으면 검증하다가 자기 서버나 LAN을 차단해 락아웃될 수 있다.

로그 분석에서 본 172.30.1.254(내부 모니터링 LAN IP)도 이걸로 보호된다.

결과

엔진이 뜨자 nginx 양쪽 가상호스트 로그를 실시간으로 파싱하기 시작했고, 63개 시나리오가 로드됐다.

CAPI 커뮤니티 블록리스트도 연동돼 약 15,000개의 알려진 악성 IP가 즉시 nftables 차단 목록에 올라갔다.

아직 단 한 줄의 룰도 직접 안 썼는데 1.5만 개를 막고 시작하는 셈이다.


5. L4 구축 — AppSec WAF

IPS는 로그를 보고 사후에 IP를 막는다.

WAF는 요청을 통과시키기 전에 내용을 검사해 그 요청 하나를 막는다. CrowdSec의 AppSec 컴포넌트가 이 역할이다.

Lua 모듈 — 재컴파일 없이

AppSec가 인라인으로 동작하려면 nginx가 각 요청을 AppSec 엔진으로 보내야 하고, 그러려면 nginx에 Lua가 필요하다.

nginx를 재컴파일하거나 OpenResty로 갈아탈 각오를 했다. 다행히 Ubuntu가 동적 모듈을 패키지로 제공했다.

sudo apt-get install -y libnginx-mod-http-lua   # 재컴파일 불필요
sudo apt-get install -y crowdsec-nginx-bouncer

nginx -t로 검증하며 한 단계씩 진행했다. 프로덕션 엣지를 건드리는 가장 위험한 구간이라, 매 변경마다 설정 테스트를 먼저 했다.

AppSec 컴포넌트 배선

AppSec를 127.0.0.1:7422에서 HTTP 엔드포인트로 띄우고, nginx 바운서가 모든 요청을 이리로 보내 검사받게 했다.

룰은 188개 — CVE 가상패칭 룰과 제네릭 공격 룰이다.

검증 먼저, 차단은 나중에

여기서 운영 판단이 들어간다.

AppSec 기본 설정은 모든 룰을 inband(즉시 차단)로 돌린다. 하지만 제네릭 SQLi/XSS 휴리스틱 룰은 Next.js의 RSC 페이로드나 playground 페이지에서 오탐을 낼 수 있다.

그래서 커스텀 설정을 만들어 단계를 나눴다.

# /etc/crowdsec/appsec-configs/zino.yaml
name: zinosec/appsec
default_remediation: ban
inband_rules:                       # 즉시 차단
  - crowdsecurity/base-config
  - crowdsecurity/vpatch-*          # CVE 가상패칭 — 저오탐, 바로 차단
outofband_rules:                    # 탐지만 (차단 안 함)
  - crowdsecurity/generic-*         # SQLi/XSS 휴리스틱 — 검증 전까지 관찰

CVE 가상패칭 룰은 특정 익스플로잇 시그니처를 정밀 매칭하므로 오탐이 거의 없어 바로 inband로 뒀다.

제네릭 룰은 일단 outofband(탐지 전용)로 두고 관찰한다.

첫 차단 — 실증

WAF가 진짜 막는지 확인할 차례.

inband 룰 중 GET 한 방으로 트리거되는 게 PHPUnit RCE(CVE-2017-9841)다.

$ curl -sk -o /dev/null -w '%{http_code}\n' \
    "https://zino.kr/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php"
403
  1. 응답 본문은 CrowdSec Ban 페이지였다.

AppSec 메트릭에 vpatch-CVE-2017-9841 트리거가 집계됐다. WAF가 인라인으로 작동한다.


6. 커스텀 룰 — 내 로그에서 룰을 만든다

2장의 핵심 통찰을 떠올리자.

zino.kr은 Next.js 앱이라 PHP·Java 스택·.git 디렉터리가 아예 없다. 이 사실을 룰로 만들면, 일반 WAF가 오탐과 씨름하는 영역을 제로 오탐으로 틀어막을 수 있다.

먼저 appsec-vpatch 시나리오의 동작을 확인했다.

이 시나리오는 log_type == 'appsec-block' 인 모든 DSL 룰 이벤트를 capacity=1로 잡아 즉시 IP 밴 결정으로 에스컬레이션한다. 즉 — 내 커스텀 룰이 요청 하나를 막으면, 그 IP는 자동으로 방화벽에서 통째로 차단된다.

룰만 잘 쓰면 단발 차단이 IP 밴까지 이어진다는 뜻이다.

네 개의 커스텀 AppSec 룰을 작성했다. 전부 zino.kr 로그 분석이 근거다.

# zinosec/no-php-execution — php_probe 827건이 근거
# 이 사이트는 Next.js. .php 요청은 100% 정찰/공격.
name: zinosec/no-php-execution
rules:
  - zones: [URI]
    transform: [lowercase]
    match:
      type: regex
      value: '\.php[0-9]?($|[/?#])'
근거 (로그)막는 것
no-php-executionphp_probe 827모든 .php 요청
no-server-secretsenv/git 3,219.git·.env·.aws·.ssh·백업파일
iot-router-rceshell_rce 351/boaform·device.rsp·/cgi-bin/·luci ;stok=
java-stack-probelog4j_ssrf 55/actuator·/druid·/solr·/jolokia

오탐 검증 — 차단과 통과를 직접 확인한다

룰을 켜고 끝낼 게 아니다.

실제로 공격은 막고 정상은 통과시키는지를 눈으로 확인해야 한다. 공격 9종과 함정성 정상 요청을 함께 쐈다.

── 커스텀 룰 차단 검증 (기대: 403) ──
  /admin.php                                403  ✓
  /.git/config                              403  ✓
  /.env.local                               403  ✓
  /boaform/admin/formLogin                  403  ✓
  /cgi-bin/luci/;stok=/locale               403  ✓
  /actuator/heapdump                        403  ✓
  /druid/index.html                         403  ✓
 
── 정상 트래픽 오탐 검증 (기대: 통과) ──
  /                                         200  ✓
  /blog                                     200  ✓
  /research                                 200  ✓
  /wp-content/uploads/2022/09/test.png      404  ✓  (구 WP 이미지 — 403 아님)
  /blog/actuators-and-php-guide             404  ✓  (slug에 php/actuator 포함 — 오탐 없음)

마지막 두 줄이 중요하다.

구 WordPress 시절 이미지 경로, 그리고 슬러그에 php·actuator 단어가 들어간 가상의 블로그 글 — 둘 다 403이 아니라 404로 통과했다.

룰의 정규식이 .php 확장자/actuator 경로 경계를 정확히 노렸기 때문이다. 거친 부분 문자열 매칭이었다면 여기서 오탐이 났을 것이다.

공격 9종 전부 차단, 정상 트래픽 오탐 0건. 그제서야 이 룰들을 inband(즉시 차단)로 승격했다.


7. L1 엣지 하드닝 + L2 통합

비정상 메서드 차단

2장에서 본 PROPFIND·CONNECT·raw 프로토콜 공격.

이건 CrowdSec까지 갈 것도 없이 nginx에서 가장 싸게 막는다.

# zinosec: 비정상 HTTP 메서드 차단
if ($request_method !~ ^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$) {
    return 405;
}

여기에 DoS 백스톱으로 limit_req(10r/s, burst 50)를 더했다.

정상 사용자나 대시보드 폴링에는 영향이 없을 만큼 넉넉하게 잡았다. rate limit은 무딘 도구라, 정밀 차단은 CrowdSec에 맡기고 nginx는 명백한 폭주만 잡게 했다.

작은 함정: 설정 백업 파일을 sites-enabled/에 두면 nginx가 그것까지 로드해서 duplicate upstream 에러가 난다. 백업은 반드시 로드 경로 밖에 둘 것.

AbuseIPDB를 L2로 편입

기존 AbuseIPDB IP 블로커는 버리지 않았다.

L2 평판 계층으로 설계에 정식 편입했다. CrowdSec(L3)이 행위로 잡는다면, AbuseIPDB(L2)는 평판으로 잡는다 — 원리가 다르니 서로를 보완한다.

둘 다 남긴 이유다.


8. 검증 — 8일치 로그를 리플레이하다

룰과 시나리오가 다 갖춰졌다.

그런데 "앞으로 들어올 공격"을 막는 건 알겠는데, "이미 들어왔던 공격"을 이 시스템이 잡아냈을지 어떻게 확인하나?

8일치 과거 로그를 CrowdSec에 그대로 리플레이했다.

sudo crowdsec -dsn "file:///tmp/zino_all.log" -type nginx

함정: CrowdSec 1.7.8의 -dsn 모드는 자체 LAPI를 띄우려다 8459 포트와 충돌한다. 서비스를 잠깐 내리고(바운서는 passthrough로 안전) 같은 DB를 향해 리플레이한 뒤 복구했다.

결과: CrowdSec가 8일치 로그에서 공격 경보 439건을 잡아냈다.

157  http-bad-user-agent          (악성 User-Agent)
 58  http-probing                 (사이트 스캐닝)
 55  http-admin-interface-probing (관리자 페이지 정찰)
 53  http-cve-2021-41773/42013    (Apache 경로 순회)
 33  http-technology-probing
 21  http-wordpress-scan
 13  http-backdoors-attempts
 13  http-sensitive-files

WAF 쪽도 따로 검증했다.

공격 15종을 모아 테스트 배터리를 돌렸다 — /admin.php, /.git/config, /boaform/..., /actuator/heapdump, PHPUnit RCE 등. 15종 전부 403 차단. AppSec 엔진 메트릭에 15 처리 / 15 차단으로 정확히 집계됐다.

이 리플레이는 두 가지를 동시에 증명했다.

하나, 이 시스템을 진작 켰다면 그 439건은 전부 잡혔다. 둘, 대시보드에 보여줄 실제 데이터가 생겼다.


9. 관제 대시보드 — 무엇이 잡혔는지 한눈에

방어를 구축했으면, 무엇을 막고 있는지 볼 수 있어야 한다.

안 보이는 방어는 운영되지 않는다. 그래서 playground에 통합 보안 관제 대시보드를 만들었다 — 기존 IP 블로커 모니터링과 통합해서.

데이터 파이프라인

cscli는 root 권한이 필요하고, 홈페이지 프로세스는 jinho 권한으로 돈다.

그래서 기존 AbuseIPDB 블로커와 똑같은 패턴을 썼다 — root cron이 데이터를 JSON 파일로 덤프하고, Next.js가 그 파일을 읽는다.

cscli (root) ──2분 cron──▶ /var/cache/crowdsec-dashboard/state.json

                          /api/security 라우트가 읽음

                          /playground/security 페이지가 시각화

설계 노트 하나.

CrowdSec의 실시간 메트릭(시나리오·룰 카운터)은 프로세스 메모리 기반이라 재시작하면 0으로 초기화된다. 그래서 "무엇을 탐지했는가"는 휘발성 메트릭이 아니라 DB에 영속되는 alert 레코드에서 집계하도록 export 스크립트를 짰다.

대시보드

보안 관제 센터 대시보드 — 4계층 방어 흐름도와 공격 피드
상단의 4계층 흐름도는 각 계층이 지금 무엇을 막고 있는지 실시간 수치로 보여준다.

맨 위에 4계층 방어 흐름도를 뒀다 — L1 엣지 / L2 IP평판 / L3 IPS / L4 WAF.

각 계층의 라이브 수치를 띄우고, 그 아래 KPI 카드, 공격 출발 국가 분포, 그리고 탭으로 나뉜 상세 패널을 배치했다.

WAF 탭 — 커스텀 룰 4종이 보라색으로 강조되어 트리거 횟수와 함께 표시된다
WAF 탭. 직접 만든 zinosec 커스텀 룰 4종이 보라색으로 강조되고, CVE 가상패칭 룰과 함께 트리거 횟수가 집계된다.
IPS 탭 — 시나리오별 탐지 막대 그래프와 최다 공격 출발지 IP 테이블
IPS 탭. 8일치 로그 리플레이로 잡힌 시나리오별 탐지 분포와 최다 공격 IP.

공격 피드 탭은 IPS·WAF 경보를 시간순으로 흘려보여서, 지금 이 순간 누가 어디를 찌르고 있는지 보인다.

IP 평판 탭은 기존 AbuseIPDB 차단 목록이다. 한 화면 안에서 4계층이 전부 통합됐다.

대시보드는 여기서 직접 볼 수 있다 → /playground/security


10. 마치며 — 직접 확인하고 차단한다는 것

미니 PC 한 대짜리 집 서버에 4계층 방어가 섰다.

  • L1 nginx — 비정상 메서드·과도 요청을 405/rate limit으로
  • L2 AbuseIPDB — 평판 70+ IP를 403으로 (15분 주기)
  • L3 CrowdSec IPS — 행위 탐지 + 커뮤니티 블록리스트 1.5만 IP를 nftables drop
  • L4 AppSec WAF — CVE 가상패칭 + 커스텀 룰 4종으로 요청 인라인 차단

8일치 로그 리플레이로 439건의 과거 공격이 탐지 가능했음을 확인했다. WAF 공격 테스트는 15종 전부 차단, 커스텀 룰은 오탐 0건을 검증했다.

가장 크게 배운 건 따로 있다.

보안 룰은 일반론이 아니라 내 데이터에서 나와야 한다는 것. "PHP를 안 쓴다"는 내 스택의 사실 하나가, 일반 WAF가 오탐과 씨름하는 PHP 정찰 차단을 제로 오탐으로 끝냈다.

남의 모범사례를 복붙하는 것보다 내 로그 8일치를 파싱하는 게 더 정확한 룰을 줬다.

물론 한계는 분명하다.

이 구성은 볼류메트릭 DDoS는 못 막는다. 회선을 포화시키는 물량 공세는 업스트림(Cloudflare 등)의 영역이고, 호스트 단 방어가 다루는 건 지능형 공격이다.

그리고 WAF 룰은 살아있는 시스템이다. 오탐이 나오면 튜닝하고, 새 공격 패턴이 보이면 룰을 더해야 한다. 한 번 짓고 끝나는 게 아니다.

그래서 대시보드를 만들었다.

무엇이 들어오고 무엇이 막히는지 보이지 않으면, 방어는 그냥 켜둔 채 잊혀진다. 이제 관제 센터를 열면 내 서버가 지금 이 순간 무엇과 싸우고 있는지 보인다.

그게 이 프로젝트의 진짜 결과물이다.

ShareX

이 글이 도움이 됐나요?