← 문제로

42. 프록시 뒤 클라이언트 주소 식별: 신뢰 경계와 스트림 경계 (C#)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 프록시 뒤 클라이언트 주소 식별: 신뢰 경계와 스트림 경계 (C#)

난이도: 중상

요약

레이트 리밋·밴·로그의 기준이 되는 "클라이언트 IP"를 두 가지 방식으로 얻는데, 둘 다 틀렸다. 소켓 피어 주소는 LB 주소이고(→ 전 유저가 한 키를 공유해 서로를 밴시킨다), PROXY 헤더는 연결 출처를 검증하지 않고 채택한다(→ 누구나 IP 를 위조해 밴·리밋을 우회한다). 여기에 헤더를 스트림에서 잘라내지 않아 프로토콜 파싱까지 어긋난다.

문제점

1) 실제 클라 IP 대신 LB 주소 사용 — (A) [신뢰 경계/집계 키]

  • 증상: 접속 로그의 IP 가 LB 노드 수만큼의 값으로만 채워진다. RateCounter 가 LB 주소 하나에 전체 트래픽을 집계해, 초당 10 연결을 넘기는 순간 모든 정상 유저가 차단된다. GeoIp 는 항상 LB 가 있는 리전을 반환한다.
  • 재현 조건: PROXY 헤더가 없거나 (B) 파싱이 실패하는 모든 경우. 접속이 몰리는 시간대에 즉시 전면 차단으로 나타난다.
  • 근본 원인: 프록시가 개입하면 TCP 피어 주소는 더 이상 클라이언트를 식별하지 않는다. L4 프록시 환경에서 소켓 주소를 그대로 신원으로 쓰는 것이 잘못이다.

2) PROXY 헤더를 출처 검증 없이 신뢰 — (B) [스푸핑/권한 상승]

  • 증상: IP 밴이 무력화된다. 공격자가 직접(LB 를 우회해) 서버 포트에 접속하거나, LB 가 헤더를 덮어쓰지 않는 구성이면, 첫 바이트로 PROXY TCP4 1.2.3.4 ... 를 보내 임의 IP 를 주장할 수 있다. 밴된 IP 는 아무 IP 나 사칭하면 되고, 레이트 리밋은 매 연결 다른 IP 를 주장해 무한히 우회한다. 반대로 타인의 IP 를 사칭해 그 IP 를 밴시키는 공격도 가능하다.
  • 재현 조건: nc <server> <port> 로 직접 접속 후 PROXY TCP4 8.8.8.8 ...\r\n 전송.
  • 근본 원인: PROXY protocol 은 "연결이 신뢰된 프록시에서 왔을 때만" 유효하다는 전제 위에 설계됐다. 그 전제를 검사하는 코드가 없다. 즉 신뢰 경계가 코드에 표현되지 않았다.

3) 파싱의 견고성 부재 — (B) [입력 검증/가용성]

  • first.Split(' ') 후 parts[2] 를 무조건 접근한다. "PROXY " 로 시작하되 토큰이 부족하면 IndexOutOfRangeException, 형식이 잘못되면 IPAddress.Parse 가 FormatException 을 던진다. 인증 전 경로에서 원격 트리거 가능한 예외이며, await 되는 핸들러에서 던져지면 소켓이 닫히지 않고 누수될 수 있다.

4) 헤더를 스트림에서 소비하지 않음 — (B) [스트림 경계]

  • 증상: PROXY 헤더 뒤에 이어 붙어 온 첫 게임 패킷 바이트가 버려진다. 클라는 응답을 못 받고 타임아웃되며, 로그에는 원인이 남지 않는다.
  • 근본 원인: TCP 는 스트림이다. 한 번의 ReceiveAsync 가 "PROXY 헤더 정확히 하나"를 준다는 보장이 없다. 헤더가 여러 번에 나뉘어 올 수도(→ StartsWith 실패), 헤더+패킷이 한 번에 올 수도 있다(→ 뒷부분 유실). \r\n 까지만 소비하고 나머지를 다음 파서에 넘겨야 한다.

5) 레이트 카운터의 무한 증가 — [자원 관리]

  • _rate 에 IP 키가 계속 추가되기만 하고 만료·정리가 없다. (2)와 결합하면 위조 IP 로 딕셔너리를 무한히 키워 메모리 고갈을 유도할 수 있다.

수정안

아래는 구조를 보이기 위한 의사코드다. FramedStream, IPNetwork, _rate.Acquire, _log 등은 이 설계에서 새로 도입하는 요소이며 문제 코드의 스텁과 이름이 다르다.

