보안 가이드

X-Forwarded-For(XFF) 스푸핑이란: 신뢰 프록시 설정 오류와 방어

X-Forwarded-For(XFF)는 클라이언트가 위조할 수 있는 헤더입니다. '모든 프록시 신뢰' 설정이 위조 값을 앱까지 통과시키는 구조와, 신뢰 프록시 설정을 바로잡는 방어법을 정리합니다.

게시 2026-06-08 9분 읽기
이 페이지에서

X-Forwarded-For(XFF)는 트래픽이 프록시를 거칠 때 앱에 "진짜 방문자 IP는 이것이다"라고 알려주는 헤더입니다. 함정: 클라이언트가 그 값을 자유롭게 위조할 수 있습니다. 여기 흔한 1인 개발자 사례 — "XFF 스푸핑 스캔에 당했다" — 를 교훈으로 바꾸고, 익명화하고, 재현 가능한 공격 절차 없이 정리합니다.

사례 파일
유형
X-Forwarded-For 스푸핑 + 인젝션 탐침(자동 스캔)
영향
없음(심층 방어로 차단됨)
드러난 경위
공격 요청이 500을 냄; 예외 이메일 폭주
효과가 있었던 안전장치
① 플레이스홀더(바인딩 값) ② DB 문자셋 검증
남은 설정 오류
"모든 프록시 신뢰" — 실제로는 앞에 프록시가 없는데
대응
경계에서 IP 헤더 정제(단기) → 신뢰 프록시 설정 수정(근본 원인)
0
실제 영향(누출/변조)
2
효과가 있었던 안전장치
전부
신뢰된 프록시(와일드카드)
경계
주요 수정

먼저 가르기: "공격"인가 "내 실수"인가?

오류를 보고 "공격이다!"로 비약하지 마세요. 방금 설정을 바꿨다면 "내 변경이 이걸 했을지도"를 똑같이 진지하게 저울질하세요.

1

자기 변경을 의심한다

가설: 최근 설정 변경이 사용자를 반복 재로그인하게 만들었다. 하지만 그것은 저장 값의 잘못된 바이트 시퀀스를 설명할 수 없다 → 기각.
2

값을 증거로 삼는다

나타난 것은 다중 URL 인코딩된 따옴표와 과도하게 긴(중복) 바이트 시퀀스였다 — 진짜 사용자나 프록시가 만들지 않는 값 → 악성 페이로드로 확정.
3

표적인가 스캔인가?

표적 공격이라기보다, 인터넷 전체를 무차별로 훑는 자동 스캐너일 가능성이 높다.

본 사이트의 견해: 증상에서 거슬러 올라갈 때는 각 가설을 데이터로 검증하라

"방금 건드린 게 수상하다"는 좋은 출발점입니다. 하지만 증거(여기선 값 그 자체)가 그것을 반박하면, 놓으세요. 여러 가설을 세우고 직감이 아니라 데이터로 하나씩 배제하면, 원인을 잘못 짚는 일이 크게 줄어듭니다.

공격은 무엇이었나: 인젝션 탐침을 담은 위조 XFF

봇은 탐침 문자열을 IP "대신" XFF에 담고, 끝에 정상으로 보이는 IP 하나만 붙입니다. 확인하려는 것은 하나입니다:

"이 앱이 XFF 값을 신뢰해 DB 쿼리에 날것 그대로 섞거나, HTML에 그대로 출력하는가?"

앱이 허술하면 SQL 인젝션이나 XSS가 XFF를 통해 성공합니다. "IP를 나르는 헤더"를 임의 문자열을 보내는 곳으로 악용하는 것입니다.

피해가 없었던 이유: 두 가지 안전장치

안전장치 ① 바인딩 값(플레이스홀더)

IP 값이 문자열 연결이 아니라 플레이스홀더를 통해 데이터로 전달되었습니다. 입력이 명령으로 해석될 수 없으므로 SQLi가 설계상 형성될 수 없습니다. 프레임워크를 쓰는 주된 이점 중 하나입니다.

