127.0.0.1 - - [04/Jul/2024:23:46:02 +0900] "GET /?data=test HTTP/1.1" 403 162 "-" "curl/8.5.0"
127.0.0.1 - - [04/Jul/2024:23:46:08 +0900] "GET /?data=hello HTTP/1.1" 200 615 "-" "curl/8.5.0"

쿼리 파라미터 하나만 다른 두 요청이다. test를 보낸 쪽은 403, hello를 보낸 쪽은 200을 받았다. 앞 글에서는 ModSecurity가 무엇인지 개념만 짚었다. 이번에는 실제로 요청을 막는 장면까지 가 봤다.

전편 “ModSecurity. Opensource Web Application Firewall” 링크: https://jaylab.blog/modsecurity-waf-docker-setting/

이 글에서는 Ubuntu 컨테이너 하나에 Nginx와 ModSecurity를 깔고, 위 로그가 나오기까지와 막힌 요청이 audit log에 어떻게 남는지를 정리한다.

Ubuntu 컨테이너 하나에 전부 올린다

구성은 단순하다. Ubuntu 컨테이너(A)를 하나 띄우고, 그 안에 앞서 다뤘던 Nginx Proxy Manager를 구성한 뒤 ModSecurity를 설치한다.

# 우분투 컨테이너 실행
docker run -it --name my-ubuntu-container ubuntu:latest /bin/bash

# NGINX Proxy Manager 설치
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -

# apt install -y nodejs

ModSecurity는 Nginx 모듈과 룰셋, 두 패키지다

ModSecurity 관련 패키지를 찾아보고, Nginx용 모듈과 OWASP CRS(Core Rule Set)를 설치한다. 엔진(libnginx-mod-http-modsecurity)과 규칙(modsecurity-crs)은 따로 온다. 엔진만 있으면 아무것도 막지 않고, 규칙만 있으면 읽어 줄 쪽이 없다.

# ModSecurity 설치
apt-cache search modsecurity
apt install libnginx-mod-http-modsecurity
apt install modsecurity-crs
apt install vim

ls /usr/share/modsecurity-crs/
# [실행결과]
# owasp-crs.load  rules  uti

cat /usr/share/modsecurity-crs/owasp-crs.load
# [실행결과]
# ##
# ## This file loads OWASP CRS's rules when the package is installed
# ## It is Included by libapache2-mod-security2
# ##
# Include /etc/modsecurity/crs/crs-setup.conf
# IncludeOptional /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
# Include /usr/share/modsecurity-crs/rules/*.conf
# IncludeOptional /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf

