본문 바로가기

캡스톤 디자인/정보보호 프로젝트실습

[팰월드 안티치트 프로젝트] #5 전용서버 메모리 분석 - 스피드핵

본 글은 졸업작품(안티치트 시스템 개발)을 위한 학술 목적의 보안 연구입니다. 치트의 동작 원리를 분석하여 탐지 및 방어 방안을 설계하는 데 활용합니다.

 

📌 이 글은 팰월드 안티치트 프로젝트의 학습 기록 시리즈입니다. 안티치트를 만들기 위해 먼저 "공격자가 게임을 어떻게 공격하는지" 이해하는 과정을 정리합니다.
이전 글(#4)에서는 서버 프로세스에 안티치트가 없다는 것을 확인하고, SDK 역추적으로 스테이터스 포인트(UnusedStatusPoint)를 변조하는 데 성공했다. 서버에서 바꾼 값이 클라이언트에 즉시 전파되는 것도 확인했다.
이번 글에서는 같은 서버 메모리 변조 기법을 이동속도에 적용한다. 스피드핵은 온라인 게임에서 가장 흔한 치트 중 하나이며, 대부분의 안티치트가 탐지 대상으로 삼는 항목이다. 팰월드 전용서버에서 이동속도를 변조하면 실제로 캐릭터가 빨라지는지, 그리고 서버가 이를 막는지 확인한다.

 

이 글에서 다루는 내용:

- SDK 덤프에서 UE5 이동속도 관련 클래스 구조 분석
- #4의 포인터 체인을 확장하여 CharacterMovementComponent까지 도달
- 서버 메모리에서 MaxWalkSpeed, SprintMaxSpeed 등 읽기 및 변조
- 변조 결과 관찰: 즉시 적용되지만 끊김(rubber-banding) 발생
- 안티치트 관점의 취약점 정리 및 탐지 방안

 

분석 환경:

항목  내용
서버 PalServer-Win64-Shipping-Cmd.exe (Steam Dedicated Server)
클라이언트 Palworld v0.7.2.87654 (Steam)
SDK 덤프 Dumper-7
동적 분석 Cheat Engine 7.6
접속 환경 로컬 전용서버 (127.0.0.1:8211)

 

1단계: SDK에서 이동속도 관련 클래스 찾기

#4에서는 "바꾸고 싶은 값"에서 출발해 역추적하는 방식으로 포인터 체인을 구성했다. 이번에도 같은 방식을 사용한다. 목표는 캐릭터의 이동속도를 변조하는 것이다.

[1] 이동속도는 어디에 있는가?
#4에서 스테이터스 포인트를 찾을 때는 Pal_structs.hpp에서 "StatusPoint"를 검색했다. 스테이터스 포인트는 팰월드 고유의 게임 데이터이기 때문이다.
이동속도는 다르다. "캐릭터가 걷고, 달리고, 수영한다"는 건 팰월드만의 기능이 아니라 모든 3D 게임의 기본 기능이다. UE5 엔진은 이런 기본적인 이동 기능을 엔진 차원에서 미리 만들어두었다. 그래서 이동속도 관련 변수는 팰월드 전용 파일(Pal_classes.hpp)이 아니라 엔진 기본 파일(Engine_classes.hpp)에 있다.

 

Engine_classes.hpp를 열고 MaxWalkSpeed를 검색한다. 다음 줄이 나온다:

float		MaxWalkSpeed;		// 0x01F8(0x0004)(Edit, BlueprintVisible, ZeroConstructor, IsPlainOldData, NoDestructor, HasGetValueTypeHash, NativeAccessSpecifierPublic)
float		MaxWalkSpeedCrouched;		// 0x01FC(0x0004)(Edit, BlueprintVisible, ZeroConstructor, IsPlainOldData, NoDestructor, HasGetValueTypeHash, NativeAccessSpecifierPublic)

검색 결과는 이 2개뿐이다. 하지만 바로 아래 줄을 보면 이름이 다른 속도 변수들이 더 있다

 

이 변수들이 속한 클래스를 확인하려면 위로 스크롤한다. UCharacterMovementComponent라는 클래스 안에 들어있는 것을 볼 수 있다. 이것이 UE5 엔진이 캐릭터 이동을 담당하는 표준 컴포넌트이다.

 

[2] 팰월드 전용 속도 필드
팰월드는 UE5 기본 클래스를 상속받아 UPalCharacterMovementComponent라는 자체 클래스를 만들었다. Pal_classes.hpp에서 PalCharacterMovementComponent를 검색하면 팰월드가 추가한 속도 필드들이 나온다.

// UPalCharacterMovementComponent 클래스 내부 (팰월드 전용)
// 부모: UCharacterMovementComponent (위의 모든 필드를 그대로 포함)

float  DyingMaxSpeed;           // 0x1138  ← 빈사 상태 속도
float  FatigueMaxSpeed;         // 0x113C  ← 피로 상태 속도
float  SprintMaxSpeed;          // 0x1140  ← 달리기 최대 속도
float  SprintMaxAcceleration;   // 0x1144  ← 달리기 가속도
float  GliderMaxSpeed;          // 0x114C  ← 글라이더 속도
float  GliderGravityScale;      // 0x1154  ← 글라이더 중력
float  SlidingMaxSpeed;         // 0x1160  ← 슬라이딩 최대 속도
float  ClimbMaxSpeed;           // 0x1184  ← 등반 속도
float  GrapplingMaxSpeed;       // 0x118C  ← 그래플링 속도
float  OverrideFlySpeed;        // 0x1198  ← 비행 속도 오버라이드
float  DashSwimMaxSpeed;        // 0x1E20  ← 대쉬 수영 속도
...

 

UE5 기본 MaxWalkSpeed`(0x01F8)는 일반 걷기 속도이고, 팰월드가 추가한 SprintMaxSpeed는 달리기 속도이다. 두 가지 모두 변조 대상이다.

 

[1]에서 찾은 MaxWalkSpeed 같은 변수들은 UE5 엔진이 기본으로 제공하는 것이다. 하지만 팰월드에는 엔진에 없는 이동 방식도 있다. 달리기, 글라이더, 등반, 그래플링 같은 것들. 이런 기능을 위해 팰월드는 엔진의 이동 클래스를 상속받아 자체 클래스를 만들었다. Pal_classes.hpp에서 PalCharacterMovementComponent를 검색하면 다음이 나온다.

: public UCharacterMovementComponent 이것은 "엔진의 이동 클래스를 상속받는다"는 뜻이다. 상속이란 부모가 가진 모든 변수를 그대로 물려받고, 거기에 자기만의 변수를 더 추가할 수 있는 것이다. UE5 엔진은 걷기, 수영, 비행 같은 기본적인 이동 기능을 이미 만들어두었다. 팰월드는 이걸 물려받아 그대로 쓰되, 엔진에 없는 달리기(쉬프트), 글라이더, 등반 같은 이동 방식을 위해 속도 변수를 추가했다. 별도의 객체 2개가 아니라 하나의 객체 안에 전부 들어있다. CE에서 이 객체의 주소(0x1234)만 찾으면, 오프셋만 바꿔서 엔진 속도든 팰월드 속도든 전부 접근할 수 있다.

 

[3] 이 컴포넌트에 어떻게 접근하는가? — 역추적

[1]에서 MaxWalkSpeed를 찾았고, 그것이 UCharacterMovementComponent 클래스 안에 있다는 걸 확인했다. #4에서 했던 것과 똑같이, "이걸 누가 갖고 있지?"를 찾아야 한다. Engine_classes.hpp에서 UCharacterMovementComponent를 검색한다. 그러면 ACharacter 클래스 안에서 다음이 나온다

class UCharacterMovementComponent*		CharacterMovement;		// 0x0320(0x0008)(Edit, BlueprintVisible, ExportObject, BlueprintReadOnly, ZeroConstructor, EditConst, InstancedReference, NoDestructor, UObjectWrapper, HasGetValueTypeHash, NativeAccessSpecifierPrivate)

 

ACharacter의 +0x0320에 포인터로 들어있다. 그런데 ACharacter는 #4에서 이미 확보한 PawnPrivate가 바로 이것이다. APalCharacter는 ACharacter를 상속받으므로 +0x0320 오프셋이 그대로 적용된다. #4에서는 Pawn 이후에 +0x0628(CharacterParameterComponent)로 갔지만, 이번에는 +0x0320(CharacterMovement)으로 가면 된다.

GWorld부터 Pawn까지의 경로는 그대로 재사용한다. 그러면 최종적으로 아래와 같은 포인터가 완성이 된다.

GWorld (base + 0x882DA60) → UWorld + 0x0158 → GameState (AGameStateBase) + 0x02A8 → PlayerArray[0] (APlayerState) + 0x0308 → PawnPrivate (APalCharacter) +0x0320 → CharacterMovementComponent ★ 새로 추가 + 0x01F8 → MaxWalkSpeed (float, 걷기)
GWorld (base + 0x882DA60) → UWorld + 0x0158 → GameState (AGameStateBase) + 0x02A8 → PlayerArray[0] (APlayerState) + 0x0308 → PawnPrivate (APalCharacter) +0x0320 → CharacterMovementComponent ★ 새로 추가 + 0x1140 → SprintMaxSpeed (float, 달리기)

 

2단계: Cheat Engine에서 실제 메모리 따라가기

#4와 동일하게 CE에서 서버 프로세스(PalServer-Win64-Shipping-Cmd.exe)에 어태한 후, Add Address Manually → Pointer 체크, Hexadecimal 체크

Base Address: "PalServer-Win64-Shipping-Cmd.exe"+882DA60
오프셋 (위에서 아래로):

  1F8     ← MaxWalkSpeed
  320     ← CharacterMovement
  308     ← PawnPrivate
  0       ← PlayerArray[0]
  2A8     ← PlayerArray
  158     ← GameState

  Type: Float
왜 Float인가? SDK에서 float MaxWalkSpeed로 선언되어 있기 때문이다. #4에서 UnusedStatusPoint가 uint16이라 2 Bytes로 읽었던 것과 같은 원리다. SDK의 타입 선언이 CE에서 읽을 타입을 결정한다.

 

#4에서는 오프셋이 7단계였지만, 이번에는 6단계로 더 짧다. GWorld부터 Pawn까지(158, 2A8, 0, 308)는 #4와 완전히 동일하고, 마지막 두 줄(320, 1F8)만 다르다. OK를 누르면 값이 43AF0000으로 표시된다. 43AF0000은 10진수로 350인것을 알 수 있다. 이것이 MaxWalkSpeed의 기본값이다.

다른 속도값을 보려면 맨 위 오프셋(1F8)만 바꾸면 된다:
- 1F8 → MaxWalkSpeed (걷기)
- 1140 → SprintMaxSpeed (달리기)
- 204 → MaxFlySpeed (비행)

 

같은 방식으로 맨 위 오프셋만 1140으로 바꾸면 SprintMaxSpeed(달리기 속도)도 확인할 수 있다. 값이 43FA0000으로 표시된다.

43FA0000는 10진수로 500을 의미한다.

 

3단계: 변조 테스트

MaxWalkSpeed를 350에서 2000으로, SprintMaxSpeed를 500에서 3000으로 변경했다.

MaxWalkSpeed를 350에서 2000, SprintMaxSpeed를 500에서 3000으로 변조한 직후, 게임 내에서 캐릭터의 이동속도가 즉시 증가했다. 서버 메모리만 건드렸을 뿐인데 클라이언트에 바로 반영된 것이다. 체감으로는 캐릭터가 마치 차를 탄 것처럼 빠르게 이동했다. 같은 거리를 걷는 데 원래 몇 초 걸리던 것이 1초도 안 되어 도달할 정도였다.

화면 녹화 중 2026-04-02 192854.mp4
11.76MB

 

 

 

그러나 심한 끊김(rubber-banding)이 발생했다. 캐릭터가 앞으로 빠르게 이동했다가 갑자기 뒤로 끌려오고, 다시 앞으로 튕겨나가는 현상이 반복되었다. 마치 고무줄에 묶인 것처럼 앞뒤로 왔다 갔다 하는 느낌이다.

 

#4의 스테이터스 포인트 변조에서는 끊김 없이 깔끔하게 반영되었는데, 이동속도는 왜 끊기는 걸까?

UE5는 네트워크 게임에서 이동의 반응성을 높이기 위해 Client-Side Prediction(클라이언트 측 예측)을 사용한다. 쉽게 말하면, 클라이언트가 서버 응답을 기다리지 않고 자기가 먼저 이동을 계산해서 화면에 보여주는 것이다. 문제는 서버 메모리만 변조했기 때문에, 클라이언트는 아직 원래 속도(350)로 예측하고 있다는 것이다

  1. 내가 W키를 누른다
  2. 클라이언트: "MaxWalkSpeed는 350이니까 여기쯤 가겠지" → 화면에 먼저 표시
  3. 서버: "MaxWalkSpeed는 2000이니까 저~기까지 갔다" → 결과를 클라이언트에 전송
  4. 클라이언트: "어? 내 예측이랑 서버 결과가 다르네?" → 서버 위치로 강제 보정
  5. 다음 프레임에서 또 350으로 예측 → 또 불일치 → 또 보정
  6. 이것이 매 프레임 반복 → 끊김(rubber-banding)

즉, 끊김은 서버가 속도 변조를 탐지해서 막은 것이 아니다. 서버는 2000이라는 값을 아무 검증 없이 그대로 받아들였다. 끊기는 이유는 순전히 클라이언트와 서버의 속도 기준이 달라서 예측이 어긋나기 때문이다.

분석 결과 정리

서버 측에서 발견된 취약점

1. 속도 값 변조 차단  : MaxWalkSpeed를 2000으로 올려도 서버가 거부하지 않음

2. 이동 거리 검증 : 비정상적으로 빠른 이동을 탐지하지 않음

3. 속도 범위 검증 : 기본값 350인데 2000이 들어와도 제한 없음

서버가 이동속도에 대해 어떠한 검증도 수행하지 않는다. 끊김이 발생한 것은 UE5 엔진의 예측 시스템 때문이지, 서버가 속도 변조를 탐지해서 막은 것이 아니다.

 

안티치트 설계 시 고려사항

1. 속도 범위 검증: MaxWalkSpeed가 기본값(350)의 일정 범위(예: ±20%)를 벗어나면 탐지한다.
2. 이동 거리 검증: 매 서버 틱마다 이전 위치 → 현재 위치 거리를 계산하고, MaxWalkSpeed × deltaTime을 초과하면 탐지한다. 이 방식은 텔레포트 핵도 함께 잡을 수 있다.
3. 메모리 무결성 검사: CharacterMovementComponent의 속도 필드를 주기적으로 기본값과 비교하여, 게임 로직이 아닌 외부 변조를 탐지한다.
4. 클라이언트-서버 불일치 탐지: 클라이언트 예측 위치와 서버 위치의 차이가 지속적으로 임계값을 넘으면 탐지한다. 끊김 자체가 불일치의 증거이므로, 이를 역으로 탐지 지표로 활용할 수 있다.


다음 글에서는 포획률(PalCaptureRate)의 서버 변조를 분석한다. 이동속도와 달리 포획률은 서버에서 판정이 이루어지므로, 서버 값을 변조하면 끊김 없이 직접적으로 영향을 미친다. 보스급 팰의 포획률 상한(캡) 문제와 이를 우회하기 위한 코드 패치까지 다룰 예정이다.