본문 바로가기

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

[팰월드 안티치트 프로젝트] #3 팰월드 치트 동작 원리 분석 - Cheat Engine CT 파일 리버싱 (Inf Spher)

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

 

📌 이 글은 팰월드 안티치트 프로젝트의 학습 기록 시리즈입니다. 안티치트를 만들기 위해 먼저 "공격자가 게임을 어떻게 공격하는지" 이해하는 과정을 정리합니다.

이전 Damage/Health 분석에서는 데미지 값(R15 레지스터)을 0으로 만드는 방식이었다면, 이번 Inf Sphere 치트는 스피어 수량 체크를 우회하는 방식으로 동작한다. 동일한 분석 방법론(AOB 패턴 추출 → IDA 검색 → 브레이크포인트 증명 → 인젝션 코드 분석)을 따르되, 치트의 접근 방식이 다르기 때문에 그 차이를 중심으로 정리한다.

 

이 글에서 다루는 내용:

- CT 파일에서 Inf Sphere 스크립트의 AOB 패턴 추출
- IDA Pro에서 패턴 검색으로 인젝션 포인트 확인
- 해당 코드가 속한 함수(UPalShooterComponent)의 전체 흐름 분석
- 브레이크포인트로 스피어 투척 시 실행되는 코드임을 증명
- 인젝션 코드의 동작 원리 분석 (inc를 이용한 수량 체크 우회)

 

분석 환경: 

항목  내용
게임 Palworld v0.7.2.87654 (Steam)
엔진 Unreal Engine 5.1 
바이너리 Palworld-Win64-Shipping.exe
정적 분석 IDA Pro + IDA MCP
동적 분석 Cheat Engine 7.6
CT 파일 FearLess Revolution — VoidUzumaki 제작 (v0.7.0 대응)

 

1단계 CT 스크립트에서 AOB 패턴 추출:

CT 파일에서 Inf Sphere 항목을 우클릭 → Change Script를 통해 내부 스크립트를 확인하면 다음과 같다.

local moduleName = "Palworld-Win64-Shipping.exe"
local pattern = "83 B8 54 01 00 00 00 74 ?? 48 ?? ?? ?? ?? ?? ?? 48 ?? ?? ?? ?? ?? ?? 75 ?? 40 ?? ?? 48 ?? ?? E8 ?? ?? ?? ?? B2 ?? 48 ?? ?? E8 ?? ?? ?? ?? 84 ?? 74 ?? 40 ?? ?? 74 ?? 40 ?? ?? 75 ?? 48 ?? ?? E8 ?? ?? ?? ?? 48 ?? ?? E8"
local name = "comparesSpheresZero"
local targetIndex = 1
local many = false

AOB에서 ??란?
패턴에서 `??`는 와일드카드로, 게임 버전에 따라 바뀔 수 있는 바이트를 의미한다. 게임이 업데이트되면 코드의 위치(주소)가 밀릴 수 있지만, 바이트 패턴 자체는 같은 명령어이므로 검색하면 찾아진다. 고정된 바이트와 와일드카드를 조합하여 유니크한 패턴을 만들어, 어떤 버전에서도 해당 코드를 찾을 수 있도록 한 것이다.

 

2단계 IDA에서 패턴 검색으로 위치 확인:

이 패펀을 IDA에 검색하여 이 코드가 게임 바이너리 어디에 존재하는지 확인해보자.

IDA Pro에서 Palworld-Win64-Shipping.exe를 열고, Alt+B (Search → Sequence of bytes)를 눌러 다음 바이트를 검색한다:

 

검색 결과, 전체 exe에서 딱 1곳에서 매칭된다:

주소: 0x2E71172
소속 함수: sub_2E710C0

단 1곳에서만 발견되엇다. CT 파일이 이 패턴을 사용하는 이유가 여기에 있다. 검색하면 무조건 정확한 위치 하나만 나오기 때문에, 원하는 코드를 확실하게 특정할 수 있다.

검색 결과를 더블클릭을 하면 0x2E71172로 이동하며 다음 어셈블리를 볼 수 있다:

매칭된 바이트 83 B8 54 01 00 00 00을 어셈블리로 해석하면 다음과 같다:

cmp dword ptr [rax+00000154h], 0

 

cmp는 compare(비교)의 약자로, 두 값을 비교하는 명령어이다. 여기서는 [rax+0x154] 주소에 있는 4바이트(dword) 값이 0인지를 비교한다. 뒤에서 확인하겠지만, 이 오프셋 0x154에는 스피어 수량(StackCount)이 저장되어 있다. 즉, 이 한 줄은 "팰 스피어가 0개인가?"를 확인하는 코드이다. 0이면 투척을 차단하고, 0이 아니면 투척을 허용한다.

인젝션 코드 (ASM)

