← Back

게임 서버 보안: 치트 방지와 서버 권위 모델

클라이언트는 신뢰할 수 없다

온라인 게임 보안의 첫 번째 원칙입니다. 클라이언트에서 보내는 모든 데이터는 조작될 수 있습니다. 메모리 에디터, 패킷 조작 도구, 변조된 클라이언트 등을 통해 공격자는 어떤 값이든 보낼 수 있습니다.

따라서 게임의 모든 중요한 판정은 서버에서 수행해야 합니다.

서버 권위 모델 (Server Authority)

기본 원칙

클라이언트: "나는 위치 (100, 0, 200)에 있다" → 서버가 검증
클라이언트: "나는 몬스터에게 9999 데미지를 줬다" → 서버가 계산
클라이언트: "나는 아이템을 획득했다" → 서버가 판정

클라이언트는 입력(이동 방향, 스킬 사용, 아이템 사용)만 보내고, 결과는 서버가 계산해서 통보합니다.

이동 처리

void handleMoveRequest(Session& session, const MovePacket& pkt)
{
    auto& player = getPlayer(session);
    auto now = getServerTime();

    // 1. 이동 속도 검증
    float dt = (now - player.lastMoveTime()) / 1000.0f;
    float maxDistance = player.moveSpeed() * dt * SPEED_TOLERANCE;
    float actualDistance = distance(player.position(), pkt.targetPosition);

    if (actualDistance > maxDistance)
    {
        // 비정상 이동 — 서버가 계산한 위치로 강제 보정
        auto correctedPos = player.position() +
            normalize(pkt.targetPosition - player.position()) * maxDistance;
        player.setPosition(correctedPos);
        sendPositionCorrection(session, correctedPos);
        logSuspiciousActivity(session, "speed_hack", actualDistance, maxDistance);
        return;
    }

    // 2. 충돌 검증 — 벽 통과 여부
    if (collisionSystem_.hasObstacle(player.position(), pkt.targetPosition))
    {
        sendPositionCorrection(session, player.position());
        logSuspiciousActivity(session, "wall_hack");
        return;
    }

    // 3. 유효한 이동 — 반영
    player.setPosition(pkt.targetPosition);
    player.setLastMoveTime(now);
    broadcastMovement(player);
}

SPEED_TOLERANCE는 네트워크 지연을 고려한 여유분입니다. 보통 10~20% 정도의 마진을 둡니다.

전투 처리

void handleAttackRequest(Session& session, const AttackPacket& pkt)
{
    auto& attacker = getPlayer(session);
    auto target = getEntity(pkt.targetId);

    if (!target)
        return;

    // 1. 사거리 검증
    float dist = distance(attacker.position(), target->position());

    if (dist > attacker.attackRange() * RANGE_TOLERANCE)
    {
        sendError(session, "Target out of range");
        return;
    }

    // 2. 쿨다운 검증
    auto now = getServerTime();

    if (now - attacker.lastAttackTime() < attacker.attackCooldown())
    {
        sendError(session, "Attack on cooldown");
        return;
    }

    // 3. 데미지 계산은 서버에서
    int damage = calculateDamage(attacker, *target);
    target->takeDamage(damage);
    attacker.setLastAttackTime(now);

    // 4. 결과 브로드캐스트
    broadcastDamage(attacker.id(), target->id(), damage);
}

클라이언트가 "내가 준 데미지는 9999"라고 보내도, 서버는 무시하고 자체 공식으로 계산합니다.

패킷 검증 체크리스트

모든 패킷에 대해 다음 항목을 검증합니다.

기본 검증

bool validatePacket(Session& session, const Packet& pkt)
{
    // 1. 패킷 크기 검증
    if (pkt.size() > MAX_PACKET_SIZE || pkt.size() < MIN_PACKET_SIZE)
        return false;

    // 2. OpCode 유효성
    if (!isValidOpcode(pkt.opcode()))
        return false;

    // 3. 세션 상태 확인 — 로그인 전에 게임 패킷을 보내면 차단
    if (!session.isAuthenticated() && !isAuthPacket(pkt.opcode()))
        return false;

    // 4. 패킷 빈도 제한
    if (session.packetCount() > MAX_PACKETS_PER_SECOND)
    {
        session.close("Rate limit exceeded");
        return false;
    }

    return true;
}

비즈니스 로직 검증

void handleUseItem(Session& session, const UseItemPacket& pkt)
{
    auto& player = getPlayer(session);

    // 아이템 소유 확인
    if (!player.inventory().hasItem(pkt.itemId))
    {
        logSuspiciousActivity(session, "use_unowned_item");
        return;
    }

    // 아이템 사용 조건 확인 (레벨, 클래스 등)
    auto& item = getItemData(pkt.itemId);

    if (player.level() < item.requiredLevel)
    {
        sendError(session, "Level requirement not met");
        return;
    }

    // 쿨다운 확인
    if (player.isItemOnCooldown(pkt.itemId))
    {
        sendError(session, "Item on cooldown");
        return;
    }

    // 서버에서 아이템 효과 적용
    applyItemEffect(player, item);
    player.inventory().removeItem(pkt.itemId);
}

이상 행동 탐지

통계 기반 탐지