public sealed class LoginAcceptor
{
    // 신뢰된 프록시 대역만 PROXY 헤더를 말할 수 있다.
    private static readonly IPNetwork[] TrustedProxies =
    {
        IPNetwork.Parse("10.0.0.0/8"),
    };

    public async Task OnAcceptedAsync(Socket socket)
    {
        try
        {
            var peer = (IPEndPoint)socket.RemoteEndPoint;
            bool fromTrustedProxy = TrustedProxies.Any(n => n.Contains(peer.Address));

            var stream = new FramedStream(socket);   // 남은 바이트를 보존하는 리더
            IPAddress clientAddress;

            if (fromTrustedProxy)
            {
                // (4) 헤더를 CRLF 까지만 소비하고 나머지는 스트림에 남긴다.
                var header = await stream.ReadLineAsync('\r', '\n', maxLen: 108, timeout: TimeSpan.FromSeconds(3));
                if (!ProxyProtocol.TryParseV1(header, out clientAddress))
                {
                    socket.Close();      // (3) 신뢰 프록시가 보낸 헤더는 반드시 유효해야 한다
                    return;
                }
            }
            else
            {
                // 직접 접속: PROXY 헤더를 보내든 말든 무시하고 피어 주소를 쓴다.
                clientAddress = peer.Address;
            }

            var info = new ConnectionInfo { ClientAddress = clientAddress };

            if (_bans.IsBanned(clientAddress)) { socket.Close(); return; }
            if (_rate.Acquire(clientAddress) == false) { socket.Close(); return; }

            info.Region = GeoIp.Lookup(clientAddress);
            Log(info);
            await HandleSessionAsync(stream, info);
        }
        catch (Exception ex)
        {
            _log.Warn(ex, "handshake failed");
            socket.Close();              // (3) 어떤 경로로도 소켓이 누수되지 않게
        }
    }
}

정책을 한 줄로 요약하면 이렇다. 신뢰된 프록시에서 온 연결은 PROXY 헤더를 필수로 요구하고, 그 외 연결에서는 PROXY 헤더를 절대 채택하지 않는다. "있으면 쓰고 없으면 피어 주소" 같은 관용적 처리가 바로 우회 통로가 된다.

레이트 리밋 키는 만료가 있는 구조(슬라이딩 윈도우 + TTL 캐시)로 두고, IPv6 는 개별 주소가 아니라 /64 프리픽스로 묶어 집계한다.

// IPv6 는 한 사용자가 /64 를 통째로 갖는 것이 보통이므로 주소 단위 집계는 무의미하다
static string RateKey(IPAddress a) =>
    a.AddressFamily == AddressFamily.InterNetworkV6
        ? new IPAddress(a.GetAddressBytes()[..8].Concat(new byte[8]).ToArray()).ToString()
        : a.ToString();

더 나은 설계

  • PROXY protocol v2(바이너리) 사용: 고정 시그니처 12바이트 + 길이 필드라 스트림 경계 처리와 검증이 훨씬 단순하고, "우연히 PROXY 로 시작하는 게임 패킷"과 혼동되지 않는다. 트레이드오프 — 텍스트가 아니라 디버깅 가시성이 떨어진다.
  • LB 를 L7 이 아닌 DSR/IP 투명 모드로 구성: 서버가 원래 클라 IP 를 그대로 보게 되어 애플리케 이션에서 신원 문제를 다룰 필요가 없다. 트레이드오프 — 네트워크 구성 복잡도·제약.
  • 레이트 리밋을 LB/엣지 계층으로 이동: 애플리케이션 소켓을 소비하기 전에 차단하는 편이 자원 효율이 높다. 트레이드오프 — 게임 로직 기반 세밀한 정책은 여전히 앱에 필요.
  • 신뢰 경계를 타입으로 표현: TrustedPeer/UntrustedPeer 를 구분해, 신뢰 경계를 넘은 값이 검증 없이 흘러가지 못하게 컴파일 타임에 강제.

면접 포인트

  1. "클라이언트 IP" 가 무엇인지 — 프록시/NAT/CGNAT/IPv6 환경에서 이 값이 무엇을 의미하고 무엇을 의미하지 않는지. X-Forwarded-For 를 그대로 믿으면 안 되는 이유와 같은 구조.
  2. 신뢰 경계(trust boundary)를 코드로 표현하기. "신뢰된 출처에서만 유효한 메타데이터"를 출처 검증 없이 채택하는 결함 패턴(PROXY protocol, XFF, JWT alg:none 등).
  3. TCP 스트림에서 프리앰블을 파싱할 때 "한 번의 recv = 한 개의 논리 단위"라는 가정이 왜 틀리며, 남은 바이트를 어떻게 다음 파서에 넘겨야 하는가.