-- Run setup
setupPattern(pattern, name, targetIndex, moduleName, many)
{$asm}
//aobscanmodule(comparesSpheresZero, Palworld-Win64-Shipping.exe, 83 BE 54 01 00 00 00)
alloc(newmem0,$1000,comparesSpheresZero)
label(code0)
label(return0)

newmem0:
  inc dword ptr [rax+00000154]          ; 수량 +1
  
code0:
  cmp dword ptr [rax+00000154],00       ; 원래 비교 코드
  jmp return0                           ; 원래 다음 줄로 복귀

comparesSpheresZero:                    ; 원래 코드 위치
  jmp newmem0                           ; 커스텀 코드로 점프 (5바이트)
  nop 2                                 ; 남은 2바이트 채움
  
return0:                                ; 원래 코드의 다음 줄

comparesSpheresZero는 AOB 패턴으로 찾은 원래 위치이다. 이 자리의 cmp 명령어(7바이트)를 jmp(5바이트) + nop(2바이트)로 덮어씌워, 게임이 이 위치에 도달하면 커스텀 코드(newmem0)로 이동시킨다.

inc는 increment(증가)의 약자로, 해당 메모리 값을 1 증가시키는 명령어이다. 

 

여기서는 팰 스피어 수량을 1 증가시켜서, 바로 다음에 실행되는 cmp(0과 비교)에서 수량이 절대 0으로 판정되지 않게 만드는 것이다. 수량이 0이었더라도 비교 직전에 1이 되기 때문에, 스피어가 없어도 "있다"고 통과시키는 원리이다.


치트 비활성화 시에는 원래 바이트(83 B8 54 01 00 00 00)를 다시 써넣어 복원한다.

[DISABLE]
comparesSpheresZero:
  db 83 B8 54 01 00 00 00              ; 원래 바이트 복원


이 코드가 정확히 무엇을 하는지는 뒤에서 상세히 분석한다.

 

3단계 소속 함수 전체 흐름 분석:

AOB가 매칭된 0x2E7117는 함수 sub_2E710C0 내부에 위치한다. IDA MCP를 통해 이 함수를 분석한 결과, UPalShooterComponent 클래스의 멤버 함수임을 확인하였다.
UPalShooterComponent는 팰월드에서 무기 발사/투척을 담당하는 컴포넌트이며, 다음과 같은 exec 함수들을 가지고 있다:

execPullTrigger		 → 트리거 당기기 (발사)
execReleaseTrigger	 → 트리거 놓기 (발사 후 정리)
execCanShoot 		 → 발사 가능 여부 확인
execChangeWeapon	 → 무기 교체


sub_2E710C0 함수의 전체 흐름을 3단계로 나누어 정리하면 다음과 같다

[1] 준비 단계 — "무기 들고 있어?"
함수에 진입하면 가장 먼저 현재 장착된 무기가 유효한지 확인한다. this+0x2D0 오프셋에서 현재 무기 포인터를 가져온 뒤, 이 객체가 실제로 존재하는지, UClass 타입이 올바른지를 검증한다. 멀티플레이 환경인지도 여기서 체크한다. 
무기가 유효하지 않으면 함수는 여기서 바로 종료된다. 장착한 무기가 없거나 잘못된 상태면 투척 자체를 시도하지 않는 것이다.

 

[2] 검증 단계 — "스피어 남아있어?" ← CT가 건드리는 부분
무기가 유효하다면, 다음으로 현재 선택된 스피어 슬롯의 아이템 정보를 조회한다. 여기서 두 가지를 확인한다.

- 수량 체크 [rax+0x154]: 스피어가 0개인지 확인한다. 0이면 dil 플래그를 0으로 설정하여 투척을 차단한다.
- 아이템 ID 체크 [rax+0x12C]: 아이템이 빈 슬롯(None)인지 확인한다. 빈 아이템이면 마찬가지로 투척을 차단한다.
이 두 체크를 모두 통과해야 dil 플래그가 1로 유지되어 다음 단계로 넘어갈 수 있다. CT의 inc 명령어는 바로 이 수량 체크 직전에 끼어들어 수량을 +1 함으로써, 0개여도 1개로 판정되게 만드는 것이다.

 

[3] 실행 단계 — "던져!"
모든 검증을 통과하면 실제 투척이 실행된다. sub_2AF79B0(PullTrigger)을 호출하여 스피어를 날리고, sub_2AF8D00(ReleaseTrigger)으로 투척 후 상태를 정리한다. 이후 무기 상태를 업데이트하고 투척 관련 플래그들을 리셋한다.  투척이 완료되면 별도의 함수(DecrementCurrentSelectPalSphere)에서 인벤토리의 스피어 수량이 1 차감된다.

 

CT는 왜 [2]만 건드리는가?

