본문 바로가기
Nginx

Nginx 보안 설정 실무 - server_tokens, TLS, 보안 헤더, limit_req, 444 차단

강철지그·2026년 8월 13일·조회 2

새 서버를 인터넷에 열어두고 access log를 지켜보면 몇 분 지나지 않아 낯선 요청이 쌓이기 시작한다. wp-login.php, .env, phpMyAdmin처럼 있지도 않은 경로를 두드리는 자동 스캐너들이다. 워드프레스를 쓰지 않는 서버에도 어김없이 찾아오는 걸 보면, 이런 스캔은 피할 수 있는 일이 아니라 전제로 깔고 설정해야 하는 대상이다.

결론부터 말하면, Nginx에서 server_tokens로 버전 노출을 끄고, TLS는 1.2와 1.3만 허용하고, HSTS를 포함한 보안 헤더를 일관되게 내려주고, limit_req로 요청 속도를 제한하고, 명백한 악성 요청은 444로 끊는다. 이 다섯 가지만 적용해도 자동화된 스캔과 저강도 공격 대부분은 로그 한 줄로 끝난다.

1. 개요

이 글의 기준 환경은 Ubuntu 22.04에 배포판 패키지로 설치한 Nginx 1.24 이상이다. 설정 파일 경로는 /etc/nginx/nginx.conf와 /etc/nginx/conf.d/ 기준으로 적었고, 다른 배포판이면 경로만 바꾸면 같은 내용이 적용된다.

범위를 먼저 분명히 하자. 여기서 다루는 것은 웹서버 자체의 방어선이다. SQL 인젝션 같은 애플리케이션 취약점은 코드와 WAF의 영역이고, Nginx 설정으로는 공격자가 정보를 수집하고 자동화 도구를 돌리는 비용을 올리는 것이 목표다. 모든 변경은 nginx -t로 문법을 검증한 뒤 systemctl reload nginx로 적용한다. reload는 기존 연결을 끊지 않으므로 서비스 중에 적용해도 된다.

2. 버전 정보 노출 줄이기 - server_tokens

기본 설정의 Nginx는 응답 헤더와 오류 페이지에 자기 버전을 그대로 적어 보낸다. 직접 확인해 보면 이렇다.

$ curl -sI https://example.com | grep -i server
Server: nginx/1.24.0

공격자는 이 버전 문자열을 취약점 데이터베이스와 대조하는 것으로 정찰을 시작한다. http 블록에 한 줄이면 꺼진다.

# /etc/nginx/nginx.conf
http {
    server_tokens off;
}
$ sudo nginx -t && sudo systemctl reload nginx
$ curl -sI https://example.com | grep -i server
Server: nginx

버전 번호가 사라지고 404 같은 기본 오류 페이지 하단의 서명에서도 함께 빠진다. 다만 Server: nginx라는 이름까지 지우려면 headers-more-nginx-module 같은 서드파티 모듈을 넣어 다시 빌드해야 한다. 패키지 업데이트를 포기하면서까지 이름을 숨길 가치는 없다고 봐서, 저는 여기까지만 적용합니다.

3. TLS 프로토콜과 암호 스위트

TLS 1.0과 1.1은 이미 주요 브라우저가 지원을 끊은 구식 프로토콜이라 열어둘 이유가 없다. 1.2와 1.3만 남기고, 암호 스위트는 Mozilla가 관리하는 intermediate 프로파일을 그대로 가져오는 것이 검증된 선택이다.

# /etc/nginx/conf.d/ssl.conf 또는 server 블록
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;

두 가지를 짚어두면, ssl_prefer_server_ciphers off는 최신 클라이언트가 자기에게 유리한 암호를 고르게 두라는 뜻으로 intermediate 프로파일의 권장값이다. 그리고 ssl_ciphers 지시자는 TLS 1.2까지만 적용되고 TLS 1.3의 암호 스위트는 별도 기본값으로 협상된다. 1.3 쪽은 손댈 필요가 없다.

