44. 인벤토리 자동 정렬과 슬롯 인덱스 명령의 TOCTOU (C#)
난이도 중해설 — 인벤토리 자동 정렬과 슬롯 인덱스 명령의 TOCTOU (C#)
난이도: 중
요약
클라이언트가 보낸 슬롯 인덱스로 아이템을 조회·조작하는 UseItem과, 슬롯 배치를
통째로 바꾸는 AutoSort가 아무 동기화 없이 서로 다른 스레드에서 실행된다.
인덱스로 아이템을 "찾는 시점"과 그 아이템을 "쓰는 시점" 사이에 정렬이 끼어들면
같은 슬롯 번호가 다른 아이템을 가리키게 되어, 사용자가 지목하지 않은 아이템이
소비/장착되거나, Item.Count -= 1 이 두 스레드에서 겹치며 수량이 깨진다.
문제점
- 분류: TOCTOU(검사-사용 시점 분리) + 데이터 레이스.
- 증상: (1) A 슬롯의 물약을 쓰려 했는데 정렬 후 그 자리에 온 B 아이템이 소비됨(오사용).
(2)
_slots[slotIndex]캐시(A) 이후AutoSort(B)가 배열을 재배치하면,UseItem은 이미 손에 쥔 옛item참조로Count를 깎고_slots[slotIndex]=null을 쓴다 → 엉뚱한 슬롯을 비우거나 방금 정렬로 채워진 아이템을 지운다. (3)item.Count -= 1자체가 비원자라 두 사용 요청이 겹치면 이중 차감/유실. - 재현 조건: 사용 요청과 정렬이 밀리초 단위로 겹치는 부하(자동전투 매크로 + 주기적 정렬). 단일 스레드 테스트로는 재현되지 않는다.
- 근본 원인: 슬롯 인덱스는 안정적 식별자가 아니다. 인덱스는 배치가 바뀌면
가리키는 대상이 변한다. 또한
_slots배열과Item.Count에 대한 상호 배제가 없다.
수정안
정렬로도 안 바뀌는 안정적 식별자(슬롯 고유 ID 또는 아이템 인스턴스 GUID) 로 명령을 표현하고, 인벤토리 전체 연산을 하나의 락으로 직렬화한다.
public sealed class Item
{
public long InstanceId; // 생성 시 1회 부여, 정렬해도 불변
public int ItemId;
public int Count;
public bool Consumable;
}
public sealed class Inventory
{
private readonly Item[] _slots;
private readonly object _gate = new object();
// 클라이언트는 slotIndex 대신 기대하는 InstanceId도 함께 보낸다.
public bool UseItem(int slotIndex, long expectedInstanceId)
{
lock (_gate)
{
if (slotIndex < 0 || slotIndex >= _slots.Length) return false;
var item = _slots[slotIndex];
// 낙관적 검증: 그 사이 정렬로 대상이 바뀌었으면 거부(클라 재동기화 유도).
if (item == null || item.InstanceId != expectedInstanceId)
return false;
if (!item.Consumable) { Equip(item); return true; }
item.Count -= 1;
ApplyConsumeEffect(item);
if (item.Count <= 0) _slots[slotIndex] = null;
return true;
}
}
public void AutoSort()
{
lock (_gate)
{
var packed = _slots.Where(s => s != null).OrderBy(s => s.ItemId).ToList();
for (int i = 0; i < _slots.Length; i++)
_slots[i] = i < packed.Count ? packed[i] : null;
}
}
}
핵심은 두 가지다: ① 락으로 _slots·Count를 함께 보호해 검사와 사용을 원자화,
② 인덱스가 아니라 인스턴스 ID로 대상을 확정해 정렬이 끼어들었을 때 오사용 대신
안전하게 거부(클라이언트가 최신 배치를 다시 받도록).
더 나은 설계
- 버전/세대 토큰: 인벤토리에
version을 두고 정렬·이동 때마다 증가시킨다. 클라 명령에 자신이 본 version을 실어 보내면, 서버는 version 불일치 시 거부하고 최신 스냅샷을 내려준다(광범위한 UI 데스싱크 방지). 트레이드오프: 정렬 직후 대기 중이던 정상 명령도 한 번 반려되어 클라 왕복이 1회 늘 수 있다. - 단일 액터(스레드) 모델: 한 플레이어의 인벤토리 조작을 그 플레이어 전용 작업 큐에서 순차 처리하면 락 자체가 사라진다. MMO에서 흔한 패턴이며 경합·데드락을 구조적으로 제거하지만, 크로스-플레이어 거래처럼 두 인벤토리를 동시에 잠가야 하는 연산은 별도 조정(락 순서/2PC)이 필요하다.
면접 포인트
- "슬롯 인덱스로 오는 클라 명령을 왜 그대로 믿으면 안 되나?" → 인덱스는 배치 의존적 식별자다. 안정적 식별자(인스턴스 ID)와 낙관적 검증으로 오사용을 막는다.
- "락 하나로 다 감싸면 성능은?" → 플레이어 단위 인벤토리는 경합 도메인이 좁아 플레이어별 락/액터로 샤딩하면 충분하다. 전역 락이 아니라 소유자 단위 락이 핵심.
해설 — 인벤토리 자동 정렬과 슬롯 인덱스 명령의 UAF·TOCTOU (C++)
난이도: 중상
요약
useItem은 슬롯 인덱스로 얻은 원시 포인터 Item*(A) 를 잡은 뒤 그 대상을
읽고 쓴다. 동시에 autoSort(B)는 unique_ptr들을 move로 재배치하며, 정렬 결과
남는 슬롯을 nullptr로 밀어 넣어 원래 있던 아이템을 파괴한다. 두 함수가 동기화
없이 다른 스레드에서 실행되면, useItem이 쥔 Item*이 autoSort의 unique_ptr
파괴로 해제된 메모리(use-after-free) 를 가리키게 된다.
문제점
- 분류: use-after-free + 데이터 레이스 + TOCTOU.
- 증상:
item->count -= 1또는slots_[slotIndex].reset()이 이미 해제된 객체를 건드려 힙 손상/크래시. 재현 조건이 맞으면 조용히 다른 아이템 메모리를 덮어써 수량·종류가 뒤바뀐다. 정렬이 대상을 옮겼을 뿐인데도 인덱스는 그대로라 엉뚱한 슬롯을 비우는 논리 결함까지 겹친다. - 재현 조건: 사용 요청과 정렬이 겹치는 부하.
unique_ptr소멸이 다른 스레드의 포인터 역참조와 인터리브될 때. ASan(-fsanitize=address)이면 heap-use-after-free로 즉시 잡힌다. - 근본 원인: (1)
slots_벡터·Item에 대한 상호 배제 부재, (2) 소유권을 가진unique_ptr가 이동/파괴되는 동안 그 대상을 원시 포인터로 별도 스레드가 사용, (3) 슬롯 인덱스가 정렬로 의미가 바뀌는 불안정 식별자.
수정안
전체 인벤토리 연산을 뮤텍스로 직렬화하고, 정렬로도 불변인 인스턴스 ID로 대상을 확정한다. 락 범위 안에서만 포인터를 잡아 쓰면 수명이 보장된다.
#include <mutex>
struct Item { long instanceId; int itemId; int count; bool consumable; };
class Inventory {
public:
explicit Inventory(std::size_t n) : slots_(n) {}
bool useItem(std::size_t slot, long expectedId) {
std::lock_guard<std::mutex> lk(mtx_); // 검사~사용 원자화
if (slot >= slots_.size()) return false;
Item* it = slots_[slot].get();
if (!it || it->instanceId != expectedId) return false; // 정렬로 바뀌었으면 거부
if (!it->consumable) { equip(it); return true; }
it->count -= 1;
applyConsumeEffect(it);
if (it->count <= 0) slots_[slot].reset();
return true;
}
void autoSort() {
std::lock_guard<std::mutex> lk(mtx_); // 이동/파괴도 락 안에서만
std::vector<std::unique_ptr<Item>> packed;
for (auto& s : slots_) if (s) packed.push_back(std::move(s));
std::sort(packed.begin(), packed.end(),
[](const auto& a, const auto& b){ return a->itemId < b->itemId; });
for (std::size_t i = 0; i < slots_.size(); ++i)
slots_[i] = (i < packed.size()) ? std::move(packed[i]) : nullptr;
}
private:
std::vector<std::unique_ptr<Item>> slots_;
std::mutex mtx_;
void equip(Item*) {} void applyConsumeEffect(Item*) {}
};
useItem이 잡은 Item*은 락을 쥔 동안에만 유효하고, 그 사이 autoSort가
소멸을 못 하므로 UAF가 사라진다. 인스턴스 ID 검증으로 오사용도 막는다.
더 나은 설계
- 소유권 유지 스냅샷: 락 밖에서 오래 쓸 값은
unique_ptr가 아니라shared_ptr<Item>로 잡아 참조 카운트로 수명을 연장한다. 단, 정렬이 만드는 "논리적 이동"과 별개로 살아있는 복제본이 생기므로 상태 갱신 경로를 단일화해야 한다. - 플레이어 단위 액터: 인벤토리 조작을 소유자 전용 단일 스레드 큐에서 순차 처리하면 락·UAF가 원천 제거된다. 크로스-플레이어 거래만 별도 조정.
- 핸들(index+generation): 슬롯을
{index, generation}핸들로 참조하고 재배치 때 generation을 올리면, stale 핸들을 O(1)로 거부할 수 있다(ABA 방지).
면접 포인트
- "unique_ptr를 쓰는데 왜 UAF가?" → 소유권은
unique_ptr에 있어도, 그 대상을 가리키는 원시 포인터를 다른 스레드가 동시에 역참조하면 소멸과 경합한다. 스마트 포인터는 수명 관리 도구지 스레드 안전 보장이 아니다. - "인덱스 vs 인스턴스 ID vs 핸들" → 배치가 바뀌는 컨테이너에서 인덱스는 불안정. 안정 식별자 또는 generation 핸들로 stale 접근을 검출하라.