[1]의 무기 유효성 확인이나 [3]의 실제 투척 코드는 조작할 필요가 없다. 무기가 유효한지는 정상적으로 확인해야 크래시가 나지 않고, 투척 코드 자체는 원래대로 동작해야 스피어가 실제로 날아간다. CT가 원하는 것은 "수량이 0이어도 던질 수 있게" 만드는 것뿐이므로, 검증 단계의 수량 체크 한 곳만 속이면 충분한 것이다.

 

4단계 검증 로직 상세 분석:

함수 전체 흐름에서 [2] 검증 단계가 CT의 인젝션 대상이라고 했다. 이제 CE Memory View에서 이 구간의 어셈블리를 직접 보면서, 한 줄씩 무슨 일이 일어나는지 살펴보자.

mov  dil, 1                    ; "던질 수 있다"고 일단 1로 설정

함수는 처음에 dil을 1로 세팅한다. dil은 rdi 레지스터의 최하위 1바이트로, 여기서는 "스피어를 던져도 되는가?"를 저장하는 플래그로 쓰인다. 1이면 투척 가능, 0이면 투척 불가이다.

call sub_2A8EE90               ; 현재 선택된 스피어 슬롯 조회
                               ; 반환값: rax = 스피어 아이템 객체 주소

 

현재 장착된 스피어의 아이템 정보를 가져온다. 반환값이 rax에 담기며, 이 안에 수량(+0x154)과 아이템 ID(+0x12C)가 들어있다.

cmp  dword ptr [rax+154h], 0   ; ★ 스피어 수량이 0인가?
jz   short 0x2E7118B           ; 0이면 → dil=0으로 점프

CT가 건드리는 바로 그 지점이다. 스피어 수량을 0과 비교해서, 0이면 0x2E7118B(dil을 0으로 만드는 곳)으로 점프한다. jz는 jump if zero의 약자로, 비교 결과가 "같다(0이다)"이면 점프하는 명령어이다.

mov  rcx, cs:qword_8E4FF70     ; 빈 아이템(None) ID 로드
cmp  [rax+12Ch], rcx           ; 아이템 ID가 빈 ID인가?
jnz  short 0x2E7118E           ; 빈 ID가 아니면 → 유효한 스피어 → 통과

수량이 있더라도 아이템 자체가 빈 슬롯이면 안 되므로, ID도 확인한다. jnz는 jump if not zero로, "다르면(빈 ID가 아니면)" 검증을 통과시킨다.

xor  dil, dil                  ; dil = 0 (투척 불가)

수량이 0이거나 빈 아이템이면 여기로 오게 된다. xor dil, dil은 dil을 자기 자신과 XOR 해서 0으로 만드는 명령어이다. 이제 투척 불가 상태가 된다. 

call sub_2AE3F50               ; 발사 가능 여부 최종 확인
test al, al                    ; 반환값 확인
jz   short 0x2E711BE           ; 불가능하면 → 스킵

무기 자체의 발사 가능 상태를 한 번 더 확인한다. 쿨다운 중이거나 다른 이유로 발사할 수 없으면 여기서 걸린다.

test dil, dil                  ; ★ 최종 판단: dil이 0인가?
jz   short 0x2E711BE           ; 0이면 → 투척 안 함

최종 관문이다. 앞에서 수량과 ID를 체크한 결과가 dil에 담겨있다. 0이면 투척이 차단되고, 1이면 다음으로 넘어간다.

call sub_2AF79B0               ; ★ PullTrigger — 스피어 투척!
call sub_2AF8D00               ; ★ ReleaseTrigger — 투척 후 정리

모든 검증을 통과하면 여기까지 도달하여 실제로 스피어가 날아간다.

 

5단계 브레이크포인트로 증명:

지금까지 정적 분석으로 코드의 구조를 파악하였다. 하지만 이 코드가 실제로 스피어 투척 시 실행되는지를 직접 증명해야 한다. Damage/Health 분석에서 브레이크포인트를 걸어 피격 시 R15에 데미지 값이 담기는 것을 확인한 것과 동일한 방법을 사용한다.

 

CE Memory View에서 cmp dword ptr [rax+154h], 0 위치에 F5로 브레이크포인트를 설정한다.

 

게임으로 돌아가서 팰 스피어를 던진다. 던지는 순간 게임이 멈춘다. 게임이 멈췄다는 것은, 브레이크포인트를 건 바로 그 코드가 실행되었다는 뜻이다. 즉, 스피어 투척 시 이 코드를 거친다는 것이 확인된 것이다. 브레이크포인트에 걸렸을 때 레지스터 창에서 RAX 값을 확인한다. 

 

RAX에 0x23CF04E8000이 담겨있었고, 여기에 오프셋 0x154를 더한 0x23CF04E8154 주소로 이동하면 다음 값이 보인다. 이 시점에 인벤토리에서 스피어를 7개 보유하고 있었다. 메모리 값과 실제 보유 수량이 정확히 일치한다.

 

