38. 세션 간 공유 직렬화 버퍼 오염: 데이터 유출·프레임 붕괴 (C#)
난이도 상해설 — 세션 간 공유 직렬화 버퍼 오염: 데이터 유출·프레임 붕괴 (C#)
난이도: 상
요약
_scratch 는 static 이라 모든 세션·모든 스레드가 하나의 버퍼를 공유한다. 여러 세션이 동시에 SendAsync 하면 서로의 직렬화 결과를 덮어쓴다. 게다가 Serialize 이후 await socket.SendAsync 사이에 제어가 넘어가므로, 첫 세션의 전송이 버퍼를 다 읽기 전에 다른 세션이 같은 버퍼를 다시 직렬화한다. 결과적으로 한 세션의 데이터가 다른 세션의 소켓으로 나가거나(정보 유출) 프레임이 섞여 붕괴한다.
문제점
-
[공유 가변 상태/데이터 레이스] 정적 버퍼를 여러 세션이 공유 → 상호 덮어쓰기
- 증상:
(A)의 단일 버퍼에 세션 A 가 직렬화한 뒤(B)에서 전송을 시작(await 로 양보)한 순간, 세션 B 가 같은_scratch에 자기 메시지를 직렬화한다. 세션 A 의SendAsync가 버퍼를 읽을 땐 이미 B 의 바이트가 들어 있어, A 의 소켓으로 B 의 데이터(예: 귓속말·인벤토리·좌표)가 나간다. - 재현: 두 세션이 서로 다른 스레드/태스크에서 거의 동시에 전송. 부하가 높을수록 상시 발생. 특히
Serialize→await사이 양보 지점이 창을 넓힌다. - 근본원인: 전송이 끝날 때까지 살아있어야 할 버퍼를 호출 간·세션 간 공유했고,
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대여-반납으로 호출 단위 격리를 얻되, 반납 시점(전송 완료 후)을 정확히 잡아야 함을 설명할 수 있어야 한다.
해설 — 세션 간 공유 직렬화 버퍼 오염: 데이터 유출·프레임 붕괴 (C++)
난이도: 상
요약
scratch 는 static 멤버라 프로세스 전체가 하나의 버퍼를 공유한다. 여러 I/O 스레드가 각자 세션의 send 를 동시에 호출하면 같은 버퍼에 서로의 직렬화 결과를 덮어쓴다(데이터 레이스). 그 결과 한 세션의 바이트가 다른 세션의 소켓으로 나가는 정보 유출과 프레임 붕괴가 발생한다. 전송이 비동기이거나 부분 전송(sys_send 가 일부만 전송)이라면, 나머지를 보내기 전에 버퍼가 덮여 프레임이 더 쉽게 섞인다.
문제점
-
[공유 가변 상태/데이터 레이스] 정적 버퍼를 여러 스레드/세션이 공유
- 증상:
(A)의 단일 버퍼에 스레드 T1(세션 A)이 직렬화하는 사이 스레드 T2(세션 B)가 같은 버퍼에 직렬화한다.(B)의sys_send가 읽는 바이트는 두 세션이 섞인 쓰레기다. TSan 으로 즉시 검출. - 재현: 두 세션을 서로 다른 I/O 스레드에서 거의 동시에 전송. 부하가 높을수록 상시.
- 근본원인: 전송 완료까지 온전해야 할 버퍼를 스레드·세션 간 공유했다.
static은 동기화를 주지 않는다.
- 증상:
-
[정보 유출/보안] 세션 경계를 넘는 데이터 누출
- 증상: 다른 플레이어의 사적 데이터(귓속말·좌표·인벤토리)가 엉뚱한 클라이언트로 전송.
- 근본원인: 세션별 격리 붕괴.
-
[부분 전송과 결합]
sys_send부분 전송 시 잔여 바이트가 덮임- 증상: 한 번에 다 못 보낸 프레임의 나머지를 보내기 전에 다른 세션이 버퍼를 갱신 → 프레임 절단·혼선.
수정안
직렬화 버퍼는 최소 스레드별(thread_local), 안전하게는 호출/세션별로 분리하고, 전송 완료까지 수명을 유지한다.
struct MessageSender {
void send(int fd, const Message& msg) {
thread_local uint8_t scratch[4096]; // 스레드별 분리(레이스 제거)
size_t len = msg.serialize(scratch, sizeof(scratch));
// 부분 전송 처리: 다 보낼 때까지 같은 버퍼를 유지하며 반복
size_t off = 0;
while (off < len) {
int n = sys_send(fd, scratch + off, len - off);
if (n <= 0) break; // EAGAIN 등은 상위 흐름제어에서 처리
off += (size_t)n;
}
}
};
주의: thread_local 은 같은 스레드에서 한 전송이 끝나기 전에 다른 세션 전송이 끼어들지 않을 때만 안전하다(콜백에서 재진입하면 다시 오염). 비동기 전송이라 버퍼 수명이 콜백까지 이어져야 한다면 호출/세션별 힙 버퍼(또는 세션 소유 버퍼 + 세션 단일 송신 루프)를 써서 in-flight 동안 살아있게 한다.
더 나은 설계
- 세션당 단일 송신 루프 + 세션 전용 버퍼: 세션 내 전송이 순차라 재사용이 안전하고, 세션 간 분리로 유출 불가.
- 버퍼 소유권을 전송 완료까지: 비동기(io_uring/IOCP/epoll 부분전송)에서는 완료 전 버퍼를 재사용·해제하지 않는다.
- 성능이 중요하면 세션별 링버퍼/
writevscatter-gather 로 복사·syscall 을 줄이면서 격리 유지.
면접 포인트
static/전역 스크래치 버퍼는 멀티스레드·비동기 전송에서 세션 격리를 깬다. "할당 절약"을 위해 공유 버퍼를 두면 정보 유출로 되돌아온다.- 최소 방어는
thread_local이지만, 비동기·재진입·부분 전송에서는 그것도 부족 — 버퍼 수명이 전송 완료까지 이어져야 함을 설명할 수 있어야 한다. - 이 결함이 크래시가 아니라 보안(정보 유출) 문제로 나타나는 이유를 짚는다.