← 글 목록

서버 로그에서 발견한 X-Forwarded-For 스푸핑과 인증 우회 취약점 (연재 1/3)

/ 8분 분량

운영 중인 개인 블로그 서버의 방문자 로그를 들여다보다가 이상한 패턴들을 발견해서 하나씩 파헤쳐봤습니다. 단순한 봇 스캔인 줄 알았는데, 파다 보니 실제로 뚫려 있던 인증 우회 취약점까지 찾게 된 하루였습니다.

학습 주제

  • 웹 서버 방문자 로그 분석을 통한 공격 패턴 식별
  • X-Forwarded-For 헤더 스푸핑과 인증 우회의 원리
  • nginx 리버스 프록시 설정과 신뢰 경계(trust boundary) 문제
  • 학습 날짜: 2026년 8월 4일

탐구 과정

방문자 로그 대시보드를 보는데 /.env, /.git/config, /wp-content/plugins/hellopress/wp_filemanager.php 같은 낯선 경로 접근이 계속 찍혀 있었습니다. 내 IP는 나니까 넘어가는데, 나머지 트래픽이 뭔지 궁금했습니다. 특정 인물이 나를 노리고 들어온 건지, 아니면 그냥 흔한 잡음인지부터 구분해야 했습니다.

로그를 패턴별로 묶어보니 답이 나왔습니다. 인터넷에 항시 떠도는 자동화된 무차별 스캐닝(mass scanning)이었습니다. 특정 사이트를 노린 게 아니라 공개된 IP 대역을 훑으면서 취약한 서버를 찾는 봇들이었습니다.

그런데 로그를 더 살펴보다가 이상한 걸 하나 발견했습니다. /blog/, /blog/wp-json/ 같은 경로가 IP 127.0.0.1, User-Agent WordPress/6.9.4로 찍혀 있었던 겁니다. 문제는 이 서버엔 실제로 워드프레스가 붙어 있지 않다는 점이었습니다. nginx 설정을 보니 /blog 전용 location도 없고 전부 다른 서비스로 흘러가는 구조였습니다. 그렇다면 이 127.0.0.1은 진짜 로컬호스트 요청이 아니라, 외부에서 IP를 위조해서 "로컬호스트 = 신뢰됨" 우회를 노린 요청일 가능성이 있었습니다.

여기서부터 백엔드가 클라이언트 IP를 어떻게 판단하는지 코드를 직접 확인하게 됐습니다.

핵심 학습 내용

로그에서 확인한 공격 유형 정리

패턴 정체
/.env, /.git/config 설정파일/저장소 노출 스캔 (DB 비밀번호, API 키, 소스코드 탈취 목적)
/vendor/phpunit/phpunit/.../eval-stdin.php 대량 경로 CVE-2017-9841 — PHPUnit eval-stdin.php가 실수로 배포되면 stdin으로 넘긴 코드를 그대로 실행하는 RCE 취약점
/geoserver/web/ GeoServer RCE 취약점 스캔
/wp_filemanager.php, /222.php, /info.php 등 이미 심어졌을지 모를 웹쉘 존재 확인용 파일명
/containers/json 열린 Docker API 데몬 탐색
/wp-login.php, /admin 관리자 로그인 무차별 대입/존재 확인

이런 트래픽은 대부분 "인터넷 배경 소음"에 가깝지만, .env/.git 노출이나 PHPUnit RCE는 실제로 취약하면 바로 뚫리는 종류라 방치할 순 없었습니다.

X-Forwarded-For 헤더가 왜 위험한가

nginx 설정에 이렇게 되어 있었습니다.

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

여기서 $proxy_add_x_forwarded_for는 클라이언트가 보낸 기존 X-Forwarded-For 값 뒤에 실제 접속 IP를 **추가(append)**할 뿐, 기존 값을 덮어쓰지 않습니다. 즉 누구든 요청에 X-Forwarded-For: 127.0.0.1 헤더를 직접 붙여 보내면, nginx를 거친 뒤 최종 헤더는 127.0.0.1, <진짜 공격자 IP> 형태가 됩니다.

그런데 백엔드 코드는 이렇게 되어 있었습니다.

req.headers['x-forwarded-for']?.split(',')[0].trim()

맨 앞 값을 클라이언트 IP로 채택하는 구조입니다. 결국 공격자가 조작한 127.0.0.1이 그대로 "클라이언트 IP"로 인식되고, isTrustedIp()가 이를 로컬호스트로 판단해서 autoAuth 미들웨어가 로그인 절차 없이 세션을 관리자로 자동 인증해버리는 구조였습니다.

