← 문제로

24. 중첩 컨테이너 재귀 역직렬화 (깊이 제한 부재 / 노드 예산 부재)

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

해설 — 중첩 컨테이너 재귀 역직렬화 (깊이 제한 부재 / 노드 예산 부재)

난이도: 상

요약

ParseValue 는 List 를 만나면 자식마다 자기 자신을 재귀 호출(A) 하지만 깊이 상한이 없다. 공격자가 List(count=1) 을 수천 단계 중첩한 작은 패킷을 보내면 호출 스택이 선형으로 깊어져 StackOverflowException 이 난다 — .NET 에서 이 예외는 catch 할 수 없고 프로세스를 즉시 종료시킨다(원격 DoS). 또한 (B)의 count 와 (C)의 new List<Value>((int)count) 가 신뢰 불가 값이라, count 를 거대하게 주면 과대 선할당(OOM) 또는 (int)count 음수 캐스트로 인한 예외가 나고, 전체 노드 수에 상한이 없어 메모리/CPU 를 고갈시킬 수 있다.

문제점

1. (스택 고갈) 재귀 깊이 무제한 — (A)

  • 증상: 중첩 깊이만큼 스택 프레임이 쌓여 한계를 넘으면 StackOverflowException. .NET 에서는 try/catch 로 처리 불가, 호스트 프로세스가 그대로 죽는다(서버 전체 다운).
  • 재현조건: [2][01 00 00 00](List, count=1) 블록을 N번 이어 붙이고 끝에 [1][int]. N 수천이면 충분.
  • 근본원인: 깊이를 추적·제한하는 인자가 없다. "정상 2~3단계" 가정만 있고 강제가 없다.

2. (메모리 증폭 / 캐스트 결함) count 기반 선할당 — (B)(C)

  • 증상: new List<Value>((int)count) 에서 count=0x7FFFFFFF 면 거대 용량 선할당 시도 → OutOfMemoryException. count >= 0x80000000 이면 (int) 캐스트가 음수가 되어 ArgumentOutOfRangeException(또는 unchecked 환경에 따라 다른 실패). 실제 자식 바이트가 없어도 선할당만으로 자원 소모.
  • 근본원인: 선언 개수를 남은 바이트와 대조하지 않고 신뢰. 부호 없는 와이어 값을 int 로 무검증 캐스트.

3. (CPU/노드 폭증) 전체 노드 총량 무제한

  • 얕고 넓은 트리(자식 매우 많음)로도 노드 수·파싱 시간을 폭증시킬 수 있다.

수정안

깊이 한도와 노드 예산을 파서에 명시하고, count 를 남은 바이트로 상한한다.

public readonly struct ParseLimits { public int MaxDepth=>32; public int MaxNodes=>100_000; }

static Value ParseValue(ref ByteReader r, int depth, ref int nodes)
{
    if (depth > 32) throw new FormatException("too deep");
    if (++nodes > 100_000) throw new FormatException("too many nodes");

    var v = new Value { Tag = (Tag)r.ReadU8() };
    if (v.Tag == Tag.Int) { v.IntVal = r.ReadI32(); return v; }
    if (v.Tag != Tag.List) throw new FormatException("bad tag");      // 미지 태그 거절

    uint count = r.ReadU32();
    if (count > (uint)r.Remaining)                                    // 자식당 최소 1바이트
        throw new FormatException("count exceeds input");
    v.Children = new List<Value>(Math.Min((int)count, 1024));        // 점진 확장
    for (uint i = 0; i < count; i++)
        v.Children.Add(ParseValue(ref r, depth + 1, ref nodes));     // 깊이 +1
    return v;
}
  • MaxDepth(32~64)로 스택 오버플로 차단, MaxNodes 로 폭/총량 차단.
  • count > Remaining 으로 거대 선할당 거부. (int) 캐스트 전에 범위 검증.
  • 미지 태그는 화이트리스트로 즉시 거절.

더 나은 설계

  • 재귀를 명시적 스택(반복 루프)으로 바꾸면 콜스택 대신 힙 컬렉션을 쓰고 그 크기를 직접 상한할 수 있어, 잡을 수 없는 StackOverflow 위험을 구조적으로 없앤다.
  • 깊이/노드/바이트/문자열 길이 등 메시지 전체 리소스 예산을 파서 진입점에서 한 번 정의해 공유.
  • 포맷 스펙에 최대 중첩 깊이를 명시하고 게이트웨이에서 1차 검증.

면접 포인트

  1. .NET 의 StackOverflowExceptioncatch 불가 — 깊이 무제한 재귀 파서는 작은 입력으로 서버를 죽이는 원격 DoS 다. 발생 자체를 깊이 한도로 막아야 한다.
  2. 와이어의 개수/길이는 항상 남은 입력으로 상한하고, 부호 없는 값을 int 로 캐스트하기 전 범위를 검증한다.
  3. 신뢰 불가 입력 파서는 깊이·노드·메모리 예산을 명시하고, 가능하면 재귀를 반복+명시 스택으로 전환한다.