본문으로 건너뛰기
>_ITDITD웹 보안 플랫폼

보안 가이드

X-Forwarded-For(XFF) 스푸핑이란 — 신뢰 프록시 설정의 함정

X-Forwarded-For(XFF)는 클라이언트가 위조할 수 있는 헤더입니다 — 스캐너가 SQLi/XSS 탐침을 그 안에 숨기고, '모든 프록시 신뢰' 설정이 그 가짜 값을 앱까지 닿게 합니다. 방어적 사례 연구: 심층 방어가 어떻게 버텼는지, 그리고 진짜 신뢰 프록시 수정.

게시 2026-06-08 8분 읽기

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를 나르는 자리"를 임의 문자열의 인젝션 지점으로 악용하는 것입니다.

영향이 0이었던 이유: 두 방패

방패 ① 바인딩 값(플레이스홀더)

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_addressuser_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 등)을 함께 정제하세요.