본 글은 졸업작품(안티치트 시스템 개발)을 위한 학술 목적의 보안 연구입니다. 치트의 동작 원리를 분석하여 탐지 및 방어 방안을 설계하는 데 활용합니다.
📌 이전 글(#7)에서는 텔레포트를 분석했다. 메모리 직접 쓰기로는 UE5 리플리케이션의 dirty flag 메커니즘 때문에 좌표 변경이 클라이언트에 전파되지 않았고, ProcessEvent를 통한 K2_SetActorLocation 함수 호출로 성공시켰다.
이번 글에서는 제작(Craft) 시 재료 무소비 치트를 분석한다.
이 글에서 다루는 내용
- SDK 덤프에서 제작 시스템 구조 분석 (FPalItemRecipe 레시피 구조체, UPalDebugSetting 디버그 플래그)
- CDO 변조 시도 → Shipping 빌드 컴파일 아웃으로 실패, HW 브레이크포인트로 검증
- 동적 분석(값 스캔 + HW 쓰기 브레이크포인트)으로 StackCount 쓰기 코드 추적
- 코드 인젝션 v1(하위 함수 차단) 시도 → "1/2" 버그 발견 및 원인 분석
- IDA 디컴파일로 상위 함수(sub_142BF5AA0) 구조 파악 및 R8B 파라미터 역할 규명
- 코드 인젝션 v2(상위 함수 루프 전체 스킵)로 재료 무소비 완전 달성
- 안티치트 관점 탐지 방안 정리 (코드 무결성 검사, 재료 소비 크로스 체크, 메모리 페이지 보호 모니터링)
분석 환경
| 항목 | 내용 |
| 서버 | 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에서 제작 시스템 구조 분석
[1] 접근 전략 — 왜 레시피 하나하나를 바꾸지 않는가?
팰월드에는 수백 개의 제작 레시피가 있다. 각 레시피마다 다른 재료를 요구한다. 게임의 제작 로직을 단순화하면
제작 요청 → 레시피 확인 → 재료 보유 체크 → 재료 차감 → 아이템 지급
이 흐름에서 재료 차감 단계를 무력화하면, 레시피가 몇 개든 상관없이 모든 제작에 적용된다. 포획률 분석(#6)에서도 같은 원리였다.
[2] SDK에서 레시피 구조체 확인
제작 시스템을 분석하려면 먼저 게임이 레시피 정보를 어떤 구조로 저장하는지 알아야 한다. SDK 덤프 파일 중 Pal_structs.hpp(팰월드의 모든 구조체 정의가 담긴 파일)에서 "Recipe"를 검색하면 이 구조체를 찾을 수 있다.

FPalItemRecipe란? 이름에서 알 수 있듯이 팰월드의 레시피(제작법) 구조체다. 요리의 레시피처럼 "어떤 재료가 몇 개 필요하고, 무엇이 만들어지는가"를 정의하는 데이터다.
레시피 데이터를 변조하면 수백 개의 레시피를 하나하나 바꿔야 한다. 그보다는 재료 차감 로직 자체를 무력화하는 것이 효율적이다. UE5 게임은 개발 중 테스트 편의를 위해 "재료 소비 안 함", "무적 모드" 같은 디버그 플래그를 만들어두는 경우가 많다. 개발자도 매번 재료를 모아서 테스트하기 귀찮기 때문이다. 혹시 이런 스위치가 이미 존재한다면, 켜기만 하면 끝이므로 가장 쉬운 공격 경로가 된다. 그래서 SDK에서 "ConsumeMaterial"을 검색했다.
ConsumeMaterial란?
Consume = 소비하다, Material = 재료, 합치면 "재료를 소비하다"라는 뜻
검색 결과, UPalDebugSetting이라는 개발자 디버그 설정 클래스에서 재료 소비 관련 플래그들을 확인할 수 있다
class UPalDebugSetting final : public UObject // 크기 0x0610
{
bool bNotConsumeMaterialsInRepair; // 0x015D
bool bNotConsumeMaterialsInBuild; // 0x0181
bool bNotConsumeMaterialsInCraft; // 0x0214 ← 핵심
bool bSelectableRecipeWhenNothingMaterials; // 0x0216
};
이 중 bNotConsumeMaterialsInCraft가 핵심이다. 이름 그대로 "제작 시 재료를 소비하지 않음"이라는 뜻이므로, 이 값을 true로 바꾸면 모든 제작에서 재료가 소비되지 않을 것으로 기대할 수 있다.
2단계: CDO 변조 시도
[1] UClass 탐색: PropertiesSize 기반 식별
이전 글(#6 포획률 분석)에서 UPalGameSetting의 UClass를 찾아 CDO에 접근했던 것과 같은 구조다. UClass를 찾으면 CDO(기본값 인스턴스)에 접근할 수 있고, 거기에 원하는 오프셋 값을 쓰면 된다
UClass 찾기 → UClass+0x110에서 CDO 포인터 → CDO+0x0214에 1 쓰기
실제로 CE에서 PropertiesSize 스캔을 통해 UPalDebugSetting의 UClass를 찾고, CDO에 접근하여 디버그 플래그를 활성화해본다
local base = getAddress("PalServer-Win64-Shipping-Cmd.exe")
for off = 0x8580000, 0x85B0000, 8 do
local entry = base + off
local ptr = readQword(entry)
if ptr and ptr ~= 0 then
local ok, val = pcall(readInteger, ptr + 0x58)
if ok and val == 0x610 then
print(string.format("Found! Cache: Base+0x%X, UClass: 0x%X", off, ptr))
end
end
end
이 스크립트는 서버 바이너리의 .bss 영역(전역변수들이 저장된 영역)을 8바이트 단위로 순회하면서, 각 위치에 저장된 포인터를 따라간다. 따라간 주소에서 +0x58 위치를 읽어 그 값이 0x610(UPalDebugSetting의 클래스 크기)과 일치하는지 비교한다. 일치하면 해당 포인터가 UPalDebugSetting의 UClass이므로, 캐시 위치와 UClass 주소를 출력한다
UClass+0x58에는 해당 클래스의 인스턴스 크기(PropertiesSize)가 저장되어 있으므로, SDK에서 확인한 UPalDebugSetting의 크기 0x0610을 기준으로 .bss 영역을 스캔하여 UClass를 역으로 찾는다.
실행 결과, Base+0x8595240과 Base+0x8595248 두 곳에서 동일한 UClass(0x2271B787B40)가 발견되었다. 8바이트 차이로 인접한 두 슬롯에 같은 포인터가 캐싱된 것이므로, 0x2271B787B40이 UPalDebugSetting의 UClass다.

UClass를 찾았으므로, CDO에 접근할 수 있다. CDO(Class Default Object)는 해당 클래스의 기본값이 저장된 인스턴스로, UClass+0x110 위치에 그 포인터가 저장되어 있다. CDO+0x0214가 bNotConsumeMaterialsInCraft(제작 시 재료 무소비) 플래그이므로, 이 값을 1(true)로 설정하면 모든 제작에서 재료가 소비되지 않을 것으로 기대할 수 있다.


결과: 실패 / 1로 설정했는데도 재료가 정상적으로 차감되었다.


값을 1로 설정한 뒤 인게임에서 아이템을 제작해보았다. 그러나 재료는 정상적으로 차감되었다. 디버그 플래그를 활성화했음에도 효과가 없었다.
[2] 실패 원인 규명
왜 실패했는지 확인하기 위해 CDO+0x0214에 HW 읽기 브레이크포인트를 설정하고 제작을 시도했지만 브레이크포인트에 아무것도 잡히지 않았다. 이 값을 읽는 코드 자체가 바이너리에 존재하지 않는 것이다.

팰월드 서버 실행 파일 이름(PalServer-Win64-Shipping-Cmd.exe)에서 알 수 있듯이, 이 서버는 UE5의 Shipping 빌드(배포용)로 컴파일되어 있다. Shipping 빌드에서는 디버그 전용 코드가 컴파일 시점에 제거되기 때문에, bNotConsumeMaterialsInCraft 플래그 데이터는 메모리에 남아있어도 그것을 읽는 코드 자체가 존재하지 않는다. 따라서 CDO 변조로는 재료 무소비를 달성할 수 없다고 판단했다.
3단계: 동적 분석 — StackCount 쓰기 코드 추적
CDO 변조가 안 되므로, 실제 재료 차감이 일어나는 코드를 동적 분석으로 찾는다. 재료가 차감된다는 건 결국 인벤토리 어딘가에서 숫자가 줄어든다는 뜻이다. 그렇다면 그 숫자가 메모리 어디에 저장되어 있는지를 알아야 한다. SDK 덤프에서 "ItemSlot", 즉 인벤토리의 아이템 칸을 담당하는 클래스를 찾아보면 UPalItemSlot이 나온다.

이 클래스 안에 StackCount라는 멤버가 있는데, 이름 그대로 해당 칸에 쌓여있는 아이템 개수를 저장하는 값이다.
int32 StackCount; // 0x0154(0x0004)
이 StackCount는 클래스 시작 주소로부터 +0x154 위치에 int32로 저장되어 있으므로, 예를 들어 어떤 슬롯의 시작 주소가 0x1000이라면 그 슬롯의 수량은 항상 0x1154에 있다. 이 오프셋을 기억해두면 나중에 브레이크포인트에서 진짜 게임 코드를 식별하는 근거가 된다.
이제 이 StackCount의 실제 메모리 주소를 CE 값 스캔으로 찾는다
[2] 재료 수량이 저장된 메모리 주소 찾기 (값 스캔)
재료 수량이 저장된 메모리 주소를 알아야 하므로, 인벤토리의 팰디움 파편 개수를 기준으로 CE 값 스캔을 수행한다.
현재 인벤토리에 팰디움 파편이 530개 있다. 이 값을 기준으로 CE에서 값 스캔을 수행하고, 팰 스피어를 제작할 때마다 줄어드는 수량으로 재검색하여 후보를 좁혀나간다.

530으로 검색한 결과 4045건이 나왔다.

기가 팰 스피어를 1개 제작한 뒤 값을 528로 줄인다음에 Next Scan을 수행한 결과, 후보가 2건으로 줄었다.

2건의 후보 주소 모두에 HW 쓰기(write) 브레이크포인트를 설정한다. 설정 직후에는 아직 StackCount에 쓰기가 발생하지 않았으므로, 양쪽 모두 히트 0건의 빈 창이 표시된다.
이 상태에서 팰 스피어를 1개 더 제작하면, 실제로 StackCount를 변경하는 코드가 브레이크포인트에 걸린다.

팰 스피어 1개 제작 후, 두 주소 모두에서 히트가 발생했다

첫번째 주소에 걸린 명령어는 VCRUNTIME140.dll의 mov [rax],ecx로, C런타임 라이브러리의 범용 메모리 복사 함수였다. 이는 게임 로직이 아니라 리플리케이션이나 직렬화 과정에서 값이 복사될 때 걸린 것이므로 무시한다. 두 번째 주소에 걸린 명령어는 mov [rbx+00000154],r15d였다. 여기서 +0x154 오프셋이 하드코딩되어 있는데, 이것은 SDK에서 확인한 UPalItemSlot::StackCount의 오프셋과 정확히 일치한다. 즉 rbx에 UPalItemSlot 인스턴스 포인터가 들어있고, r15d에 차감 후의 새로운 수량이 담겨서 StackCount 필드에 직접 쓰는 코드다. 범용 복사 함수와 달리 특정 오프셋에 직접 접근하고 있으므로, 이 명령어가 실제 재료 차감을 수행하는 게임 코드라고 확정할 수 있다.
실제 재료 차감 코드를 찾았으므로 Show disassembler 버튼을 눌러 해당 명령어 주변의 어셈블리를 확인한다. 파란색으로 하이라이트된 첫 번째 줄이 브레이크포인트에서 잡힌 mov [rbx+00000154],r15d이며, 주소는"PalServer-Win64-Shipping-Cmd.exe"+2C07146이다.

이 주소가 게임 바이너리 내부의 오프셋이므로, 이후 코드 인젝션이나 IDA 정적 분석에서 동일한 위치를 찾을 때 기준점이 된다.
4단계: 코드 인젝션 v1
mov [rbx+0x154], r15d는 StackCount에 값을 쓰는 명령어인데, 재료 차감뿐 아니라 아이템 획득 시에도 동일하게 사용된다. 이 명령어를 무조건 NOP하면 아이템을 얻는 것까지 막혀버리므로, 조건부로 차단해야 한다. 새로 쓰려는 값(r15d)이 현재 값([rbx+0x154])보다 작을 때, 즉 수량이 줄어드는 경우에만 쓰기를 건너뛰고, 그 외에는 정상 실행되도록 한다.
앞서 Show disassembler로 열린 Memory Viewer에서 상단 메뉴의 Tools → Auto Assemble을 클릭하면 스크립트 편집 창이 열린다.

여기에 조건부 차단 스크립트를 입력한 뒤 Execute를 누른다.
[ENABLE]
aobscanmodule(INJECT_V1, PalServer-Win64-Shipping-Cmd.exe, 44 89 BB 54 01 00 00 45 85 FF)
alloc(newmem_v1, 2048, INJECT_V1)
label(returnhere_v1)
label(skip_write)
newmem_v1:
cmp r15d, [rbx+00000154] // 새 수량 vs 현재 수량
jl skip_write // 새 값 < 현재 → 감소 차단
mov [rbx+00000154], r15d // 원본 명령어 (증가/유지는 허용)
jmp returnhere_v1
skip_write:
jmp returnhere_v1 // StackCount 변경 없이 복귀
INJECT_V1:
jmp newmem_v1
nop
nop
returnhere_v1:
[DISABLE]
INJECT_V1:
db 44 89 BB 54 01 00 00
스크립트의 동작을 정리하면 다음과 같다. 먼저 alloc으로 게임 메모리에 새로운 공간을 할당한다. 원래 명령어 (mov [rbx+154], r15d)가 있던 자리는 jmp newmem으로 덮어써서 새 공간으로 점프시킨다. 새 공간에서는 cmp r15d, [rbx+154]로 새로 쓰려는 값과 현재 값을 비교한다. 새 값이 더 작으면 수량이 줄어드는 것이므로 jl skip으로 쓰기를 건너뛰고, 그렇지 않으면 원래대로 mov를 실행한다. 어느 쪽이든 마지막에는 jmp returnhere로 원래 코드 흐름에 복귀한다.

스크립트를 실행하여 코드 인젝션이 적용된 상태에서 실제로 팰 스피어를 제작하여 재료가 차감되지 않는지 확인한다.


결과: 팰 스피어 제작 시 재료가 차감되지 않는 것을 확인했다. 그러나 문제가 발생했다. 첫 번째 제작은 정상적으로 완료되었지만, 이후 제작 키(F)를 눌러도 제작이 실행되지 않았다. UI에 "1/2"가 표시되며 연속 제작 자체가 불가능해졌다.

"1/2" 버그 원인 분석
v1 인젝션은 StackCount 쓰기를 차단하여 재료 차감 자체는 막았다. 그러나 상위 함수에서는 재료를 차감하는 루프가 여전히 돌고 있다. 이 루프는 차감 후 결과를 확인하는데, 우리가 쓰기를 막았기 때문에 값이 변하지 않은 것을 감지하고 비정상 상태로 판단한 것이다. 즉 하위 명령어 하나만 막는 것으로는 부족하고, 상위 함수의 전체 흐름을 파악해야 한다.
5단계: IDA 정적 분석 — 상위 함수 구조 파악
v1 인젝션에서 하위 명령어 하나만 막았더니 "1/2" 버그가 발생했으므로, 상위 함수의 전체 흐름을 파악할 필요가 있다. CE에서 찾은 주소 "PalServer-Win64-Shipping-Cmd.exe"+2C07146은 IDA의 기본 베이스 주소(0x140000000)를 더하면 0x142C07146이 된다. IDA에서 G키로 이 주소로 이동하면 해당 명령어가 속한 함수를 확인할 수 있다.

0x142C07146으로 이동하면 이 명령어가 속한 함수 sub_142C06FF0을 확인할 수 있다. 하지만 이 함수 안에서만 봐서는 "1/2" 버그의 원인을 알 수 없으므로, 함수 시작 지점에서 X키(cross-reference)를 눌러 이 함수를 호출하는 상위 함수를 찾는다.

Xrefs에 상위 함수가 5개 나왔다. 이 중 sub_로 시작하는 게임 함수 2개만 재료 차감과 관련이 있을 수 있으므로, 이 2개에 브레이크포인트를 걸고 실제로 제작을 수행했다.
"PalServer-Win64-Shipping-Cmd.exe"+2BDFE30


sub_142BDFE30에 브레이크포인트를 걸고 제작을 수행했으나 걸리지 않았다.
다음으로 sub_142BF5AA0로 진행해보겠다.
"PalServer-Win64-Shipping-Cmd.exe"+2BF5AA0


그 결과 sub_142BF5AA0에서 브레이크포인트가 걸렸으므로, 이 함수가 재료 차감 루프를 관리하는 상위 함수라고 확정했다. 이제 이 함수의 내부 구조를 파악하기 위해 IDA에서 F5로 디컴파일한다.
IDA Pro로 sub_142BF5AA0 (Base+0x2BF5AA0)을 디컴파일했다. 원본 소스가 없으므로 IDA는 변수명을 자동 생성하지만(a1, v3, v38 등), 게임 로직 관점에서 의미를 붙여 정리하면 다음과 같은 구조이다:
// sub_142BF5AA0 — 아이템 변경 일괄 처리 함수
//
// 제작 버튼을 누르면 서버가 "변경 목록"을 만든다.
// 예: 팰 스피어 제작 → [팰디움 -1, 돌 -3, 나무 -5, 팰스피어 +1]
// 이 함수는 그 목록을 받아서 항목별로 인벤토리를 갱신한다.
void ProcessItemChanges(ContainerManager* mgr, ChangeList* list, uint8 opType)
{
Item* cur = list->Data; // 목록의 첫 항목
Item* end = cur + list->Count; // 목록의 끝 (항목 1개 = 88바이트)
// ===== 메인 루프: 변경 목록을 앞에서부터 하나씩 처리 =====
while (cur != end)
{
Slot* slot = FindSlot(mgr, cur); // 인벤토리에서 해당 아이템 슬롯 찾기
if (!slot) goto NEXT;
if (opType == 3) // 삭제 요청이면
slot->Durability = 0; // 내구도 0으로
SetItemSlot(slot, cur->NewCount); // ★ 슬롯 수량 덮어쓰기 (sub_142C06FF0)
changedContainers.Add(cur); // "이 컨테이너 변경됨" 기록
changedSlots.Add(cur); // "이 슬롯 재계산 필요" 기록
NEXT:
cur++; // 다음 항목으로 (88바이트 이동)
}
// ===== 후처리: 루프에서 기록해둔 것을 한꺼번에 반영 =====
for each slot in changedSlots:
RecalcSlotState(slot); // 슬롯 상태 재계산 (sub_142BF4F10)
NotifyClient(slot); // 클라이언트에 알림 (sub_140B2EE30)
for each container in changedContainers:
RefreshContainerUI(container); // 인벤토리 UI 갱신 (sub_142B2AD40)
}
위 의사코드에서 핵심은 메인 루프 안의 3단계 처리이다:
1. SetItemSlot() — 실제 수량 변경 (114→113)
2. changedContainers.Add() — "이 컨테이너 변경됨" 기록
3. changedSlots.Add() — "이 슬롯 재계산 필요" 기록
v1 인젝션은 1번(SetItemSlot 내부의 쓰기 명령어)만 막았다. 수량은 안 줄었지만, 2번과 3번은 여전히 실행되어 시스템에 "소비가 일어났다"고 기록되었다. 루프가 끝난 뒤 후처리에서 기록된 슬롯을 재계산할 때, 실제 수량과 기록된 변경 내역이 불일치하여 "1/2" 상태가 된 것이다. 따라서 수량 쓰기 하나만 막는 것이 아니라, 소비 항목일 때 루프 본문 전체(수량 변경 + 변경 기록)를 통째로 건너뛰어야 시스템에 아무런 흔적이 남지 않는다.
6단계: 코드 인젝션 v2
5단계에서 파악한 루프 구조를 바탕으로 인젝션 전략을 세운다. 루프 자체를 막으면 재료 소비뿐 아니라 아이템 생산까지 막히므로, 루프는 정상적으로 돌리되 각 항목을 처리하기 직전에 "이것이 소비인지 생산인지"를 판별하는 분기를 삽입한다. 새 수량이 현재 수량보다 적으면 소비이므로 루프 끝(LABEL_29)으로 건너뛰고, 그 외(생산/유지)는 원래 코드를 그대로 실행한다. 이렇게 하면 재료는 차감되지 않으면서 제작된 아이템은 정상적으로 인벤토리에 들어온다.
이 전략을 실제로 적용하려면 두 가지를 정해야 한다. 어디에 분기를 삽입할 것인가(인젝션 포인트)와, 소비 항목일 때 어디로 보낼 것인가(스킵 대상)이다. CE 메모리 뷰에서 "PalServer-Win64-Shipping-Cmd.exe"+2BF5AA0으로 이동하면 이 함수의 디스어셈블리를 볼 수 있다.
"PalServer-Win64-Shipping-Cmd.exe"+2BF5AA0

아래로 내려가면서 call sub_142C06FF0(SetItemSlot 호출)을 찾는다. 인젝션은 이 호출보다 위에 삽입해야 수량이 변경되기 전에 끼어들 수 있다. 또한 코드 인젝션은 기존 명령어를 jmp(5바이트)로 덮어쓰는 방식이므로, 덮어쓸 명령어가 최소 5바이트 이상이어야 한다. call 위의 명령어들은 대부분 3바이트짜리뿐인데, cmp byte ptr [rsp+A0h], 3만 8바이트이다. jmp(5바이트) + nop(3바이트)으로 딱 맞게 덮어쓸 수 있어 이 위치를 인젝션 포인트로 선정했다.

이 8바이트를 jmp newmem으로 덮어쓰면, 루프가 해당 위치에 도달할 때마다 우리가 할당한 코드 케이브(newmem)로 점프한다. 코드 케이브에서 하는 일은 단순하다. 비교 2번, 점프뿐이다
newmem: cmp byte ptr [rsp+A0h], 3 ; opType가 3(삭제)인가?
je original_code ; 삭제면 → 원래 코드로 (삭제는 막지 않음) mov eax, [r8] ; 새 수량 읽기
cmp eax, [r10+154h] ; 현재 수량과 비교
jge original_code ; 새 수량 >= 현재 → 생산이니까 원래 코드로
jmp LABEL_29 ; 새 수량 < 현재 → 소비니까 스킵
original_code:
cmp byte ptr [rsp+A0h], 3 ; 덮어쓴 원본 명령어 실행
jmp returnhere ; 원래 흐름으로 복귀
새로운 로직을 작성한 것이 아니라, 각 항목이 소비인지 생산인지만 판별하여 방향을 정해주는 분기이다. 소비 항목은 LABEL_29(다음 항목)으로 보내 수량 변경과 기록을 통째로 건너뛰고, 생산 항목은 원래 코드로 돌려보내 정상 처리한다
이 로직을 CE의 Auto Assemble 스크립트로 작성한다. AA 스크립트는 [ENABLE](활성화)과 [DISABLE](비활성화) 두 섹션으로 구성되어, 체크박스 하나로 ON/OFF 토글이 가능하다.
[ENABLE]
aobscanmodule(INJECT_POINT, PalServer-Win64-Shipping-Cmd.exe, 80 BC 24 A0 00 00 00 03 8B 43 08)
alloc(newmem, 2048, INJECT_POINT)
label(returnhere)
label(original_code)
registersymbol(INJECT_POINT)
newmem:
cmp byte ptr [rsp+000000A0], 3
je original_code
mov eax, [r8]
cmp eax, [r10+00000154]
jge original_code
jmp INJECT_POINT+113
original_code:
cmp byte ptr [rsp+000000A0], 3
jmp returnhere
INJECT_POINT:
jmp newmem
nop
nop
nop
returnhere:
[DISABLE]
INJECT_POINT:
db 80 BC 24 A0 00 00 00 03
unregistersymbol(INJECT_POINT)
dealloc(newmem)
aobscanmodule은 바이너리에서 80 BC 24 A0 00 00 00 03 8B 43 08(11바이트 패턴)을 검색하여 인젝션 포인트를 자동으로 찾는다. 주소를 직접 입력하지 않으므로 서버 업데이트로 베이스 주소가 바뀌어도 패턴이 동일하면 동작한다. INJECT_POINT+113은 인젝션 포인트에서 0x113(275)바이트 뒤인 LABEL_29 위치이다. [DISABLE]에서는 원본 8바이트(80 BC 24 A0 00 00 00 03)를 복원하여 원래 상태로 되돌린다.


CE에서 AA 스크립트를 적용한 뒤, 인젝션이 정상적으로 설치되었는지 메모리 뷰에서 확인한다. 인젝션 포인트(Base+0x2BF5BE8)로 이동하면 원래 cmp 명령어가 jmp newmem + nop nop nop으로 바뀌어 있어야 한다.

메모리 패치가 확인되었으니, 실제로 인게임에서 아이템을 제작하여 치트가 정상 동작하는지 검증한다.
테스트 순서
1. 제작 전 인벤토리에서 재료 수량을 확인하고 기록해둔다
2. 작업대에서 아이템을 제작한다
3. 제작 후 인벤토리를 열어 재료 수량을 비교한다


기가 스피어를 제작한 결과, 소비 재료인 팰지움 파편의 수량이 19개로 변화 없이 고정되어 있는 것을 확인할 수 있다.

재료가 차감 되지 않기 때문에 제작 조건이 계속 충족된 상태로 유지되며, 제작이 무한 반복된다. 생산된 기가 스피어는 정상적으로 인벤토리에 추가되었으며, v1에서 발생했던 "1/2" 버그도 나타나지 않았다.
추가 발견: 아이템 복제 현상
인젝션 적용 후, 제작뿐 아니라 인벤토리에서 아이템을 다른 칸으로 이동하는 것만으로도 아이템이 복제되는 현상이 발생한다.
원인 : v2 인젝션은 sub_142BF5AA0 함수 내부에서 수량 감소를 차단하는 방식이다. 이 함수는 제작 전용이 아니라, 인벤토리 내 아이템 수량 변경이 발생하는 모든 상황에서 공통으로 호출된다. 아이템을 A 슬롯에서 B 슬롯으로 이동하면 내부적으로 두 건의 수량 변경이 발생한다. A 슬롯의 차감이 스킵되어 원본이 그대로 남고, B 슬롯에는 정상적으로 추가되므로 결과적으로 아이템이 복제된다.
분석 결과 정리
서버 측에서 발견된 취약점
1. 아이템 수량 변조 차단 없음: SetItemSlot 호출을 스킵해도 서버가 이를 감지하지 못함
2. 제작 재료 소비 검증 없음: 제작 완료 후 재료가 차감되지 않았는데도 생산 아이템이 정상 지급됨
3. 제작 전후 인벤토리 정합성 검증 없음: 제작에 필요한 재료 수량과 실제 차감된 수량을 비교하지 않음
안티치트 설계 시 고려사항
1. 제작 전후 수량 검증: 제작 완료 시점에 소비 재료의 차감량이 레시피에 정의된 수량과 일치하는지 서버에서 검증한다.
2. 인벤토리 정합성 검사: 일정 주기로 인벤토리의 총 아이템 수량이 입출력 이력과 일치하는지 비교한다. 재료는 줄지 않는데 생산품만 늘어나는 패턴은 명백한 이상 징후이다.
3. 메모리 무결성 검사: 제작 함수(sub_142BF5AA0) 진입부의 코드가 변조되지 않았는지 주기적으로 원본 바이트와 비교한다. jmp 명령어로 덮어씌워진 경우 코드 인젝션으로 탐지할 수 있다.
4. 제작 속도 이상 탐지: 동일 아이템이 비정상적인 속도로 연속 생산되는 경우를 탐지한다. 재료가 무한인 상태에서는 제작이 무한 반복되므로, 단위 시간당 제작 횟수가 임계값을 초과하면 탐지 지표로 활용할 수 있다.
'캡스톤 디자인 > 정보보호 프로젝트실습' 카테고리의 다른 글
| [팰월드 안티치트 프로젝트] #10 전용서버 메모리 분석 - 온도 시스템 (0) | 2026.04.18 |
|---|---|
| [팰월드 안티치트 프로젝트] #9-1 전용서버 메모리 분석 - ESP (0) | 2026.04.16 |
| [팰월드 안티치트 프로젝트] #7 전용서버 메모리 분석 - 텔레포트 (0) | 2026.04.10 |
| [팰월드 안티치트 프로젝트] #6 전용서버 메모리 분석 - 포획률 (0) | 2026.04.07 |
| [팰월드 안티치트 프로젝트] #5 전용서버 메모리 분석 - 스피드핵 (0) | 2026.04.02 |