certbot으로 인증서를 받았다면 /etc/letsencrypt/options-ssl-nginx.conf가 이미 include되어 있을 수 있다. 같은 지시자가 두 곳에 있으면 기동이 실패하므로, 어느 쪽에서 관리할지 한 곳으로 정리한다.

4. 보안 헤더와 add_header 상속 함정

브라우저 쪽 방어는 응답 헤더로 지시한다. HSTS(Strict-Transport-Security)는 이 도메인에 앞으로 HTTPS로만 접속하라고 브라우저에 기억시키는 헤더고, X-Content-Type-Options는 브라우저가 Content-Type을 멋대로 추측해 실행하는 것을 막는다. X-Frame-Options는 내 페이지를 남의 iframe에 넣는 클릭재킹을 차단한다.

# server 블록 (HTTPS 쪽)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000" always;

always를 붙여야 404나 500 같은 오류 응답에도 헤더가 실린다. HSTS는 되돌리기 어려운 헤더라서 처음에는 max-age=300처럼 짧게 걸어 며칠 지켜본 뒤 1년으로 올리는 순서를 권한다. includeSubDomains는 모든 서브도메인이 HTTPS라는 확신이 있을 때만 붙인다. 내부용 서브도메인 하나가 HTTP로 남아 있으면 그대로 접속 불능이 된다.

여기에 잘 알려진 함정이 하나 있다. 정적 파일 location에 캐시 헤더를 하나 추가했더니, 그 경로의 응답에서 위 보안 헤더가 전부 사라졌다. add_header는 현재 레벨에 지시자가 하나라도 있으면 상위 레벨 것을 아예 상속하지 않기 때문이다. 문서에 명시된 동작인데 처음 겪으면 당황하게 된다.

# 함정: 이 location 응답에는 보안 헤더가 하나도 안 실린다
location ~* \.(css|js|png)$ {
    add_header Cache-Control "public, max-age=31536000";
}

해결은 공통 헤더를 별도 파일로 빼서 add_header를 쓰는 모든 레벨에 include하는 것이다. 이렇게 바꾸고 나면 성공합니다.

# /etc/nginx/snippets/security-headers.conf 로 분리
# server 블록과 add_header를 쓰는 각 location에 include
location ~* \.(css|js|png)$ {
    include snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=31536000";
}

5. 요청 속도 제한 - limit_req

limit_req는 키(여기서는 IP)별로 초당 요청 수를 제한하는 모듈이다. 브루트포스와 무차별 크롤링의 속도를 웹서버 선에서 깎는 용도로, 상태 저장용 zone을 http 블록에 만들고 server나 location에서 적용한다.

# http 블록
limit_req_zone $binary_remote_addr zone=req_perip:10m rate=10r/s;
limit_req_status 429;

# server 블록
limit_req zone=req_perip burst=20 nodelay;

# 로그인처럼 민감한 경로는 별도 zone으로 더 빡빡하게
limit_req_zone $binary_remote_addr zone=login_perip:10m rate=1r/s;
location = /login {
    limit_req zone=login_perip burst=5 nodelay;
    proxy_pass http://127.0.0.1:3000;
}

$binary_remote_addr는 IP를 문자열이 아니라 바이너리로 저장해 zone 메모리를 아끼는 변수다. 10m이면 IP 상태를 대략 16만 개 수준까지 담을 수 있어 zone 크기로 고민할 일은 거의 없다. burst는 순간적으로 몰리는 요청을 담아두는 여유분이고, nodelay를 붙이면 여유분 안의 요청은 지연 없이 바로 처리된다. 한도를 넘으면 위 설정 기준으로 429가 나간다.

주의할 점은 NAT다. 회사망이나 통신사 공유 IP 뒤에는 수십 명이 있을 수 있으므로, 사용자용 경로의 rate를 너무 조이면 정상 사용자부터 걸린다. 넉넉하게 시작해서 access log의 429 비율을 확인하며 줄여가는 쪽이 뒤탈이 없다.