안전장치 ② DB 문자셋 검증

잘못된 바이트(과도하게 긴 UTF-8 등)는 컬럼의 문자셋이 저장할 수 없는 무효 시퀀스입니다. DB가 쓰기를 거부하고 예외를 던졌습니다 — 공격이 실패했고 탐지되었습니다.

일어난 일의 전부: 공격자 자신의 요청이 500을 냈고 예외 이메일이 폭주했습니다. 누출도, 변조도, 무단 로그인도 없었습니다. 심층 방어가 의도대로 작동한 교과서적 사례입니다.

진짜 문제: 모든 프록시를 신뢰함

여기서 멈추면 문제가 없는 것처럼 보이지만, 하나는 무시할 수 없습니다. 위조된 XFF가 애초에 왜 IP 처리에 닿았는가?

✗ 모든 프록시 신뢰(와일드카드)

클라이언트의 가짜 XFF → '진짜 IP'로 채택됨 → 로그, 세션, IP 검사가 가짜 값을 쓰게 됨

✓ 아무것도 신뢰 안 함(기본값)

가짜 XFF 무시됨 → 진짜 소켓 IP가 사용됨 → 스푸핑이 무효

신뢰 프록시가 와일드카드면, 누구의 위조된 XFF든 '진짜 IP'로 통과합니다. 앞에 프록시가 없으면 '아무것도 신뢰하지 않기'가 올바릅니다.

이 설정에는 리버스 프록시도 CDN도 없었습니다 — 웹 서버가 직접 노출되어 있었습니다. 그래서 XFF를 신뢰할 필요가 전혀 없었는데, "모든 프록시 신뢰"로 설정되어 있었습니다. "그냥 전부 허용"은 개발 중의 설정이 프로덕션까지 남는 흔한 경우입니다.

놓치기 쉬운 점: XFF 스푸핑은 인젝션만의 문제가 아니다

클라이언트 IP를 신뢰하는 것은 무엇이든 가짜 값을 쓰게 됩니다: 속도 제한 우회, IP 허용 목록 우회, 차단 회피, 위조된 감사 로그. 강한 인젝션 방어가 있어도, 이것은 별개의 문제입니다. "어디서 IP를 신뢰하는가?"를 인벤토리화하고 그 신뢰의 근거(= 프록시 설정)를 점검하세요.

수정: 경계에서 정제 → 신뢰를 올바르게 설정

1

경계에서 IP 헤더를 정제한다(단기, 즉시)

신뢰 프록시 로직이 돌기 전에 XFF를 쪼개고, 각 부분을 "유효한 IP인가?"로 검증해 유효한 IP만 남기고; 없으면 헤더를 통째로 버립니다. 진짜 사용자의 진짜 IP는 통과하므로 영향 없음; 탐침 문자열은 폐기됩니다.
2

신뢰 프록시를 올바르게 설정한다(근본 원인)

와일드카드를 버리세요. 앞에 프록시 없음 → 아무것도 신뢰하지 않기. 프록시 신뢰는 http/https 탐지에도 영향을 주므로, 변경이 리다이렉트 루프를 일으킬 수 있습니다 — 한 환경에서 먼저 검증하세요.
3

'유효한 IP'를 '진짜 IP'와 혼동하지 않는다

정제는 형식만 바로잡을 뿐입니다. 형식이 올바른 IP라도 위조된 값일 수 있습니다. 진짜 IP에 대한 신뢰는 2단계의 프록시 설정에서만 옵니다.

검증: 고친 뒤, 공격을 재현해 확인하라

"고친 것 같다"를 "확실히 멈췄다"로 바꾸세요. ① 한 환경에만 적용 → ② 같은 잘못된 헤더를 직접 보냄 → ③ 오류 수가 늘지 않음을 확인 → ④ 정상 접근이 여전히 작동함을 확인 → ⑤ 전체에 롤아웃. 단계적 롤아웃이 사고를 막습니다.

