본문으로 바로가기

BJCHOI.DEV

WEB / APP / DATA SCIENCE / ML KR
시스템 시간

00:00:00:00

화면
언어 KO EN

Nginx 환경에서 클라이언트 실제 IP 추출 시 보안 취약점 및 해결 정리

WAS 환경에서 클라이언트 IP 획득 로직 확인 결과, 신뢰 프록시 IP가 아닌 경우 remoteAddr 를 그대로 반환. X-Forwarded-For 헤더가 존재하면 쉼표로 분리하여 IP 목록 생성 후, 역순으로 신뢰 프록시 대역을 건너뛰며 최초의 외부 IP 를 실제 클라이언트 IP 로 확정하도록 Nginx 설정과 WAS 추출 로직을 정리함.

트러블슈팅 2023-06-08

1. 무넺

  • Nginx 뒤에 WAS를 둘 경우 request.getRemoteAddr()는 Nginx 내부 IP만 반환함.

  • 인터넷에 널리 퍼진 다중 헤더 순차 검증 방식(X-Forwarded-For, Proxy-Client-IP, WL-Proxy-Client-IP 등)을 그대로 사용할 경우 헤더 위조(IP Spoofing)에 노출됨.

  • 관리자 접근 제어(IP 차단/허용)가 무력화될 수 있어 Nginx 설정 및 WAS 추출 로직을 신뢰 프록시 기반으로 정리함.


2. 기존 방식의 문제

기존 다중 헤더 순차 조회 코드

public String getIp(HttpServletRequest request) {
    String ip = request.getHeader("X-Forwarded-For");
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getHeader("Proxy-Client-IP");
    }
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getHeader("WL-Proxy-Client-IP");
    }
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getHeader("HTTP_CLIENT_IP");
    }
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getHeader("HTTP_X_FORWARDED_FOR");
    }
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getRemoteAddr();
    }
    return ip;
}

문제 분석

  1. 클라이언트 조작 가능 헤더 무조건 신뢰 (IP Spoofing)

    • X-Forwarded-For는 클라이언트가 임의로 요청 헤더에 삽입할 수 있음.

    • 공격자가 X-Forwarded-For: 127.0.0.1 또는 허용된 관리자 IP를 주입하여 전송 시, 첫 번째 값을 그대로 읽어 인증을 통과하게 됨.

  2. 다중 프록시 환경에서 쉼표(,) 미처리

    • 프록시나 로드밸런서를 거칠 때마다 IP가 쉼표로 연결(Client_IP, Proxy1_IP, Proxy2_IP)되나, 전체 문자열을 그대로 반환하여 IP 형식 검증 실패 또는 DB 조회 오류 발생.
  3. 환경에 맞지 않는 불필요한 헤더 검사

    • WL-Proxy-Client-IP(WebLogic), Proxy-Client-IP(Apache Web Server) 등 현재 인프라와 무관한 웹로직/구형 프록시 헤더를 불필요하게 검사함.

3. X-Forwarded-For 동작 원리 및 검증 기준

X-Forwarded-For 구조

X-Forwarded-For: <Client_IP>, <Proxy1_IP>, <Proxy2_IP>
  • 프록시 서버는 이전 연결의 출발지 IP를 기존 X-Forwarded-For 문자열 끝에 추가함.

  • 맨 왼쪽(Client_IP)은 클라이언트가 얼마든지 위조하여 보낼 수 있으므로 신뢰할 수 없음.

  • 안전하게 신뢰할 수 있는 구간은 **직접 구축/관리하는 마지막 프록시(오른쪽 끝)**부터임.

올바른 IP 추출 기준

  • getRemoteAddr() 및 XFF의 가장 오른쪽 IP부터 역방향(오른쪽 → 왼쪽)으로 검사.

  • 사전에 지정된 신뢰 프록시(Trusted Proxy) 대역에 속하는 IP는 건너뜀.

  • 신뢰 프록시 대역을 벗어난 최초의 외부 IP를 실제 클라이언트 IP로 확정함.


4. 해결 방안 1: Nginx 단 설정

Nginx의 ngx_http_realip_module을 사용하여 Nginx 단에서 실제 IP를 판별하고 WAS에 단일 헤더로 전달하는 방식.

nginx.conf

# 신뢰할 수 있는 상위 프록시/로드밸런서/내부망 대역 등록
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 127.0.0.1;

# 실제 IP를 추출할 헤더 지정
real_ip_header X-Forwarded-For;

# 신뢰 프록시를 제외하고 가장 마지막 외부 IP를 $remote_addr에 바인딩
real_ip_recursive on;

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://was_upstream;

        # Nginx가 검증한 실제 클라이언트 IP를 단일 헤더로 WAS에 전달
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
  • real_ip_recursive on: 오른쪽 끝부터 거슬러 올라가며 set_real_ip_from에 포함된 신뢰 프록시를 제외한 첫 번째 비신뢰 IP를 $remote_addr로 교체함.

  • WAS에서는 request.getHeader("X-Real-IP") 또는 request.getRemoteAddr()만 확인하면 됨.


5. 해결 방안 2: Java / Spring 단 구현

Nginx 설정을 변경하기 어렵거나 WAS 단에서 직접 검증해야 할 경우의 구현.

ClientIpResolver.java

import org.springframework.security.web.util.matcher.IpAddressMatcher;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.util.*;

public class ClientIpResolver {

    // 신뢰할 수 있는 내부 프록시 / 로드밸런서 IP 대역
    private static final List<IpAddressMatcher> TRUSTED_PROXIES = Arrays.asList(
            new IpAddressMatcher("10.0.0.0/8"),
            new IpAddressMatcher("172.16.0.0/12"),
            new IpAddressMatcher("192.168.0.0/16"),
            new IpAddressMatcher("127.0.0.1/32")
    );

    public static String getClientIp(HttpServletRequest request) {
        String remoteAddr = request.getRemoteAddr();

        // 직전 연결 IP가 신뢰 프록시가 아니면 해당 IP를 그대로 반환
        if (!isTrustedProxy(remoteAddr)) {
            return remoteAddr;
        }

        String xffHeader = request.getHeader("X-Forwarded-For");
        if (!StringUtils.hasText(xffHeader)) {
            return remoteAddr;
        }

        // X-Forwarded-For IP 목록 분리
        String[] rawIps = xffHeader.split(",");
        List<String> ipList = new ArrayList<>();
        for (String ip : rawIps) {
            String trimmed = ip.trim();
            if (StringUtils.hasText(trimmed)) {
                ipList.add(trimmed);
            }
        }

        // 오른쪽 끝(최근 프록시)부터 역순으로 탐색
        for (int i = ipList.size() - 1; i >= 0; i--) {
            String ip = ipList.get(i);
            if (!isTrustedProxy(ip)) {
                return ip; // 신뢰 대역을 벗어난 첫 번째 외부 IP
            }
        }

        return remoteAddr;
    }

    private static boolean isTrustedProxy(String ip) {
        if (!StringUtils.hasText(ip)) {
            return false;
        }
        for (IpAddressMatcher matcher : TRUSTED_PROXIES) {
            if (matcher.matches(ip)) {
                return true;
            }
        }
        return false;
    }
}