단일 행동은 정상이지만, 패턴이 비정상인 경우를 탐지합니다.

class AnomalyDetector
{
public:
    void recordAction(uint64_t playerId, ActionType type)
    {
        auto& stats = playerStats_[playerId];
        stats.actionCounts[type]++;
        stats.lastActionTime = getServerTime();
    }

    void analyze(uint64_t playerId)
    {
        auto& stats = playerStats_[playerId];

        // 킬/데스 비율이 비정상적으로 높음
        float kdr = static_cast<float>(stats.kills) / std::max(stats.deaths, 1);

        if (kdr > SUSPICIOUS_KDR_THRESHOLD && stats.kills > MIN_KILLS_FOR_CHECK)
            flagForReview(playerId, "abnormal_kdr", kdr);

        // 이동 보정 빈도가 높음 (속도핵 시도)
        if (stats.moveCorrections > CORRECTION_THRESHOLD)
            flagForReview(playerId, "frequent_corrections", stats.moveCorrections);

        // 초당 패킷 수가 비정상적
        if (stats.packetsPerSecond > SUSPICIOUS_PPS)
            flagForReview(playerId, "high_packet_rate", stats.packetsPerSecond);
    }

private:
    struct PlayerStats
    {
        std::unordered_map<ActionType, int> actionCounts;

        int kills = 0;

        int deaths = 0;

        int moveCorrections = 0;

        float packetsPerSecond = 0.0f;

        uint64_t lastActionTime = 0;
    };

    std::unordered_map<uint64_t, PlayerStats> playerStats_;
};

단계적 대응

즉시 차단보다 단계적 대응이 효과적입니다.

1단계: 로그 기록 — 의심 활동을 상세히 기록
2단계: 경고 — 클라이언트에 경고 메시지 전송
3단계: 제한 — 특정 기능 제한 (거래 금지, 매칭 제한)
4단계: 임시 차단 — 일정 기간 접속 차단
5단계: 영구 차단 — 계정 영구 정지

오탐(false positive)을 줄이기 위해, 자동 차단은 명확한 증거가 있을 때만 적용하고, 나머지는 사람이 확인합니다.

패킷 암호화

패킷 내용이 평문으로 전송되면 패킷 스니퍼로 프로토콜을 분석하고 조작할 수 있습니다.

세션 키 교환

class SessionEncryption
{
public:
    // 연결 시 키 교환
    void onConnect(Session& session)
    {
        // 서버가 랜덤 세션 키 생성
        auto sessionKey = generateRandomKey(32);

        // RSA 공개키로 세션 키 암호화해서 클라이언트에 전송
        auto encrypted = rsaEncrypt(sessionKey, clientPublicKey);
        session.send(EncryptionSetupPacket{encrypted});

        // 이후 통신은 세션 키로 AES 암호화
        session.setEncryptionKey(sessionKey);
    }
};

XOR 기반 간단한 난독화

완전한 암호화가 과도하다면, 패킷을 XOR로 난독화하는 것만으로도 캐주얼한 패킷 조작을 방지할 수 있습니다.

void xorEncrypt(uint8_t* data, size_t length, const uint8_t* key, size_t keyLength)
{
    for (size_t i = 0; i < length; ++i)
        data[i] ^= key[i % keyLength];
}

단, XOR는 보안 수준이 낮으므로 민감한 데이터(결제, 인증)에는 적합하지 않습니다.

재화/아이템 보안

서버 측 인벤토리 관리

struct TradeRequest
{
    uint64_t fromPlayerId;

    uint64_t toPlayerId;

    uint64_t itemId;

    int goldAmount;
};

bool processTrade(const TradeRequest& trade)
{
    auto& from = getPlayer(trade.fromPlayerId);
    auto& to = getPlayer(trade.toPlayerId);

    // 원자적 거래 처리
    if (!from.inventory().hasItem(trade.itemId))
        return false;

    if (to.gold() < trade.goldAmount)
        return false;

    if (to.inventory().isFull())
        return false;

    // 모든 조건 통과 후 한 번에 처리
    from.inventory().removeItem(trade.itemId);
    to.inventory().addItem(trade.itemId);
    to.removeGold(trade.goldAmount);
    from.addGold(trade.goldAmount);

    // 거래 로그 기록 (추후 롤백용)
    logTrade(trade);

    return true;
}

모든 재화 변동은 서버에서만 처리하고, 트랜잭션 로그를 남겨 문제 발생 시 롤백할 수 있도록 합니다.

마무리

게임 서버 보안의 핵심은 서버 권위입니다. 클라이언트는 입력만 전달하고, 모든 판정은 서버가 수행합니다.

핵심 원칙을 요약하면 다음과 같습니다.

  1. 클라이언트의 모든 입력을 서버에서 검증
  2. 게임 로직(데미지, 이동, 아이템)은 서버에서만 계산
  3. 이상 행동을 통계적으로 탐지
  4. 단계적 대응 (로그 → 경고 → 제한 → 차단)
  5. 패킷 암호화로 프로토콜 분석 방지
  6. 모든 재화 변동에 대한 로그 기록

완벽한 보안은 불가능하지만, 치트의 비용을 충분히 높이면 대부분의 공격을 억제할 수 있습니다.