← 문제로

22. 선행 퀘스트 상태를 클라가 보낸 값으로 신뢰하는 상황 (서버 권위)

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

해설 — 선행 퀘스트 상태를 클라가 보낸 값으로 신뢰하는 상황 (서버 권위)

난이도: 하

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

서버가 권위 데이터(_serverCompleted, _serverLevel)를 들고 있으면서도, 정작 검증은 클라이언트가 보낸 값(req.CompletedQuestIds, req.PlayerLevel)으로 한다(A). 또 진행도도 클라가 보고한 누적 카운트를 그대로 대입한다(B). 둘 다 전형적인 "클라 신뢰" 취약점으로, 패킷만 조작하면 선행 없이 퀘스트를 받고 한 방에 보상까지 받는다. 정답 한 줄: 모든 자격/진행 판정은 서버 보유 권위 데이터로만 하고, 진행도는 클라 보고가 아니라 서버가 판정한 실제 게임 이벤트로만 증가시킨다(보고는 무시 또는 검증용으로만).


문제점

(A) 선행/레벨 검증을 클라 입력으로 수행 — 인증/검증(권한) (서버 권위 위반) ★간판

  • 분류 태그: 입력 신뢰 / 권한 우회.
  • 증상: 클라가 CompletedQuestIds에 임의 선행 ID 를 넣고 PlayerLevel을 999 로 채워 보내면, 실제로는 선행을 깨지 않았고 레벨도 낮은데 퀘스트가 수락된다.
  • 재현조건: 변조된 AcceptQuest 패킷 1회. 정상 클라조차 캐시가 어긋나면 오판.
  • 근본 원인: 서버에 _serverCompleted/_serverLevel가 있는데 사용하지 않는다. 신뢰 경계(클라↔서버)를 넘어온 값을 권위로 취급했다.

(B) 진행도를 클라 보고값으로 대입 — 검증/치팅 (서버 권위 위반) ★간판

  • 분류 태그: 입력 신뢰 / 치팅.
  • 증상: ReportProgressKillCount를 그대로 저장 → 클라가 KillCount=9999를 보내면 즉시 목표 도달, ClaimReward로 보상 수령. 처치를 한 번도 안 해도 가능.
  • 재현조건: 변조된 진행 보고 1회.
  • 근본 원인: 진행도는 서버가 판정한 처치 이벤트의 결과여야 하는데, 클라 보고를 권위로 삼았다. 게다가 "대입"이라 음수/역행/감소도 막지 못한다.

(부수) 사전 검증 없는 딕셔너리 접근 — 견고성

  • 증상: _defs[req.QuestId]는 존재하지 않는 questId 면 KeyNotFoundException. 악의적/오래된 클라가 보낸 미지의 ID 로 서버 핸들러가 예외를 던진다.
  • 근본 원인: 외부 입력으로 들어온 키를 TryGetValue 없이 인덱싱.

(부수) ClaimReward 의 멱등성 부재 — 정합

  • 증상: 진행도가 목표 이상인 동안 ClaimReward를 반복 호출하면… 첫 호출에서 _activeProgress를 지우므로 2회차는 막히지만, 동시 2스레드면 둘 다 count 통과 후 둘 다 _grantGold → 중복 지급(check-then-act). 단일 스레드 가정이 깨지면 위험.

수정안

핵심: 신뢰 경계를 명확히 하고 권위 데이터로만 판정. 진행도는 서버 이벤트로만 증가.

public bool AcceptQuest(long playerId, int questId)   // 클라 상태 필드는 받지 않는다
{
    if (!_defs.TryGetValue(questId, out var def)) return false;   // 미지 questId 거부

    var completed = _serverCompleted.TryGetValue(playerId, out var d) ? d : null;
    bool prereqOk = def.RequiredQuestId == 0
                    || (completed != null && completed.Contains(def.RequiredQuestId));
    if (!prereqOk) return false;

    int level = _serverLevel.TryGetValue(playerId, out var lv) ? lv : 1;  // 권위 레벨
    if (level < def.RequiredLevel) return false;

    // 이미 진행 중/완료면 거부(중복 수락 방지)
    if (_activeProgress.ContainsKey((playerId, questId))) return false;
    if (completed != null && completed.Contains(questId)) return false;

    _activeProgress[(playerId, questId)] = 0;
    return true;
}

// 진행도는 클라 보고가 아니라 "서버가 판정한 처치"가 호출한다
public void OnMonsterKilled(long playerId, int questId, int monsterType)
{
    if (!_activeProgress.TryGetValue((playerId, questId), out var c)) return;
    var def = _defs[questId];
    // (필요시 monsterType이 이 퀘스트 대상인지 검증)
    _activeProgress[(playerId, questId)] = Math.Min(c + 1, def.TargetKillCount);
}

public bool ClaimReward(long playerId, int questId)
{
    var def = _defs[questId];
    // 원자적 제거로 멱등 보장: 제거에 성공한 호출만 보상 지급
    if (!_activeProgress.TryGetValue((playerId, questId), out var count)) return false;
    if (count < def.TargetKillCount) return false;
    if (!_activeProgress.Remove((playerId, questId))) return false;  // 경합 시 1명만 통과

    _grantGold(def.RewardGold);
    (_serverCompleted.TryGetValue(playerId, out var done)
        ? done : _serverCompleted[playerId] = new HashSet<int>()).Add(questId);
    return true;
}

포인트

  • QuestAcceptRequest에서 CompletedQuestIds/PlayerLevel 같은 상태 필드를 아예 받지 않는다(받더라도 무시). 요청은 "무엇을 하려는가" 만, 상태는 서버가 안다.
  • 진행도 진입점을 클라 보고가 아니라 서버 판정 이벤트(OnMonsterKilled)로 바꾼다.
  • 동시성/멱등: _activeProgress.Remove 성공 여부로 보상 1회 보장(멀티스레드면 락 또는 ConcurrentDictionary 의 원자 연산 사용).

더 나은 설계 (+트레이드오프)

  1. 신뢰 경계 원칙을 코드 타입으로 강제: 클라 DTO 에는 의도(questId)만 두고 상태 필드를 두지 않는 타입을 쓰면, 실수로라도 신뢰할 수 없다. 트레이드오프: DTO 분리 비용.
  2. 진행도 서버 단일 출처: 처치/수집 등 목표 이벤트를 서버 전투/판정 루프가 발행하는 도메인 이벤트로 모으고, 퀘스트 시스템이 구독해 카운트. 클라 보고는 UI 예측용일 뿐 권위가 아님. 트레이드오프: 이벤트 파이프라인 구축.
  3. 검증 가능한 진행도(anti-cheat): 비정상 속도(초당 처치율)·위치 정합성 등으로 서버 이벤트조차 교차검증. 트레이드오프: 비용/오탐.
  4. 완료 멱등 키: ClaimReward에 questId 단위 멱등 처리(이미 완료 집합 검사 + 원자 제거)로 중복 지급 원천 차단.

면접 포인트 (예상 질문)

  1. "클라가 보낸 레벨/선행 목록을 믿으면 안 되는 이유"를 신뢰 경계(trust boundary) 개념으로 설명하라. 어디까지가 적(adversary) 통제 영역인가?
  2. 진행도를 "클라 보고값 대입" 대신 "서버 이벤트 증가"로 바꿔야 하는 이유와, 그래도 남는 치팅 가능성(예: 매크로)은 어떻게 줄이나?
  3. ClaimReward를 멱등하게 만드는 방법 두 가지(원자 제거 / 완료 집합 검사)와 분산 환경에서의 차이는?