6. 스캐너 차단 - 444와 숨김 파일

sqlmap이나 nikto처럼 User-Agent를 숨기지도 않는 스캐너는 응답 자체를 주지 않는 것이 깔끔하다. Nginx 전용 코드 444는 아무 응답 없이 연결을 끊는다.

# http 블록
map $http_user_agent $bad_bot {
    default 0;
    ~*(sqlmap|nikto|masscan|nmap|acunetix) 1;
}

# server 블록
if ($bad_bot) {
    return 444;
}

다음은 숨김 파일이다. .env, .git 같은 dot 파일이 문서 루트에 노출되면 그대로 자격증명 유출로 이어지므로 경로 자체를 막는다. 그런데 처음에 아래처럼 전부 막았더니 며칠 뒤 인증서 갱신이 실패합니다. certbot의 웹루트 검증이 .well-known/acme-challenge 경로를 쓰기 때문이다.

# 실패한 시도: certbot 갱신까지 막힌다
location ~ /\. { deny all; }

# 해결: .well-known만 예외로 두고 나머지 dot 경로 차단
location ~ /\.(?!well-known) {
    deny all;
    access_log off;
}

PHP를 아예 쓰지 않는 서버라면 .php 요청도 전부 스캔이므로 같은 방식으로 444 처리해도 된다. 단, 리버스 프록시 뒤에 PHP 애플리케이션이 하나라도 있으면 안 되니 서버 구성을 확인하고 적용한다.

7. 적용 검증과 마무리

자, 이제 전체를 검증한다. 문법 검사, reload, 그리고 밖에서 헤더를 직접 확인하는 세 단계다.

$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx
$ curl -sI https://example.com | grep -iE 'server|strict|x-content|x-frame'
Server: nginx
Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN

TLS 구성은 Qualys SSL Labs의 서버 테스트로 등급까지 확인할 수 있고, 보안 헤더는 securityheaders.com이 빠뜨린 항목을 짚어준다. 여기까지 적용하면 버전 정찰, 구식 TLS 협상, 클릭재킹, 브루트포스, dot 파일 스캔에 대한 기본 방어선이 선다. 반복 공격 IP를 자동으로 차단하는 fail2ban 연계는 분량이 있어 다음 글에서 따로 다룬다.

자주 묻는 질문

server_tokens off를 설정하면 Nginx 버전이 완전히 숨겨지나요?

응답 헤더와 기본 오류 페이지에서 버전 번호가 사라지고 Server: nginx까지만 남습니다. 소프트웨어 이름 자체를 지우거나 바꾸려면 headers-more-nginx-module 같은 서드파티 모듈을 빌드에 포함해야 합니다.

HSTS를 켰다가 문제가 생기면 바로 되돌릴 수 있나요?

브라우저가 max-age 동안 정책을 기억하기 때문에 서버에서 헤더를 지워도 이미 방문한 사용자는 계속 HTTPS로만 접속합니다. 처음에는 max-age를 300처럼 짧게 시작해 문제가 없는지 확인한 뒤 값을 늘리는 순서가 안전합니다.

limit_req 때문에 정상 사용자가 차단되지는 않나요?

IP당 초당 요청 수를 기준으로 잡고 burst로 순간 몰림을 흡수하면 일반 사용자에게는 거의 영향이 없습니다. 다만 회사망이나 통신사 NAT처럼 여러 사용자가 한 IP를 공유하는 경우가 있으므로, 처음에는 넉넉한 rate로 시작해 로그의 429 발생량을 확인하며 조여가는 쪽을 권합니다.

return 444는 표준 HTTP 상태 코드인가요?

아닙니다. 444는 Nginx 전용 내부 코드로, 아무 응답도 보내지 않고 연결을 끊으라는 지시입니다. 스캐너 입장에서는 서버가 응답하지 않는 것처럼 보여 배너나 오류 페이지로 정보를 수집할 수 없습니다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.