WAF를 간단히 실습해 보려고 Docker로 띄울 방법을 찾았는데, 웹에서 쓸 만한 레퍼런스를 끝내 찾지 못했다. 혹시나 해서 docker search modsecurity를 쳐 봤고, 거기서 실마리가 나왔다.

이 글은 ModSecurity 컨테이너 하나를 띄워 SQL Injection 요청을 한 번 보내 보는 짧은 실습이다. 요청이 막히는 것 자체보다 막힌 이유가 로그에 어떻게 찍히는지를 중심으로 정리했다.

WAF와 ModSecurity

WAF(Web Application Firewall)는 XSS, SQL Injection, Command Execute처럼 웹 서비스를 통해 들어오는 공격을 막는 보안 모듈이다. 포트와 IP를 보는 네트워크 방화벽과 달리 HTTP 요청의 내용을 보고 판단하는 애플리케이션 계층(L7) 방화벽이다.

ModSecurity는 오픈소스 WAF다. 웹 클라이언트와 웹 서버 사이에서 요청을 검사하고, 악의적인 트래픽이면 차단이나 로깅처럼 미리 정의한 행동을 한다. 원래는 Apache 웹서버의 모듈로 시작했지만, 이번에 띄운 컨테이너의 로그를 보면 ModSecurity v3.0.12가 ModSecurity-nginx v1.0.3 connector를 통해 nginx에 붙어 있다. 엔진은 규칙을 실행할 뿐이고, 무엇을 막을지는 규칙 세트가 정한다. 이미지를 고를 때 규칙이 들어 있는지부터 본 이유다.

규칙 세트가 들어 있는 이미지를 골랐다

docker search modsecurity 결과에서 관련 있어 보이는 이미지는 두 개였다. owasp/modsecurity는 설명에 “No CRS"가 붙어 있었고, owasp/modsecurity-crs는 OWASP Core Rule Set(CRS)이 함께 들어 있는 이미지였다. 엔진만 있는 이미지로는 막을 규칙이 없으니 후자를 골랐다.

owasp/modsecurity-crs의 nginx 태그 이미지를 받아 컨테이너를 띄웠는데 첫 실행에서는 접속이 되지 않았다. 확인해 보니 컨테이너는 8080 포트를 리스닝하고 있었고, 포트 매핑을 8080:8080으로 고쳐 다시 띄웠다.

# 기존 컨테이너 정리
docker stop modsecurity-crs
docker rm modsecurity-crs

# 컨테이너가 8080을 리스닝하므로 호스트 8080 → 컨테이너 8080으로 매핑
docker run -d -p 8080:8080 --name modsecurity-crs owasp/modsecurity-crs:nginx

localhost:8080에 접속하면 nginx의 “An error occurred.” 페이지가 뜬다. WAF 뒤에 연결한 백엔드가 없으니 에러 페이지가 나오는 게 맞다. 여기서 확인할 것은 컨테이너가 요청을 받아 응답한다는 사실 하나였다.

SQL Injection 한 줄을 보내 봤다

다른 터미널에서 docker logs -f modsecurity-crs로 로그를 띄워 두고, 쿼리 스트링에 전형적인 SQL Injection 문자열을 넣어 요청했다.

curl "http://localhost:8080/?test='OR'1'='1"

응답은 403이었고, 로그에는 이렇게 찍혔다.

