본 글은 졸업작품(안티치트 시스템 개발)을 위한 학술 목적의 보안 연구입니다. 치트의 동작 원리를 분석하여 탐지 및 방어 방안을 설계하는 데 활용합니다.
📌 이 글은 팰월드 안티치트 프로젝트의 학습 기록 시리즈입니다. 안티치트를 만들기 위해 먼저 "공격자가 게임을 어떻게 공격하는지" 이해하는 과정을 정리합니다.
이전 글(#5)에서는 서버 메모리에서 이동속도(MaxWalkSpeed)를 변조하여 스피드핵을 구현하고, UE5의 Client-Side Prediction 모델과의 충돌로 끊김이 발생하는 것까지 확인했다.
이번 글에서는 팰월드의 핵심 시스템인 포획(캡처)을 분석한다. 팰 스피어를 던지면 서버에서 포획 성공/실패가 어떻게 판정되는지, 그리고 그 판정을 변조하여 포획률 100%를 만들 수 있는지 검증한다. 단순히 "포획률 배율"을 올리는 것만으로는 부족하다는 것을 발견하고, IDA 디컴파일을 통해 포획 판정의 실제 내부 구조를 밝혀낸다.
이 글에서 다루는 내용:
- SDK 덤프에서 포획 관련 구조체/클래스 분석
- 포획 확률 계산 공식과 관여 요소 정리
- 포획 판정이 2단계로 나뉘어져 있다는 발견
- IDA 디컴파일로 흔들림 테스트(Wobble Test) 공식 분석
- BP_PalGameSetting 런타임 인스턴스 탐색 및 변조
- 최종 포획률 100% 달성 및 안티치트 관점 정리
분석 환경:
| 항목 | 내용 |
| 서버 | PalServer-Win64-Shipping-Cmd.exe (Steam Dedicated Server) |
| 클라이언트 | Palworld v0.7.2.87654 (Steam) |
| SDK 덤프 | Dumper-7 |
| 동적 분석 | Cheat Engine 7.6 |
| 정적 분석 | IDA Pro 9.0 |
| 접속 환경 | 로컬 전용서버 (127.0.0.1:8211) |
1단계: SDK에서 포획 판정 변수 찾기
#4에서 스테이터스 포인트를 찾을 때는 SDK에서 "StatusPoint"를 검색하여 역추적하는 방식을 사용했다. 이번에도 같은 접근법을 쓴다. 목표는 포획률을 변조하여 100%로 만드는 것이다. 메모리에서 값을 바꾸려면 먼저 "그 값이 어떤 구조체의 어떤 오프셋에 있는지"를 알아야 한다. SDK에서 포획 판정 관련 변수를 찾는 것이 첫 단계다.
[1] 게임에서 관찰한 포획 동작
SDK를 검색하기 전에, 먼저 게임에서 포획이 어떻게 동작하는지 관찰한다. 팰에게 스피어를 던지면:
1. 스피어가 팰을 감싼다
2. 스피어가 까딱까딱 흔들린다(2번)
3. 모든 흔들림을 통과면 포획 성공, 중간에 실패하면 팰이 튀어나온다
포획의 핵심은 이 "까딱거리는 판정(흔들림 테스트)"이다. 이 판정을 제어하는 변수를 찾으면 포획률을 조작할 수 있을 것이다.
[2] "CaptureJudge"를 검색하면 무엇이 나오는가?
"까딱거리는 판정" = Judge(판정)이다. Pal_classes.hpp에서 CaptureJudge를 검색한다.
첫 번째: 판정 주체 — APalCaptureJudgeObject
class APalCaptureJudgeObject : public AActor
{
public:
uint8 Pad_290[0x28]; // 0x0290(0x0028)(Fixing Struct Size After Last Property [ Dumper-7 ])
public:
void CaptureResult_ToALL(class APalCharacter* Character, const struct FCaptureResult& Result);
void ChallengeCapture(class APalCharacter* Character, float capturePower);
void ChallengeCapture_ToServer(class APalCharacter* Character, float capturePower);
void OnCaptureSuccess(const class APalCharacter* Character, const struct FCaptureResult& Result);
void OnFailedByMP(const class APalCharacter* Character, const struct FCaptureResult& Result);
void OnFailedByTest(const class APalCharacter* Character, const struct FCaptureResult& Result);
void OnFailedFinish();
void OnSuccessFinish();
함수 이름에서 포획 흐름을 읽을 수 있다:
1. 클라이언트가 스피어를 던진다
2. ChallengeCapture_ToServer() — 클라이언트가 서버에 판정을 요청하는 RPC
3. ChallengeCapture() — 서버에서 실제 판정을 수행
4. 결과에 따라 OnCaptureSuccess(), OnFailedByMP(), OnFailedByTest() 중 하나가 호출
5. CaptureResult_ToALL() — 결과를 모든 클라이언트에 전파
_ToServer라는 접미사가 붙은 RPC 함수가 있다는 것은, 포획 판정이 서버에서 수행된다는 의미다. 클라이언트 메모리를 아무리 변조해도 서버가 재계산하기 때문에, 포획률을 바꾸려면 반드시 서버 프로세스의 메모리를 건드려야 한다.
두 번째: 판정 설정값 — UPalGameSetting 안의 CaptureJudgeRateArray
TArray<float> CaptureJudgeRateArray; // 0x10D8(0x0010)(Edit, BlueprintVisible, BlueprintReadOnly, ZeroConstructor, DisableEditOnInstance, NativeAccessSpecifierPublic)
CaptureJudgeRateArray 위로 스크롤하면 이 변수가 속한 클래스가 UPalGameSetting이라는 것을 알 수 있다. 이 클래스는 팰월드의 각종 게임 설정값을 담고 있는 클래스로, 포획 판정에 사용되는 흔들림 확률 배열도 여기에 포함되어 있다.

2단계: IDA 디컴파일 : 실제 판정 로직 분석
SDK에서 CaptureJudgeRateArray가 흔들림 판정에 사용된다는 것을 알았다. 하지만 이 배열이 어떤 공식으로 사용되는지는 SDK만으로는 알 수 없다. 실제 판정 로직을 확인하려면 서버 바이너리를 IDA에서 디컴파일해야 한다.
[1] ChallengeCapture 함수 찾기
IDA에서 서버 바이너리(PalServer-Win64-Shipping-Cmd.exe)를 열고 Strings 창(Shift+F12)을 열고 "ChallengeCapture"를 검색하면 3개의 결과가 나온다. 이 중 ChallengeCapture (두 번째, _ToServer가 붙지 않은 것)를 더블클릭하면 .rdata섹션으로 이동한다.

UE5에서는 네이티브 함수를 등록할 때 함수 이름을 문자열로 저장하기 때문에, 문자열 검색으로 관련 코드를 추적할 수 있다.
.rdata에서 문자열 위치에 커서를 놓고 우클릭 → "Jump to xref to operand"를 선택하면, 이 문자열을 참조하는 코드 주소 목록이 나온다. 이것이 UE5의 네이티브 함수 등록 테이블이다. UE5는 Blueprint에서 호출할 수 있는 C++ 함수를 등록할 때, 함수 이름 문자열과 실제 함수 포인터를 이 테이블에 짝지어 저장한다.


문자열 참조를 따라가면 ChallengeCapture의 네이티브 구현 함수에 도달한다. 디컴파일하면 다음과 같다:
__int64 __fastcall sub_142AD4710(__int64 a1, __int64 a2)
{
__int64 v5; // [rsp+20h] [rbp-28h]
int v6; // [rsp+28h] [rbp-20h]
__int64 v7; // [rsp+30h] [rbp-18h] BYREF
int v8; // [rsp+38h] [rbp-10h]
sub_142E63000();
LOBYTE(v5) = 1;
HIDWORD(v5) = 3;
LOBYTE(v6) = 0;
v8 = v6;
*(_BYTE *)(a1 + 656) = 1;
*(_QWORD *)(a1 + 664) = a2;
v7 = v5;
return sub_142677910(a1, a2, &v7);
}
디컴파일 결과만 보면 의미를 알 수 없는 변수명뿐이다. 하지만 1단계에서 분석한 SDK 정보를 교차 대조하면 각 부분의 의미를 추론할 수 있다. 크게 세 부분으로 나누어 분석한다
Part 1: 초기값 분석 (v5, v6)
APalCaptureJudgeObject의 다른 함수들(OnCaptureSuccess, OnFailedByMP, OnFailedByTest)을 보면, 모두 FCaptureResult를 파라미터로 받는다. ChallengeCapture가 판정을 시작하고 이 함수들이 결과를 처리하므로, ChallengeCapture안에서 세팅하는 3개 값은 FCaptureResult를 초기화하는 것이라고 추론할 수 있다
Pal_structs.hpp에서 FCaptureResult의 SDK 구조체:

IDA 코드의 3개 값을 이 구조체에 대입하면 아래와 같은 표가 된다.
| IDA 코드 | 값 | FCaptureResult 필드 | 의미 |
| LOBYTE(v5) = 1 | 1 (True) | IsSuccess | 성공으로 설정 |
| HIDWORD(v5) = 3 | 3 | TestSuccessCount | 까딱 3번 성공 |
| LOBYTE(v6) = 0 | 0 (None) | FailedCaptureType | 실패 사유 없음 |
Part 2: 파라미터 분석 (a1, a2)
C++에서 클래스 멤버 함수는 항상 첫 번째 파라미터(a1)로 자기 자신을 받는다. 이 함수는 APalCaptureJudgeObject의 함수이므로, a1은 포획 판정 객체 자신이다. SDK에서 ChallengeCapture(APalCharacter* Character, float, capturePower)의 시그니처를 확인했으므로, a2는 스피어를 던져서 잡으려는 대상, 즉 타겟 팰이라는 것을 알 수 있다
Part 3: Blueprint 호출 확인 (sub_142677910)
Part 1, 2에서 ChallengeCapture의 C++ 코드에 판정 로직이 없다는 것을 확인했다. 그렇다면 마지막 줄 sub_142677910이 실제 판정을 수행하는 곳인데, 이것이 정말 Blueprint 호출인지 확인하기 위해 직접 디컴파일해보았다.
__int64 __fastcall sub_142677910(__int64 *a1, __int64 a2, __int64 *a3)
{
v5 = *a1;
v6 = *(v5 + 608);
v7 = sub_1431BC410(a1, qword_...);
return v6(a1, v7, v9);
}
UE5에서 모든 UObject는 vtable(가상 함수 테이블)을 갖고 있으며, ProcessEvent는 이 vtable의 특정 위치에 존재한다. vtable 오프셋에서 함수 포인터를 가져오고, UFunction을 검색한 뒤 호출하는 이 패턴은 UE5 ProcessEvent의 전형적인 형태이다. 이를 통해 ChallengeCapture의 마지막 호출이 Blueprint 함수를 실행하는 ProcessEvent임을 확정할 수 있다.
즉, 포획 판정의 실제 로직(HP 체크, 흔들림 테스트)은 C++ 네이티브 코드가 아닌 Blueprint에 구현되어 있다. Blueprint 자체는 IDA로 디컴파일할 수 없지만, Blueprint 안에서 호출하는 C++ 함수들은 추적할 수 있다. 다음으로 그 함수들을 분석한다.
[2] sub_142E63000 — 설정 인스턴스를 가져오는 함수 분석
ChallengeCapture의 첫 줄에서 호출하는 sub_142E63000을 디컴파일한다. 이 함수가 무엇을 하는지는 아직 모르지만, 반환값이 다른 함수에서 어떻게 사용되는지 추적하면 정체를 알 수 있다.
__int64 sub_142E63000()
{
__int64 v0; // rax
__int64 result; // rax
_QWORD *v2; // rax
_QWORD *v3; // rbx
v0 = sub_142B8F590();
if ( !v0 || (result = *(_QWORD *)(v0 + 1032)) == 0 )
{
v2 = (_QWORD *)sub_1426E4710();
v3 = v2;
if ( !v2[34] )
(*(void (__fastcall **)(_QWORD *))(*v2 + 1008LL))(v2);
return v3[34];
}
return result;
}
sub_142E63000의 디컴파일 결과를 한 줄씩 분석하면 다음과 같다.
먼저 sub_142B8F590()을 호출하여 어떤 객체를 가져온다. 이 객체가 존재하고, 그 안의 +1032(0x408) 위치에 값이 저장되어 있으면 해당 값을 바로 반환한다. 이것이 경로 1이다. 만약 객체를 가져오지 못했거나, +1032 위치가 비어있으면 if문 안으로 진입한다. 여기서 sub_1426E4710()을 호출하여 다른 방법으로 가져온 뒤, v2[34] (v2[34] = v2 + (34 × 8바이트) = v2 + 272 = v2 + 0x110) 위치의 값을 반환한다. UE5에서 +0x110은 UClass의 ClassDefaultObject(CDO)를 가리키는 위치이다. 이것이 경로 2 (CDO 폴백)이다.
Class Default Object(CDO)란?
UE5에서는 클래스를 정의하면 엔진이 자동으로 해당 클래스의 기본 객체(CDO, Class Default Object)를 하나 생성한다. 이것은 클래스의 기본값이 채워진 템플릿 역할을 하며, 엔진 내부에서 사용된다. 게임이 실제로 사용하는 것은 CDO가 아니라 런타임 인스턴스이다. 런타임 인스턴스는 서버가 시작될 때 에셋 파일(예: BP_PalGameSetting)을 디스크에서 읽어와서 생성된 객체로, 실제 게임 설정값이 들어있다.
[3] 흔들림 테스트 함수
[1]에서 ChallengeCapture가 Blueprint로 판정을 위임한다는 것을 확인했고, [2]에서 sub_142E63000이 어떤 설정 인스턴스를 반환한다는 것까지 확인했다. 하지만 두 가지 문제가 남아있다:
sub_142E63000이 반환하는 것이 정확히 무엇인지 모른다
Blueprint는 IDA로 디컴파일할 수 없다
직접 따라가는 것이 불가능하므로 역방향으로 접근한다. 1단계 SDK 분석에서 CaptureJudgeRateArray의 오프셋이 0x10D8이라는 것을 이미 알고 있다. 그렇다면 sub_142E63000의 반환값을 받아서 +0x10D8을 읽는 함수가 있다면, 그 함수가 흔들림 판정 로직이고, 동시에 sub_142E63000이 UPalGameSetting을 반환한다는 것도 확정할 수 있다.
찾는 방법: IDA에서 sub_142E63000 함수 위치로 이동한 뒤 X키(xref, 이 함수를 호출하는 곳 목록)를 누른다. 목록에 나온 함수들을 하나씩 F5로 디컴파일하면서, 코드 안에 +0x10D8(10진수 4312)이 쓰여있는 함수를 찾는다.


이렇게 찾은 함수가 sub_142E81B80이다.
sub_142E81B80을 디컴파일하면:
void WobbleTest(pal, capture_power, ..., result_array, bypass_flag) {
// 사전 조건 체크
if (!(bypass_flag || PreConditionCheck())) {
result_array.add(false); // 즉시 실패
return;
}
// 설정 인스턴스 가져오기
settings = sub_142E63000(); // [2]에서 분석한 함수!
float capture_rate = CalcCaptureRate(settings, pal, capture_power);
// ★ 흔들림 테스트 공식 ★
float* arr = *(settings + 0x10D8); // CaptureJudgeRateArray.Data
int count = *(settings + 0x10E0); // CaptureJudgeRateArray.Count
for (int i = 0; i < count; i++) {
float threshold = powf(capture_rate, arr[i]); // rate^exponent
float random = (rand() & 0x7FFF) / 32768.0; // [0, 1) 난수
bool pass = threshold >= random;
result_array.add(pass);
}
}
이 함수는 크게 두 갈래로 나뉜다. if 조건을 통과하면 흔들림 테스트를 실행하고, 실패하면 즉시 실패를 기록한다.
사전 조건 체크 (if문)
if ( (a5 || sub_142E817E0(v8)) && !*(_BYTE *)(*(_QWORD *)(v9 + 1584) + 1432LL) )
이 조건이 통과해야 흔들림 테스트가 진행된다. a5는 bypass 플래그이고, sub_142E817E0은 사전 조건(HP 체크 등)을 검사하는 함수다. 뒤쪽의 +1432 위치 플래그는 캡처 불가 상태(보스 등)를 나타낸다. 이 조건을 못 넘으면 else로 빠진다.
else — 사전 조건 실패 시
*(_BYTE *)(v10 + *(_QWORD *)a4) = 0; // 결과 배열에 false(0) 저장
흔들림 테스트를 실행하지 않고, 결과 배열에 바로 실패(0)를 기록한다. 게임에서 스피어가 까딱도 안 하고 바로 튀어나오는 경우가 이것이다.
if 안쪽 — 흔들림 테스트 실행
사전 조건을 통과하면 본격적인 판정이 시작된다.
v11 = sub_142E63000(); // [2]에서 분석한 함수 호출
v13 = sub_1426E6550(v11, v12, a1, a2); // 포획률 계산
v14 = *(float **)(v11 + 4312); // ★ 4312 = 0x10D8
v15 = *(float *)&v13; // 계산된 포획률 (float)
여기서 핵심이 되는 것은 v11 + 4312이다. 10진수 4312를 16진수로 변환하면 0x10D8이고, 이것은 1단계 SDK 분석에서 확인한 UPalGameSetting.CaptureJudgeRateArray의 오프셋과 정확히 일치한다. 이를 통해 두 가지가 동시에 확정된다:
sub_142E63000은 UPalGameSetting 인스턴스를 반환하는 함수임이 확정
이 함수가 CaptureJudgeRateArray를 실제로 읽는 흔들림 테스트 함수임이 확정
이어서 for 루프를 분석한다:
for ( i = &v14[*(int *)(v11 + 4320)]; v14 != i; ++v14 )
v14는 배열의 시작 포인터이고, i는 &v14[count] = 배열 끝 포인터다. v14 != i일 때까지 반복하므로 배열 원소 개수만큼 반복한다(기본 3번). v11 + 4320은 0x10E0으로, SDK의 CaptureJudgeRateArray.Count 위치와 일치한다.
루프 안의 핵심 판정:
v17 = powf(v15, *v14); // capture_rate ^ exponent
v20 = v17 >= (float)((float)(v18 & 0x7FFF) * 0.000030518509); // threshold >= random
*(_BYTE *)(v19 + *(_QWORD *)a4) = v20; // 결과 배열에 저장
0.000030518509는 1/32768이므로, (rand() & 0x7FFF) * (1/32768) = 0에서 1 사이의 난수를 생성한다. 정리하면
흔들림 테스트의 핵심 공식은 다음과 같다:
흔들림 통과 조건: powf(capture_rate, exponent) >= random[0,1)
capture_rate : 포획률 (0~1 사이 값, 팰의 HP/레벨 등에 의해 결정됨)
exponent : CaptureJudgeRateArray의 각 원소 (기본값: [0.444, 0.333, 0.222])
random : 0에서 1 사이의 난수
왜 powf(거듭제곱)를 쓰는가?
게임에서 스피어가 까딱거리는 것은 연출일 뿐만 아니라, 실제로 3번의 독립적인 확률 판정이 이루어지고 있다. 문제는 단순히 포획률을 3번 판정하면 확률이 너무 낮아진다는 것이다. 예를 들어, 포획률 10%짜리 팰을 잡는다고 하자. 만약 각 흔들림마다 10%를 그대로 적용하면
0.1 × 0.1 × 0.1 = 0.001 (0.1%)
3번 연속 통과해야 하니 확률이 0.1%까지 떨어진다. 원래 10%로 설계된 팰인데, 까딱거림 연출을 넣었다는 이유만으로 100배나 잡기 어려워지면 밸런스가 깨진다. 이 문제를 해결하기 위해 개발자가 사용한 것이 거듭제곱(powf)이다. 핵심 원리는 간단하다:
0.1 ^ 1.0 = 0.1 (그대로)
0.1 ^ 0.5 = 0.316 (31%로 올라감)
0.1 ^ 0.1 = 0.794 (79%로 올라감)
지수가 작을수록 결과값이 1.0에 가까워진다. 즉, exponent가 작을수록 그 흔들림을 통과하기 쉬워진다. 그렇다면 exponent를 0.0으로 만들면 어떻게 될까? 수학에서 어떤 수의 0제곱은 항상 1이다 (x^0 = 1.0). 따라서 exponent를 전부 0.0으로 바꾸면
0.1 ^ 0.0 = 1.0
0.5 ^ 0.0 = 1.0
0.01 ^ 0.0 = 1.0 ← 포획률이 아무리 낮아도 1.0
threshold가 항상 1.0이 되므로, 1.0 >= random[0,1)은 무조건 참이다. 포획률이 아무리 낮은 팰이라도 흔들림 테스트를 100% 통과하게 된다.
[4] 사전 조건 체크 — IgnoreFirstCaptureFailedHPRate의 발견
흔들림 테스트 함수에는 for 루프에 들어가기 전에 사전 조건 체크가 있었다. 이 조건을 통과하지 못하면 흔들림 테스트 자체가 실행되지 않고, else로 빠져서 바로 실패가 기록된다. 디컴파일 코드를 보면 이 조건 안에 HP를 검증하는 로직이 포함되어 있다.
HP로 검증한다는 것을 알았으니, SDK에서 UPalGameSetting클래스 안의 HP 관련 변수를 찾는다. 이름에 "HP"와 "Capture"가 모두 포함된 변수를 검색하면 다음이 발견된다:
float IgnoreFirstCaptureFailedHPRate; // UPalGameSetting + 0x11A0
변수 이름을 해석하면: Ignore(무시) / First Capture Failed(첫 포획 실패를) / HPRate(HP 비율)
즉, "이 HP 비율 이상이면 포획 실패를 무시하지 않는다" = HP가 이 비율 이상이면 무조건 실패시킨다는 의미다.
IDA 디컴파일을 통해 포획 판정의 전체 구조가 밝혀졌다. 스피어를 던지면 서버는 먼저 팰의 HP가 IgnoreFirstCaptureFailedHPRate 이상인지 체크한다. 이 조건에 걸리면 흔들림 테스트 자체를 진행하지 않고 바로 실패 처리된다HP 체크를 통과하면 비로소 흔들림 테스트가 시작되고, CaptureJudgeRateArray의 각 원소를 exponent로 사용하여 powf(capture_rate, exponent) >= random 판정을 반복한다. 모든 흔들림을 통과하면 포획 성공이다.
따라서 포획률 100%를 만들려면 2개 값을 변조해야 한다. 첫 번째는 IgnoreFirstCaptureFailedHPRate(오프셋 +0x11A0)를999.0 같은 큰 값으로 올려서, "HP가 999% 이상일 때만 실패" = 사실상 HP 체크를 무력화하는 것이다. 두 번째는 CaptureJudgeRateArray(오프셋 +0x10D8)의 모든 원소를 0.0으로 바꿔서, x^0 = 1.0이 되어 흔들림 테스트를 무조건 통과시키는 것이다.
3단계: CE에서 런타임 인스턴스 찾기
2단계에서 IDA 디컴파일을 통해 변조해야 할 값 2개를 알아냈다. IgnoreFirstCaptureFailedHPRate(+0x11A0) 와 CaptureJudgeRateArray(+0x10D8), 둘 다 UPalGameSetting 안에 있다. 이제 실제 서버 메모리에서 이 객체를 찾아야 한다.
[1] UClass 포인터 찾기
2단계 [2]에서 분석한 sub_142E63000(GetPalGameSetting) 함수는 내부에서 sub_1426E4710을 호출하여 UPalGameSetting의 UClass를 가져왔다. 이 함수를 IDA에서 디컴파일하면, UClass 포인터가 전역변수 qword_148598888에 저장되는 것을 확인 할 수 있다. 0x148598888은 IDA 기준 주소다. IDA는 64비트 Windows 바이너리의 시작 주소를 0x140000000으로 설정한다. 실제 메모리에서 접근하려면 바이너리 시작 주소를 빼서 오프셋을 구해야 한다:
0x148598888 - 0x140000000 = 0x8598888
CE에서는 모듈이름 + 오프셋으로 주소를 지정할 수 있다. 메모리 뷰어의 주소창에 다음을 입력한다:
PalServer-Win64-Shipping-Cmd.exe+8598888

이 주소에 저장된 8바이트 값이 UPalGameSetting의 네이티브 UClass 포인터다.
[2] IgnoreFirstCaptureFailedHPRate 기본값 확인
2단계 [2]에서 GetPalGameSetting 함수에는 두 가지 경로가 있었다. 경로 1은 런타임 인스턴스, 경로 2는 CDO 폴백이었다. CDO는 UClass + 0x110 위치에 저장되어 있다.

CDO에서 +0x11A0을 읽으면 IgnoreFirstCaptureFailedHPRate의 값을 확인할 수 있다.

결과: 0.3. 팰의 HP가 30% 이상이면 포획을 무조건 실패시키는 기본값이다.
[3] float 0.3 전체 스캔
CDO에서 기본값이 0.3이라는 것을 확인했다. 런타임 인스턴스에도 같은 위치(+0x11A0)에 같은 기본값이 들어있을 것이다. CE 메인 화면에서 Scan Type을 Exact Value, Value Type을 Float로 설정하고 0.3을 검색하면 약 19만 개의 결과가 나온다.

19만 개를 수동으로 확인할 수는 없다. 여기서 핵심 아이디어는 다음과 같다. IgnoreFirstCaptureFailedHPRate는 객체의 +0x11A0 위치에 있으므로, 스캔 결과 주소에서 0x11A0을 빼면 객체의 시작 주소가 된다. UE5에서 모든 UObject는 +0x10 위치에 자신이 어떤 클래스인지를 가리키는 포인터(ClassPrivate)를 가지고 있고, 이 ClassPrivate의 SuperStruct(+0x40)가 [1]에서 읽은 네이티브 UClass와 일치하면, 그 객체는 UPalGameSetting의 BP 서브클래스 인스턴스라는 뜻이다.
이 필터링은 CE의 Lua Engine(Table → Show Cheat Table Lua Script)에서 다음 스크립트를 실행하여 자동화한다
local native_uclass = readQword(getAddress("PalServer-Win64-Shipping-Cmd.exe") + 0x8598888)
local scanResult = AOBScan("9A 99 99 3E", "+W-C")
if scanResult then
for i = 0, scanResult.getCount() - 1 do
local addr = tonumber("0x" .. scanResult[i])
local obj = addr - 0x11A0
if obj > 0x10000000000 then
local cls = readQword(obj + 0x10)
if cls and cls ~= native_uclass and cls > 0x10000000000 then
local super = readQword(cls + 0x40)
if super == native_uclass then
local arr_count = readInteger(obj + 0x10E0)
print(string.format("Found: 0x%X (arr_count=%d)", obj, arr_count or 0))
end
end
end
end
scanResult.destroy()
end
필터링 결과 2개의 후보가 발견된다. 하나는 BP 서브클래스 CDO, 하나는 런타임 인스턴스다.

[4] 데이터 브레이크포인트
2개 후보 중 어떤 것이 게임이 실제로 사용하는 런타임 인스턴스인지 확정해야 한다. 각 후보의 +0x11A0 주소를 CE에 추가한 뒤, 우클릭 → Find out what accesses this address를 설정한다. 그리고 게임에서 스피어를 던진다.



히트가 발생한 객체가 서버가 포획 판정에 실제로 사용하는 BP_PalGameSetting 런타임 인스턴스다.
[5] 런타임 인스턴스의 원본값 확인
변조하기 전에, 런타임 인스턴스에 실제로 어떤 값이 들어있는지 확인한다. Lua Engine에서 다음 스크립트를 실행한다
local target = 0x20DB7E62070
local arr_data = readQword(target + 0x10D8)
local arr_count = readInteger(target + 0x10E0)
for i = 0, arr_count - 1 do
writeFloat(arr_data + i * 4, 0.0)
end
for i = 0, arr_count - 1 do
print(string.format("[%d] = %f", i, readFloat(arr_data + i * 4)))
end
런타임 인스턴스의 +0x10D8에는 CaptureJudgeRateArray의 데이터 포인터가, +0x10E0에는 배열의 원소 개수가 저장되어 있다. 데이터 포인터가 가리키는 위치에서 float를 하나씩 읽으면 각 흔들림 단계의 exponent 값을 확인할 수 있다.

2단계 IDA 분석에서 예측한 흔들림 테스트 공식과 정확히 일치한다. IgnoreFirstCaptureFailedHPRate는 [2]에서 이미 0.3으로 확인했으므로, 변조해야 할 원본값이 모두 확인되었다.
4단계: 값 변조 및 포획 테스트
3단계에서 BP_PalGameSetting 런타임 인스턴스를 찾았고, 원본값도 확인했다. 이제 실제로 값을 변조하고 포획률 100%가 되는지 테스트한다.
[1] IgnoreFirstCaptureFailedHPRate 변조
CE 메인 화면에서 런타임 인스턴스의 +0x11A0 주소에 해당하는 값(0.3)을 더블클릭하여 999.0으로 변경한다. 이렇게 하면 "HP가 999% 이상일 때만 실패" = 사실상 HP 체크가 무력화된다. HP를 전혀 깎지 않아도 흔들림 테스트가 진행된다.

[2] CaptureJudgeRateArray 변조
배열의 각 원소를 개별적으로 변경해야 하므로 Lua Engine에서 다음 스크립트를 실행한다.
local target = 0x20DB7E62070
local arr_data = readQword(target + 0x10D8)
local arr_count = readInteger(target + 0x10E0)
for i = 0, arr_count - 1 do
writeFloat(arr_data + i * 4, 0.0)
end
for i = 0, arr_count - 1 do
print(string.format("[%d] = %f", i, readFloat(arr_data + i * 4)))
end
3개의 원소를 모두 0.0으로 덮어쓴 뒤, 다시 읽어서 변조가 적용되었는지 확인한다. 2단계에서 분석한 대로 powf(capture_rate, 0.0) = 1.0이므로, 흔들림 테스트가 무조건 통과된다.

[3] 포획 테스트
두 값을 모두 변조한 상태에서 게임에서 스피어를 던진다. HP를 깎지 않은 팰에게 던져도 스피어가 까딱거린 뒤 포획에 성공한다.

포획률 100% 달성. 정상적인 게임에서는 절대 포획할 수 없는 그린모스(레이드 보스급 팰)에게 스피어를 던져도 한 번에 포획에 성공한다. IDA에서 분석한 판정 구조와 변조 전략이 정확했음이 실제 테스트로 검증되었다.
분석 결과 정리
서버 측에서 발견된 취약점
1. 안티치트 미적용 : 서버 프로세스에 EAC 등의 보호가 전혀 없어 CE 어태치, DLL 인젝션이 자유롭게 가능하다.
2. 메모리 무결성 검증 없음: BP_PalGameSetting 런타임 인스턴스의 값을 외부에서 변조해도 감지/차단하는 메커니즘이 존재하지 않는다. IgnoreFirstCaptureFailedHPRate를 999.0으로, CaptureJudgeRateArray를 [0.0, 0.0, 0.0]으로 변경해도 서버는 아무런 이상 없이 변조된 값을 사용한다.
3. 포획 판정 결과 검증 없음: HP가 30% 이상인 팰이 포획되거나, 레이드 보스급 팰이 한 번에 포획되는 등 비정상적인 결과가 발생해도 이를 탐지하는 로직이 존재하지 않는다.
안티치트 설계 시 고려사항
이번 분석을 통해 서버 측 보호의 부재가 확인되었다. 안티치트 시스템 설계 시 다음 사항을 고려해야 한다
1. 서버 프로세스 보호: 서버에도 메모리 무결성 검증을 적용하여, 외부 프로세스에 의한 읽기/쓰기를 감지한다.
2. 게임 설정값 무결성 검사: BP_PalGameSetting 런타임 인스턴스의 원본값을 서버 시작 시 스냅샷으로 저장하고, 주기적으로 현재 값과 비교한다. IgnoreFirstCaptureFailedHPRate가 0.3이 아니거나, CaptureJudgeRateArray가 [0.444, 0.333, 0.222]가 아니면 변조된 것이다.
3. 포획 결과 통계 분석: HP가 높은 팰을 반복적으로 포획하거나, 포획률이 극히 낮은 팰을 연속으로 포획하는 플레이어를 탐지한다.
4. 판정 로직 내부 검증: 흔들림 테스트에서 powf(capture_rate, exponent)의 결과값이 항상 1.0이면 비정상이다. exponent가 0.0인지 체크하는 로직을 추가하면 변조를 실시간으로 감지할 수 있다.
'캡스톤 디자인 > 정보보호 프로젝트실습' 카테고리의 다른 글
| [팰월드 안티치트 프로젝트] #8 전용서버 메모리 분석 - 재료 무소비, 아이템 복제, 무한 획득" (0) | 2026.04.16 |
|---|---|
| [팰월드 안티치트 프로젝트] #7 전용서버 메모리 분석 - 텔레포트 (0) | 2026.04.10 |
| [팰월드 안티치트 프로젝트] #5 전용서버 메모리 분석 - 스피드핵 (0) | 2026.04.02 |
| [팰월드 안티치트 프로젝트] #4 전용서버 메모리 분석 - 서버 측 변조는 정말 가능한가? (0) | 2026.03.31 |
| [팰월드 안티치트 프로젝트] #3 팰월드 치트 동작 원리 분석 - Cheat Engine CT 파일 리버싱 (Inf Spher) (0) | 2026.03.26 |