후속: IP 헤더를 막자, 공격이 User-Agent로 옮겨갔다

정제를 추가하고 수십 분 뒤, 같은 종류의 오류가 돌아왔습니다 — 이번엔 다른 컬럼에서: ip_address가 아니라 user_agent. IP 헤더에서 막히자, 공격자가 같은 페이로드를 User-Agent 헤더로 옮긴 것입니다. 다른 접근이 필요했습니다.

입력: XFF · User-Agent · Referer · … (끝없음)
↓ 하지만 실제로 저장되는 것은
싱크: sessions 테이블의 ip_address & user_agent뿐
↓ 그러니 정제할 입력은
그 둘로 들어가는 헤더(IP 헤더 + User-Agent)뿐
위험한 헤더를 하나씩 막으면 공격은 다른 헤더로 옮겨갈 뿐입니다. 신뢰할 수 없는 입력이 결국 저장되는 곳(싱크)에서 거슬러 올라가면, 처리할 입력이 짧고 정해진 목록이 됩니다.

올바른 질문은 "어느 헤더가 위험한가?"가 아니라 "신뢰할 수 없는 입력이 결국 어느 DB 컬럼에 떨어지는가?"입니다. 싱크가 ip_address와 user_agent뿐이라면, 그곳으로 들어가는 입력(IP 헤더 + User-Agent, 안전을 위해 Referer까지)을 정제하면 세션 유래 오류가 설계상 멈춥니다. 저장되지 않는 다른 헤더로 페이로드를 옮겨도 오류는 생기지 않습니다. 정상 User-Agent는 ASCII/유효 UTF-8이므로, 무효 바이트만 벗겨내도 영향이 없습니다.

본 사이트의 견해: 입력을 하나씩 막지 말고 싱크에서 거슬러 올라가라

헤더를 하나씩 막는 방식은 통하지 않습니다. 공격자는 언제든 다른 헤더를 쓸 수 있습니다. 싱크(저장 컬럼 / 출력)에서 거꾸로 추론해 거기에 닿는 모든 입력을 한꺼번에 열거하세요. 공격이 IP 헤더에서 User-Agent로 옮겨간 이 사례가 보여준 대로: 한 헤더를 패치할 때 같은 싱크에 닿는 다른 입력도 동시에 처리하세요. 그리고 다시 — 두 번째 공격을 재현해 멈춘 것을 확인하고 나서 롤아웃했습니다.

또다시: 값이 저장되기 직전에 방어한다

User-Agent까지 처리한 직후, 같은 ip_address 컬럼 오류가 돌아왔습니다. 진입 정제는 배포되어 있었고, 자가 테스트에서 같은 잘못된 헤더를 보내도 재현되지 않았습니다 — 그런데 프로덕션에서는 오류가 계속 재발했습니다. 이것이 이 사례의 핵심입니다.

이유: 프레임워크의 "클라이언트 IP 해석" 로직이 내부적으로 복잡합니다 — 여러 헤더 표기(X-Forwarded-For, Forwarded, 커스텀 헤더), 프록시 신뢰 설정과의 조합, 파싱 순서와 캐싱. 그 내부 경로 어딘가에서, 악성 값이 우리가 정제하지 않은 경로를 통해 "클라이언트 IP"로 다시 나타났습니다. 진입점에서의 검사만으로는 모든 것을 잡을 수 없습니다.

✗ 진입에서 방어

요청 헤더 정제 → 악성 클라이언트 IP가 다른 내부 경로로 다시 나타남 → 우회됨

✓ 출구에서 방어

DB 쓰기 직전에 검사 → 모든 값이 지나가는 단 한 곳 → 우회 없음

진입 필터(들어오는 값 검사)는 내부 해석 경로로 우회될 수 있습니다. 출구(DB 쓰기 직전)는 모든 값이 반드시 지나가는 단 한 곳이므로, 아무것도 빠져나가지 못합니다.