정리하면 이런 흐름이었습니다.

  1. 공격자가 X-Forwarded-For: 127.0.0.1 헤더를 붙여 요청
  2. nginx가 실제 IP를 뒤에 append → 127.0.0.1, <실제 IP>
  3. 백엔드가 split(',')[0]으로 맨 앞 값만 신뢰 → 127.0.0.1
  4. "신뢰된 로컬호스트"로 오인 → 비밀번호 없이 관리자 세션 부여

로그에 찍혀 있던 워드프레스 스캔처럼 보이는 요청들이 사실은 이 우회를 노린 시도였을 가능성이 컸습니다.

이해한 내용

가장 크게 배운 건, 클라이언트가 보낸 헤더는 절대 그 자체로 신뢰해선 안 된다는 점입니다. X-Forwarded-For는 원래 프록시 체인에서 원본 요청자를 추적하기 위한 헤더인데, append 방식으로만 처리하면 클라이언트가 조작한 값과 실제 값이 뒤섞여버립니다. 이걸 안전하게 쓰려면 두 가지 중 하나가 필요합니다.

  • nginx에서 append가 아니라 $remote_addr로 덮어쓰기(overwrite)
  • 백엔드에서 Express의 req.ip(hop-aware, trust proxy 설정과 연동)처럼 프록시 홉 수를 명시적으로 신뢰하는 방식 사용

또 하나 인상 깊었던 건 autoAuth처럼 "로컬호스트니까 자동 로그인" 같은 편의 기능이 설계 자체로 위험한 패턴이라는 점입니다. 신뢰 판단의 근거(IP)가 조작 가능하다면, 그 위에 세운 모든 인증 로직이 무너집니다.

그리고 조사 과정에서 두 가지를 더 발견했습니다.

  • pm2가 NODE_ENV=development로 떠 있었던 것 (프로덕션 설정이 실제로 적용 안 되고 있었음)
  • 백엔드와 프론트엔드가 0.0.0.0에 바인딩되어 있어서, nginx를 완전히 우회해 포트로 직접 붙으면 방금 고친 방어도 무력화될 수 있었던 것

이 두 가지는 로그만 봐서는 알 수 없고 실제 프로세스/네트워크 설정을 확인해야만 드러나는 문제였습니다. 겉으로 드러난 증상(로그의 이상한 요청) 뒤에 근본 원인이 여러 겹으로 쌓여 있을 수 있다는 걸 체감했습니다.

실전 적용

이번에 확인하고 적용한 것들을 정리하면 다음과 같습니다.

  • nginx X-Forwarded-For를 append 대신 $remote_addr로 덮어쓰도록 수정
  • 백엔드/프론트엔드 모두 헤더 직접 파싱 대신 req.ip 또는 마지막 값 기준으로 신뢰
  • autoAuth를 NODE_ENV==='development'에서만 동작하도록 제한
  • pm2를 --env production으로 명시 재기동
  • 백엔드/프론트엔드를 127.0.0.1에만 바인딩해서 nginx 우회 경로 차단
  • 방문자 로그 기록 API를 내부 전용으로 제한 + 레이트리밋 추가
  • nginx limit_req_zone으로 요청 속도 제한, fail2ban에 .env/.git/wp-login/eval-stdin 시그니처 잡는 jail 추가

적용 후에는 스푸핑된 헤더로 직접 백엔드에 요청했을 때 401로 막히고, 정상 트래픽은 그대로 200이 나오는 걸 curl로 재확인했습니다. 코드를 고치는 것으로 끝나지 않고, 실제로 재현 가능한 시나리오로 검증하는 과정이 중요하다는 걸 새삼 느꼈습니다.

추가 학습 계획

  • trust proxy 설정을 홉 수 기준으로 더 세밀하게 관리하는 법 (다중 프록시 환경에서는 어떻게 달라지는지)
  • fail2ban 필터를 더 정교하게 만들어서 404 폭주/스캐닝 패턴을 빠르게 잡는 법
  • WAF(Web Application Firewall) 도입 여부 검토 — nginx 레벨에서 알려진 취약점 스캔 패턴 자체를 차단하는 방법
  • CVE 데이터베이스를 주기적으로 확인하는 습관 (PHPUnit eval-stdin 같은 오래된 취약점이 여전히 스캔되는 걸 보고, 패치되지 않은 구식 취약점이 얼마나 오래 살아남는지 체감)