2024/07/03 16:36:38 [error] 98#98: *43 [client 172.17.0.1] ModSecurity: Access denied with code 403 (phase 2). Matched "Operator `Ge' with parameter `5' against variable `TX:BLOCKING_INBOUND_ANOMALY_SCORE' (Value: `5' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "222"] [id "949110"] [rev ""] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [data ""] [severity "0"] [ver "OWASP_CRS/4.3.0"] [maturity "0"] [accuracy "0"] [tag "modsecurity"] [tag "anomaly-evaluation"] [tag "OWASP_CRS"] [hostname "172.17.0.2"] [uri "/"] [unique_id "172002459857.523994"] [ref ""], client: 172.17.0.1, server: localhost, request: "GET /?test='OR'1'='1 HTTP/1.1", host: "localhost:8080"
172.17.0.1 - - [03/Jul/2024:16:36:38 +0000] "GET /?test='OR'1'='1 HTTP/1.1" 403 146 "-" "curl/8.4.0" "-"
{"transaction":{"client_ip":"172.17.0.1","time_stamp":"Wed Jul  3 16:36:38 2024","server_id":"c670a1574c42004e6043ee8f3dd5301d3cdc8e0c","client_port":59482,"host_ip":"172.17.0.2","host_port":8080,"unique_id":"172002459857.523994","request":{"method":"GET","http_version":1.1,"uri":"/?test='OR'1'='1","headers":{"Host":"localhost:8080","User-Agent":"curl/8.4.0","Accept":"*/*"}},"response":{"body":"<html>\r\n<head><title>403 Forbidden</title></head>\r\n<body>\r\n<center><h1>403 Forbidden</h1></center>\r\n<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n","http_code":403,"headers":{"Server":"nginx","Date":"Wed, 03 Jul 2024 16:36:38 GMT","Content-Length":"146","Content-Type":"text/html","Connection":"keep-alive"}},"producer":{"modsecurity":"ModSecurity v3.0.12 (Linux)","connector":"ModSecurity-nginx v1.0.3","secrules_engine":"Enabled","components":["OWASP_CRS/4.3.0\""]},"messages":[{"message":"SQL Injection Attack Detected via libinjection","details":{"match":"detected SQLi using libinjection.","reference":"v11,9","ruleId":"942100","file":"/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf","lineNumber":"46","data":"Matched Data: s&sos found within ARGS:test: 'OR'1'='1","severity":"2","ver":"OWASP_CRS/4.3.0","rev":"","tags":["application-multi","language-multi","platform-multi","attack-sqli","paranoia-level/1","OWASP_CRS","capec/1000/152/248/66","PCI/6.5.2"],"maturity":"0","accuracy":"0"}},{"message":"Inbound Anomaly Score Exceeded (Total Score: 5)","details":{"match":"Matched \"Operator `Ge' with parameter `5' against variable `TX:BLOCKING_INBOUND_ANOMALY_SCORE' (Value: `5' )","reference":"","ruleId":"949110","file":"/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf","lineNumber":"222","data":"","severity":"0","ver":"OWASP_CRS/4.3.0","rev":"","tags":["modsecurity","anomaly-evaluation","OWASP_CRS"],"maturity":"0","accuracy":"0"}}]}}

로그의 두 규칙이 말해 주는 것

긴 로그지만 messages 안의 규칙 두 개만 보면 된다.

규칙 ID파일한 일
942100REQUEST-942-APPLICATION-ATTACK-SQLI.conflibinjection으로 ARGS:test 값 'OR'1'='1에서 SQL Injection 패턴(s&sos)을 탐지
949110REQUEST-949-BLOCKING-EVALUATION.conf누적 점수 TX:BLOCKING_INBOUND_ANOMALY_SCORE가 5 이상(Ge 5)이라 403으로 차단

942100은 요청을 막지 않았다. 탐지하고 점수를 올렸을 뿐이다. 실제로 막은 것은 그 뒤에 도는 949110이고, 이 규칙은 앞선 규칙들이 쌓은 점수가 임계값 5 이상인지만 본다. CRS는 규칙 하나가 걸렸다고 막지 않고, 규칙들이 쌓은 점수가 임계값에 이르면 막는다. 이번에는 942100 하나가 5점을 채워 Total Score: 5로 차단됐다.

이 구조 덕분에 오탐이 나는 규칙이 있으면 그 규칙만 끄는 대신 임계값을 조정하는 선택지가 생긴다. 942100 태그의 paranoia-level/1도 같은 맥락이다. 탐지 강도를 단계로 나눠 두고, 기본값은 가장 느슨한 1단계다.

실습은 여기까지다. 요청 하나가 막히는 걸 본 것에 가깝고, 실제 서비스 앞에 붙이는 구성은 다음 글에서 이어간다.

정리

  • ModSecurity는 엔진이고, 무엇을 막을지는 OWASP CRS 같은 규칙 세트가 정한다. 그래서 owasp/modsecurity-crs 이미지를 골랐다.

  • 차단은 탐지 규칙이 아니라 점수 평가 규칙(949110)이 한다. 로그에서 942100과 949110을 나눠 읽으면 왜 막혔는지가 보인다.

  • 이미지의 nginx는 8080을 리스닝한다. 포트 매핑을 8080:8080으로 맞춰야 한다.

참고