← 문제로

38. 세션 간 공유 직렬화 버퍼 오염: 데이터 유출·프레임 붕괴 (C#)

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

해설 — 세션 간 공유 직렬화 버퍼 오염: 데이터 유출·프레임 붕괴 (C#)

난이도: 상

요약

_scratchstatic 이라 모든 세션·모든 스레드가 하나의 버퍼를 공유한다. 여러 세션이 동시에 SendAsync 하면 서로의 직렬화 결과를 덮어쓴다. 게다가 Serialize 이후 await socket.SendAsync 사이에 제어가 넘어가므로, 첫 세션의 전송이 버퍼를 다 읽기 전에 다른 세션이 같은 버퍼를 다시 직렬화한다. 결과적으로 한 세션의 데이터가 다른 세션의 소켓으로 나가거나(정보 유출) 프레임이 섞여 붕괴한다.

문제점

  • [공유 가변 상태/데이터 레이스] 정적 버퍼를 여러 세션이 공유 → 상호 덮어쓰기

    • 증상: (A) 의 단일 버퍼에 세션 A 가 직렬화한 뒤 (B) 에서 전송을 시작(await 로 양보)한 순간, 세션 B 가 같은 _scratch 에 자기 메시지를 직렬화한다. 세션 A 의 SendAsync 가 버퍼를 읽을 땐 이미 B 의 바이트가 들어 있어, A 의 소켓으로 B 의 데이터(예: 귓속말·인벤토리·좌표)가 나간다.
    • 재현: 두 세션이 서로 다른 스레드/태스크에서 거의 동시에 전송. 부하가 높을수록 상시 발생. 특히 Serializeawait 사이 양보 지점이 창을 넓힌다.
    • 근본원인: 전송이 끝날 때까지 살아있어야 할 버퍼를 호출 간·세션 간 공유했고, await 경계를 넘겨 그 버퍼의 소유가 겹쳤다.
  • [정보 유출/보안] 세션 경계를 넘는 데이터 누출

    • 증상: 단순 크래시가 아니라 다른 플레이어의 사적 데이터가 엉뚱한 클라이언트로 전송 → 심각한 프라이버시/치팅 문제.
    • 근본원인: 세션별 격리가 깨졌다.

수정안

전송 버퍼는 호출(또는 세션)마다 독립이어야 하고, 그 수명은 전송 완료까지 이어져야 한다. 풀을 쓰면 대여-반납으로 격리한다.

public async Task SendAsync(Socket socket, IMessage msg)
{
    byte[] buf = ArrayPool<byte>.Shared.Rent(4096);   // 이 호출 전용 버퍼
    try
    {
        int len = msg.Serialize(buf);
        await socket.SendAsync(new ArraySegment<byte>(buf, 0, len), SocketFlags.None);
        // 전송이 완료된 뒤에 반납 (await 이후이므로 버퍼는 그동안 살아있다)
    }
    finally { ArrayPool<byte>.Shared.Return(buf); }
}

세션당 전용 버퍼를 두는 방법도 있지만, 그 경우엔 같은 세션이 동시에 두 번 전송하지 않도록 세션 송신을 직렬화(단일 송신 루프)해야 한다.

더 나은 설계

  • 세션당 단일 송신 루프 + 세션 전용 버퍼: 세션 하나의 전송은 순차 처리되므로 버퍼 재사용이 안전하고, 세션 간에는 버퍼가 분리돼 유출이 불가능.
  • 버퍼 소유권을 전송 완료까지 명확히: await 를 넘겨 버퍼를 공유·조기 반납하지 않는다(이 카테고리의 "비동기 송신 버퍼 조기 반납" 결함과 짝을 이루는 원칙).
  • 성능이 정말 중요하면 PipeWriter/IBufferWriter 기반으로 세션별 파이프라인을 구성해 복사·할당을 줄이면서 격리를 유지.

면접 포인트

  • "할당을 줄이려는 정적/공유 스크래치 버퍼"는 동시성·비동기와 만나면 세션 격리를 깬다. 재사용 버퍼는 소유 범위(스레드/세션/호출)를 명확히 하고 await 경계를 넘기지 말아야 한다.
  • 이 결함은 크래시보다 정보 유출로 나타나 더 위험하다 — 격리 위반을 성능 최적화로 착각하면 안 된다.
  • ArrayPool 대여-반납으로 호출 단위 격리를 얻되, 반납 시점(전송 완료 후)을 정확히 잡아야 함을 설명할 수 있어야 한다.