22. 선행 퀘스트 상태를 클라가 보낸 값으로 신뢰하는 상황 (서버 권위)
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 선행 퀘스트 상태를 클라가 보낸 값으로 신뢰하는 상황 (서버 권위)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
서버가 권위 데이터(_serverCompleted, _serverLevel)를 들고 있으면서도, 정작 검증은
클라이언트가 보낸 값(req.CompletedQuestIds, req.PlayerLevel)으로 한다(A). 또
진행도도 클라가 보고한 누적 카운트를 그대로 대입한다(B). 둘 다 전형적인 "클라 신뢰"
취약점으로, 패킷만 조작하면 선행 없이 퀘스트를 받고 한 방에 보상까지 받는다. 정답 한 줄:
모든 자격/진행 판정은 서버 보유 권위 데이터로만 하고, 진행도는 클라 보고가 아니라
서버가 판정한 실제 게임 이벤트로만 증가시킨다(보고는 무시 또는 검증용으로만).
문제점
(A) 선행/레벨 검증을 클라 입력으로 수행 — 인증/검증(권한) (서버 권위 위반) ★간판
- 분류 태그: 입력 신뢰 / 권한 우회.
- 증상: 클라가
CompletedQuestIds에 임의 선행 ID 를 넣고PlayerLevel을 999 로 채워 보내면, 실제로는 선행을 깨지 않았고 레벨도 낮은데 퀘스트가 수락된다. - 재현조건: 변조된
AcceptQuest패킷 1회. 정상 클라조차 캐시가 어긋나면 오판. - 근본 원인: 서버에
_serverCompleted/_serverLevel가 있는데 사용하지 않는다. 신뢰 경계(클라↔서버)를 넘어온 값을 권위로 취급했다.
(B) 진행도를 클라 보고값으로 대입 — 검증/치팅 (서버 권위 위반) ★간판
- 분류 태그: 입력 신뢰 / 치팅.
- 증상:
ReportProgress가KillCount를 그대로 저장 → 클라가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 의 원자 연산 사용).
더 나은 설계 (+트레이드오프)
- 신뢰 경계 원칙을 코드 타입으로 강제: 클라 DTO 에는 의도(questId)만 두고 상태 필드를 두지 않는 타입을 쓰면, 실수로라도 신뢰할 수 없다. 트레이드오프: DTO 분리 비용.
- 진행도 서버 단일 출처: 처치/수집 등 목표 이벤트를 서버 전투/판정 루프가 발행하는 도메인 이벤트로 모으고, 퀘스트 시스템이 구독해 카운트. 클라 보고는 UI 예측용일 뿐 권위가 아님. 트레이드오프: 이벤트 파이프라인 구축.
- 검증 가능한 진행도(anti-cheat): 비정상 속도(초당 처치율)·위치 정합성 등으로 서버 이벤트조차 교차검증. 트레이드오프: 비용/오탐.
- 완료 멱등 키:
ClaimReward에 questId 단위 멱등 처리(이미 완료 집합 검사 + 원자 제거)로 중복 지급 원천 차단.
면접 포인트 (예상 질문)
- "클라가 보낸 레벨/선행 목록을 믿으면 안 되는 이유"를 신뢰 경계(trust boundary) 개념으로 설명하라. 어디까지가 적(adversary) 통제 영역인가?
- 진행도를 "클라 보고값 대입" 대신 "서버 이벤트 증가"로 바꿔야 하는 이유와, 그래도 남는 치팅 가능성(예: 매크로)은 어떻게 줄이나?
ClaimReward를 멱등하게 만드는 방법 두 가지(원자 제거 / 완료 집합 검사)와 분산 환경에서의 차이는?
해설 · C++
해설 — 선행 퀘스트 상태를 클라가 보낸 값으로 신뢰하는 상황 (서버 권위, C++)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
서버가 권위 데이터(serverCompleted_, serverLevel_)를 보유하는데도, 자격 검증은
클라가 보낸 값(req.completedQuestIds, req.playerLevel)으로 한다(A). 진행도도 클라
보고 카운트를 그대로 대입한다(B). 둘 다 전형적 "클라 신뢰" 취약점으로, 패킷 조작만으로
선행 없이 퀘스트를 받고 한 방에 보상까지 받는다. 정답 한 줄: 자격/진행 판정은 서버 권위
데이터로만, 진행도는 서버가 판정한 실제 이벤트로만 증가시킨다(클라 보고는 무시/검증용).
문제점
(A) 선행/레벨 검증을 클라 입력으로 수행 — 인증/검증(권한) (서버 권위 위반) ★간판
- 분류 태그: 입력 신뢰 / 권한 우회.
- 증상: 클라가
completedQuestIds에 임의 선행 ID,playerLevel에 999 를 넣으면 선행 미완료·저레벨인데도 수락 통과. - 재현조건: 변조된
AcceptQuest패킷 1회. - 근본 원인:
serverCompleted_/serverLevel_가 있는데 안 쓴다. 신뢰 경계를 넘어온 값을 권위로 취급.
(B) 진행도를 클라 보고값으로 대입 — 검증/치팅 (서버 권위 위반) ★간판
- 분류 태그: 입력 신뢰 / 치팅.
- 증상:
ReportProgress가killCount를 그대로 저장 →killCount=9999면 즉시 목표 도달,ClaimReward로 수령. 처치 0회로도 가능. "대입"이라 역행/음수도 무방비. - 근본 원인: 진행도는 서버 판정 결과여야 하는데 클라 보고를 권위로 삼음.
(부수) defs_.at(req.questId) — 견고성/DoS
- 증상: 미지의 questId 면
std::out_of_range예외. 외부 입력을 검사 없이.at(). - 근본 원인: 신뢰 불가 키를
find없이 조회.
(부수) ClaimReward 멱등성/동시성 — 정합
- 증상: 단일 스레드면
erase로 2회차가 막히나, 동시 2스레드면 둘 다targetKillCount통과 후 둘 다grantGold_→ 중복 지급(check-then-act).
수정안
bool AcceptQuest(std::int64_t playerId, int questId) { // 클라 상태 필드는 받지 않음
auto dit = defs_.find(questId);
if (dit == defs_.end()) return false; // 미지 questId 거부
const QuestDef& def = dit->second;
auto cit = serverCompleted_.find(playerId);
bool prereqOk = def.requiredQuestId == 0
|| (cit != serverCompleted_.end()
&& cit->second.count(def.requiredQuestId) > 0);
if (!prereqOk) return false;
int level = 1;
if (auto lv = serverLevel_.find(playerId); lv != serverLevel_.end()) level = lv->second;
if (level < def.requiredLevel) return false;
if (active_.count({playerId, questId})) return false; // 중복 수락 방지
if (cit != serverCompleted_.end() && cit->second.count(questId)) return false;
active_[{playerId, questId}] = 0;
return true;
}
// 진행도는 서버가 판정한 처치 이벤트가 호출
void OnMonsterKilled(std::int64_t playerId, int questId /*, int monsterType*/) {
auto it = active_.find({playerId, questId});
if (it == active_.end()) return;
const QuestDef& def = defs_.at(questId);
it->second = std::min(it->second + 1, def.targetKillCount);
}
bool ClaimReward(std::int64_t playerId, int questId) {
auto it = active_.find({playerId, questId});
if (it == active_.end()) return false;
const QuestDef& def = defs_.at(questId);
if (it->second < def.targetKillCount) return false;
active_.erase(it); // 원자 제거(멀티스레드면 락으로 보호)
grantGold_(def.rewardGold);
serverCompleted_[playerId].insert(questId);
return true;
}
포인트
- 요청 DTO 에서 상태 필드(
completedQuestIds/playerLevel)를 받지 않거나 무시한다. - 진행도 진입점을 서버 판정 이벤트(
OnMonsterKilled)로 교체. - 멀티스레드 서버라면
active_/serverCompleted_를std::mutex로 보호하고 보상은 "제거 성공한 1명만" 지급.
더 나은 설계 (+트레이드오프)
- 타입으로 신뢰 경계 강제: 클라 요청 타입에 상태 필드를 두지 않으면 실수로도 신뢰 불가. 트레이드오프: DTO 분리.
- 진행도 서버 단일 출처: 전투/판정 루프가 발행하는 도메인 이벤트를 퀘스트 시스템이 구독. 클라 보고는 예측 UI 전용. 트레이드오프: 이벤트 파이프라인.
- anti-cheat 교차검증: 초당 처치율·위치 정합으로 서버 이벤트조차 검증. 비용/오탐.
- 완료 멱등 키: questId 단위 완료 집합 + 원자 제거로 중복 지급 차단.
면접 포인트 (예상 질문)
- 신뢰 경계(trust boundary) 관점에서 "클라가 보낸 선행/레벨"을 믿으면 안 되는 이유는?
- 진행도를 "보고값 대입" 대신 "서버 이벤트 증가"로 바꾸는 이유와 남는 치팅 대응은?
.at()로 외부 입력 키를 조회할 때의 위험과 안전한 대안(find)은?