본문 바로가기

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

[팰월드 안티치트 프로젝트] #7 전용서버 메모리 분석 - 텔레포트

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

 

📌 이전 글(#6)에서는 서버 메모리에서 포획 판정 변수(CaptureJudgeRateArray, IgnoreFirstCaptureFailedHPRate)를 변조하여 포획률 100%를 구현했다. IDA 디컴파일로 흔들림 테스트의 실제 공식(powf(capture_rate, exponent) >= random)을 밝혀내고, exponent를 0.0으로 만들면 x^0 = 1.0이 되어 무조건 통과한다는 것을 검증했다.
이번 글에서는 텔레포트를 분석한다. 지금까지의 치트(스탯 포인트, 스피드핵, 포획률)는 모두 서버 메모리의 값을 직접 변경하는 방식이었다. 텔레포트도 같은 방식으로 가능할까? 플레이어의 좌표를 직접 바꾸면 원하는 위치로 이동할 수 있을까? 결론부터 말하면, 메모리 직접 쓰기로는 불가능하다. UE5의 리플리케이션 시스템이 이를 막고 있으며, 대신 엔진 함수를 직접 호출하는 완전히 다른 접근이 필요하다.

 

이 글에서 다루는 내용:

- IDA에서 텔레포트 관련 함수 탐색 및 UFunction 캐시 주소 발견
- ProcessEvent vtable 인덱스 확인 및 K2_SetActorLocation 호출
- 텔레포트 성공 및 클라이언트 동기화 검증
- 이전 치트(값 변조)와의 근본적 차이 분석
- 안티치트 관점의 탐지 방안

 

분석 환경:

항목  내용
서버 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단계: IDA에서 텔레포트 관련 함수 탐색

이전 글들(#4~#6)에서는 SDK에서 변수 이름을 검색하여 역추적하는 방식으로 시작했다. 이번에는 IDA에서 함수 이름을 직접 검색하는 것에서 출발한다. 메모리 값을 바꾸는 것이 아니라 게임 함수를 호출해야 하기 때문이다.

[1] "TeleportTo" 문자열 검색
IDA에서 서버 바이너리를 열고 Strings 창(Shift+F12)에서 "TeleportTo"를 검색한다. UE5에서는 네이티브 함수를 등록할 때 함수 이름을 문자열로 저장하기 때문에, 문자열 검색으로 관련 함수를 모두 찾을 수 있다.

검색하면 여러개의 결과가 나온다. 이 중에서 주목해야 할 함수는 TeleportToLocation이다. 나머지 함수들(TeleportToBossTower, TeleportToNearestCamp 등)은 목적지가 미리 정해져 있어서, 보스 타워나 캠프처럼 게임이 지정한 장소로만 이동할 수 있다. 반면 TeleportToLocation은 float X, Y, Z를 파라미터로 직접 받는다. 공격자가 원하는 아무 좌표든 넣을 수 있으므로, 치트 관점에서 가장 위험하고 범용적인 함수다. 이것을 추적한다.


[2] 문자열에서 함수 등록 테이블로 이동

Strings 창에서 TeleportToLocation 문자열을 더블클릭하면 .rdata 섹션의 문자열 저장 위치(0x14686B6A8)로 이동한다. 이 문자열 오른쪽을 보면 DATA XREF가 2개 표시되어 있는데, 이것은 이 문자열을 참조하는 코드가 2군데 있다는 뜻이다.

 

여기서 우클릭 → Jump to xref(또는 X키)를 누르면 참조 주소 목록이 나온다. 그 중 0x1468682B8로 점프하면 FNameNativePtrPair 테이블에 도착한다.

FNameNativePtrPair 테이블이란?
UE5가 "이 이름의 함수는 실제로 어떤 코드에 연결되는지"를 저장해둔 매핑 테이블이다. 게임이 시작될 때 엔진이 이 테이블을 읽으면서 "TeleportToLocation이라는 이름은 이 함수다"라고 등록한다.

 

테이블에서 TeleportToLocation 문자열 포인터(0x1468682B8) 바로 위 8바이트(0x1468682B0)를 보면 sub_14268E540이라는 함수 포인터가 있다.

 

[3] 소유 클래스 확인 — UPalCheatManager
테이블을 더 넓게 보면, 이 테이블 자체가 하나의 클래스에 속한 함수들의 목록이다. 테이블 위쪽으로 스크롤하면 "UPalCheatManager"문자열이 보인다. 이 테이블에 나열된 함수들(TeleportToLocation, TeleportToNearestCamp, TeleportToSafePoint 등)이 전부 UPalCheatManager 클래스의 멤버 함수라는 것을 여기서 확인할 수 있다.

 

SDK(Pal_classes.hpp)에서 실제로 확인해보면, UPalCheatManager 클래스가 존재하고, 아래로 내려가다 보면 TeleportToLocation이 float 형식의 X, Y, Z 파라미터를 받는 것을 확인할 수 있다.

 

이 파라미터가 메모리에서 실제로 어떻게 배치되는지 확인하기 위해 Pal_parameters.hpp를 열어본다. UE5에서 ProcessEvent로 함수를 호출할 때는 파라미터를 일반 함수처럼 직접 넘기는 것이 아니라, 구조체 하나에 담아서 포인터로 전달해야 하기 때문이다. 확인해보면 float X, Y, Z 3개(12바이트)뿐인 단순한 구조다.

 

[4] UFunction 캐시 주소 발견

[3]에서 이 함수가 UPalCheatManager 소속이고 파라미터가 float 3개라는 것을 확인했다. 이제 다시 [2]의 테이블로 돌아가서, TeleportToLocation 문자열 포인터(0x1468682B8) 바로 위 8바이트(0x1468682B0)에 있던 sub_14268E540을 따라간다. 이 함수를 더블클릭하여 이동한 뒤 F5로 디컴파일하면 아래와 같은 코드가 나타난다.

__int64 sub_14268E540()
{
  __int64 result; // rax

  result = qword_148593D48;
  if ( !qword_148593D48 )
  {
    sub_1431CF6F0(&qword_148593D48, &off_1480FD940);
    return qword_148593D48;
  }
  return result;
}

 

이 코드의 동작은 단순하다. qword_148593D48에 값이 있으면 그대로 반환하고, 없으면 새로 만들어서 저장한 뒤 반환한다. 즉, 처음 한 번만 생성하고 이후에는 캐시된 값을 재사용하는 구조다.

UE5는 이런 패턴의 함수를 Z_Construct라고 부른다. 모든 함수 정보를 UFunction이라는 객체로 관리하는데, 게임 시작 시 수천 개를 한꺼번에 만들면 느리므로 이처럼 처음 필요할 때 한 번만 만들어서 전역변수에 캐시해두는 방식(lazy-init)을 사용한다.

 

여기서 중요한 것은 qword_148593D48이다.  이 전역변수에는 TeleportToLocation 함수의 정보를 담고 있는 객체(UFunction)의 주소가 저장된다. IDA 기준 주소에서 바이너리 시작 주소를 빼면 런타임 오프셋이 나온다

0x148593D48 - 0x140000000 = 0x8593D48

서버 실행 중에 Base + 0x8593D48을 읽으면 TeleportToLocation의 함수 정보 객체(UFunction) 주소를 얻을 수 있다. 이 주소를 나중에 ProcessEvent에 넘기면 텔레포트가 실행된다. 

 

[5] 그런데 데디서버에서 CheatManager는 NULL이다
UFunction 주소를 찾았으니 이제 이 함수를 실행할 차례다. ProcessEvent로 함수를 호출하려면 "어떤 객체에서 실행할지"도 지정해야 한다. TeleportToLocation은 UPalCheatManager의 함수이므로, UPalCheatManager 인스턴스가 필요하다. PalCheatManager는 UE5 엔진의 UCheatManager를 상속받은 클래스이므로

 

Engine_classes.hpp에서 CheatManager를 검색한다

class UCheatManager*		CheatManager;		// 0x03F8(0x0008)(BlueprintVisible, BlueprintReadOnly, ZeroConstructor, Transient, NoDestructor, UObjectWrapper, HasGetValueTypeHash, NativeAccessSpecifierPublic)

 

위쪽으로 스크롤하면 이것이 APlayerController 클래스 안에 있다는 것을 알 수 있다

 

PlayerController + 0x3F8에 CheatManager 포인터가 있다. 이전 글(#4)에서 확보한 포인터 체인(GWorld → GameState → PlayerArray → PlayerState → Owner)으로 PlayerController까지 도달한 뒤, +0x3F8을 읽어보면 NULL이 나오는 것을 확인 할 수 있다.

데디케이티드 서버에서는 CheatManager가 기본적으로 생성되지 않는다. CheatManager는 개발 빌드에서만 활성화되는 디버그 기능이기 때문이다. 즉, TeleportToLocation은 사용할 수 없다.

그러면 좌표를 그냥 직접 덮어쓰면 되지 않을까? 안 된다. UE5의 리플리케이션은 엔진 함수를 통해 값이 변경될 때 "바뀌었음" 표시(dirty flag)를 남기고, 이 표시가 있는 것만 클라이언트에 전송한다. 메모리를 직접 쓰면 값은 바뀌지만 이 표시가 안 남아서 서버가 무시한다. 대안 함수가 필요하고, 엔진 함수를 통해 호출해야 한다.

 

[6] 대안 — K2_SetActorLocation

#5(스피드핵)에서 이동속도를 찾을 때 Engine_classes.hpp에서 엔진 기본 기능을 찾았던 것처럼, 위치 변경도 엔진 기본 함수를 찾는다. SDK에서 AActor 클래스를 보면 위치 관련 함수 중 K2_SetActorLocation이 있다. UE5에서 Actor의 월드 좌표를 변경하는 엔진 기본 함수다. IDA에서 K2_SetActorLocation을 검색하면 아래와 같이 4개의 결과가 나오는 것을 확인 할 수있다.


IDA Strings에서 K2_SetActorLocation을 검색하면 4개의 결과가 나온다. 이 중 Actor.이 붙지 않은 K2_SetActorLocation을 선택한다. Actor.이 붙은 것은 경로 표기가 포함된 문자열이고, 붙지 않은 것이 함수 등록 테이블에서 실제 함수 이름으로 사용되는 문자열이다. 이후 과정은 [2]~[4]에서 TeleportToLocation을 추적한 것과 동일하다. 문자열을 더블클릭하여 .rdata로 이동하고, xref를 따라 함수 등록 테이블로 점프한 뒤, 문자열 포인터 위 8바이트에 있는 함수를 따라가서 디컴파일하면 UFunction 캐시 주소를 얻을 수 있다.

__int64 sub_1446FF490()
{
  __int64 result; // rax

  result = qword_1487F4908;
  if ( !qword_1487F4908 )
  {
    sub_1431CF6F0(&qword_1487F4908, &off_14823EC40);
    return qword_1487F4908;
  }
  return result;
}

qword_1487F4908은 IDA 기준 절대주소다. 여기서 IDA 기본 베이스 0x140000000을 빼면 0x87F4908 이다. 이것은 런타임에 서버 Base + 0x87F4908을 읽으면 K2_SetActorLocation의 UFunction 포인터를 얻을 수 있다.

 

2단계: 포인터 체인 구성

K2_SetActorLocation을 Pawn에서 호출하려면, 서버 메모리에서 Pawn의 주소를 찾아야 한다. #4~#6과 동일한 포인터 체인으로 PlayerState까지 접근한 뒤, PawnPrivate(+0x308)로 Pawn을 가져온다.

GWorld (Base + 0x882DA60) → UWorld + 0x0158 → GameState + 0x02A8 → PlayerState[0] + 0x0308 → PawnPrivate (APalCharacter)

 

Pawn을 확보했으니, 텔레포트 전에 현재 좌표를 읽어보자.

 

 

Engine_classes.hpp에서 AActor를 보면 RootComponent(+0x0198)가 있다. RootComponent는 Actor의 월드 좌표를 저장하는 최상위 컴포넌트다.

class USceneComponent*		RootComponent;		// 0x0198(0x0008)

 

RootComponent의 타입이 USceneComponent이므로, 같은 SDK에서 USceneComponent 클래스를 검색한다. 위치 관련 멤버를 찾으면 RelativeLocation(+0x0128)이 있다. FVector 타입으로, double X, Y, Z 3개가 들어있다

FVector 타입이란? UE5에서 3D 좌표를 담는 구조체다. X, Y, Z 세 개의 double 값으로 구성되어 있고, 총 24바이트다.

 

SDK에서 찾은 포인터 체인이 실제로 맞는지 CE에서 검증한다.

  local base = getAddress("PalServer-Win64-Shipping-Cmd.exe")                                                                                                                                                                
  local uworld = readQword(base + 0x882DA60)                                                                                                                                                                                 
  local gs = readQword(uworld + 0x158)
  local ps = readQword(readQword(gs + 0x2A8))
  local pawn = readQword(ps + 0x308)
  local rc = readQword(pawn + 0x198)

  local x = readDouble(rc + 0x128)
  local y = readDouble(rc + 0x130)
  local z = readDouble(rc + 0x138)
  print(string.format("현재 위치: X=%.1f, Y=%.1f, Z=%.1f", x, y, z))

 

좌표가 정상적으로 읽힌다. 포인터 체인이 유효한 것이 확인되었다

 

현재 좌표가 어디에 저장되어 있는지 확인했으니, 이제 이 좌표를 바꿀 방법이 필요하다. 앞서 메모리를 직접 쓰면 dirty flag가 안 남는다고 했다. K2_SetActorLocation을 호출해야 하고, UE5에서 UFunction을 호출하는 방법이 ProcessEvent다.

ProcessEvent란?
UE5에서 UFunction(블루프린트/C++ 함수)을 실행하는 함수다. "이 객체에서, 이 함수를, 이 파라미터로 실행해라"를 하는 거다.

 

ProcessEvent를 호출하려면 세 가지가 필요하다.

1. 호출 대상 객체 — Pawn (포인터 체인으로 이미 확보)
2. UFunction 포인터 — K2_SetActorLocation (Base + 0x87F4908, [6]에서 확보)
3. ProcessEvent 함수 포인터 — Pawn의 vtable에서 찾아야 한다

1, 2는 있으니까 남은 건 3번, vtable에서 ProcessEvent 위치를 찾는 거다.

 

ProcessEvent는 가상함수이므로 vtable에 들어있다. vtable은 객체의 첫 번째 포인터(+0x00)에 저장된 함수 주소 배열이다.

[Pawn + 0x00] → vtable

이전 글(#6 포획률)에서 *(vtable + 608)이라는 힌트를 확인한 적이 있다. 함수 포인터 하나가 8바이트이므로, 608 / 8 = 76 = 0x4C. vtable의 0x4C번째가 ProcessEvent다.

 

이제 ProcessEvent 호출에 필요한 세 가지가 다 갖춰졌다. 남은 건 K2_SetActorLocation의 파라미터 구조체를 만드는 것이다.

SDK(Engine_parameters.hpp)에서 K2_SetActorLocation을 검색하면 파라미터 구조체가 나온다. ProcessEvent에 넘길 입력값들이 어떤 형식인지 정의되어 있다.

  struct Actor_K2_SetActorLocation {                                                                                                                                                                                         
      FVector    NewLocation;     // 0x0000 — 목표 좌표 (double X, Y, Z)                                                                                                                                                     
      bool       bSweep;          // 0x0018 — 충돌 감지 여부
      FHitResult SweepHitResult;  // 0x0020 — 충돌 결과 저장용
      bool       bTeleport;       // 0x0108 — 텔레포트 모드 여부
  };
  // 전체 크기: 0x0110

전체 크기는 0x0110(272바이트)이지만, 실제로 설정해야 하는 값은 NewLocation(목표 좌표)과 bTeleport(true)뿐이다. 나머지는 0으로 채우면 된다.

 

3단계: 텔레포트 실행

  local base = getAddress("PalServer-Win64-Shipping-Cmd.exe")                                                                                                                                                                
                                                                                                                                                                                                                               -- 포인터 체인: GWorld → Pawn                                                                                                                                                                                              
  local uworld = readQword(base + 0x882DA60)                                                                                                                                                                                 
  local gs = readQword(uworld + 0x158)
  local ps = readQword(readQword(gs + 0x2A8))
  local pawn = readQword(ps + 0x308)

  -- 현재 좌표 읽기
  local rc = readQword(pawn + 0x198)
  local curX = readDouble(rc + 0x128)
  local curY = readDouble(rc + 0x130)
  local curZ = readDouble(rc + 0x138)
  print(string.format("이동 전: X=%.1f, Y=%.1f, Z=%.1f", curX, curY, curZ))

  -- ProcessEvent + K2_SetActorLocation
  local vtable = readQword(pawn)
  local processEvent = readQword(vtable + 0x4C * 8)
  local ufunc = readQword(base + 0x87F4908)

  -- 파라미터 구조체 (0x110바이트)
  local params = allocateMemory(0x110)
  for i = 0, 0x10F do writeBytes(params + i, 0) end
  
  -- 목표 좌표 설정 (여기를 수정)
  writeDouble(params + 0x00, curX + 10000)   -- NewLocation.X
  writeDouble(params + 0x08, curY)            -- NewLocation.Y
  writeDouble(params + 0x10, curZ)            -- NewLocation.Z
  writeBytes(params + 0x108, 1)               -- bTeleport = true

  -- 호출
  executeCodeEx(0, nil, processEvent, pawn, ufunc, params)
  deAlloc(params)

  -- 이동 후 좌표 확인
  local newX = readDouble(rc + 0x128)
  local newY = readDouble(rc + 0x130)
  local newZ = readDouble(rc + 0x138)
  print(string.format("이동 후: X=%.1f, Y=%.1f, Z=%.1f", newX, newY, newZ))

 

목표 좌표를 curX + 10000으로 설정하고 스크립트를 실행했다. X축으로 10000만큼 이동했고, 클라이언트 화면에서도 캐릭터가 즉시 텔레포트된 것을 확인했다. 서버에서 ProcessEvent를 통해 호출했기 때문에 리플리케이션이 자동으로 처리되어, 메모리 직접 쓰기와 달리 클라이언트 동기화에 문제가 없었다.

#5(스피드핵)에서는 서버 메모리만 변조하면 클라이언트 예측과 불일치하여 끊김이 발생했다. 텔레포트는 끊김 없이 깔끔하게 이동한다. K2_SetActorLocation은 엔진의 정상적인 위치 변경 경로를 사용하므로, 내부적으로 리플리케이션이 자동으로 트리거되기 때문이다.

분석 결과 정리

서버 측에서 발견된 취약점

1. 안티치트 미적용: 서버 프로세스에 EAC 등의 보호가 전혀 없어 CE 어태치가 자유롭게 가능하다.

2. 메모리 무결성 검증 없음: 서버 메모리의 값을 외부에서 변조해도 감지/차단하는 메커니즘이 존재하지 않는다.

3. ProcessEvent 호출 제한 없음: 외부에서 vtable을 통해 ProcessEvent를 호출해도 정상 호출과 구분하지 않는다. 호출 스택이나 호출 빈도를 검증하는 로직이 없다.

4. 위치 변경 검증 없음: K2_SetActorLocation 호출 시 이동 거리나 이동 빈도에 대한 제한이 없어, 물리적으로 불가능한 이동도 허용된다.

 

안티치트 설계 시 고려사항

1. 서버 프로세스 보호: 서버에도 메모리 무결성 검증을 적용하여, 외부 프로세스에 의한 읽기/쓰기를 감지한다.

2. 위치 연속성 검증: 프레임 간 이동 거리가 물리적으로 불가능한 값이면 플래그한다. 정상적인 최대 이동속도 기반으로 임계값을 설정할 수 있다.

3. ProcessEvent 호출 패턴 감시: K2_SetActorLocation이 게임 로직 외부에서 호출되는 경우를 탐지한다. 호출 스택 검증으로 정상/비정상 호출을 구분할 수 있다.

4. 텔레포트 쿨다운 검증: 정상적인 패스트 트래블은 쿨다운이 있다. 짧은 시간 내 반복 이동은 비정상으로 판단할 수 있다.