42. 프록시 뒤 클라이언트 주소 식별: 신뢰 경계와 스트림 경계 (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를 구분해, 신뢰 경계를 넘은 값이 검증 없이 흘러가지 못하게 컴파일 타임에 강제.
면접 포인트
- "클라이언트 IP" 가 무엇인지 — 프록시/NAT/CGNAT/IPv6 환경에서 이 값이 무엇을 의미하고 무엇을 의미하지 않는지. X-Forwarded-For 를 그대로 믿으면 안 되는 이유와 같은 구조.
- 신뢰 경계(trust boundary)를 코드로 표현하기. "신뢰된 출처에서만 유효한 메타데이터"를
출처 검증 없이 채택하는 결함 패턴(PROXY protocol, XFF, JWT
alg:none등). - TCP 스트림에서 프리앰블을 파싱할 때 "한 번의 recv = 한 개의 논리 단위"라는 가정이 왜 틀리며, 남은 바이트를 어떻게 다음 파서에 넘겨야 하는가.
해설 — 프록시 뒤 클라이언트 주소 식별: 신뢰 경계·스트림 경계와 시스템 콜 반환값 (C++)
난이도: 최상
요약
C# 판과 동일한 신뢰 경계 결함(LB 주소를 집계 키로 사용, PROXY 헤더 무검증 채택)에 더해,
C++ 판은 시스템 콜 반환값 미검사, recv 반환 0/-1 의 오해, 주소 패밀리 고정(IPv6 미대응),
참조를 무효화하는 컨테이너 사용, fd 누수 가 겹쳐 메모리 안전성·가용성까지 위협한다.
문제점
1) 실제 클라 IP 대신 LB 주소 사용 — (A) [신뢰 경계/집계 키]
- 증상: 로그 IP 가 LB 주소 몇 개로만 채워지고,
rate_가 그 한 키에 전체 트래픽을 집계해 초당 10 연결을 넘는 순간 모든 정상 유저가 차단된다. - 재현 조건: PROXY 헤더가 없거나 (B) 조건을 못 맞추는 모든 연결.
- 근본 원인: 프록시 뒤에서
getpeername은 프록시를 가리킨다.
2) PROXY 헤더를 출처 검증 없이 신뢰 — (B) [스푸핑/권한 상승]
- 증상: 밴 우회, 레이트 리밋 무한 우회, 타인 IP 사칭에 의한 무고한 밴.
- 재현 조건: LB 를 거치지 않고 서버 포트에 직접 접속해
PROXY TCP4 8.8.8.8 ...전송. - 근본 원인: PROXY protocol 은 신뢰된 프록시에서 온 연결에서만 유효하다는 전제를 갖는데, 그 전제를 검사하지 않는다.
3) getpeername·recv 반환값 미검사 — (A)+(B) [미초기화 읽기/UB]
- 증상:
getpeername실패 시peer는 0 초기화 상태 그대로라0.0.0.0이 클라 IP 가 된다.recv가-1(EAGAIN/EINTR)을 반환하면 이 코드는n > 0검사로 길이 0 문자열을 만들지만, 이는 "연결 종료"와 "일시적 오류"와 "데이터 없음"을 구분하지 못해 정상 연결을 조용히 버린다.n == 0(상대 정상 종료)도 마찬가지로 구분되지 않는다. - 근본 원인: POSIX 시스템 콜의 세 가지 결과(성공/EOF/오류)를 하나로 뭉갰다.
4) 헤더를 스트림에서 소비하지 않음 — (B) [스트림 경계]
- 증상:
recv로 읽은 256바이트에 PROXY 헤더 뒤 첫 게임 패킷이 함께 들어와도 전부 버려진다. 헤더가 두 세그먼트로 쪼개져 오면rfind("PROXY ", 0)자체가 실패해 (1) 경로로 빠진다. - 근본 원인: TCP 는 스트림이며 한 번의
recv는 논리 단위와 무관하다.\r\n까지만 소비하고 나머지를 보존해 다음 파서에 넘겨야 한다.
5) fd 누수와 타임아웃 부재 — [자원 관리/가용성]
handleSession이 어떻게 fd 를 다루는지와 무관하게, 이 함수의 정상 경로에서는close가 전혀 보이지 않는다. 예외가 나면 fd 가 샌다. 또 인증 전 연결에 핸드셰이크 타임아웃이 없어 연결만 맺고 아무것도 보내지 않는 클라가 fd 를 무한 점유한다(slowloris).
6) IPv4 고정 — (A) [호환성]
sockaddr_in/AF_INET하드코딩. IPv6 클라의getpeername결과를sockaddr_in으로 해석해 엉뚱한 주소가 나온다.sockaddr_storage를 써야 한다.
7) rate_[key] 의 무한 증가와 참조 무효화 — [자원/수명]
operator[]는 없는 키를 삽입한다. (2)와 결합하면 위조 IP 로 맵을 무한히 키워 메모리를 고갈시킬 수 있다. 만료·정리가 없다. 또 이 맵을 여러 accept 스레드가 동시에 쓰면unordered_map은 동기화되지 않으므로 rehash 중 접근이 데이터 레이스/UB 가 된다.
수정안
// 신뢰 대역 판정
bool isTrustedProxy(const sockaddr_storage& ss);
// CRLF 까지만 소비하고 남은 바이트를 보존하는 버퍼드 리더
class BufferedConn {
public:
explicit BufferedConn(int fd) : fd_(fd) {}
// 성공: true / EOF·오류·상한 초과: false
bool readLineCRLF(std::string& out, std::size_t maxLen);
// 이후 파서는 buf_ 에 남은 바이트부터 이어서 읽는다
private:
int fd_;
std::string buf_;
};
void LoginAcceptor::onAccepted(int fd) {
FdGuard guard(fd); // (5) RAII 로 모든 경로에서 close 보장
sockaddr_storage peer{}; // (6) IPv4/IPv6 양쪽 수용
socklen_t plen = sizeof(peer);
if (::getpeername(fd, reinterpret_cast<sockaddr*>(&peer), &plen) != 0) return; // (3)
setRecvTimeout(fd, std::chrono::seconds(3)); // (5) 핸드셰이크 타임아웃
BufferedConn conn(fd);
std::string clientAddress;
if (isTrustedProxy(peer)) {
std::string header;
if (!conn.readLineCRLF(header, 107)) return; // (3)(4) 상한·오류 처리
if (!ProxyProtocol::parseV1(header, clientAddress)) return; // 신뢰 프록시면 필수·유효
} else {
clientAddress = toIpString(peer); // 직접 접속: PROXY 헤더는 절대 채택하지 않는다
}
if (bans_.isBanned(clientAddress)) return;
if (!rate_.acquire(clientAddress)) return; // TTL 있는 슬라이딩 윈도우 리미터
ConnectionInfo info{clientAddress, geoIpLookup(clientAddress)};
log(info);
guard.release(); // 소유권을 세션으로 이전
handleSession(std::move(conn), info);
}
recv 반환값은 반드시 세 갈래로 나눈다.
ssize_t n = ::recv(fd, buf, sizeof(buf), 0);
if (n > 0) { /* 데이터 n 바이트 */ }
else if (n == 0) { /* 상대가 정상 종료(EOF) — 재시도 금지, 정리 */ }
else if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) { /* 재시도 */ }
else { /* 진짜 오류 — 정리 */ }
레이트 맵은 스레드 안전 + 만료가 있어야 한다. 샤딩된 뮤텍스 맵이나 TTL 캐시를 쓰고,
IPv6 는 개별 주소가 아니라 /64 프리픽스로 집계한다.
더 나은 설계
- PROXY protocol v2(바이너리): 12바이트 시그니처 + 길이 필드라 스트림 경계 처리와 검증이 단순하고, 게임 패킷과 혼동될 여지가 없다. 트레이드오프 — 디버깅 가시성 하락.
- IP_TRANSPARENT / DSR LB 구성: 서버가 원래 클라 주소를 그대로 본다. 트레이드오프 — 네트워크 구성 제약과 운영 복잡도.
- 엣지(LB/방화벽)에서 레이트 리밋: 앱 fd 를 소비하기 전에 차단. 트레이드오프 — 게임 로직 기반 세밀 정책은 여전히 앱 몫.
- 주소 타입을 값 객체로 봉인(
ClientAddrvsPeerAddr): 신뢰 경계를 타입으로 표현해 검증 없는 대입을 컴파일 타임에 막는다.
면접 포인트
recv의 반환값 0 / -1(EAGAIN·EINTR) / -1(기타)의 의미 차이와, 이를 뭉개면 생기는 버그 (좀비 연결, 조용한 연결 폐기, 바쁜 대기).- 신뢰 경계를 코드로 표현하는 법. PROXY protocol·X-Forwarded-For 처럼 "신뢰된 출처에서만 유효한 메타데이터"를 출처 검증 없이 받아들이는 결함 패턴.
sockaddr_invssockaddr_storage, IPv6 환경에서 IP 기반 집계·차단의 단위를 /64 로 잡아야 하는 이유.