본문 바로가기

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

[팰월드 안티치트 프로젝트] #4 전용서버 메모리 분석 - 서버 측 변조는 정말 가능한가?

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

 

📌 이 글은 팰월드 안티치트 프로젝트의 학습 기록 시리즈입니다. 안티치트를 만들기 위해 먼저 "공격자가 게임을 어떻게 공격하는지" 이해하는 과정을 정리합니다.
이전 글(#2, #3)에서는 클라이언트 측 치트를 분석했다. CT 파일의 AOB 패턴을 IDA에서 추적하여 데미지 무효화(Inf Health)와 스피어 수량 우회(Inf Sphere)의 동작 원리를 확인했다. 이들은 모두 클라이언트(Palworld.exe)의 코드를 패치하는 방식이었다.
이번 글에서는 관점을 바꾼다. 안티치트는 대부분 클라이언트에 적용된다. 그렇다면 서버 측에는 어떤 보호가 있을까? 팰월드 전용서버(Dedicated Server)를 로컬에 구축하고, 서버 프로세스의 메모리를 직접 분석한다. 전용서버는 포트포워딩을 설정하면 외부 플레이어도 접속할 수 있는 멀티플레이 환경이다. 만약 서버 메모리 변조가 가능하고, 그 값이 접속한 클라이언트에 그대로 전파된다면, 이는 한 명이 아닌 서버 전체에 영향을 주는 취약점이 된다.

 

왜 전용서버를 분석하는가?

팰월드는 싱글플레이에서도 내부적으로 서버-클라이언트 구조로 동작한다. 싱글의 로컬 서버든, 별도로 설치하는 전용서버(Dedicated Server)든 UE5 엔진 기반의 동일한 게임 로직과 메모리 구조를 사용한다. 기술적으로 큰 차이는 없다. 그럼에도 전용서버를 선택한 이유는 단순하다. 멀티플레이 환경에서 서버 메모리를 직접 조작하면 어떤 일이 벌어지는지 확인해보고 싶었기 때문다.


이 글에서 검증하는 것:

1. 서버 프로세스에 안티치트나 메모리 무결성 검증이 존재하는가?
2. 서버 메모리를 변조하면 접속한 클라이언트에 실제로 반영되는가?

 

이 글에서 다루는 내용:

- 전용서버 환경 구축 및 안티치트 적용 여부 확인
- Dumper-7을 이용한 서버 바이너리 UE5 SDK 구조체 덤프
- SDK 역추적으로 UnusedStatusPoint에서 GWorld까지 포인터 체인 구성
- Cheat Engine에서 포인터 체인을 따라 실제 메모리 주소 도달
- 서버 메모리 직접 변조 및 클라이언트 반영 검증
- 안티치트 관점의 취약점 정리

 

분석 환경: 

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

 

1단계: 전용서버 환경 구축 및 안티치트 확인:

스팀 라이브러리에서 "Palworld Dedicated Server"를 설치하면 전용서버를 실행할 수 있다. 실행 파일은 PalServer-Win64-Shipping-Cmd.exe이며, 실행하면 CMD 창에 서버 로그가 출력된다.

서버가 실행된 상태에서 클라이언트(Palworld.exe)를 열고 127.0.0.1:8211로 접속한다.

 

2단계: Dumper-7로 UE5 SDK 구조체 덤프

Dumper-7의 빌드와 DLL 인젝션 방법은 https://jdh07310.tistory.com/114에서 이미 다뤘다. 클라이언트(Palworld-Win64-Shipping.exe)에 Dumper-7을 인젝션하여 GWorld, GNames, GObjects 오프셋을 추출하고 SDK를 덤프하는 과정까지 진행 했었다. 

이번에는 동일한 작업을 전용서버 프로세스에 적용한다. Dumper-7을 빌드하고 인젝션하는 절차는 같지만, 대상 프로세스와 결과 오프셋이 다르다.

주의 사항
1. Xenos에서 프로세스를 선택할 때 PalServer-Win64-Shipping-Cmd.exe를 선택할 것 — 런처인 PalServer.exe와 헷갈리지 않도록 주의
2. 서버가 완전히 로딩되어 포트 8211로 리스닝 중인 상태에서 인젝션해야 한다

 

인젝션에 성공하면 콘솔에 서버 바이너리 기준의 오프셋이 출력된다:

GObjects: 0x86D1400
GNames:   0x85D6938
GWorld:   0x882DA60

 

#1에서 클라이언트 기준으로 추출했던 값(GWorld: 0x90D3030, GNames: 0x8EC5100, GObjects: 0x8F64810)과는 다른 것을 확인할 수 있다. 같은 게임이지만 바이너리가 다르기 때문에 오프셋도 다르다. 이 오프셋들이 이후 Cheat Engine에서 포인터 체인을 추적할 때 시작점이 된다.

 

3단계: SDK 구조체 역추적 - "바꾸고 싶은 값"에서 역추적하기

Dumper-7이 생성한 SDK 파일은 수천 개, 코드는 수만 줄이다. 이걸 처음부터 전부 읽을 수는 없다. 그래서 역추적 방식을 사용한다. 목표를 먼저 정하고, 그 값이 어디에 있는지 거꾸로 추적해 올라가는 것이다.

목표: 서버 메모리에서 스테이터스 포인트를 늘려서 스탯을 마음대로 찍어보자

 

그런데 서버 메모리는 수십 GB인데, 스탯 포인트 값이 어디에 있는지 어떻게 알 수 있을까? 여기서 SDK 덤프 파일이 사전 역할을 한다. SDK 파일에는 UE5 엔진이 사용하는 모든 클래스, 구조체, 변수의 이름과 메모리 오프셋이 들어있다. 소스 코드가 없어도, 변수 이름으로 검색하면 원하는 데이터가 메모리 어디에 위치하는지 알 수 있다.

 

Dumper-7이 생성한 파일은 수천 개지만, 파일 이름에 규칙이 있다:

파일  내용
Pal_structs.hpp 팰월드 고유 구조체 — 게임 데이터가 저장되는 위치(오프셋)
Pal_classes.hpp 팰월드 고유 클래스 — 구조체를 누가 갖고 있는지
Engine_structs.hpp UE5 엔진 기본 구조체
Engine_classes.hpp UE5 엔진 기본 클래스

스테이터스 포인트는 팰월드 고유의 게임 데이터이므로, Pal_structs.hpp부터 열어서 관련 이름을 검색하면 된다.

 

Pal_structs.hpp에서 "StatusPoint"를 검색하면 여러 결과가 나온다. 그 중 눈에 띄는 두 가지이다.

int32	StatusPoint;		// 0x0008(0x0004)(BlueprintVisible, BlueprintReadOnly, ZeroConstructor, IsPlainOldData, NoDestructor, HasGetValueTypeHash, NativeAccessSpecifierPublic)
uint16	UnusedStatusPoint;	// 0x0174(0x0002)(Edit, ZeroConstructor, IsPlainOldData, NoDestructor, HasGetValueTypeHash, NativeAccessSpecifierPublic)

 

우리의 목표는 "포인트를 늘려서 스탯을 마음대로 찍는 것"이다. 이미 배분된 값을 바꾸는 것보다, 아직 안 쓴 포인트 수량을 늘리면게임 내에서 직접 원하는 스탯에 배분할 수 있다. 따라서 UnusedStatusPoint를 선택한다.

그런데 이건 어떤 구조체 안에 들어있는 필드일 뿐이다. 위로 스크롤하면 이 필드가 속한 구조체를 확인할 수 있다.

구조체를 찾았다. 하지만 구조체는 "이런 데이터가 이런 순서로 들어있다"는 설계도일 뿐이다. 실제 메모리에서 이 값에 접근하려면, 이 구조체를 실제로 들고 있는 클래스를 찾아야 한다. 구조체 정의는 Pal_structs.hpp에 있지만, 그것을 사용하는 클래스는 Pal_classes.hpp에 있다. 그래서 Pal_classes.hpp로 넘어가서 구조체 이름 FPalIndividualCharacterSaveParameter를 검색한다.

 

Pal_classes.hpp 파일에서 FPalIndividualCharacterSaveParameter를 검색한다. 검색하면 결과가 여러 개 나온다. 그 중에서 주목할 것은 SaveParameter라는 이름의 변수이다.

struct FPalIndividualCharacterSaveParameter	SaveParameter;	// 0x0388(0x0338)(Edit, Net, DisableEditOnTemplate, RepNotify, NativeAccessSpecifierPublic)
이것을 선택한 이유는:
- SaveParameter : 이름 그대로 "저장되는 파라미터". 플레이어 스탯이 실제로 저장되는 곳이다.
- Net : 서버에서 값을 바꾸면 클라이언트에 자동으로 전파된다는 뜻이다. 서버 메모리를 변조하는 우리 목적에 이 태그가 있어야 의미가 있다.
- RepNotify : 값이 변경되면 클라이언트에 알림까지 보낸다는 뜻이다.

 

즉, 서버에서 바꾸면 실제로 게임에 반영되는 진짜 데이터이다. 하지만 아직 끝이 아니다. 이 클래스에는 어떻게 접근하는가?

이 변수가 속한 클래스를 위로 스크롤해서 확인하면 UPalIndividualCharacterParameter이다.


UPalIndividualCharacterParameter를 누가 갖고 있는지 찾기 위해 Pal_classes.hpp에서 검색하면 여러 결과가 나온다.

IndividualParameter					// UPalCharacterParameterComponent 내부
CurrentGliderIndividualParameter			// 글라이더 관련
ReplicateIndividualParameter				// 복제용

 

우리가 찾는 건 글라이더나 복제용이 아닌, 캐릭터 자체의 파라미터다. 수식어 없이 가장 직접적인 이름인 IndividualParameter를 선택한다.

이 변수가 속한 클래스를 위로 스크롤해서 확인하면 UPalCharacterParameterComponent이다.

 

이 변수는 UPalCharacterParameterComponent(캐릭터 파라미터 컴포넌트) 클래스의 +0x0130에 위치해 있다.

class UPalIndividualCharacterParameter*		IndividualParameter;		// 0x0130(0x0008)(Edit, BlueprintVisible, ExportObject, BlueprintReadOnly, Net, ZeroConstructor, InstancedReference, RepNotify, NoDestructor, PersistentInstance, HasGetValueTypeHash, NativeAccessSpecifierPublic)

 

UPalCharacterParameterComponent를 누가 갖고 있는지 찾기 위해 Pal_classes.hpp에서 검색하면 여러 결과가 나온다. 하지만 대부분은 함수(GetCharacterParameter() 등)이고, 실제 멤버 변수로 갖고 있는 것은 두 개다.

CharacterParameterComponent;   // APalCharacter 내부 (+0x0628)
ParameterComponent;            // 다른 클래스 내부 (+0x06D8), Transient(임시)

ParameterComponent는 Transient 태그가 붙어있어 임시 데이터일 뿐이다. 반면 CharacterParameterComponent는 이름 그대로 캐릭터의 파라미터 컴포넌트이다. 이 변수가 속한 클래스를 위로 스크롤해서 확인하면 APalCharacter이다

APalCharacter(플레이어 캐릭터 클래스) 안에 위치해 있다. 우리가 찾는건 플레이어 캐릭터의 스탯이므로 이것을 선택한다.

class UPalCharacterParameterComponent*		CharacterParameterComponent;		// 0x0628(0x0008)(Edit, BlueprintVisible, ExportObject, BlueprintReadOnly, ZeroConstructor, EditConst, InstancedReference, NoDestructor, HasGetValueTypeHash, NativeAccessSpecifierPublic)

 

APalCharacter는 게임에서 돌아다니는 플레이어 캐릭터 자체이다. 지금까지는 스탯 데이터에서 출발해 그것을 관리하는 컴포넌트들을 거쳐 캐릭터 자체까지 도달했다. 여기서부터는 질문이 바뀐다. "이 클래스를 누가 갖고 있지?"가 아니라, "이 캐릭터에 어떻게 접근하지?"가 되는 것이다. 

캐릭터(Pawn)는 팰월드 고유 구조가 아니라 UE5 엔진이 기본으로 관리하는 구조이다. 따라서 Pal_classes.hpp가 아닌 Engine_classes.hpp로 넘어가서 찾아야 한다.

 

Engine_classes.hpp에서 APalCharacter를 검색하면 나오지 않는다. 이는 APalCharacter가 팰월드 고유 클래스이기 때문이다. Engine_classes.hpp는 UE5 엔진 기본 코드이므로 팰월드 고유 클래스를 직접 알지 못한다. 엔진 입장에서 모든 캐릭터는 기본 클래스인 APawn이다. 따라서 APawn으로 검색한다. 여러 결과가 나오지만, 대부분은 함수이거나 다른 용도의 변수이다.

Instigator;        // AActor — 데미지를 준 주체
PawnOwner;         // UMovementComponent — 이동 컴포넌트 소유자, Transient(임시)
Pawn;              // AController — 컨트롤러가 조종 중인 Pawn
PawnPrivate;       // APlayerState — 플레이어 상태가 가리키는 캐릭터
AcknowledgedPawn;  // APlayerController — 클라이언트가 인식한 Pawn

우리가 찾는 건 플레이어의 캐릭터이므로, 플레이어 상태가 소유한 캐릭터를 가리키는 PawnPrivate를 선택한다. 

이 변수가 속한 클래스를 위로 스크롤해서 확인하면 APlayerState이다. 

class APawn*		PawnPrivate;		// 0x0308(0x0008)(BlueprintVisible, BlueprintReadOnly, ZeroConstructor, NoDestructor, UObjectWrapper, HasGetValueTypeHash, NativeAccessSpecifierPrivate)

 

이 변수는 APlayerState 클래스의 +0x0308에 위치해 있다. APlayerState는 플레이어 한 명의 상태 정보를 관리하는 UE5 표준 클래스이다.

 

APlayerState에 어떻게 접근하는지 찾기 위해 Engine_classes.hpp에서 APlayerState를 검색하면 여러 결과가 나온다. 그 중 눈에 띄는 두 가지가 있다.

PlayerState;    // 단일 변수 — 특정 하나의 플레이어 상태
PlayerArray;    // TArray<APlayerState*> — 접속한 모든 플레이어의 목록

서버에는 여러 명이 접속할 수 있다. 배열에서 인덱스로 원하는 플레이어를 골라서 접근할 수 있으므로 PlayerArray를 선택한다.

 

이 변수가 속한 클래스를 위로 스크롤해서 확인하면 AGameStateBase이다.

TArray<class APlayerState*>		PlayerArray;		// 0x02A8(0x0010)(BlueprintVisible, BlueprintReadOnly, ZeroConstructor, Transient, UObjectWrapper, NativeAccessSpecifierPublic)

이 변수는 AGameStateBase 클래스의 +0x02A8에 위치해 있다. AGameStateBase는 현재 게임의 전체 상태를 관리하는 UE5 표준 클래스이다.

 

AGameStateBase에 어떻게 접근하는지 찾기 위해 Engine_classes.hpp에서 AGameStateBase를 검색한다.

// UWorld 클래스 내부
class AGameStateBase*  GameState;  // 0x0158

UWorld의 +0x0158에 위치해 있다. UWorld는 UE5 엔진에서 현재 게임 월드 전체를 관리하는 최상위 클래스이다. 그리고 이 UWorld가 바로 Dumper-7에서 추출한 GWorld 오프셋으로 접근하는 것이다.

GWorld = base + 0x882DA60 → UWorld 포인터

 

밑에서부터 하나씩 올라온 결과를 순서대로 정리하면:

GWorld (base + 0x882DA60) → UWorld + 0x0158 → GameState (AGameStateBase) + 0x02A8 → PlayerArray[0] (APlayerState) + 0x0308 → PawnPrivate (APalCharacter) + 0x0628 → CharacterParameterComponent + 0x0130 → IndividualParameter + 0x0388 → SaveParameter (인라인) + 0x0174 → ★ UnusedStatusPoint
참고: 인라인(inline) 구조체란?
포인터 체인에서 대부분의 단계는 포인터(*)로 연결되어 있어서 주소를 읽고 따라가야 한다. 하지만 SaveParameter는 포인터가 아니라 데이터가 직접 들어있는 인라인 구조체이다. CE에서 따라갈 때 포인터는 주소를 읽어서 이동하지만, 인라인은 현재 주소에 오프셋을 더하기만 하면 된다.

 

SDK 파일만 읽고, "이걸 누가 갖고 있지?"를 반복하는 것만으로 GWorld에서 스테이터스 포인트까지의 완전한 경로를 구성할 수 있었다. 이 포인터 체인이 다음 단계에서 Cheat Engine으로 실제 메모리를 따라갈 때 사용할 지도가 된다.

 

4단계: Cheat Engine에서 포인터 체인 따라가기:

이론적으로 구성한 포인터 체인을 Cheat Engine에서 실제로 따라가며 검증한다.

 

Step 1: GWorld → UWorld
CE에서 서버 프로세스(PalServer-Win64-Shipping-Cmd.exe)에 어태치한 후, 메인 화면 하단의 Add Address Manually를 클릭한다.

주소: PalServer-Win64-Shipping-Cmd.exe + 0x882DA60
읽기: qword (8바이트)
결과: UWorld 주소 = 19571549BA0

 

참고: 왜 8바이트(Qword)로 읽는가?
64비트 프로세스에서 포인터(주소)의 크기는 8바이트이다. 지금 하고 있는 건 포인터 체인을 따라가는 것이므로, 각 단계에서 읽는 값은 "다음 객체의 주소"이다. 주소 = 포인터 = 8바이트. 마지막에 UnusedStatusPoint에 도달하면 그때는 실제 데이터이므로 해당 타입(uint16 = 2바이트)으로 바꿔서 읽는다.

 

SDK에서 추출한 오프셋은 모두 16진수(0x)이므로, CE에서 Hexadecimal을 체크하지 않으면 10진수로 인식되어 전혀 다른 주소로 이동하게 된다.


Step 2~7: UWorld → ... → IndividualParameter ( 포인터 체인 한번에 연결하기 )

UWorld 주소를 확인했으면, CE의 포인터 기능을 사용하여 나머지 체인을 한번에 연결할 수 있다.
Add Address Manually에서 Pointer 체크박스와 Hexadecimal체크박스를 체크한다. Base Address에 "PalServer-Win64-Shipping-Cmd.exe"+882DA60을 입력하고, 오프셋을 위에서 아래로 다음과 같이 설정한다:

4FC     ← 맨 위 (0x388 + 0x174, SaveParameter 인라인 오프셋)
130     ← IndividualParameter
628     ← CharacterParameterComponent
308     ← PawnPrivate
0       ← PlayerArray[0] (배열의 첫 번째 원소)
2A8     ← PlayerArray
158     ← GameState

 

참고: CE 포인터 오프셋 순서
CE의 포인터 기능에서 오프셋은 아래에서 위로 적용된다. 맨 아래 오프셋(158)이 가장 먼저 적용되고, 맨 위 오프셋(4FC)이 마지막에 적용된다. 

 

맨 위 오프셋 4FC는 0x388 + 0x174를 합친 값이다. 0x388은 IndividualParameter에서 SaveParameter까지의 오프셋이고, 0x174는 SaveParameter 안에서 UnusedStatusPoint까지의 오프셋이다. 이 둘을 합친 이유는, SaveParameter가 포인터가 아닌 인라인 구조체이기 때문이다. 포인터는 주소를 읽어서 따라가야 하지만, 인라인은 그냥 오프셋을 더하기만 하면 된다. CE에서 맨 위

오프셋만 역참조 없이 더하기로 처리되므로, 인라인 오프셋을 맨 위에 합쳐서 배치한 것이다.

 

UnusedStatusPoint의 타입은 uint16(2바이트)이다. SDK에서 0x0174(0x0002)로 표시되어 있었는데, 여기서 0x0002가 이 필드의 크기를 의미한다. 따라서 CE에서 Type을 2 Bytes로 설정한다.

 

OK를 누르면 체인이 연결되며 최종 주소와 값이 표시된다. Value에 0000으로 표시되어 있다면, 현재 캐릭터의 미사용 스테이터스 포인트가 0이라는 뜻이다.

 

이 값을 100으로 변경해보자. CE에서 Hexadecimal이 체크되어 있으므로, 10진수 100에 해당하는 16진수 64를 입력한다.

값을 입력하면 게임 내에서 스테이터스 포인트가 100으로 증가한 것을 확인할 수 있다. 서버 메모리의 값을 변경했을 뿐인데, 클라이언트에 즉시 반영된 것이다. 이것이 SDK에서 확인했던 Net 태그의 효과다. 서버에서 값이 바뀌면 클라이언트에 자동으로 전파된다.

분석 결과 정리

서버 측에서 발견된 취약점

1. 안티치트 미적용 : 서버 프로세스에 EAC 등의 보호가 전혀 없어 CE 어태치, DLL 인젝션이 자유롭게 가능하다.
2. 메모리 무결성 검증 없음 : 서버 메모리의 값을 외부에서 변조해도 감지/차단하는 메커니즘이 존재하지 않는다.
3. Net Replicated 값 무조건 신뢰: SaveParameter의 Net 태그로 인해 서버에서 변경한 값이 클라이언트에 그대로 전파되었다. 변조된 값인지 검증하는 로직은 확인되지 않았다.


안티치트 설계 시 고려사항

이번 분석을 통해 서버 측 보호의 부재가 확인되었다. 안티치트 시스템 설계 시 다음 사항을 고려해야 한다:
1. 서버 프로세스 보호 : 서버에도 메모리 무결성 검증을 적용하여, 외부 프로세스에 의한 읽기/쓰기를 감지한다.
2. 값 범위 검증 : 스테이터스 포인트가 비정상적으로 증가하는 등의 변화를 탐지한다.
3. Net Replicated 값 검증 : 서버에서 변경된 값이 게임 로직을 통해 변경된 것인지 확인하는 무결성 검사를 추가한다