그래서 우리는 접근을 바꿔 출구(DB 쓰기 직전)에서 방어했습니다. 세션 저장 클래스를 서브클래싱하고 값 생성 부분만 오버라이드(개념): 저장 IP는 유효한 IP가 아니면 null이 되고; 저장 User-Agent는 무효 바이트가 벗겨지고 길이가 상한됩니다. 클라이언트 IP 해석이 내부적으로 어떻게 동작하든, 검사가 항상 쓰기 직전에 돌므로 아무것도 빠져나가지 못합니다. 두 공격 경로로 악성 헤더를 각각 수십 번씩 보냈지만, 오류 수는 늘지 않았고 정상 세션은 계속 작동했습니다.

본 사이트의 견해: 마지막 검사는 출구(사용 지점)에 둔다

보안의 기본 규칙은 "입력을 신뢰하지 말고, 사용 지점(출구)에서 항상 검증/이스케이프하라"입니다. 진입 검사는 일찍 거부하는 데 도움이 되지만, 프레임워크가 내부적으로 해석/도출하는 정보(클라이언트 IP 같은)는 진입 검사를 지나 다시 나타날 수 있습니다. 위험한 싱크(DB, HTML, 명령) 직전에 검증/이스케이프하는 곳은 모든 값이 지나가는 단 한 곳이므로, 마지막 검사는 거기에 둡니다. 진입과 출구를 별개의 두 가지로 하세요.

교훈: 잘못된 입력은 눈에 보이는 오류로 거부되는 편이 더 안전하다

여기서 공격은 실패했고 오류 알림 폭주로 가시화되었습니다. 그것은 나쁜 결과가 아닙니다: 눈에 보이는 오류로 거부되는 것이, 알아채지 못한 채 통과되는 것보다 안전합니다. 오류 알림을 잡음으로 치부하지 말고, 이상을 알아채는 데 쓰세요. "영향을 막았다"에서 멈추지 말고, "왜 내부에 닿았는가"를 고치세요. 그것이 본 사이트의 입장입니다.

다음으로 읽기

FAQ

QX-Forwarded-For를 신뢰해도 되나요?
A

그대로는 안 됩니다. XFF는 클라이언트가 자유롭게 설정할 수 있는 헤더입니다 — 공격자가 진짜처럼 보이는 IP나 인젝션 탐침 문자열을 위조할 수 있습니다. 신뢰할 수 있는 유일한 XFF는 당신이 앞에 둔, 명시적으로 신뢰하는 프록시가 추가한 것뿐입니다.

Q리버스 프록시나 CDN을 쓰지 않는다면요?
A

어떤 XFF도 전혀 신뢰하지 마세요(프레임워크 기본값을 두세요). 앞에 프록시가 없는데 '모든 프록시 신뢰'면, 누구든 당신의 앱에 가짜 IP를 넘길 수 있습니다. 경로에 프록시 없음 → 아무것도 신뢰하지 않기가 올바른 설정입니다.

QIP 헤더 정제만으로 충분한가요?
A

인젝션 탐침과 오류 폭주는 멈추지만, 그것은 단기 수정일 뿐입니다. '유효해 보이는 IP'는 '진짜 IP'가 아닙니다. 진짜 클라이언트 IP에 대한 신뢰는 정제가 아니라 신뢰 프록시 설정에서 와야 합니다.

QIP 헤더를 고쳤는데 같은 오류가 다시 왔습니다. 왜죠?
A

같은 페이로드가 다른 헤더(예: User-Agent 헤더)로 옮겨가 같은 저장 컬럼에 닿은 것입니다. 헤더를 하나씩 패치하면 공격자는 다른 헤더로 옮겨갈 뿐입니다. '신뢰할 수 없는 입력이 결국 어느 DB 컬럼 / 출력에 닿는가?'에서 거꾸로 추론하고, 그곳으로 들어가는 모든 입력(IP 헤더 + User-Agent 등)을 함께 정제하세요.