Run으로 게임을 재개한 뒤 스피어를 한 번 더 던져서 다시 브레이크포인트에 걸리게 한다. 같은 방법으로 [rax+154h]를 확인하면

첫 번째: [RAX+154h] = 7 (7개), 두 번째: [RAX+154h] = 6 (6개)가 되는 것을 확인 할 수 있다.

 

이제 이 검증 로직에 CT의 인젝션 코드(inc)가 끼어들면 어떻게 되는지 살펴보자.

정상 흐름에서는 스피어를 던질 때마다 수량이 1씩 줄어든다. 3개를 가지고 있으면 3번 던질 수 있고, 수량이 0이면 cmp [rax+154h], 0에서 "0이다"로 판정되어 dil이 0이 된다. 이후 test dil, dil에서 걸려 투척이 차단된다. 더 이상 스피어를 던질 수 없다.

 

치트 흐름에서는 CT는 cmp 직전에 inc [rax+154h]를 끼워넣는다. 수량이 0이었더라도 비교 직전에 1로 올라가기 때문에, cmp 1, 0이 되어 "0이 아니다"로 판정된다. dil은 1을 유지하고 투척이 실행된다. 이후 수량이 다시 감소하여 0이 되지만, 다음 투척 시 또 inc로 1이 되므로 같은 과정이 무한히 반복된다. 스피어가 실제로 늘어나는 것이 아니라, 0이 되려는 순간마다 1로 복구되어 검증을 통과시키는 원리이다

 

분석한 내용이 실제로 동작하는지 확인해보자. CE에서 Inf Sphere 항목의 체크박스를 켜서 치트를 활성화한다.

 

이 순간 게임 메모리의 0x2E71172 위치에 있던 원래 코드(cmp [rax+154h], 0)가 jmp newmem0으로 덮어씌워진다. 이제 스피어를 던질 때마다 비교 직전에 inc가 실행되어 수량이 0으로 판정되지 않는다.

 

치트를 활성화한 상태에서 스피어를 2개 던져보았다. 원래대로라면 6개에서 4개로 줄어야 하지만, 브레이크포인트에서 확인한 [rax+154h] 값은 여전히 6이었다. inc가 매번 +1을 해주기 때문에, 투척으로 -1이 되어도 다음 체크에서 다시 +1이 되어 수량이 사실상 유지되는 것이다.

단, 스피어를 0개인 상태에서는 동작하지 않는다. 수량이 0이 되면 인벤토리에서 아이템 슬롯 자체가 사라지기 때문에, 최소 1개 이상 보유한 상태에서 치트를 활성화해야 한다.

 

안티치트 관점 — 탐지 및 방어

1. 서버측 수량 검증: 클라이언트가 스피어를 던질 때마다 서버에서 인벤토리 수량을 확인. 서버 기록과 불일치하면 비정상으로 판단.
2. 투척 횟수 vs 보유량 비교: 서버에서 "이 플레이어가 10개를 가지고 있는데 15번 던졌다"는 상황을 탐지.
3. 코드 무결성 체크: `0x2E71172` 위치의 바이트가 원래 값(`83 B8`)에서 `E9`(jmp opcode)로 변경되었는지 주기적으로 검사.
4. 수량 변동 패턴 감시: 정상적으로는 수량이 단조 감소해야 하는데, 투척 직전마다 +1 되는 비정상 패턴을 감지.

 

마무리

이번 글에서는 팰월드 CT 파일의 Inf Sphere 치트를 분석하였다.
AOB 패턴 스캔으로 스피어 수량 체크 코드의 위치(0x2E71172)를 찾고, IDA Pro에서 해당 함수의 (UPalShooterComponent::sub_2E710C0)의 전체 흐름을 파악하였다. 브레이크포인트를 걸어 이 코드가 스피어 투척 시 실행되며, [rax+154h]에 실제 스피어 수량이 담긴다는 것까지 증명하였다.

치트의 핵심은 inc dword ptr [rax+154h] 단 한 줄이다. 수량 비교(`cmp`) 직전에 값을 +1 하여, 수량이 0이더라도 1로 판정되게 만든다. 이를 통해 스피어가 절대 소진되지 않는 무한 스피어 상태를 만들어내는 것이다.
이전에 분석한 Damage/Health 치트는 레지스터 값을 0으로 만드는 방식이었고, Inf Sphere 치트는 메모리 값을 비교 직전에 증가시키는 방식이었다. 기법(AOB + 코드 인젝션)은 같지만 접근 방식이 다르다는 점에서, 치트 제작자들이 상황에 맞게 다양한 우회 전략을 사용한다는 것을 확인할 수 있었다.