클라이언트는 신뢰할 수 없다
온라인 게임 보안의 첫 번째 원칙입니다. 클라이언트에서 보내는 모든 데이터는 조작될 수 있습니다. 메모리 에디터, 패킷 조작 도구, 변조된 클라이언트 등을 통해 공격자는 어떤 값이든 보낼 수 있습니다.
따라서 게임의 모든 중요한 판정은 서버에서 수행해야 합니다.
서버 권위 모델 (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;
}
모든 재화 변동은 서버에서만 처리하고, 트랜잭션 로그를 남겨 문제 발생 시 롤백할 수 있도록 합니다.
마무리
게임 서버 보안의 핵심은 서버 권위입니다. 클라이언트는 입력만 전달하고, 모든 판정은 서버가 수행합니다.
핵심 원칙을 요약하면 다음과 같습니다.
- 클라이언트의 모든 입력을 서버에서 검증
- 게임 로직(데미지, 이동, 아이템)은 서버에서만 계산
- 이상 행동을 통계적으로 탐지
- 단계적 대응 (로그 → 경고 → 제한 → 차단)
- 패킷 암호화로 프로토콜 분석 방지
- 모든 재화 변동에 대한 로그 기록
완벽한 보안은 불가능하지만, 치트의 비용을 충분히 높이면 대부분의 공격을 억제할 수 있습니다.