owasp-crs.load에 규칙이 직접 들어 있지는 않다. Include 지시어로 규칙 파일들을 가리킬 뿐이다. 설정(crs-setup.conf) → 사용자 예외 → 본 규칙(rules/*.conf) → 응답 쪽 예외 순서다.

Nginx를 띄우고 로그를 열어 둔다

# nginx 설치
apt install nginx

# nginx 실행
nginx
# [실행결과]
# nginx is running

# nginx 프로세스 확인1
ps aux | grep nginx

# nginx 프로세스 확인2
service nginx status

# 80포트 리스닝 중인지 확인
ss -tuln | grep 80

# log 확인하기
tail -f /var/log/nginx/error.log
tail -f /var/log/nginx/access.log

tail -f가 터미널을 붙잡고 있으니, 요청은 다른 터미널에서 같은 컨테이너로 들어가서 보낸다.

# 다른 터미널에서 우분투 컨테이너 진입
docker exec -it my-ubuntu-container /bin/bash

# Nginx 상태 확인
curl -I http://localhost
# 우분투 컨테이너에서 access log 확인 -> 정상

test가 들어간 요청만 403을 받는다

tail -f로 지켜본 access log다. HEAD는 curl -I, GET은 그냥 curl로 보낸 요청이다.

127.0.0.1 - - [04/Jul/2024:23:45:31 +0900] "HEAD /?data=test HTTP/1.1" 403 0 "-" "curl/8.5.0"
127.0.0.1 - - [04/Jul/2024:23:45:55 +0900] "HEAD /?data=hello HTTP/1.1" 200 0 "-" "curl/8.5.0"
127.0.0.1 - - [04/Jul/2024:23:46:02 +0900] "GET /?data=test HTTP/1.1" 403 162 "-" "curl/8.5.0"
127.0.0.1 - - [04/Jul/2024:23:46:08 +0900] "GET /?data=hello HTTP/1.1" 200 615 "-" "curl/8.5.0"

메서드와 상관없이 data=test면 403, data=hello면 200이다. 여기까지는 Nginx access log라서 “막혔다"는 사실만 보인다. 왜 막혔는지는 ModSecurity의 audit log에 따로 남는다.

audit log는 막힌 요청만 기록한다

audit log 경로는 /etc/nginx/modsecurity.conf의 SecAuditLog 항목에서 확인할 수 있다.

경로는 /var/log/nginx/modsec_audit.log였다. 이 파일을 tail -f로 열어 두면, 규칙에 걸리지 않는 요청을 보낼 때는 아무것도 찍히지 않다가 규칙에 걸리는 요청이 들어오는 순간 아래 로그가 나온다.

---AU9HhbBg---A--
[04/Jul/2024:23:51:11 +0900] 172010467164.403561 127.0.0.1 57376 127.0.0.1 80
---AU9HhbBg---B--
HEAD /?data=test HTTP/1.1
Host: localhost
User-Agent: curl/8.5.0
Accept: */*

---AU9HhbBg---D--

---AU9HhbBg---F--
HTTP/1.1 403

---AU9HhbBg---H--
ModSecurity: Access denied with code 403 (phase 1). Matched "Operator `Contains' with parameter `test' against variable `ARGS:data' (Value: `test' ) [file "/etc/nginx/modsec/modsec_test.conf"] [line "1"] [id "1234"] [rev ""] [msg "param contains test"] [data ""] [severity "0"] [ver ""] [maturity "0"] [accuracy "0"] [hostname "127.0.0.1"] [uri "/"] [unique_id "172010467164.403561"] [ref "o0,4v12,4"]

---AU9HhbBg---I--

---AU9HhbBg---J--

---AU9HhbBg---Z--

---AU9HhbBg---X-- 줄이 섹션 구분자이고, 뒤 글자가 스크린샷의 SecAuditLogParts ABIJDEFHZ와 대응한다. 읽을 곳은 세 군데다.

  • B: 들어온 요청 헤더. HEAD /?data=test

  • F: 돌려준 응답 헤더. HTTP/1.1 403

  • H: 판정 이유. /etc/nginx/modsec/modsec_test.conf 1번째 줄의 규칙 id 1234가 ARGS:data 값에 test가 포함된 것을 보고(Contains) phase 1에서 403으로 끊었다. 메시지는 param contains test.

phase 1은 요청 헤더를 받은 직후 단계다. 쿼리 스트링은 요청 줄에 들어 있으니 본문을 읽기도 전에 판정이 끝난다.

테스트한 건 직접 만든 규칙 하나다

403이 나왔다고 WAF가 다 된 것은 아니다. 이번에 확인한 것은 modsec_test.conf에 둔 테스트 규칙(id 1234) 하나가 걸리는 장면이다. 앞에서 설치한 OWASP CRS의 규칙이 SQL Injection이나 XSS 같은 실제 공격 패턴을 막는지는 아직 보지 않았다. owasp-crs.load의 주석에도 “libapache2-mod-security2에서 Include된다"고 적혀 있으니 Nginx 쪽에서 CRS를 읽게 하는 연결은 따로 확인해야 한다.

컨테이너를 이미지로 굳힐 때는 Nginx가 포그라운드에서 돌아야 컨테이너가 바로 종료되지 않는다. 아래 둘 중 하나를 쓴다.

# 컨테이너 시작시 Nginx가 포그라운드에서 사용될 수 있도록 하는 명령어들(아래 중 한가지 선택)
CMD ["nginx", "-g", "daemon off;"]   # Dockerfile에 쓸 때
nginx -g 'daemon off;'               # 쉘에서 직접 띄울 때

정리

  • ModSecurity는 엔진과 규칙이 따로 온다. Nginx 모듈과 CRS를 각각 깔고 연결해야 한다.

  • access log는 403이라는 결과만, audit log는 어느 규칙이 왜 막았는지를 보여준다.

  • 이번에 막힌 건 테스트 규칙 하나다. CRS 규칙 검증은 다음 단계다.

?data=test 하나 막는 데 생각보다 많은 파일을 거쳤다. 그래도 audit log의 H 섹션을 한 번 읽어 두면, 나중에 CRS가 정상 요청을 막았을 때 어디부터 볼지는 안다.

참고자료