네트워크 보안
서버 권한 설계, 입력 검증, 치트 방지 - 안전한 멀티플레이어 환경 구축
학습 목표
- 서버 권한(Server Authority) 설계 원칙 이해
- 클라이언트 입력 검증 패턴 구현
- 일반적인 치트 유형과 대응 방법
- Easy Anti-Cheat(EAC) 통합
1. 서버 권한 설계 원칙
1.1 절대 신뢰하지 않는 클라이언트
네트워크 보안의 첫 번째 원칙: 클라이언트로부터 오는 모든 데이터는 조작될 수 있습니다.
┌─────────────────────────────────────────────────────────────────┐
│ 보안 아키텍처 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [클라이언트] [서버] │
│ │ │ │
│ │ "포션 사용 요청" ──────►│ 1. 포션 소유 확인 │
│ │ │ 2. 쿨다운 확인 │
│ │ │ 3. 효과 적용 (서버 권한) │
│ │ ◄────── 결과 복제 ───────│ 4. 클라이언트에 결과 전송 │
│ │
│ ========== 잘못된 설계 (절대 금지) ========== │
│ │
│ [클라이언트] [서버] │
│ │ │ │
│ │ "체력을 100으로" ───────►│ 그대로 적용 ← 위험! │
│ │ │ │
└─────────────────────────────────────────────────────────────────┘
1.2 안전한 RPC 설계
// SecurePlayerController.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "SecurePlayerController.generated.h"
UCLASS()
class MYGAME_API ASecurePlayerController : public APlayerController
{
GENERATED_BODY()
public:
// ========================================
// ===== 잘못된 설계 (절대 하지 마세요) =====
// ========================================
// 클라이언트가 자신의 체력을 직접 설정 - 치터가 악용!
// UFUNCTION(Server, Reliable)
// void Server_SetMyHealth(float NewHealth); // 위험!
// 클라이언트가 자신의 위치를 직접 설정 - 텔레포트 치트!
// UFUNCTION(Server, Reliable)
// void Server_SetMyPosition(FVector NewPosition); // 위험!
// 클라이언트가 데미지 양을 직접 전달 - 원킬 치트!
// UFUNCTION(Server, Reliable)
// void Server_DealDamage(AActor* Target, float Damage); // 위험!
// ========================================
// ===== 올바른 설계 (서버 권한) =====
// ========================================
// 클라이언트는 "행동"만 요청, 서버가 결과 결정
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestUseItem(int32 ItemSlotIndex);
// 공격 요청 - 서버가 히트 판정 및 데미지 계산
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestAttack(const FVector& AimLocation, float ClientTimestamp);
// 상호작용 요청 - 서버가 거리 및 유효성 검증
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestInteraction(AActor* TargetActor);
// 스킬 사용 요청 - 서버가 마나/쿨다운 확인
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestUseSkill(int32 SkillId, const FVector& TargetLocation);
protected:
// 마지막 요청 시간 (스팸 방지)
float LastItemUseTime;
float LastAttackTime;
static constexpr float MIN_ITEM_USE_INTERVAL = 0.5f;
static constexpr float MIN_ATTACK_INTERVAL = 0.1f;
};
1.3 Validation 함수 구현
// SecurePlayerController.cpp
#include "SecurePlayerController.h"
#include "MyCharacter.h"
#include "InventoryComponent.h"
// ===== 아이템 사용 검증 =====
bool ASecurePlayerController::Server_RequestUseItem_Validate(int32 ItemSlotIndex)
{
// 1. 슬롯 인덱스 범위 검증
if (ItemSlotIndex < 0 || ItemSlotIndex >= MAX_INVENTORY_SLOTS)
{
UE_LOG(LogSecurity, Warning,
TEXT("[%s] Invalid item slot: %d (max: %d)"),
*GetNameSafe(this), ItemSlotIndex, MAX_INVENTORY_SLOTS);
return false; // 비정상 요청 -> 연결 해제 가능
}
// 2. 요청 빈도 검증 (스팸 방지)
float CurrentTime = GetWorld()->GetTimeSeconds();
if (CurrentTime - LastItemUseTime < MIN_ITEM_USE_INTERVAL * 0.5f)
{
// 너무 빠른 요청 - 약간의 여유 허용 (네트워크 지연)
UE_LOG(LogSecurity, Warning,
TEXT("[%s] Item use spam detected"),
*GetNameSafe(this));
return false;
}
return true;
}
void ASecurePlayerController::Server_RequestUseItem_Implementation(int32 ItemSlotIndex)
{
// Implementation에서 추가 검증 (연결 해제 없이)
AMyCharacter* MyChar = Cast(GetPawn());
if (!MyChar)
{
return;
}
UInventoryComponent* Inventory = MyChar->GetInventory();
if (!Inventory)
{
return;
}
// 3. 아이템 존재 여부 확인
FInventoryItem* Item = Inventory->GetItemAt(ItemSlotIndex);
if (!Item || !Item->IsValid())
{
// 클라이언트가 거짓 요청 - 무시 (연결 해제는 과함)
UE_LOG(LogSecurity, Log,
TEXT("[%s] No valid item at slot %d"),
*GetNameSafe(this), ItemSlotIndex);
return;
}
// 4. 아이템 사용 가능 여부 (쿨다운 등)
if (!Inventory->CanUseItem(ItemSlotIndex))
{
return;
}
// 5. 아이템 효과 적용 (서버 권한)
LastItemUseTime = GetWorld()->GetTimeSeconds();
Inventory->UseItem(ItemSlotIndex);
UE_LOG(LogSecurity, Log,
TEXT("[%s] Used item at slot %d"),
*GetNameSafe(this), ItemSlotIndex);
}
// ===== 공격 요청 검증 =====
bool ASecurePlayerController::Server_RequestAttack_Validate(
const FVector& AimLocation, float ClientTimestamp)
{
// 1. 타임스탬프 유효성 (미래 시간 불가)
float ServerTime = GetWorld()->GetGameState()->GetServerWorldTimeSeconds();
if (ClientTimestamp > ServerTime + 0.5f) // 0.5초 여유
{
UE_LOG(LogSecurity, Warning,
TEXT("[%s] Invalid timestamp: %f > %f"),
*GetNameSafe(this), ClientTimestamp, ServerTime);
return false;
}
// 2. AimLocation 유효성 (맵 범위 내)
if (AimLocation.ContainsNaN() ||
AimLocation.Size() > 1000000.0f) // 비정상적으로 먼 위치
{
UE_LOG(LogSecurity, Warning,
TEXT("[%s] Invalid aim location"),
*GetNameSafe(this));
return false;
}
return true;
}
void ASecurePlayerController::Server_RequestAttack_Implementation(
const FVector& AimLocation, float ClientTimestamp)
{
AMyCharacter* MyChar = Cast(GetPawn());
if (!MyChar) return;
// 공격 빈도 검증
float CurrentTime = GetWorld()->GetTimeSeconds();
float TimeSinceLastAttack = CurrentTime - LastAttackTime;
float MinAttackInterval = MyChar->GetMinAttackInterval();
if (TimeSinceLastAttack < MinAttackInterval * 0.9f) // 10% 오차 허용
{
UE_LOG(LogSecurity, Log,
TEXT("[%s] Attack too fast: %f < %f"),
*GetNameSafe(this), TimeSinceLastAttack, MinAttackInterval);
return;
}
// 서버에서 공격 수행
LastAttackTime = CurrentTime;
MyChar->PerformAttack(AimLocation, ClientTimestamp);
}
// ===== 상호작용 요청 검증 =====
bool ASecurePlayerController::Server_RequestInteraction_Validate(AActor* TargetActor)
{
// 1. 대상 유효성
if (!IsValid(TargetActor))
{
return false;
}
// 2. 상호작용 가능한 액터인지
if (!TargetActor->Implements())
{
UE_LOG(LogSecurity, Warning,
TEXT("[%s] Target is not interactable: %s"),
*GetNameSafe(this), *GetNameSafe(TargetActor));
return false;
}
return true;
}
void ASecurePlayerController::Server_RequestInteraction_Implementation(AActor* TargetActor)
{
APawn* MyPawn = GetPawn();
if (!MyPawn) return;
// 거리 검증
float Distance = FVector::Dist(MyPawn->GetActorLocation(),
TargetActor->GetActorLocation());
const float MaxInteractionDistance = 500.0f; // 5m
if (Distance > MaxInteractionDistance)
{
UE_LOG(LogSecurity, Log,
TEXT("[%s] Too far to interact: %f > %f"),
*GetNameSafe(this), Distance, MaxInteractionDistance);
return;
}
// 시야 검증 (선택사항)
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(MyPawn);
bool bHasLineOfSight = !GetWorld()->LineTraceSingleByChannel(
Hit,
MyPawn->GetActorLocation() + FVector(0, 0, 50),
TargetActor->GetActorLocation(),
ECC_Visibility,
Params
) || Hit.GetActor() == TargetActor;
if (!bHasLineOfSight)
{
UE_LOG(LogSecurity, Log,
TEXT("[%s] No line of sight to %s"),
*GetNameSafe(this), *GetNameSafe(TargetActor));
return;
}
// 상호작용 수행
IInteractable::Execute_OnInteract(TargetActor, this);
}
2. 입력 검증 시스템
2.1 입력 검증 유틸리티
// InputValidator.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
/**
* 클라이언트 입력 검증 유틸리티
* 모든 검증은 서버에서 수행
*/
class MYGAME_API FInputValidator
{
public:
// ===== 이동 검증 =====
/**
* 이동 속도 검증
* @param Character - 검증할 캐릭터
* @param ReportedVelocity - 클라이언트가 보고한 속도
* @return 유효한 속도인지 여부
*/
static bool ValidateMovementSpeed(const ACharacter* Character,
const FVector& ReportedVelocity)
{
if (!Character) return false;
UCharacterMovementComponent* Movement = Character->GetCharacterMovement();
if (!Movement) return false;
float MaxSpeed = Movement->GetMaxSpeed();
float ReportedSpeed = ReportedVelocity.Size();
// 네트워크 지연 및 가속/감속 고려 - 20% 오차 허용
const float Tolerance = 1.2f;
if (ReportedSpeed > MaxSpeed * Tolerance)
{
UE_LOG(LogSecurity, Warning,
TEXT("Suspicious speed: %f (max: %f) for %s"),
ReportedSpeed, MaxSpeed, *GetNameSafe(Character));
return false;
}
return true;
}
/**
* 위치 변화량 검증 (텔레포트 감지)
* @param OldPos - 이전 위치
* @param NewPos - 새 위치
* @param DeltaTime - 경과 시간
* @param MaxSpeed - 최대 이동 속도
* @return 유효한 이동인지 여부
*/
static bool ValidatePositionDelta(const FVector& OldPos,
const FVector& NewPos,
float DeltaTime,
float MaxSpeed)
{
float Distance = FVector::Dist(OldPos, NewPos);
// 최대 이동 가능 거리 (여유 있게)
float MaxPossibleDistance = MaxSpeed * DeltaTime * 1.5f;
// 최소 임계값 (짧은 텔레포트는 무시)
const float MinThreshold = 100.0f;
if (Distance > FMath::Max(MaxPossibleDistance, MinThreshold))
{
UE_LOG(LogSecurity, Warning,
TEXT("Suspicious teleport: %f units in %f seconds"),
Distance, DeltaTime);
return false;
}
return true;
}
// ===== 전투 검증 =====
/**
* 발사 빈도 검증
* @param LastFireTime - 마지막 발사 시간
* @param CurrentTime - 현재 시간
* @param MinFireInterval - 최소 발사 간격
* @return 유효한 발사인지 여부
*/
static bool ValidateFireRate(float LastFireTime,
float CurrentTime,
float MinFireInterval)
{
float TimeSinceLastFire = CurrentTime - LastFireTime;
// 10% 오차 허용 (네트워크 지연)
const float Tolerance = 0.9f;
if (TimeSinceLastFire < MinFireInterval * Tolerance)
{
UE_LOG(LogSecurity, Warning,
TEXT("Suspicious fire rate: %f (min interval: %f)"),
TimeSinceLastFire, MinFireInterval);
return false;
}
return true;
}
/**
* 탄약 소비 검증
* @param CurrentAmmo - 현재 탄약
* @param RequiredAmmo - 필요 탄약
* @return 탄약이 충분한지 여부
*/
static bool ValidateAmmoConsumption(int32 CurrentAmmo, int32 RequiredAmmo)
{
return CurrentAmmo >= RequiredAmmo;
}
/**
* 공격 사거리 검증
* @param AttackerLocation - 공격자 위치
* @param TargetLocation - 대상 위치
* @param MaxRange - 최대 사거리
* @return 사거리 내인지 여부
*/
static bool ValidateAttackRange(const FVector& AttackerLocation,
const FVector& TargetLocation,
float MaxRange)
{
float Distance = FVector::Dist(AttackerLocation, TargetLocation);
// 약간의 오차 허용
const float Tolerance = 1.1f;
if (Distance > MaxRange * Tolerance)
{
UE_LOG(LogSecurity, Warning,
TEXT("Attack out of range: %f > %f"),
Distance, MaxRange);
return false;
}
return true;
}
// ===== 자원 검증 =====
/**
* 인벤토리 조작 검증
* @param Inventory - 인벤토리 컴포넌트
* @param ItemId - 아이템 ID
* @param RequiredCount - 필요 수량
* @return 아이템이 충분한지 여부
*/
static bool ValidateItemCount(UInventoryComponent* Inventory,
int32 ItemId,
int32 RequiredCount)
{
if (!Inventory) return false;
int32 ActualCount = Inventory->GetItemCount(ItemId);
return ActualCount >= RequiredCount;
}
/**
* 통화 검증
* @param CurrentGold - 현재 골드
* @param RequiredGold - 필요 골드
* @return 골드가 충분한지 여부
*/
static bool ValidateCurrency(int64 CurrentGold, int64 RequiredGold)
{
// 음수 검증
if (RequiredGold < 0)
{
UE_LOG(LogSecurity, Warning,
TEXT("Negative gold requirement: %lld"), RequiredGold);
return false;
}
// 오버플로우 방지
if (RequiredGold > MAX_int64 - 1000000)
{
return false;
}
return CurrentGold >= RequiredGold;
}
// ===== 타이밍 검증 =====
/**
* 타임스탬프 유효성 검증
* @param ClientTimestamp - 클라이언트 타임스탬프
* @param ServerTimestamp - 서버 타임스탬프
* @param MaxLatency - 허용 최대 레이턴시 (기본 1초)
* @return 유효한 타임스탬프인지 여부
*/
static bool ValidateTimestamp(float ClientTimestamp,
float ServerTimestamp,
float MaxLatency = 1.0f)
{
// 미래 시간 불가
if (ClientTimestamp > ServerTimestamp + 0.1f)
{
UE_LOG(LogSecurity, Warning,
TEXT("Future timestamp: %f > %f"),
ClientTimestamp, ServerTimestamp);
return false;
}
// 너무 오래된 시간 불가
if (ClientTimestamp < ServerTimestamp - MaxLatency)
{
UE_LOG(LogSecurity, Warning,
TEXT("Stale timestamp: %f < %f - %f"),
ClientTimestamp, ServerTimestamp, MaxLatency);
return false;
}
return true;
}
};
2.2 보안 캐릭터 기본 클래스
// SecureCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "SecureCharacter.generated.h"
/**
* 보안이 강화된 캐릭터 기본 클래스
* 서버 권한 원칙을 준수
*/
UCLASS()
class MYGAME_API ASecureCharacter : public ACharacter
{
GENERATED_BODY()
public:
ASecureCharacter();
// ===== 체력 시스템 (서버 권한) =====
// 현재 체력 (읽기 전용)
UFUNCTION(BlueprintPure, Category = "Health")
float GetCurrentHealth() const { return Health; }
UFUNCTION(BlueprintPure, Category = "Health")
float GetMaxHealth() const { return MaxHealth; }
UFUNCTION(BlueprintPure, Category = "Health")
bool IsAlive() const { return Health > 0.0f; }
// 데미지 적용 (서버만 호출 가능)
UFUNCTION(BlueprintCallable, Category = "Health")
void ApplyDamage(float DamageAmount, AController* InstigatorController,
AActor* DamageCauser, const FHitResult& HitResult);
// 힐 적용 (서버만 호출 가능)
UFUNCTION(BlueprintCallable, Category = "Health")
void ApplyHeal(float HealAmount, AActor* Healer);
protected:
// 체력 (서버에서만 직접 수정)
UPROPERTY(ReplicatedUsing = OnRep_Health, BlueprintReadOnly, Category = "Health")
float Health;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Health")
float MaxHealth;
UFUNCTION()
void OnRep_Health(float OldHealth);
// 마지막 검증된 위치 (서버용)
FVector LastValidatedLocation;
float LastValidationTime;
// 위치 검증
virtual void ServerValidatePosition();
virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override;
virtual void Tick(float DeltaTime) override;
};
// SecureCharacter.cpp
#include "SecureCharacter.h"
#include "InputValidator.h"
#include "Net/UnrealNetwork.h"
#include "GameFramework/CharacterMovementComponent.h"
ASecureCharacter::ASecureCharacter()
{
MaxHealth = 100.0f;
Health = MaxHealth;
PrimaryActorTick.bCanEverTick = true;
}
void ASecureCharacter::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ASecureCharacter, Health);
}
void ASecureCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 서버에서만 위치 검증
if (HasAuthority())
{
ServerValidatePosition();
}
}
void ASecureCharacter::ServerValidatePosition()
{
float CurrentTime = GetWorld()->GetTimeSeconds();
float DeltaTime = CurrentTime - LastValidationTime;
// 0.5초마다 검증
if (DeltaTime < 0.5f)
{
return;
}
FVector CurrentLocation = GetActorLocation();
float MaxSpeed = GetCharacterMovement()->GetMaxSpeed();
// 위치 변화 검증
if (LastValidationTime > 0.0f) // 첫 검증 제외
{
if (!FInputValidator::ValidatePositionDelta(
LastValidatedLocation, CurrentLocation, DeltaTime, MaxSpeed))
{
// 의심스러운 이동 감지 - 로그 및 처리
UE_LOG(LogSecurity, Warning,
TEXT("%s: Suspicious movement detected, resetting position"),
*GetName());
// 옵션 1: 마지막 유효 위치로 복원
SetActorLocation(LastValidatedLocation);
// 옵션 2: 경고 누적 후 처리
// IncrementWarningCount();
}
}
LastValidatedLocation = GetActorLocation(); // 복원 후 위치
LastValidationTime = CurrentTime;
}
void ASecureCharacter::ApplyDamage(float DamageAmount, AController* InstigatorController,
AActor* DamageCauser, const FHitResult& HitResult)
{
// 서버에서만 실행
if (!HasAuthority())
{
UE_LOG(LogSecurity, Error,
TEXT("ApplyDamage called on client! This is a security violation."));
return;
}
// 이미 사망 시 무시
if (!IsAlive())
{
return;
}
// 데미지 검증
if (DamageAmount < 0.0f)
{
UE_LOG(LogSecurity, Warning,
TEXT("Negative damage attempted: %f"), DamageAmount);
return;
}
// 데미지 상한 검증 (무한 데미지 방지)
const float MAX_SINGLE_DAMAGE = 10000.0f;
DamageAmount = FMath::Min(DamageAmount, MAX_SINGLE_DAMAGE);
// 데미지 적용
float OldHealth = Health;
Health = FMath::Max(0.0f, Health - DamageAmount);
UE_LOG(LogGame, Log,
TEXT("%s took %f damage (%.0f -> %.0f)"),
*GetName(), DamageAmount, OldHealth, Health);
// 사망 처리
if (Health <= 0.0f)
{
OnDeath(InstigatorController, DamageCauser);
}
}
void ASecureCharacter::ApplyHeal(float HealAmount, AActor* Healer)
{
// 서버에서만 실행
if (!HasAuthority())
{
UE_LOG(LogSecurity, Error,
TEXT("ApplyHeal called on client! This is a security violation."));
return;
}
// 사망 시 힐 불가
if (!IsAlive())
{
return;
}
// 힐량 검증
if (HealAmount < 0.0f)
{
UE_LOG(LogSecurity, Warning,
TEXT("Negative heal attempted: %f"), HealAmount);
return;
}
// 힐 상한 검증
const float MAX_SINGLE_HEAL = 1000.0f;
HealAmount = FMath::Min(HealAmount, MAX_SINGLE_HEAL);
// 힐 적용
float OldHealth = Health;
Health = FMath::Min(MaxHealth, Health + HealAmount);
UE_LOG(LogGame, Log,
TEXT("%s healed for %f (%.0f -> %.0f)"),
*GetName(), HealAmount, OldHealth, Health);
}
void ASecureCharacter::OnRep_Health(float OldHealth)
{
// 클라이언트에서 체력 변화 시각화
float Delta = Health - OldHealth;
if (Delta < 0)
{
// 데미지 받음 - 피격 효과
PlayHitEffect();
}
else if (Delta > 0)
{
// 힐 받음 - 힐 효과
PlayHealEffect();
}
// 사망 효과
if (Health <= 0.0f && OldHealth > 0.0f)
{
PlayDeathEffect();
}
}
3. 일반적인 치트 유형과 대응
3.1 치트 유형별 대응
// CheatDetectionManager.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "CheatDetectionManager.generated.h"
// 위반 유형
UENUM(BlueprintType)
enum class ECheatViolationType : uint8
{
SpeedHack, // 이동 속도 조작
Teleport, // 텔레포트
RapidFire, // 발사 속도 조작
DamageHack, // 데미지 조작
ResourceHack, // 자원/아이템 조작
WallHack, // 벽 통과
AimbotSuspect, // 에임봇 의심
PacketManipulation // 패킷 조작
};
// 위반 기록
USTRUCT()
struct FViolationRecord
{
GENERATED_BODY()
ECheatViolationType Type;
float Timestamp;
FString Details;
int32 Severity; // 1-10
};
// 플레이어별 보안 데이터
USTRUCT()
struct FPlayerSecurityData
{
GENERATED_BODY()
TArray Violations;
int32 TotalViolationScore;
float LastWarningTime;
bool bIsBanned;
};
UCLASS()
class MYGAME_API UCheatDetectionManager : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// 위반 기록
void RecordViolation(APlayerController* Player,
ECheatViolationType Type,
const FString& Details,
int32 Severity = 5);
// 플레이어 보안 점수 확인
int32 GetPlayerViolationScore(APlayerController* Player) const;
// 플레이어 처리
void ProcessPlayer(APlayerController* Player);
protected:
// 플레이어별 보안 데이터
TMap PlayerSecurityMap;
// 처리 임계값
static constexpr int32 KICK_THRESHOLD = 50;
static constexpr int32 BAN_THRESHOLD = 100;
// 위반 점수 계산
int32 CalculateViolationScore(const TArray& Violations) const;
// 제재 적용
void KickPlayer(APlayerController* Player, const FString& Reason);
void BanPlayer(APlayerController* Player, const FString& Reason);
};
// CheatDetectionManager.cpp
#include "CheatDetectionManager.h"
#include "GameFramework/PlayerController.h"
#include "Kismet/GameplayStatics.h"
void UCheatDetectionManager::RecordViolation(APlayerController* Player,
ECheatViolationType Type,
const FString& Details,
int32 Severity)
{
if (!Player) return;
FUniqueNetIdRepl PlayerId = Player->GetUniqueNetIdFromCachedControllerId();
if (!PlayerId.IsValid()) return;
// 보안 데이터 가져오기 또는 생성
FPlayerSecurityData& SecurityData = PlayerSecurityMap.FindOrAdd(PlayerId);
// 이미 밴된 플레이어 무시
if (SecurityData.bIsBanned) return;
// 위반 기록 추가
FViolationRecord Record;
Record.Type = Type;
Record.Timestamp = GetWorld()->GetTimeSeconds();
Record.Details = Details;
Record.Severity = FMath::Clamp(Severity, 1, 10);
SecurityData.Violations.Add(Record);
// 점수 업데이트
SecurityData.TotalViolationScore = CalculateViolationScore(SecurityData.Violations);
UE_LOG(LogSecurity, Warning,
TEXT("Player %s violation: %s (Score: %d) - %s"),
*Player->GetPlayerState()->GetPlayerName(),
*UEnum::GetValueAsString(Type),
SecurityData.TotalViolationScore,
*Details);
// 처리
ProcessPlayer(Player);
}
int32 UCheatDetectionManager::CalculateViolationScore(
const TArray& Violations) const
{
int32 TotalScore = 0;
float CurrentTime = GetWorld()->GetTimeSeconds();
for (const FViolationRecord& Record : Violations)
{
// 시간에 따라 점수 감소 (5분마다 반감)
float Age = CurrentTime - Record.Timestamp;
float DecayFactor = FMath::Pow(0.5f, Age / 300.0f);
// 위반 유형별 기본 점수
int32 BaseScore = 0;
switch (Record.Type)
{
case ECheatViolationType::SpeedHack:
BaseScore = 10;
break;
case ECheatViolationType::Teleport:
BaseScore = 15;
break;
case ECheatViolationType::RapidFire:
BaseScore = 8;
break;
case ECheatViolationType::DamageHack:
BaseScore = 20;
break;
case ECheatViolationType::ResourceHack:
BaseScore = 25;
break;
case ECheatViolationType::WallHack:
BaseScore = 12;
break;
case ECheatViolationType::AimbotSuspect:
BaseScore = 5; // 의심만으로는 낮은 점수
break;
case ECheatViolationType::PacketManipulation:
BaseScore = 30; // 명백한 조작
break;
}
TotalScore += FMath::RoundToInt(BaseScore * Record.Severity * DecayFactor);
}
return TotalScore;
}
void UCheatDetectionManager::ProcessPlayer(APlayerController* Player)
{
FUniqueNetIdRepl PlayerId = Player->GetUniqueNetIdFromCachedControllerId();
FPlayerSecurityData* SecurityData = PlayerSecurityMap.Find(PlayerId);
if (!SecurityData) return;
// 밴 임계값 초과
if (SecurityData->TotalViolationScore >= BAN_THRESHOLD)
{
BanPlayer(Player, TEXT("Security violation threshold exceeded"));
return;
}
// 킥 임계값 초과
if (SecurityData->TotalViolationScore >= KICK_THRESHOLD)
{
KickPlayer(Player, TEXT("Suspicious activity detected"));
return;
}
// 경고 (10초마다 최대)
float CurrentTime = GetWorld()->GetTimeSeconds();
if (CurrentTime - SecurityData->LastWarningTime > 10.0f)
{
SecurityData->LastWarningTime = CurrentTime;
// 클라이언트에 경고 메시지 전송
if (AMyPlayerController* MyPC = Cast(Player))
{
MyPC->Client_ShowWarning(
TEXT("Unusual activity detected. Please ensure you're playing fairly."));
}
}
}
void UCheatDetectionManager::KickPlayer(APlayerController* Player, const FString& Reason)
{
UE_LOG(LogSecurity, Warning,
TEXT("Kicking player %s: %s"),
*Player->GetPlayerState()->GetPlayerName(),
*Reason);
// 게임 세션에서 킥
if (AGameModeBase* GameMode = GetWorld()->GetAuthGameMode())
{
GameMode->GameSession->KickPlayer(Player, FText::FromString(Reason));
}
}
void UCheatDetectionManager::BanPlayer(APlayerController* Player, const FString& Reason)
{
FUniqueNetIdRepl PlayerId = Player->GetUniqueNetIdFromCachedControllerId();
UE_LOG(LogSecurity, Error,
TEXT("Banning player %s: %s"),
*Player->GetPlayerState()->GetPlayerName(),
*Reason);
// 밴 상태 설정
FPlayerSecurityData* SecurityData = PlayerSecurityMap.Find(PlayerId);
if (SecurityData)
{
SecurityData->bIsBanned = true;
}
// TODO: 영구 밴을 위해 데이터베이스에 저장
// 플레이어 킥
KickPlayer(Player, TEXT("You have been banned for cheating."));
}
3.2 에임봇 감지
// AimbotDetector.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "AimbotDetector.generated.h"
/**
* 에임봇 감지 컴포넌트
* 서버에서 플레이어의 조준 패턴을 분석
*/
UCLASS(ClassGroup=(Security), meta=(BlueprintSpawnableComponent))
class MYGAME_API UAimbotDetector : public UActorComponent
{
GENERATED_BODY()
public:
UAimbotDetector();
// 샷 기록
void RecordShot(const FVector& AimDirection, const FVector& TargetLocation,
bool bHit, float ReactionTime);
protected:
// 샷 데이터
struct FShotRecord
{
FVector AimDirection;
FVector TargetLocation;
bool bHit;
float ReactionTime;
float Timestamp;
float AngleToTarget; // 타겟까지의 조준 각도
};
TArray RecentShots;
static constexpr int32 MAX_SHOTS_TO_ANALYZE = 50;
// 분석 지표
float AverageReactionTime;
float AverageAccuracy;
float SnapAimCount; // 급격한 조준 변화 횟수
// 에임봇 판정 임계값
static constexpr float SUSPICIOUS_REACTION_TIME = 0.1f; // 100ms
static constexpr float SUSPICIOUS_ACCURACY = 0.9f; // 90%
static constexpr float SNAP_AIM_THRESHOLD = 45.0f; // 45도 이상 급변
public:
// 분석 결과
float GetSuspicionScore() const;
bool IsSuspicious() const;
private:
void AnalyzePattern();
float CalculateSnapAimRatio() const;
};
4. Easy Anti-Cheat (EAC) 통합
4.1 EAC 설정
; DefaultEngine.ini - EAC 설정
[EasyAntiCheat]
bEnabled=true
; DefaultGame.ini
[/Script/Engine.GameSession]
bRequiresPushToTalk=true
; 프로젝트 폴더 구조
; Build/
; NoRedist/
; EasyAntiCheat/
; Settings.json
; base_private.key <- Epic Developer Portal에서 다운로드
; base_public.cer <- Epic Developer Portal에서 다운로드
4.2 EAC 관리 컴포넌트
// AntiCheatManager.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "AntiCheatManager.generated.h"
// EAC 관련 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnAntiCheatViolation, APlayerController*, Player, const FString&, Reason);
UCLASS()
class MYGAME_API UAntiCheatManager : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
// 플레이어 등록 (서버용)
UFUNCTION(BlueprintCallable, Category = "AntiCheat")
void RegisterPlayer(APlayerController* Player);
// 플레이어 등록 해제
UFUNCTION(BlueprintCallable, Category = "AntiCheat")
void UnregisterPlayer(APlayerController* Player);
// EAC 상태 확인
UFUNCTION(BlueprintPure, Category = "AntiCheat")
bool IsAntiCheatEnabled() const;
// 이벤트
UPROPERTY(BlueprintAssignable, Category = "AntiCheat")
FOnAntiCheatViolation OnViolationDetected;
protected:
// EAC 콜백 처리
void HandleClientActionRequired(APlayerController* Player, const FString& Action);
void HandleClientAuthStatusChanged(APlayerController* Player, bool bIsAuthenticated);
// 등록된 플레이어
TSet> RegisteredPlayers;
};
// AntiCheatManager.cpp
#include "AntiCheatManager.h"
#include "GameFramework/PlayerController.h"
void UAntiCheatManager::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
// EAC 초기화는 엔진 레벨에서 자동 처리
// 여기서는 게임별 설정만 추가
UE_LOG(LogSecurity, Log, TEXT("AntiCheatManager initialized"));
}
void UAntiCheatManager::Deinitialize()
{
// 모든 플레이어 등록 해제
for (auto& PlayerPtr : RegisteredPlayers)
{
if (PlayerPtr.IsValid())
{
UnregisterPlayer(PlayerPtr.Get());
}
}
RegisteredPlayers.Empty();
Super::Deinitialize();
}
void UAntiCheatManager::RegisterPlayer(APlayerController* Player)
{
if (!Player || !Player->HasAuthority())
{
return;
}
// 이미 등록되어 있으면 스킵
if (RegisteredPlayers.Contains(Player))
{
return;
}
RegisteredPlayers.Add(Player);
UE_LOG(LogSecurity, Log,
TEXT("Registered player for anti-cheat: %s"),
*Player->GetPlayerState()->GetPlayerName());
// EAC 세션에 플레이어 추가
// 실제 구현은 EOS Anti-Cheat Server SDK 사용
// EOS_AntiCheatServer_RegisterClient(...)
}
void UAntiCheatManager::UnregisterPlayer(APlayerController* Player)
{
if (!Player)
{
return;
}
RegisteredPlayers.Remove(Player);
UE_LOG(LogSecurity, Log,
TEXT("Unregistered player from anti-cheat: %s"),
*GetNameSafe(Player));
// EAC 세션에서 플레이어 제거
// EOS_AntiCheatServer_UnregisterClient(...)
}
bool UAntiCheatManager::IsAntiCheatEnabled() const
{
#if WITH_EOS_SDK
// EOS SDK가 있는 경우
return true;
#else
return false;
#endif
}
void UAntiCheatManager::HandleClientActionRequired(APlayerController* Player,
const FString& Action)
{
// EAC에서 클라이언트 조치 필요 시 호출
// 예: 게임 재시작 필요, 파일 무결성 문제 등
UE_LOG(LogSecurity, Warning,
TEXT("EAC action required for %s: %s"),
*GetNameSafe(Player), *Action);
OnViolationDetected.Broadcast(Player, Action);
}
void UAntiCheatManager::HandleClientAuthStatusChanged(APlayerController* Player,
bool bIsAuthenticated)
{
if (!bIsAuthenticated)
{
// 인증 실패 - 플레이어 킥
UE_LOG(LogSecurity, Error,
TEXT("EAC authentication failed for %s"),
*GetNameSafe(Player));
if (AGameModeBase* GameMode = GetWorld()->GetAuthGameMode())
{
GameMode->GameSession->KickPlayer(
Player,
FText::FromString(TEXT("Anti-cheat authentication failed"))
);
}
}
}
5. 네트워크 패킷 보안
5.1 패킷 무결성 검사
// PacketValidator.h
#pragma once
#include "CoreMinimal.h"
/**
* 네트워크 패킷 검증 유틸리티
*/
class MYGAME_API FPacketValidator
{
public:
/**
* RPC 호출 빈도 제한
*/
struct FRateLimiter
{
float WindowSize = 1.0f; // 측정 윈도우 (초)
int32 MaxCallsPerWindow = 60; // 윈도우당 최대 호출 수
TArray CallTimestamps;
bool CheckAndRecord(float CurrentTime)
{
// 오래된 타임스탬프 제거
CallTimestamps.RemoveAll([&](float Timestamp)
{
return CurrentTime - Timestamp > WindowSize;
});
// 제한 확인
if (CallTimestamps.Num() >= MaxCallsPerWindow)
{
return false;
}
// 현재 호출 기록
CallTimestamps.Add(CurrentTime);
return true;
}
};
/**
* 시퀀스 번호 검증 (리플레이 공격 방지)
*/
struct FSequenceValidator
{
int64 LastSequence = 0;
bool Validate(int64 Sequence)
{
// 시퀀스는 항상 증가해야 함
if (Sequence <= LastSequence)
{
UE_LOG(LogSecurity, Warning,
TEXT("Replay attack detected: seq %lld <= %lld"),
Sequence, LastSequence);
return false;
}
// 급격한 점프 감지 (패킷 주입 의심)
if (Sequence > LastSequence + 1000)
{
UE_LOG(LogSecurity, Warning,
TEXT("Sequence jump detected: %lld -> %lld"),
LastSequence, Sequence);
return false;
}
LastSequence = Sequence;
return true;
}
};
};
// 플레이어 컨트롤러에서 사용 예시
// SecurePlayerController.cpp에 추가
// 헤더에 추가
TMap RpcRateLimiters;
// 검증 함수
bool ASecurePlayerController::CheckRpcRateLimit(FName RpcName, int32 MaxPerSecond)
{
if (!HasAuthority()) return true; // 서버에서만 체크
FPacketValidator::FRateLimiter& Limiter = RpcRateLimiters.FindOrAdd(RpcName);
Limiter.MaxCallsPerWindow = MaxPerSecond;
float CurrentTime = GetWorld()->GetTimeSeconds();
if (!Limiter.CheckAndRecord(CurrentTime))
{
UE_LOG(LogSecurity, Warning,
TEXT("%s: RPC rate limit exceeded for %s"),
*GetName(), *RpcName.ToString());
return false;
}
return true;
}
// Server RPC에서 사용
void ASecurePlayerController::Server_RequestAttack_Implementation(...)
{
// Rate limit 체크
if (!CheckRpcRateLimit(GET_FUNCTION_NAME_CHECKED(ASecurePlayerController, Server_RequestAttack), 20))
{
return; // 초당 20회 이상 호출 시 무시
}
// ... 실제 로직
}
6. 보안 체크리스트
서버 권한 원칙
- 클라이언트는 절대 게임 상태를 직접 변경하지 않음
- 모든 중요 결정은 서버에서 수행
- 클라이언트는 "요청"만 전송
입력 검증
- 모든 RPC에 WithValidation 추가
- 범위, 타입, 유효성 검사
- Rate limiting 적용
- 타임스탬프 검증
치트 감지
- 이동 속도/거리 검증
- 공격 빈도/사거리 검증
- 자원 변화량 추적
- 패턴 분석 (에임봇 등)
대응 조치
- 경고 시스템 (누적 점수)
- 자동 킥/밴
- 로그 기록
- EAC/EOS 안티치트 통합
7. 실습 과제
과제 1: 안전한 거래 시스템
플레이어 간 아이템 거래 시스템을 서버 권한으로 구현하세요.
- 거래 요청/수락/거절
- 아이템 소유 검증
- 동시 거래 방지
- 거래 취소 처리
과제 2: 이상 행동 감지 시스템
여러 유형의 치트를 감지하는 시스템을 구현하세요.
- 스피드핵 감지
- 월핵 감지 (벽 너머 공격)
- 자동 조준 패턴 분석
- 리소스 조작 감지
과제 3: 보안 리포트 시스템
플레이어 신고 및 관리자 검토 시스템을 구현하세요.
- 신고 제출 UI
- 증거 수집 (리플레이 등)
- 관리자 검토 대시보드
- 제재 적용
핵심 요약
- 서버 권한: 모든 중요 게임 로직은 서버에서 처리
- 입력 검증: WithValidation으로 모든 클라이언트 입력 검증
- Rate Limiting: RPC 호출 빈도 제한으로 스팸 방지
- 치트 감지: 패턴 분석으로 이상 행동 감지
- EAC: 클라이언트 측 치트 도구 차단
- 점진적 제재: 경고 -> 킥 -> 밴 단계적 처리
PRACTICE
도전 과제
배운 내용을 직접 실습해보세요
실습 1: 서버 권한 검증 구현
Server RPC에서 클라이언트 요청을 검증하세요. 공격 RPC에서 쿨다운 체크, 사거리 확인, 인벤토리 유효성 검사를 서버에서 수행하여 치트를 방지하세요.
실습 2: 속도 해킹 탐지
서버에서 클라이언트의 이동 속도를 검증하세요. CharacterMovement::ServerCheckClientError()를 활용하고, 비정상 속도 탐지 시 로그 기록 및 경고를 발생시키세요.
심화 과제: 종합 안티치트 전략 수립
RPG 게임의 주요 치트 벡터(속도 해킹, 데미지 해킹, 아이템 복제, 월핵)를 분류하고, 각각에 대한 서버 측 검증 전략을 수립하세요. 서버 권한 모델의 한계와 추가 보안 레이어(암호화, 서명)를 설계하세요.