서비스를 배포하고 보안팀으로부터 메일을 받습니다. "취약점 스캔 결과 — TLS 1.0 활성화, 보안 헤더 미설정, 관리자 페이지 외부 접근 허용." 이게 얼마나 위험한 것인지, 어떻게 고쳐야 하는지 막막합니다.
보안은 한 번에 완벽하게 만드는 것이 아닙니다. 실무에서는 "지금 당장 꼭 해야 하는 것"부터 순서대로 적용합니다. 이 모듈은 인프라 엔지니어가 직접 Nginx 설정으로 적용할 수 있는 보안 강화 항목들을 다룹니다.
- 1Nginx에서 관리자 경로를 IP 기반으로 제한하고 숨김 파일 접근을 차단할 수 있다
- 2보안 헤더(X-Frame-Options, HSTS, CSP 등) 5개를 Nginx에 설정할 수 있다
- 3CORS 설정에서 와일드카드 대신 특정 출처만 허용하는 방법을 적용할 수 있다
- 4쿠키에 Secure, HttpOnly, SameSite 속성을 설정하는 방법을 설명할 수 있다
- 5TLS 1.0/1.1을 비활성화하고 강화된 cipher suite를 적용할 수 있다
계정 권한 관리
root 직접 접속 차단과 불필요 계정 관리
보안 감사 결과서를 받았습니다. 첫 번째 항목이 "root 직접 로그인 허용"입니다. 서버를 처음 세팅했을 때 편의를 위해 놔뒀던 설정인데, 공격자가 SSH 브루트포스를 시도할 때 root 계정이 존재하면 하나의 계정만 크래킹하면 시스템 전체를 장악할 수 있습니다. 감사에서 지적받기 전에, 서버 세팅 시 기본적으로 처리해야 하는 계정 보안 항목들이 있습니다.
서버 보안의 기본은 공격 표면을 줄이는 것입니다. 필요 없는 계정을 없애고, root 직접 접속을 막고, 필요한 권한만 부여하는 것이 출발점입니다.
확대
# root 직접 SSH 로그인 차단 (/etc/ssh/sshd_config)
grep "PermitRootLogin" /etc/ssh/sshd_config
# 기존 항목을 중복 생성하지 말고 편집기로 값을 검토·수정합니다.
sudo cp -a /etc/ssh/sshd_config "/etc/ssh/sshd_config.bak.$(date +%Y%m%d%H%M%S)"
sudoedit /etc/ssh/sshd_config
# 문법 검증에 실패하면 reload하지 않습니다.
sudo sshd -t && sudo systemctl reload sshd
# 활성 계정 목록 확인
awk -F: '$3 >= 1000 {print $1, $3, $7}' /etc/passwd
# UID 1000 이상 = 일반 사용자 계정
# 불필요 계정 잠금 (삭제보다 잠금 권장 — 파일 소유자 관계 유지)
sudo usermod -L testuser # 계정 잠금
sudo passwd -S testuser # 상태 확인 (L = Locked)
# 로그인 실패 기록 확인 (배포판에 따라 btmp 권한·도구가 다를 수 있음)
sudo lastb -F | head -20
관리자 경로 제한
Nginx에서 IP 기반 접근 제어
관리자 경로(/admin, /management, /actuator 등)를 인터넷에 공개하면 브루트포스 공격의 대상이 됩니다. 내부 IP에서만 접근하도록 제한하는 것이 기본 보안 조치입니다.
# /etc/nginx/conf.d/app.conf
server {
listen 80;
server_name example.com;
# 관리자 경로 — 내부 IP만 허용
location /admin {
allow 10.0.0.0/8; # 내부 사설 IP 대역
allow 192.168.0.0/16; # 사무실 IP 대역
deny all; # 나머지 전부 차단
}
# Spring Boot Actuator — 내부만 허용 (민감 정보 노출 방지)
location /actuator {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
}
# 숨김 파일/디렉터리 접근 차단 (.git, .env 등)
location ~ /\. {
deny all;
return 404;
}
# 특정 파일 확장자 차단
location ~* \.(sql|bak|log|conf|env)$ {
deny all;
return 404;
}
# 일반 요청
location / {
proxy_pass http://localhost:8080;
}
}
deny all만 쓰면 403을 반환해 경로 존재 여부가 노출됩니다. return 404를 함께 쓰면 경로가 아예 없는 것처럼 보입니다.
보안 헤더 설정
응답 헤더로 브라우저 보안 정책 강제하기
보안 점검 결과 "X-Frame-Options 헤더 미설정"이 취약점으로 나왔습니다. 개발팀에 전달했더니 "그게 뭔데요?"라는 답이 왔습니다. 이 헤더들은 코드 레벨 취약점이 아니라 브라우저에게 보내는 정책 지시입니다. 설정하지 않으면 클릭재킹, XSS, MIME 스니핑 같은 공격에 그대로 노출됩니다. Nginx 설정에 몇 줄만 추가하면 애플리케이션 코드를 건드리지 않고도 이 취약점들을 한번에 처리할 수 있습니다.
보안 헤더는 서버가 브라우저에게 "이 사이트에서는 이런 보안 정책을 따르라"고 지시하는 방법입니다. 코드 변경 없이 Nginx 설정만으로 주요 클라이언트 사이드 공격을 방어할 수 있습니다.
확대
# /etc/nginx/conf.d/security-headers.conf
# 또는 server 블록 내부에 추가
# Clickjacking 방지 — iframe에서 이 페이지를 로드 차단
add_header X-Frame-Options "SAMEORIGIN" always;
# MIME 스니핑 방지 — 브라우저가 Content-Type을 변경하지 못하게 함
add_header X-Content-Type-Options "nosniff" always;
# 구형 브라우저용 XSS 필터 (최신 브라우저는 CSP로 대체)
add_header X-XSS-Protection "1; mode=block" always;
# HSTS — HTTPS 연결 강제 (1년)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Content Security Policy — 리소스 출처 제한
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
# Referrer Policy — 외부 링크 클릭 시 Referer 헤더 제어
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions Policy — 브라우저 기능 접근 제한 (카메라, 마이크 등)
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
always 키워드를 붙여야 4xx, 5xx 에러 응답에도 헤더가 포함됩니다.
헤더별 목적 요약:
| 헤더 | 방어 대상 |
|---|---|
X-Frame-Options | Clickjacking (악성 iframe으로 클릭 유도) |
X-Content-Type-Options | MIME 스니핑 기반 공격 |
Strict-Transport-Security | SSL Stripping (HTTP 다운그레이드) |
Content-Security-Policy | XSS (인라인 스크립트 실행) |
Referrer-Policy | 민감한 URL 정보 외부 노출 |
# 보안 헤더 적용 확인
curl -I https://example.com \
| grep -E "X-Frame|X-Content|Strict|Content-Security|Referrer"
확대
CORS 설정
안전한 CORS 정책 구성
CORS(Cross-Origin Resource Sharing)는 API를 어떤 도메인에서 호출할 수 있는지를 제어합니다. 개발 편의를 위해 *로 열어두고 운영에 그대로 배포하는 경우가 흔합니다.
# 특정 출처만 허용하는 CORS 설정
map $http_origin $cors_origin {
default "";
"https://www.example.com" $http_origin;
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
location /api/ {
if ($cors_origin) {
add_header "Access-Control-Allow-Origin" $cors_origin always;
add_header "Access-Control-Allow-Methods" "GET, POST, PUT, DELETE, OPTIONS" always;
add_header "Access-Control-Allow-Headers" "Authorization, Content-Type" always;
add_header "Access-Control-Allow-Credentials" "true" always;
}
# Preflight 요청 처리
if ($request_method = "OPTIONS") {
add_header "Access-Control-Max-Age" 1728000;
add_header "Content-Length" 0;
return 204;
}
proxy_pass http://localhost:8080;
}
}
map 지시어로 허용 출처 목록을 관리하면, 새 도메인 추가가 목록에 한 줄 추가로 끝납니다.
확대
Cookie 보안 설정
세션 쿠키 보안 강화
쿠키에 보안 속성을 설정하지 않으면 세션 하이재킹, XSS를 통한 쿠키 탈취 위험이 있습니다. 세 가지 속성을 반드시 설정해야 합니다.
# Nginx에서 Proxy를 통해 쿠키에 보안 속성 추가
# (애플리케이션이 Set-Cookie를 발급할 때 속성이 없으면 Nginx에서 추가)
proxy_cookie_flags ~ secure httponly samesite=strict;
애플리케이션 레벨(Spring Boot 예시):
# application.yml
server:
servlet:
session:
cookie:
secure: true # HTTPS에서만 전송
http-only: true # JavaScript에서 접근 불가 (XSS 방어)
same-site: strict # 외부 사이트 요청에 쿠키 미포함 (CSRF 방어)
쿠키 보안 속성 의미:
| 속성 | 방어 대상 | 효과 |
|---|---|---|
Secure | 평문 전송 | HTTP 요청에서 쿠키 미전송 |
HttpOnly | XSS 탈취 | JavaScript document.cookie 접근 차단 |
SameSite=Strict | CSRF | 외부 사이트에서 발생한 요청에 쿠키 미포함 |
확대
TLS Hardening
TLS 버전과 암호화 스위트 강화
TLS 설정이 취약하면 구버전 프로토콜과 약한 암호화 알고리즘을 이용한 공격에 노출됩니다. 현재 권장 설정은 TLS 1.2 + 1.3만 허용하고 구버전과 약한 cipher를 제거하는 것입니다.
# /etc/nginx/conf.d/ssl.conf (또는 server 블록 내)
# TLS 버전 — 1.2와 1.3만 허용 (1.0, 1.1 제거)
ssl_protocols TLSv1.2 TLSv1.3;
# 강화된 Cipher Suite (ECDHE 기반, 순방향 암호화)
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
# 서버가 cipher 선택권을 가짐 (클라이언트가 약한 것 선택하지 못하도록)
ssl_prefer_server_ciphers on;
# ECDH 파라미터 최적화
ssl_ecdh_curve secp384r1;
# OCSP Stapling (인증서 유효성 검증 빠르게)
ssl_stapling on;
ssl_stapling_verify on;
# DH 파라미터 (Diffie-Hellman 키 교환)
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
# DH 파라미터 생성 (최초 한 번만)
openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048
# 설정 적용 후 TLS 버전 확인
openssl s_client -connect example.com:443 -tls1 2>&1 | grep "handshake failure"
# TLS 1.0이 비활성화됐으면 handshake failure 출력됨
openssl s_client -connect example.com:443 -tls1_2 2>&1 | grep "Cipher"
# TLS 1.2는 정상 접속됨
확대
실습
운영 서버 또는 테스트 서버에서 보안 헤더 적용 여부를 확인합니다. 헤더가 없으면 출력이 비어있습니다. 적용 후 다시 실행해 각 헤더가 응답에 포함됐는지 확인합니다.
curl -s -I https://example.com | grep -E 'X-Frame-Options|Strict-Transport|Content-Security|X-Content-Type'- Strict-Transport-Security 헤더를 먼저 확인 — max-age가 31536000(1년) 이상이면 정상. 0이거나 없으면 HSTS 미적용으로 SSL 다운그레이드 공격에 취약
- Server 헤더에 "nginx/1.18.0" 같이 버전이 노출되면 즉각 server_tokens off 적용 — 버전 정보는 CVE 공격자의 첫 번째 타겟. 헤더가 없거나 "nginx"만 표시되면 정상
- X-Frame-Options 없고 Content-Security-Policy도 없는 조합이면 — 클릭재킹 공격에 완전 무방비 상태. 두 헤더가 모두 누락된 서버는 보안 등급 F
OpenSSL 클라이언트로 실제 TLS 연결을 맺어 사용 중인 프로토콜 버전과 Cipher Suite를 확인합니다. TLSv1.3 또는 TLSv1.2가 출력되어야 정상이며, TLSv1이 출력되면 구버전이 활성화된 것입니다.
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | grep -E 'Protocol|Cipher|subject'- Protocol 줄을 먼저 확인 — TLSv1.3 또는 TLSv1.2면 정상. TLSv1.1 이하가 나오면 nginx ssl_protocols 설정에 구버전이 포함된 것으로 즉시 제거
- Cipher Suite에 ECDHE가 있으면 순방향 암호화(PFS) 지원 — ECDHE가 없고 RSA만 있으면 서버 키 유출 시 과거 트래픽 복호화 가능. ECDHE 포함 cipher suite로 우선순위 설정 필요
- openssl s_client -tls1로 접속 시도 시 "handshake failure"가 나야 정상 — CONNECTED가 나오면 TLS 1.0이 활성화된 것으로 PCI DSS 위반. RC4 또는 3DES가 협상되면 CVSS 7.5 이상 취약점
트러블슈팅
원인: includeSubDomains와 긴 max-age를 한 번에 설정하면, 브라우저 캐시에 저장된 HSTS 정책 때문에 HTTP로 접속을 시도하는 모든 서브도메인이 즉시 차단됩니다. HSTS는 한번 브라우저에 저장되면 만료까지 수정이 어렵습니다.
# 현재 HSTS 설정 확인
curl -I https://example.com | grep Strict
# 안전한 HSTS 적용 순서:
# 1단계: 짧은 max-age로 테스트 시작 (300초 = 5분)
add_header Strict-Transport-Security "max-age=300" always;
# 2단계: 모든 서브도메인이 HTTPS로 전환됐는지 확인
# 내부 서비스 포함 전수 점검
# 3단계: 문제 없으면 max-age 늘리기 (86400 = 1일)
add_header Strict-Transport-Security "max-age=86400" always;
# 4단계: 최종 적용 (31536000 = 1년, includeSubDomains 추가)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 브라우저에서 HSTS 캐시 삭제 방법 (Chrome):
# chrome://net-internals/#hsts → Delete domain security policies
원인: CSP의 default-src 'self' 또는 script-src 'self' 정책은 인라인 스크립트(<script> 태그 내 코드)와 eval()을 기본으로 차단합니다.
# 브라우저 개발자 도구에서 CSP 위반 확인
# Console 탭에서:
# "Refused to execute inline script because it violates the following Content Security Policy directive: 'script-src 'self''"
# 해결 방법 1: unsafe-inline 일시 허용 (보안 수준 낮음, 임시 방편)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'";
# 해결 방법 2: nonce 방식 (권장)
# Nginx에서 매 요청마다 랜덤 nonce 생성 (lua-nginx-module 필요)
# 또는 애플리케이션에서 nonce를 생성해 헤더와 스크립트 태그에 동일하게 삽입
# 해결 방법 3: 인라인 스크립트를 외부 파일로 분리 (가장 안전)
# <script>...</script> → <script src="/js/app.js"></script>
# CSP Report-Only 모드로 먼저 테스트 (차단 없이 위반 로그만)
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'" always;
# 위반 사항이 콘솔에만 기록되고 실제로 차단하지 않음 — 프로덕션 영향 없이 테스트 가능
심화 — 헤더가 조용히 사라지고, 침해가 노이즈에 묻히는 곳
심화: add_header는 상속되지 않는다 — 보안 헤더가 특정 경로에서 사라지는 이유
앞에서 보안 헤더를 server 레벨에 넣고, CORS는 location /api에 add_header로 붙였습니다. 그런데 이 둘을 함께 쓰면 정작 /api 응답에서 보안 헤더가 통째로 사라집니다 — 배포 후 스캐너가 "일부 경로만 헤더 누락"으로 잡아내는 흔한 사고입니다.
- Nginx
add_header는 병합이 아니라 치환 상속이다: 안쪽 블록(location)에add_header가 하나라도 정의돼 있으면, 바깥 블록(server·http)의add_header들은 그location에서 전부 무효가 됩니다. 상속이 '더하기'가 아니라 '현재 레벨에 정의가 있으면 상위를 통째로 버림'으로 동작하기 때문입니다. - 그래서 CORS 한 줄이 보안 헤더 전부를 지운다:
/api에Access-Control-Allow-Origin하나를add_header로 붙이는 순간, server 레벨의X-Frame-Options·Strict-Transport-Security·Content-Security-Policy가/api응답에서 사라집니다.curl -I /엔 보이는데curl -I /api/...엔 없습니다. always도 이 문제를 못 고친다:always플래그는 4xx·5xx 에러 응답에도 헤더를 붙일지 여부일 뿐, 블록 간 상속 규칙과는 무관합니다.expires,proxy_set_header같은 다른 배열형 지시어도 같은 치환 상속을 따릅니다.- 다음 단계 — 재선언하고 검증한다: 보안 헤더를 한 파일로 만들어(
include /etc/nginx/snippets/security-headers.conf;)add_header를 재정의하는 모든location에서 다시include하거나, 상속 규칙이 다른 headers-more 모듈의more_set_headers를 씁니다. 무엇보다 배포 후 루트뿐 아니라/api·에러 페이지 같은 주요 경로마다curl -I로 헤더 실재를 직접 확인해야 합니다.
# 루트엔 보안 헤더가 보이는데
curl -sI https://example.com/ | grep -E 'X-Frame|Strict-Transport|Content-Security'
# add_header(CORS)를 붙인 /api 응답엔 사라졌는지 확인
curl -sI https://example.com/api/health | grep -E 'X-Frame|Strict-Transport|Content-Security'
'설정했다'와 '모든 응답에 실제로 붙는다'는 다릅니다 — 보안 헤더는 반드시 경로별로 검증하는 것이 운영의 기본입니다.
상황: auth.log에 SSH 로그인 실패가 하루 수천 건씩 찍혀 팀은 '늘 있는 배경 소음'으로 무시해 왔습니다. 그런데 한 서버에서 크론 시간이 아닌 새벽에 대량 데이터가 외부로 나갔습니다. 돌아보니 로그인 실패도, 접근 로그도, sudo 기록도 각각 떼어 보면 정상 범위처럼 보였습니다.
원인: 단일 로그 관점의 한계입니다. 실패 로그 하나하나는 무해하지만, '한 IP에서 여러 계정으로 실패를 뿌리는 스프레이 → 그중 한 계정에서 Accepted(성공) → 곧바로 sudo로 권한 상승 → 평소와 다른 시각의 대량 아웃바운드'라는 시퀀스를 시각순으로 이어 보면 침해가 드러납니다. 개별 임계치만 감시하면 이 시퀀스가 노이즈에 묻힙니다. 특히 스프레이 뒤의 단 한 번의 '성공'이 진짜 위험 신호인데, 다들 '실패'만 세고 있었습니다.
진단: 실패·성공·권한상승을 IP·계정별로 상관지어 하나의 타임라인에 올립니다.
# 1) 한 IP가 여러 계정에 실패를 뿌렸는지(스프레이) — IP별 실패 집계
grep "Failed password" /var/log/auth.log | grep -oE "from [0-9.]+" | sort | uniq -c | sort -rn | head
# 2) 그 IP가 결국 성공했는지 — 같은 IP의 Accepted 확인
grep "Accepted" /var/log/auth.log | grep "198.51.100.7"
# 3) 성공 직후 sudo 권한 상승과 비정상 시각 활동을 같은 타임라인으로 대조
grep -E "sudo:.*COMMAND=" /var/log/auth.log | grep "deploy"
해결: 개별 임계치 알림에 더해 '실패 스프레이 → 성공 → 권한 상승 → 비정상 아웃바운드'로 이어지는 시퀀스를 하나의 상관 규칙으로 감시합니다. auth·sudo·nginx·방화벽 로그를 중앙으로 모아(Filebeat/rsyslog 기반 로그 수집 파이프라인 구성) 시각축으로 상관분석(SIEM)하는 것이 핵심입니다. 이 상관이 가능하려면 이 모듈에서 다룬 계정 보안 — PermitRootLogin no + 개인 계정 + sudo — 로 '누가 무엇을 했는지'가 계정별로 남아 있어야 합니다. 즉 이 모듈의 계정 보안은 예방일 뿐 아니라 탐지의 전제입니다. 그리고 성공 로그인은 실패보다 오히려 더 주의 깊게 봐야 합니다.
실제 업무에서 이 지식이 쓰이는 상황:
보안팀의 취약점 스캔 결과를 받았을 때, 또는 PCI-DSS/ISO27001 감사를 준비할 때 이 설정들이 체크리스트에 들어갑니다. 코드 변경 없이 Nginx 설정 몇 줄로 보안 점수를 크게 올릴 수 있습니다.
보안 설정 우선순위 (긴급→일반):
즉시 적용 (P0):
□ TLS 1.0/1.1 비활성화
□ 관리자 경로 IP 제한
□ 불필요한 HTTP 메서드(TRACE, DELETE) 차단
빠른 시일 내 (P1):
□ HSTS 설정 (짧은 max-age로 테스트 후 확장)
□ X-Frame-Options, X-Content-Type-Options
□ HttpOnly, Secure 쿠키 속성
품질 개선 (P2):
□ Content-Security-Policy (Report-Only로 시작)
□ CORS 정책 구체화
□ Nginx server_tokens off (버전 정보 숨기기)
# Nginx 버전 정보 숨기기 (http 블록에 추가)
# server_tokens off;
# 설정 후 확인
curl -I https://example.com | grep Server
# "Server: nginx" 만 나오고 버전 번호 없어야 함
명령어·단축키 빠른 참조
계정·보안 헤더·TLS를 점검하고 검증할 때 쓰는 명령을 모았습니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
grep PermitRootLogin + sed -i | root 직접 로그인 차단 | sshd_config를 no로 후 systemctl reload sshd |
awk -F: '$3>=1000' | 일반 사용자 계정 목록 | awk -F: '$3>=1000{print $1,$3,$7}' /etc/passwd |
usermod -L / passwd -S | 계정 잠금·상태 확인 | passwd -S user (L=Locked) |
lastb / last -F | 로그인 실패·성공 이력 | lastb | head -20 |
curl -I | 응답 보안 헤더 확인 | curl -sI https://host | grep -E 'X-Frame|Strict-Transport|Content-Security' |
curl -sI /api | 경로별 헤더 실재 검증 | add_header 치환 상속으로 /api엔 사라졌는지 |
openssl s_client -connect … -tls1 | 구버전 TLS 비활성 확인 | handshake failure면 정상(TLS 1.0 차단) |
openssl s_client -tls1_2 | grep Cipher | 협상 프로토콜·cipher 확인 | ECDHE 있으면 순방향 암호화(PFS) |
openssl dhparam | DH 파라미터 생성 | openssl dhparam -out dhparam.pem 2048 |
grep "Failed password" | grep -oE "from …" | sort | uniq -c | IP별 실패 스프레이 집계 | 무차별 대입 IP 탐지 |
grep "Accepted" | 스프레이 뒤 성공 로그인 확인 | grep Accepted auth.log | grep <IP> — 진짜 위험 신호 |
grep -E "sudo:.*COMMAND=" | 성공 직후 권한 상승 추적 | 계정별 감사 추적 |
server_tokens off | nginx 버전 정보 숨김 | curl -I | grep Server로 Server: nginx만 확인 |
관련 모듈로 더 깊이:
- 취약점 조치와 보안 심사 대응 실무 — 보안 운영 기본기가 실제 감사·점검 대응으로 이어지는 흐름
- SSH 접속 보안 강화와 배스천 호스트 기반 접근 제어 — 접근 통제의 핵심인 SSH 인증·계정 권한을 강화하는 법
- 웹방화벽 403 분석과 CDN 캐시 관리 실무 — 애플리케이션 레이어 공격을 WAF로 차단하는 다음 단계
다음 모듈에서는 WAF/WAAP로 웹 공격을 차단하고, CDN 캐시를 관리하는 실무를 다룹니다.