유니티 안전한 Windows 배포를 위한 체크리스트

목차

시작하며

안녕하세요.

오늘은 윈도우 플랫폼에서의 유니티 프로그램이 최소한의 보안 사항을 갖추기 위해 확인해야할 체크리스트를 소개해 드리고자 합니다. 보안을 향상하기 위한 기법은 모두 나열하기 힘들 정도로 다양합니다. 그 중에서도 기본적인 원칙들을 안내하고, 마지막으로 Shipping 하기 전에 확인해야 할 사안들을 중점적으로 다룹니다. 따라서 최종 빌드를 제출하기 전 이 체크리스트를 숙지한다면 허무하게 프로그램을 도둑맞을 가능성을 줄일 수 있습니다.

본격적으로 방어 수단을 설명하기전에, 어떤 방식으로 취약점을 공격하는지 큰 덩어리들을 소개하고 싶습니다. 배포전에 확인해야할 체크리스트라는 타이틀이지만 개발 초반이나 한창 때 확인하신다면 더욱 견고한 프로그램을 빌드하실 수 있습니다.

본 정보는 보안관련 전공자가 작성하지 않아 전문성과 공신력을 겸비한 글이라 보기 어렵습니다. 잘못된 정보 또는 오해의 소지가 있는 표현을 발견하시면 댓글이나 메일로 제보 부탁드립니다.

위험요소

  1. 로컬 파일 변조
  2. 프로젝트 복원
  3. 메모리 변조
  4. DLL 인젝션

1️⃣ 로컬 파일 변조

낮은 레벨의 해커들이 가장 먼저 시도하는 공격 대상은 사용자의 디스크 드라이브에 존재하는 파일들에 대한 변경입니다. 데이터들은 자체 포맷으로 직렬화되어, binary 방식으로 저장됩니다. Resources 폴더 안에 위치한 에셋들, 유니티로 작업한 Scene, 이 Scene에 포함된 게임오브젝트들과 에셋들이 대표적인 사례입니다. 이미지나 사운드 파일 같은 아트 에셋들이 큰 비중을 차지하지만, 간혹 직렬화 된 게임 정적 데이터가 포함되기도 합니다.

[그림 1]

[그림 1] HxD 에디터로 본 globalgamemanagers

뜻을 이해할 수 없는 문자들로 보이지만 어렴풋이 2019.4.14f1 글자를 발견할 수 있습니다. 유니티로 개발을 어느정도 진행하셨다면 유니티 엔진의 버전 컨벤션과 비슷함을 눈치채셨을 것입니다. 이렇게 식별할 수 있을 법하다는 느낌에서 더 나아가 오픈소스로 개발된 도구를 이용하면 읽을 수 있는 형태로 바꿀 수 있습니다. 프로그램에서 정적으로 사용되는 데이터 값을 임의로 변조할 수 있게 됩니다. ‘사용자의 장치에서 동작하는 코드의 결과는 신뢰하지 말라‘는 격언이 있습니다. 딱 맞는 상황은 아니지만 어느 정도 고개가 끄덕여지는 사례 중 하나입니다.

외부에 존재하는 서버로부터 네트워크를 통해 파일의 무결성을 검사하는 등의 방법으로 확인하는 대응을 할 수 있습니다.

2️⃣ 프로젝트 복원

초보 해커가 어떤 파일을 변조해야할 지 모르겠다면 어떤 선택을 할까요? 배포된 프로그램으로부터 원래의 유니티 프로젝트를 복원을 시도할지도 모릅니다. 저는 처음 상용 프로그램을 대상으로 취약점이 있는지 분석할 때 이 과정에서 실마리를 찾곤 했습니다. 프로그램으로부터 특수한 부정 이익을 취하기 위해서는 그 목표와, 동작 방식에 대한 이해가 견고해야하기 때문입니다. 필수적인 과정은 아니지만 유니티 개발을 접한 적이 있는 개발자라면 유니티스러운 워크플로우 디자인에 대한 이해도를 바탕으로 전체적인 구조를 파악하는데 큰 도움을 받을 수 있습니다.

어떤 에셋을 구매해 사용했는지, 리소스 이름 컨벤션은 어떤 스타일인지 네트워크 통신은 어떻게 하는지, 서버의 주소는 어디인지, 아직 공개되지 않은 콘텐츠가 포함되어 있는지 등등 거의 대부분의 정보를 획득할 수 있습니다. 이 프로젝트 복원 과정 역시 오픈소스로 개발된 도구들에서부터 시작됩니다.


실제 예시를 보겠습니다. (uTinyRipper 이용)

[그림 2]

[그림 2] 배포된 프로그램 폴더를 uTinyRipper에 임포트 해 분석한 모습

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Assets
- AnimationClip
- AnimatorController
- Avater
- Cubemap
- Material
- Mesh
- PrefabInstance
- Resources
- Scene
- ScriptableObject
- Scripts
- Shader
- Texture2D
- Texture3D
ProjectSettings
- AudioManager.asset
- EditorBuildSettings.asset
...

현재 스팀에서 앞서해보기를 진행중인 유니티 게임을 추출한 파일 구조입니다. Resources 폴더 안에 위치하던 에셋들은 Resources.Load<Sprite>("Sprite/UI/Close");처럼 string 경로로 호출되기에 그 경로가 유지됩니다. 그 외에 scene에 포함되었거나, Resources 폴더에 포함된 프리팹, 머티리얼, 스크립터블 오브젝트 등등 에셋이 레퍼런스를 갖고 있는 모든 에셋들은 그 타입별로 폴더에 나뉘어 저장됩니다.

이들 중 가장 중요하다고 볼 수 있는 script 들도 Scripts 폴더에 DLL 별로 나뉘어 저장됩니다. 예시로 복원한 프로젝트에서는 Assembly-CSharp, Unity.TextMeshPro, DOTween 같은 폴더들을 확인할 수 있었습니다. 그러나 IL 코드들을 모두 디컴파일 해 주지는 않고, public, [Serialized] 필드만 선언해주어 컴포넌트가 잡고있던 다른 에셋의 레퍼런스 정보를 잃지 않도록 복원해주는 수준이었습니다.

예시 프로젝트는 il2cpp를 사용하지 않고 mono 방식으로 배포를 하고 있어 닷넷디컴파일 도구인 dnSpydotPeek을 이용해서 C# 코드로 복원했습니다. 디컴파일된 코드 내용들을 유니티 에디터에 넣어주니 스팀을 통해 배포된 클라이언트와 똑같은 방식으로 유니티 에디터에서 플레이가 가능해졌습니다.

이제 저는 이 프로그램을 실행하며, 로컬에서만 판정되는 데이터들을 인스펙터를 통해 마음대로 조정이 가능하고 심지어 C# 코드를 수정해 원하는 대로 동작을 수정할 수 있습니다. 마음만 먹는다면 서버 주소를 바꾸고, 다른 결제 모듈을 붙여 빌드해 스팀을 통해 플레이하지 않아도 동작하는 속칭 크랙 버전 빌드를 생성할 수도 있게 됩니다. 더 나아가 클라이언트 코드를 통해 서버 동작을 유추해, 프리 서버를 만들 수도 있습니다.

이 정도 수준까지 코드가 복원되는 것은 mono로 빌드 되었을 때만 가능한 이야기입니다. il2cpp로 빌드하면 C# 코드를 복원해 구동하기까지는 굉장히 오랜 시간이 걸립니다. IL 코드를 C# 코드로 바꾸는 것과 CPP 코드를 C# 코드로 바꾸는 것은 하늘과 땅 차이의 노동력을 요구하기 때문입니다.

코드 난독화, 에셋 번들 암호화, il2cpp 빌드 를 통해 어느정도 방지효과를 기대할 수 있습니다.

3️⃣ 메모리 변조

컴퓨터에서 프로그램을 실행하면 파일 시스템에 존재하던 데이터가 메모리에 적재됩니다. 프로그램 내에서 사용하는 재화, 캐릭터의 최대 체력 수치, 마나 수치 등 지정된 위치에 데이터가 저장되고 코드에 의해 값이 읽어들여지고 쓰입니다. 프로그램의 메모리에는 자기 자신만 접근할 수 있는 것은 아닙니다. 다른 프로세스의 메모리에도 임의의 위치에 원하는 값을 쓰거나 읽을 수 있습니다. 이 작업을 일련의 규칙에 따라 수행해주는 프로그램들이 존재합니다.

치트엔진

[그림 3]

치트엔진에 대해 한 번 쯤은 들어보셨을거라 생각합니다. 옛 플래시 게임을 즐기셨다면 ‘치트오매틱’ 같은 툴이나 ‘트레이너’들을 사용해본 추억이 있으실지도 모릅니다. 위 도구들은 모두 프로세스에 접근해 메모리상의 값을 임의로 쓰거나, 데이터를 읽어주는 역할을 합니다. 직관적이고 간단한 동작이지만 매우 강력하여 아직까지도 치트엔진을 원천차단하는것은 쉬운 일이 아닙니다. 가장 원초적이면서 효과적인 공격 수단입니다.

프로그램에서의 값은 계속해서 변화합니다. 예를 들어, 몇 차례의 단계를 거치면 재화의 수치를 조작할 수 있습니다. 치트엔진을 통해 현재 프로그램에서 표기되는 재화 수치를 검색합니다. 재화가 100이면 100을 검색하고, 프로그램에서 재화를 사용합니다. 10을 사용하여 90이 되었습니다. 90을 검색합니다. 이와 같은 방식으로 타겟 값을 변화시켜가며 하나의 주소가 남을 때 까지 반복합니다. 치트엔진에서 제공하는 값 쓰기 기능으로 9,999,999를 입력합니다. 다시 10만큼의 재화를 사용하면, 재화의 값이 9,999,989로 표시됩니다.

다른 예시로, 캐릭터의 체력을 고정하기도 합니다. 재화를 변경할 때와 마찬가지로 비슷하게 체력 메모리의 위치를 찾습니다. 그리고 체력 메모리에 접근하는 메서드를 찾고, 체력 메모리의 값을 읽을 때 고정된 값을 돌려주도록 설정합니다. 캐릭터가 공격을 당해도 체력이 닳지 않게됩니다.

더 자세한 치트엔진의 사용 방법은 생략하겠습니다.

비교적 최근 사용되고 있는 린 엔진 역시 치트 엔진과 비슷하게 메모리를 읽고 변조할 수 있는 기능을 탑재하고 있습니다.

4️⃣ DLL 인젝션

프로그램은 실행과정에서 구동에 필요한 라이브러리들을 로드합니다. 사용자 레벨에서 이용하는 프로그램들은 대부분 Ring3 영역에서 실행됩니다. 컴퓨터의 하드웨어와 운영체제, 드라이버와 명령을 주고 받기 위해서는 Windows가 제공하는 dll 들을 이용해 통신하게 됩니다. 자주 사용하는 라이브러리로는 kernel32.dll, ntdll.dll 등이 있습니다.

마이크로소프트에서 제공하는 Process Explorer 도구를 이용하여 직접 빌드한 League of Legends 모작 프로그램을 실행시켜 로드된 모듈들을 살펴보았습니다. 렌더링을 담당하는 d3d11.dll, 유니티 내장 라이브러리 baselib.dll, 게임 로직이 포함된 GameAssembly.dll들이 프로세스 아래에 적재되어 있습니다.

[그림 4]

[그림 4] Process Explorer로 확인한 모듈 리스트

DLL 인젝션 공격은 원하는 동작을 수행하기 위해 임의의 코드를 실행하는 DLL을 프로세스에 강제로 주입합니다. 인젝션은 Injection으로, 주사한다는 의미를 갖고 있습니다. 이 방법을 통해 내부 모듈로서 동작하는 과정에서 외부에서 실행되기 민감한 코드에 접근하고, 제어할 수 있습니다.

DLL 인젝터를 이용해 직접 생성한 라이브러리를 외부에서 임의로 로드시켜보았습니다.

[그림 5]

DLL을 타겟 프로세스에 인젝션하는 방식도 다양하게 존재합니다. 허용되지 않는 모듈 로드 감지를 피하기 위한 메모리에 매뉴얼하게 매핑하는 방법, LdrLoadDll을 이용하는 방법 등이 있습니다. 예시에서는 별다른 기법을 적용하지 않고 CreateRemoteThread와 WriteProcessMemory를 이용했기 때문에 Process Explorer에서도 모듈 확인이 가능했습니다.

DLL 인젝션은 곧 메서드 후킹으로 이어집니다. 단순히 값을 읽고 쓰는 것만을 위해서는 메모리 변조를 하는 선택이 간편합니다. 복잡한 로직이 필요하거나 메서드를 후킹해서 조작해야할 때 주로 DLL 인젝션을 사용합니다. 이러한 이유로 대부분의 상용 치트 프로그램은 DLL 인젝션을 기반으로하고 있습니다. 치트로 골머리를 앓던 배틀그라운드, APEX LEGEND, CSGO, R6X, Fortnite 등도 예외는 아니었습니다.

후킹에 대해 필요한 부분만 간추려 설명드리겠습니다. 특정한 함수가 호출 될 때 다른 함수가 호출되도록 해주는 기법입니다. 함수가 실행되는 부분의 메모리에 다른 주소로 점프하는 바이트 코드를 패치하여, 로직의 흐름을 납치하는 것입니다. 예제를 살펴보겠습니다. A 함수를 후킹해 B 함수를 호출하도록 했습니다. B에서는 자기가 할 일을 수행하고, A를 호출해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void A() {
printf("A");
}

void B() {
printf("B");
A();
}

int main() {
A();
HOOK_FUNCTION(A, B);
A();
}

// output
// ABA

후킹하기 전에는 A를 출력했지만 후킹 후에는 BA를 차례로 출력합니다. 이처럼 특정한 함수가 호출되는 것을 감지할 수 있습니다. 다른 동작을 수행시키거나 알림을 받는 용도로 사용합니다.

DX11 후킹

Windows 플랫폼에서 실행되는 대부분의 게임은 DirectX 프레임워크를 이용해 제작됩니다. 간혹 다른 그래픽 아키텍쳐를 사용하는 경우도 있으나, 유니티로 제작된 프로그램들은 DX11 또는 DX12를 사용합니다. 프로그램의 로직이 완료되고, 변경 사항에 따라 렌더링 해야 할 데이터들을 present 단계에서 그리게됩니다. 이 present 단계를 후킹해 게임 렌더링 이후 원하는 그림을 더 그리게 해 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 타입 지정, 전방 선언
typedef HRESULT (__fastcall* IDXGISwapChainPresent)
(IDXGISwapChain* pSwapChain, UINT syncInterval, UINT flags);
IDXGISwapChainPresent fnIDXGISwapChainPresent;
HRESULT onPresent(IDXGISwapChain* _chain, UINT syncInterval, UINT flags);

// DllMain에서 호출합니다.
void hook() {
... // vTable 가져오는 과정 생략
fnIDXGISwapChainPresent = (IDXGISwapChainPresent)(DWORD_PTR)pSwapChainVTable[SC_PRESENT];
HOOK_FUNCTION(fnIDXGISwapChainPresent, onPresent);
}

// 매 프레임 렌더링 될 때 임의의 GUI와 ESP를 그립니다.
HRESULT onPresent(IDXGISwapChain* _chain, UINT syncInterval, UINT flags) {

MENU::Draw();
GAME::Draw();
ESP::Draw();

return fnIDXGISwapChainPresent(_chain, syncInterval, flags);
}

콘셉은 다르지 않습니다. 결과물을 예시로 살펴보는 것이 이해에 도움이 됩니다.

[그림 6]

[그림 6] 리그 오브 레전드에서 DLL 인젝션, DX9 후킹과 평타로 제압할 수 있는 미니언 표기 기능 구현

로직 구현

리버싱을 통해 클래스, 구조체의 각 필드 위치를 확인합니다. 정보를 토대로 메모리 적재 위치를 정렬해 필드를 선언합니다. 클래스의 인스턴스가 메모리 어디에 있는지 알면 형변환으로 인스턴스 데이터를 가져올 수 있습니다. 적 챔피언의 위치와 내 스킬의 사거리를 파악해 자동으로 스킬을 사용해주는 로직을 작성했습니다.

(이해를 돕기 위해 작성된 코드로, 일부 누락된 선언도 있습니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
struct Hero {
char pad_0[0x48];
std::string championName;
char pad_1[0x8];
int level;
char pad_3[0x142];
float baseAttackDamage;
char pad_4[0x2C];
float additionalAttackDamage;
char pad_2[0x24];
float attackRange;

void CastSpell(int skillIndex, Vector3f position, Obj_AI* target)
{
typedef void (__fastcall* fnCastspell)
(Hero* owner, int skillIndex, Vector3f pos, Obj_AI* tar);
((fnCastSpell)(GetModuleHandle(0) + (DWORD)oCastSpell))(this, skillIndex, position, target);
}
};

struct Minion {
char pad_0[0x24];
float health;
char pad_1[0x18];
float maxHealth;
};

#define oLocalPlayer 0x38D214;
#define oCastSpell 0x281C82;

Hero* GetLocalPlayer() {
return *(Hero**)(GetModuleHandle(0) + (DWORD)oLocalPlayer);
}

HRESULT onPresent(IDXGI...) {
auto local = GetLocalPlayer();

// minion last hit highlight
auto objects = object_manager::GetMinions(Team::Enemy);
for (auto minion : minions) {
if (minion->position.DistanceTo(local->position) < local->attackRange * 1.5f) {
auto dmg = local->baseAttackDamage + local->additionalAttackDamage;
if (minion->health < dmg)
{
ImGui::LocalWorldCircle(&minion->position, minion->bound, Color::Green);
}
}
}

// shoot skill
auto enemies = object_manager::GetChamps(Team::Enemy);
for (auto enemy : enemies) {
auto skrg = local->spellbook->GetSkill(0)->GetRange();
if (enemy->position.DistanceTo(local->position) < skrg) {
if (local->spellbook->CanCastSpell(0)) {
local->CastSpell(0, enemy->position, enemy);
return;
}
}
}

return fnIDXGI...(IDXGI...);
}

onPresent는 앞서 DX에서 후킹해 매 프레임 렌더링 될 때 호출되는 함수입니다. 유니티에서의 Update 문과 유사한 라이프사이클을 가진다고 생각해주세요.

매 프레임 미리 찾아둔 메모리 주소로부터 내 플레이어(LocalPlayer)의 인스턴스를 가져옵니다. object_manager에서 미니언 벡터를 받아옵니다. 내 플레이어가 기본공격을 가해 제압할 수 있다면, 초록색 원을 그립니다.

모든 적 히어로들을 루프하며, 스킬 사거리 내 적이 있고 스킬이 사용가능한 상태라면 스킬을 적 히어로 위치에 사용합니다. 더 많은 클래스를 분석하면 상대 캐릭터 무브먼트 에이전트의 도착지점까지 알 수 있게 됩니다. 따라서 보다 정교한 예측 스킬을 사용하는 소위 헬퍼를 만들어 낼 수 있게 됩니다.

단순히 메모리 조작을 통해 값을 변경하는 식의 이익을 얻지 않으니 시스템적으로 필터링하기도 까다롭습니다. 정교하게 만들어진 툴은 여느 인간 못지않은 자연스러움을 뽐내면서도 독보적인 실력을 보여주기도 합니다. 체력이 무한이거나 공격력이 무한대거나 구매하지 않은 아이템을 쓰는 등의 일들은 어느정도 예상 가능합니다. 하지만 이런 방식의 이미 클라이언트에서 제공하고 있는 함수들의 흐름을 바꾸어 의도하지 않은 동작을 발생시키는 헬퍼류는 대응하기가 까다롭습니다.

리그 오브 레전드에서는 몇몇 메서드에 함정을 설치하고, ret check 등 기법들을 다수 사용하여 방지하고 있습니다. 더 나아가 게임 디자인적으로 헬퍼가 독보적인 성능을 발휘하기 어렵도록 밸런스를 정착시켰습니다.


목적이 GameBreaking이 아니므로 이 쯤에서 DLL 인젝션 소개를 마치도록 하겠습니다.

대응

대응 방안 소개에 앞서 결론부터 말씀드리자면 완벽한 보안 기법은 존재하지 않습니다. 제가 전해드리고 싶은 점은 모든 유니티 프로그램에 적용될 수 있는 범용 도구로부터만이라도 스스로 보호하자는 것입니다. 많은 시간과 노력을 할애하여 결과물을 만들어냈지만, 오픈소스 도구 하나로부터 그 모든 노력이 무너진다면 매우 허탈할 수 밖에 없습니다. ‘출시’라는 목표를 향해 쉴 새 없이 달려온만큼, 피땀흘려 제작된 산출물을 보호하기 위한 전략 역시 수립되어야합니다.

이 글에서 다룰 내용들은 다음과 같습니다.

  1. 코드 난독화
  2. 에셋 번들 암호화
  3. 모듈 보호
  4. il2cpp 빌드
  5. 방어형 코드 작성

1️⃣ 코드 난독화

난독화(obfuscation)는 소스코드의 텍스트를 뒤죽박죽 섞어 리버스 엔지니어링을 방해하는 작업을 일컫습니다. ‘난독’이라는 단어는 ‘읽기 어렵다’라는 의미입니다. 그렇다면 유니티 프로그램에서 난독화가 어떤 효과를 가져올 수 있을까요?

코드의 작성에 있어 네이밍 컨벤션은 항상 중요하게 여겨졌습니다. 프로그래밍 언어 별, 사용하는 솔루션 별로 다양합니다. 하물며 언더바를 사용할 지 대소문자로 구분할지 이름까지 붙여져 있습니다. 그만큼 변수와 함수, 클래스 명이 코드의 이해에 끼치는 영향이 어마어마하다는 뜻입니다. 충분히 잘 정해진 메서드의 이름은 코드 로직을 보지 않아도 어느정도 추리할 수 있게 해 줍니다.

예시 프로젝트로, 난독화 도구를 이용해 아랍어 문자로 변경했습니다.

[그림 7]

원본 소스는 이렇습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System.Collections;
using System.Collections.Generic;
using System.Net.Http;
using UnityEngine;

public class Bootstrap : MonoBehaviour
{
public int test1;
public long test2;


// Start is called before the first frame update
void Start()
{
test1 = 30;
}

// Update is called once per frame
void Update()
{
test1++;
}

private void OnTick()
{
Debug.Log(test2);
test2++;
}

private IEnumerator TCorout()
{
while (true)
{
yield return new WaitForSecondsRealtime(1);
OnTick();
}
}
}

로직이 간단하기 때문에 어느정도는 파악이 됩니다. 이해를 돕기 위해 단순한 ++ 계산이나, 코루틴 호출 정도만 사용했습니다. 이 코드는 mono로 빌드되어 IL 코드로 변환되었을 뿐입니다. il2cpp 모듈을 통해 cpp코드로 변환되면 해독의 난이도는 더 상승합니다.

유니티 에셋스토어에서 가장 인기있는 난독화 도구인 obfuscator를 사용했습니다.

암호화 != 난독화

난독화 도구의 필요성에 대해 다른 개발자분들과 얘기를 나누던 중, 암호화와 혼동하는 경우가 있음을 알게 되었습니다. 난독화를 진행해도 복호화를 통해 원본 코드를 복구해낼 수 있지 않느냐는 논지로 의문을 제기해주셨습니다.

여기서 언급되는 이름 난독화는 VMProtect와 같은 도구에서 제공하는 Mutation이나 Packing 기능을 제공하지는 않습니다. 단순히 네임스페이스, 클래스, 구조체, 변수, 프로퍼티, 함수의 이름을 랜덤으로 변경해줍니다. 이것만으로도 해커들을 충분히 괴롭게 만들 수 있습니다.

고대의 암호화처럼 알파벳을 5자리씩 시프트해 각 알파벳이 1:1로 대치되도록 변경해주는 과정이 아닙니다.

ex) myclass -> oaenuu (알파벳을 2칸씩 이동시킨 방식)

난독화 도구가 작동하는 과정

  1. 빌드 -> 유니티 링커가 IL 코드를 생성해줍니다.
  2. 난독화 도구가 콜백을 받아 IL 코드를 분석합니다.
  3. 무작위 시드로 중복되지 않는 이름들을 많이 생성합니다.
  4. 새로운 함수나 변수명이 나오면 3에서 생성한 이름을 하나 선택해 모두 바꿉니다.
  5. 난독화 도구가 추후 디버깅을 위한 이름 매핑 테이블을 생성해줍니다.

따라서 바닐라 코드에서 한 번 난독화 된 이후에는 다시 원래의 이름으로 돌아올 수 없습니다. 개발자만이 갖고 있는 매핑 테이블 데이터를 통해서만 확인이 가능해집니다.

한계

정적 분석을 방해하는데는 큰 기여를 합니다. 동적 분석과는 큰 관련이 없습니다. 원활한 동적 분석을 위해서는 정적 분석이 선행되는 것이 편리합니다. 정적 분석을 방해함으로써 얻게되는 부가적인 효과일 뿐, 근본적으로 동적 분석에 영향을 끼친다고 보기는 어렵습니다.

앞서 소개했던 치트 엔진과 같은 메모리 변조 도구들에게는 취약합니다. 어떤 타겟 수치를 변경하기 위해서 변수명이나 클래스명은 크게 중요하지 않기 때문입니다.

2️⃣ 에셋 번들 암호화

Resources 폴더 사용을 최소로하고, 수동으로 리소스를 로드하고 관리합니다. 플랫폼의 검수 없이 콘텐츠 변경이 자주 이루어져야하는 모바일 플랫폼에서는 에셋 번들이 적극 사용됩니다. 그에 반해 PC 플랫폼의 경우 비교적 용이하게 Distribution 파이프라인을 구성할 수 있어 리소스 폴더에 모두 때려박고 개발하는 경향이 없지 않아 있습니다. 하지만 변경되지 않은 리소스임에도 불구하고 인덱싱이 변경되면서 아트 리소스 전체를 새로 내려받아야하는 이슈들도 있습니다. 단지 보안이유만이 아닌 성능상이나 개발 프로세스상 권장합니다.

에셋 번들로 리소스를 관리한다고 해도 언팩의 위험은 충분히 있습니다. 어떻게 암호화가되었든, 결국 메모리에 적재하고 사용하기 위해서는 복호화 과정이 필요합니다. 그 복호화 알고리즘은 사용자의 시스템에 설치된 클라이언트 코드에 존재합니다. 이 코드가 제대로 난독화되어 숨겨지지 않는 이상, 에셋 번들을 암호화하고 관리하는 노고가 무용지물이 될 수도 있습니다.

아니, 어차피 다 뚫릴 건데 귀찮고 복잡하게 삽질할 필요 있나요?

물론, 경험이 많은 해커들에겐 복호화 로직의 위치를 들켜 리소스가 다 드러나기도 할 것입니다. 복호화를 하지 못했더라도, 메모리에 리소스가 올라가는 이상 메모리 덤프 하면 소용 없긴합니다.

하지만 깃허브에서 쉽게 구할 수 있는 오픈 소스 도구들을 저지하는 것만 해도 충분한 이점입니다. 별로 경험도 없는 해커가 버튼 몇 번 띡띡 눌러서 2년 내내 밤낮으로 개발한 프로그램의 리소스를 줄줄 빼낸다는 건 용납할 수가 없습니다.

그렇지만… 리버스 엔지니어링에 빠삭한 해커라면? 어쩔 수 없이 방패가 약했던 탓이니까요.

그렇습니다.

“졌지만 잘 싸웠다.”
이것이 최소한의 목표입니다.

간편 XOR 암호화

암호화 방법은 여러가지가 있습니다. OpenSSL을 사용하기도 하고 간편하게 인코딩만 수정하기도 합니다. 예제로는 간편한 xor 알고리즘을 이용해 로드 전 메모리에서 복호화하는 방식을 소개드리겠습니다. 흔히 사용되는 에셋 번들 생성 스크립트, 로드 스크립트에 xor 암호화와 복호화 로직이 추가된 클래스입니다.

Bundle Generator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class AssetBundleGenerator
{
[MenuItem("AB/build")]
private static void CreateHero()
{
BuildPipeline.BuildAssetBundles("Bundles", BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows64);

if (Global.useEncryption)
{
byte[] bytes = File.ReadAllBytes("Bundles/hero_001_a");
Cryptographer xor = new Cryptographer("abtest");
xor.Encrypt(bytes);
File.WriteAllBytes("Bundles/hero_001_a", bytes);
}
}
}
Encryption Utility
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Cryptographer
{
private byte[] Keys { get; set; }

public Cryptographer(string password)
{
Keys = Encoding.ASCII.GetBytes(password);
}

public void Encrypt(byte[] data)
{
for(int i = 0; i < data.Length; i++)
{
data[i] = (byte) (data[i] ^ Keys[i % Keys.Length]);
}
}

public void Decrypt(byte[] data)
{
for (int i = 0; i < data.Length; i++)
{
data[i] = (byte)(Keys[i % Keys.Length] ^ data[i]);
}
}
}
Loader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Loader : MonoBehaviour
{
private void Start()
{
byte[] decrypted = File.ReadAllBytes("Bundles/hero_001_a");

if (Global.useEncryption)
{
new Cryptographer("abtest").Decrypt(decrypted);
}

AssetBundle pack = AssetBundle.LoadFromMemory(decrypted);
GameObject marksman = pack.LoadAsset<GameObject>("marksman_01");

GameObject instance = Instantiate(marksman);
instance.transform.position = Vector3.zero;
}
}

비교적 소요 시간이 적게 걸리지만 오픈소스로 쉽게 접할 수 있는 도구들의 언팩 기능으로는 쉽게 열리지 않습니다. 이용자가 적은 소규모의 도구는 여러 게임들의 암호화에 대응하기 위해 키를 지정해주면 xor 복호화를 진행해주기도 합니다. 리소스들이 메모리에 오르내릴 때, 코드를 통해 복호화가 진행되므로 리소스를 보호하기 위해서는 코드의 보호가 선행되어야 함을 명심해주세요.

미공개 콘텐츠 유출 방지

에셋 번들로 리소스를 관리하면 얻을 수 있는 이점을 또 있습니다.

간혹 테스트 빌드에서 사용하던 미공개 콘텐츠가 Resources 폴더에 딸려 함께 배포되는 경우가 생깁니다. 또는 특정한 날에 공개해야 할 콘텐츠를 미리 클라이언트에 포함해 배포하는 경우도 있습니다. 이런 불상사를 개발 파이프라인상으로 막아줄 수 있습니다. 에셋 마다 미니멈 버전을 부여해 타겟 버전에 모자라는 경우 배포되지 않도록 하는 시스템을 만들어두면 편리합니다. 라이엇의 레전드 오브 룬테라테크블로그 포스트에서 아이디어를 얻을 수 있습니다.

3️⃣ 모듈 보호

DLL Injection을 감지해보겠습니다. DLL이 메모리에 로드되는 이벤트를 감지하는 기법은 다양하지만, 관리자 권한이 없는 상태에서 사용가능한 수단으로 한정하면 그 수가 꽤 줄어듭니다. 좀 더 복잡하고 적용이 번거로운 방법들은 각각 독립된 포스트로 튜토리얼이 작성될 예정입니다.

메모리에 DLL을 매핑하는 수 많은 방법이 존재합니다. 모두를 방어해주는 마법같은 일은 없습니다. 각자 기능을 따로 구현해 적재적소에 사용하여야합니다. 이 글에서는 C# 레이어의 기능과 LDR 2가지 기능을 이용합니다.

AppDomain.CurrentDomain

현재 도메인에 로드된 어셈블리들을 검사하는 방식으로 확인 할 수 있습니다. 닷넷에서 제공하는 기능으로 도메인이 추가로 로드되거나 언로드 될 때 호출 되는 이벤트를 등록하거나 현재 로드된 어셈블리 리스트를 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Detector : MonoBehaviour
{
private void OnEnable()
{
// 로드 된 어셈블리 확인
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (Assembly assembly in assemblies)
{
Debug.Log(assembly.Location);
}

// 로드 콜백 등록
AppDomain.CurrentDomain.AssemblyLoad += OnLoad;
}

private void OnDisable()
{
// 로드 콜백 해제
AppDomain.CurrentDomain.AssemblyLoad -= OnLoad;
}

private void OnLoad(object sender, AssemblyLoadEventArgs args)
{
// on detected
}
}

새로운 어셈블리가 로드되면 OnLoad 콜백이 실행됩니다. 어셈블리 로드 정보를 검사하여 허용되는 어셈블리인지 판단하는 로직을 추가하시면 됩니다. 매 프레임 검사를 수행하기에는 부담스러운 작업입니다. 랜덤한 주기나 필요시 간헐적으로 검사하거나, 다른 스레드에서 적당한 주기로 검사하고 메인 스레드로 디스패치해 메시지를 넘기는 것을 권장드립니다.

하지만 이 방식을 크게 신뢰하기는 힘듭니다. 무턱대고 다른 프로세스에 DLL을 주입하는 무지막지한 프로그램들도 있기 때문입니다. 확인할 수 없는 어셈블리가 로드되었다고 항상 감지되었다고 보아서는 안되고, 용의선상에 두고 추가적으로 확인하는 과정이 필요합니다. 해당 어셈블리에 대한 정보를 서버로 리포트하거나, 바이트 코드 패턴을 검사해 해킹에 자주 사용되는 함수가 사용되었는지 확인할 수 있습니다. 하지만 이 주입된 어셈블리 역시 VMProtect 등으로 가상화되어 단순 바이트 패턴 검색으로는 찾기 어렵도록 패킹되어 자체적인 방어 수단을 갖추기도 합니다.

LdrDllNotification

마이크로소프트 도큐멘테이션에서 자세한 컨벤션과 사용방법을 확인할 수 있습니다. CPP DLL 프로젝트를 생성하고, LDR Notification 콜백을 받도록 이벤트를 등록합니다. 그리고 유니티와 연결하여 유니티에서 LDR 콜백을 받을 수 있게 링크해줍니다. 정상적으로 콜백 등록이 완료되면 DLL이 로드되거나 언로드 될 때 노티파이를 넘겨줍니다. 문서를 보고 직접 코드를 작성해보겠습니다.

LDR.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#ifndef _NTAPI_
#define _NTAPI

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <winnt.h>
#include <tchar.h>
#include <cstdlib>

typedef __success(return >= 0) LONG NTSTATUS;

#ifndef NT_STATUS_OK
#define NT_STATUS_OK 0
#endif


#define STATUS_SUCCESS ((NTSTATUS)0)
#define STATUS_UNSUCCESSFUL ((NTSTATUS)0xC0000001)
#define STATUS_PROCEDURE_NOT_FOUND ((NTSTATUS)0xC000007A)
#define STATUS_INFO_LENGTH_MISMATCH ((NTSTATUS)0xC0000004)
#define STATUS_NOT_FOUND ((NTSTATUS)0xC0000225)
#define STATUS_THREAD_IS_TERMINATING ((NTSTATUS)0xc000004b)
#define STATUS_NOT_SUPPORTED ((NTSTATUS)0xC00000BB)

enum LDR_DLL_NOTIFICATION_REASON
{
LDR_DLL_NOTIFICATION_REASON_LOADED = 1,
LDR_DLL_NOTIFICATION_REASON_UNLOADED = 2,
};

typedef struct tag_UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} __UNICODE_STRING, * PUNICODE_STRING, * PCUNICODE_STRING;

typedef struct _LDR_DLL_LOADED_NOTIFICATION_DATA {
ULONG Flags; //Reserved.
PCUNICODE_STRING FullDllName; //The full path name of the DLL module.
PCUNICODE_STRING BaseDllName; //The base file name of the DLL module.
PVOID DllBase; //A pointer to the base address for the DLL in memory.
ULONG SizeOfImage; //The size of the DLL image, in bytes.
} LDR_DLL_LOADED_NOTIFICATION_DATA, * PLDR_DLL_LOADED_NOTIFICATION_DATA;

typedef struct _LDR_DLL_UNLOADED_NOTIFICATION_DATA {
ULONG Flags; //Reserved.
PCUNICODE_STRING FullDllName; //The full path name of the DLL module.
PCUNICODE_STRING BaseDllName; //The base file name of the DLL module.
PVOID DllBase; //A pointer to the base address for the DLL in memory.
ULONG SizeOfImage; //The size of the DLL image, in bytes.
} LDR_DLL_UNLOADED_NOTIFICATION_DATA, * PLDR_DLL_UNLOADED_NOTIFICATION_DATA;

typedef union _LDR_DLL_NOTIFICATION_DATA {
LDR_DLL_LOADED_NOTIFICATION_DATA Loaded;
LDR_DLL_UNLOADED_NOTIFICATION_DATA Unloaded;
} LDR_DLL_NOTIFICATION_DATA, * PLDR_DLL_NOTIFICATION_DATA;


typedef VOID(CALLBACK* PLDR_DLL_NOTIFICATION_FUNCTION)(
_In_ ULONG NotificationReason,
_In_ PLDR_DLL_NOTIFICATION_DATA NotificationData,
_In_opt_ PVOID Context
);


typedef NTSTATUS(NTAPI* _LdrRegisterDllNotification)(
_In_ ULONG Flags,
_In_ PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction,
_In_opt_ PVOID Context,
_Out_ PVOID* Cookie
);

typedef NTSTATUS(NTAPI* _LdrUnregisterDllNotification)(
_In_ PVOID Cookie
);

#endif // !_NTAPI_
dllmain.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "pch.h"
#include "LDR.h"
#include <string>
#include <iostream>
#include <TlHelp32.h>
#include <sstream>

#pragma comment(lib, "user32")

_LdrRegisterDllNotification LdrRegisterDllNotification = NULL;
_LdrUnregisterDllNotification LdrUnregisterDllNotifcation = NULL;
PVOID Cookie = NULL;

using namespace std;

BOOL GetNtFunctions()
{
HMODULE hNtDll;
const auto lpModuleName = XorStrW(L"ntdll.dll");

if (!(hNtDll = GetModuleHandle(lpModuleName)))
{
return FALSE;
}
LdrRegisterDllNotification =
(_LdrRegisterDllNotification)GetProcAddress(hNtDll, "LdrRegisterDllNotification");
LdrUnregisterDllNotifcation =
(_LdrUnregisterDllNotification)GetProcAddress(hNtDll, "LdrUnregisterDllNotification");
if (!LdrRegisterDllNotification || !LdrUnregisterDllNotifcation)
return FALSE;

return TRUE;
}



단일 파일이지만 설명을 병행하기 위해 함수별로 쪼개어 작성하였습니다. GetNtFunctions 에서는 ntdll.dll에 정의되어있는 LdrRegisterDllNotification 함수의 주소를 가져옵니다. 이 노티파이 등록 메서드를 직접 호출해 DLL 로드 콜백을 받기 위함입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void CALLBACK MyDllNotification(
ULONG Reason,
PLDR_DLL_NOTIFICATION_DATA NotificationData,
PVOID Context)
{
switch (Reason)
{
case LDR_DLL_NOTIFICATION_REASON_LOADED:
{
#ifdef _DEBUG
wcout << "[+] :: " << NotificationData->Loaded.BaseDllName->Buffer << endl;
#endif
auto dllname = NotificationData->Loaded.BaseDllName;
auto targetModule = GetModuleHandle(dllname->Buffer);

auto is_ce_64 = wcscmp(dllname->Buffer, L"MonoDataCollector64.dll") == 0;
if (is_ce_64)
{
exit(0);
}

break;
}
case LDR_DLL_NOTIFICATION_REASON_UNLOADED:
{
#ifdef _DEBUG
wcout << "[-] :: " << NotificationData->Loaded.BaseDllName->Buffer << endl;
#endif
}
default:
return;
}
}

MyDllNotification은 DLL 로드가 감지되었을 때 호출되는 콜백 함수입니다. 호출 된 이유와 데이터를 파라미터로 밀어넣어줍니다. 모듈 이름이나 경로를 확인할 수 있습니다. 여기에서 받는 모듈 데이터를 직접 처리하거나, C# 레이어로 또 다른 콜백을 호출해 돌려주어 검증하도록 구현할 수 있습니다.

예시로, 치트엔진에서 제공하는 기능 중 하나인 Mono Dissect를 방지하기 위한 로직을 구현해두었습니다. DLL이 로드되면, 모듈의 이름을 읽고 비교하여 MonoDataCollector64.dll인지 확인합니다. 만약 이름이 확인되었다면 공격 목적의 DLL로 판단하고 원하는 동작을 수행합니다. 간단하게 exit(0)을 호출하여 애플리케이션이 종료되도록 했습니다.

Mono Dissect는 프로세스에 DLL을 주입하여 게임 어셈블리에서 사용하는 클래스와 함수, 변수명들과 Virtual Address를 모두 수집하여 보여주는 기능입니다. 직접 프로세스에 파이프를 꽂는 스타일이라 Mono, il2cpp 할 것 없이 사용가능합니다.

[그림 9]

[그림 9] 프로세스가 종료되어버리면서 DllEntryPoint 에서의 작업이 완료되지 못한 채 예외가 발생

예시에서는 곧바로 프로세스를 닫아버렸지만, 비정상 행동을 감지했을 때 곧바로 종료시켜버리는 것은 좋은 대응 방법이 아닙니다. 공격자는 감지되었음을 인지하고 프로세스를 종료하는 시점을 찾아 중단점을 거는 등의 동적 분석을 통해 보호 루틴의 위치를 찾아내려고 시도할 것입니다. 적당한 텀을 두고 프로세스를 종료하거나, 분석 도구들의 xref 같은 기능이 동작하기 힘든 방식으로 프로세스 종료 루틴을 호출하도록 장치하세요.

이 콜백을 받았을 때는 DllMain이 호출되기 전이라 해당 스레드 시작부분을 찾아내어 DLL 종료 부분으로 jmp시켜주면 스스로 작업을 끝내고 언로드되는 것 처럼 통과시켜버릴 수도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
BOOL Startup()
{
NTSTATUS ret;
if (!GetNtFunctions())
return FALSE;

if (LdrRegisterDllNotification)
{
ret = LdrRegisterDllNotification(0, &MyDllNotification, NULL, &Cookie);
if (ret != STATUS_SUCCESS)
return FALSE;
else
return TRUE;
}
return FALSE;
}

void Cleanup()
{
if (LdrUnregisterDllNotifcation)
if (Cookie != NULL)
{
LdrUnregisterDllNotifcation(Cookie);
}
return;
}

extern "C" {
__declspec(dllexport) bool begin()
{
return false;
}

__declspec(dllexport) void end()
{
Cleanup();
}

StartupClaenup은 위에서 콜백 함수를 LDR 이벤트에 등록해주는 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
{
bool result;

#ifdef _DEBUG
result = AllocConsole();

FILE* file;

freopen_s(&file, "CONIN$", "r", stdin);
freopen_s(&file, "CONOUT$", "w", stderr);
freopen_s(&file, "CONOUT$", "w", stdout);

if (result)
{
cout << "console created..." << endl;
}
#endif

result = Startup();
cout << "startup result :: " << result << endl;
}

break;
case DLL_THREAD_ATTACH: break;
case DLL_THREAD_DETACH: break;
case DLL_PROCESS_DETACH:
{
Cleanup();
break;
}
}
return TRUE;
}

DLL Main은 이 DLL이 로드되면 호출됩니다. C# 코드에서 한번 호출 해 주면 스스로 Startup 로직으로 들어가 초기화를 진행합니다.

1
2
3
4
5
6
7
8
9
10
11
extern "C" {
__declspec(dllexport) bool begin()
{
return true;
}

__declspec(dllexport) void end()
{
Cleanup();
}
}

C# 레이어에서 함수를 호출하기 위해 export합니다. 유니티에서 DllImport 속성을 통해 임포트한 뒤, begin()을 호출하면 DLL이 메모리에 적재되며 동작을 시작하게 됩니다.

이렇게 윈도우가 기초적으로 제공하는 기능들을 이용해 장치한 함정들은 숙련된 공격자들에 의해 쉽게 파훼될 수 있습니다. 경험 많은 해커의 수는 많지 않지만 여러 치트 커뮤니티를 통해 정보가 공유되곤 합니다. 위 2가지에 안주 할 수 없습니다. 스텔스 인젝션 기능이 있는 인젝터나, 매뉴얼 매핑을 지원하는 인젝터들도 다수 있습니다. 각 방식을 감지하고 확인하기위해 끊임없이 다양한 방법을 시도해야합니다.

4️⃣ il2cpp 빌드

안드로이드와 iOS에서 AOT 컴파일만 허용하면서 대응을 위해 il2cpp 빌드가 주류로 자리잡았습니다. PC 플랫폼에서는 따로 배포처에서 강제하는 옵션이 없기때문에 mono로 배포를 해도 반려되거나하는 일은 없습니다. 그러나 PC에서도 성능향상측면에서 il2cpp를 이용해야한다는 정보글도 심심찮게 찾아볼 수 있습니다.

il2cpp는 유니티 빌드 파이프라인 중 하나로, 이름 그대로 직관적인 작업을 수행합니다. IL to CPP, IL 코드를 CPP 코드로 변환해 컴파일해줍니다. IL 코드는 C# 코드로 다시 복원하기 용이하고, 읽기도 쉬운편입니다. 반면 CPP 코드는 오브젝트로 컴파일되고나면 디스어셈블리 도구를 통해 확인해야하고 사람이 읽고 이해하기에도 난해합니다. 뚜렷한 이유 없이 모노 빌드를 사용하고있다면 il2cpp 빌드로 파이프라인을 변경해보세요.

단점

모노 빌드 이후 추가로 ILCPP 과정을 거치기 때문에 빌드 시간은 더 오래 걸립니다. 개발 단계에서 따로 빌드 머신 없이 자주 빌드해야 하는 상황이라면 비효율적일 수 있습니다. 빠른 개발 사이클을 위해 이를 극복하기 위해서는 Jenkins와 같은 도구로, VCS 커밋이 들어오면 해당 해쉬를 버전삼아 자동으로 빌드 수행할 빌드 머신을 세팅하는 것입니다. 여유 시스템 자원이 없다면, 개발 단계에서는 MonoStandalone 빌드를 뽑고, 하루에 한 번 정도 il2cpp로 빌드해 Mono와 마찬가지로 동작하는지 확인하는 단계를 넣을 수 있습니다.

리플렉션의 일부 기능을 사용하지 못할 수도 있습니다. 크게는 System.Reflection.Emit 네임스페이스에 정의된 기능을 사용할 수 없습니다. 나머지 리플렉션 기능에 대해서는 AOT 컴파일러가 타입을 추측할 수 있는 경우에만 사용가능합니다. 리플렉션 기능을 적극 활용하여 코어로직을 작성했다면 단번에 il2cpp 빌드로의 전환이 쉽지 않을 수 있습니다. Unity - Manual: Scripting restrictions (unity3d.com)

사용

유니티 허브를 통해 설치합니다. il2cpp 빌드를 위한 모듈은 각 에디터 버전에 종속됩니다. 에디터 설치 과정에서 il2cpp 모듈을 추가하셔도 되고, 설치 후 Add Modules 기능을 통해 후에 추가하셔도 무방합니다.

프로젝트 설정의 Player 탭에서 스크립팅 백엔드를 선택할 수 있습니다. il2cpp 모듈이 설치되지 않더라도 il2cpp 선택 아이템을 선택할 수 있으나 빌드를 시도하면 모듈이 설치되지 않았다는 팝업을 띄웁니다.

빌드를 진행하면, mono 와 유사한 방식으로 빌드를 진행합니다. 리소스를 인덱싱해 묶고, IL 코드를 생성합니다. 여기에서 추가로 IL 코드를 CPP 코드로 바꾸는 과정을 거치며 사용되지 않아 필요없는 코드를 삭제하는 Stripping 단계도 거치게됩니다. 이렇게 GameAssembly.dll은 매번 생성되지만, 다른 exedll 모듈들은 어떻게 생성되는 걸까요?

il2cpp 모듈이 설치된 경로를 확인하면, 미리 생성된 {{ProductName}}.exe, GameAssembly.dll, UnityPlayer.dll 등이 존재합니다. 이 파일들이 output 경로로 복사되면서 빌드 과정이 마무리됩니다.

활용

CPP로 변환된 코드가 GameAssembly.dll로 컴파일 되기 전에 수정할 수 있습니다. 빌드 설정 윈도우에서 Create Visual Studio Solution 옵션을 선택하면 바이너리 파일을 바로 생성해주지 않고 컴파일이 가능한 솔루션을 생성해줍니다.

이렇게 생성된 솔루션에서 일부 소스코드를 수정하고 컴파일 할 수 있습니다.

유니티 엔진 코드 라이센스 없이 진행할 수 있는 조치는 대부분 여기에서 이루어집니다. 메서드 정보를 가지고 있는 global-metadata.dat 파일을 숨기거나, 복호화 하거나 실시간으로 다운로드 받아 로컬에 캐시를 남기지 않고 바로 로드하거나, 컴파일 타임에 string을 xor로 숨겨 정적 분석에서 스트링이 드러나지 않도록 하는 등의 방법들이 이 프로젝트 내 코드를 수정하여 수행시킬 수 있습니다. 모듈 보호 섹션에서 작업해본 LDR을 이용한 구현부 역시 GameAssembly.dll에 직접 박아넣을 수도 있습니다.

예제로, global-metadata.dat 파일 이름을 변경해 다른 경로에 위치시키고 싶다고 가정해봅시다.

프로젝트의 libil2cpp\vm\GlobalMetadata.cpp 소스를 확인하면 GlobalMetadata::Initialize 메서드 안에서 global-metadata.dat 를 스트링으로 하드코딩해두고 사용하고 있음을 확인할 수 있습니다.

이 소스코드에서 파일이름을 변경해봤자, 정적 분석 도구로 해당 메서드 부분을 찾아보면 어떤 경로에서 메타데이터를 로드하는지 쉽게 알 수 있을 것입니다.

이해를 돕기 위해 이 상태로 빌드한 뒤 확인해보겠습니다. IDA로 열어보면 string 검색을 시작으로 메타데이터를 로드하는 부분을 찾아낼 수 있습니다.

xref

disassembled code

global-metadata.dat 이름을 다른 것으로 변경했더라도, 어떤 메서드에서 사용하는지 알고 있기 때문에 초보자라도 쉽게 찾아내어 메타데이터 파일을 특정할 수 있을 것입니다. 간단한 xorstring 소스를 추가해 빌드해보겠습니다.

비교를 위해 global-metadata.dat은 그대로 유지하고, IDA로 열어보았습니다.

함수는 Metadata를 검색해서 찾았습니다. 함수를 찾는 편의를 위해 바꾸지 않았습니다. 해커는 Metadata 역시 xor로 숨기더라도 엔트리포인트로부터 흐름을 따라오며 어렵지 않게 함수를 찾아낼 것입니다. xor 적용 전과 비교하면, string이 그대로 노출되지도 않으며 새로운 do-while 문이 추가된 부분을 확인할 수 있습니다. 간편한 xor 로직으로, 프로젝트에 헤더를 생성해 추가하고 사용하면 됩니다. a1 파라미터는 상위 메서드에서 인자를 넘겨줍니다.

상기 함수는 sub_1800F42B0 입니다. v81 변수와v82를 사용해 넘겨주고, v1를 리턴받습니다. 결과적으로 v1이 무엇인지 확인하면 경로 또는 로드된 메타데이터 인스턴스를 바로 알 수 있을것입니다. 하지만 위에서 말씀드렸듯이, 리버싱에 경험 많은 해커를 막는 것보다는 눈먼 공격으로부터의 대응이라도 하자는 범위에서의 방어수단입니다.

위 xor 예시는 ‘꼭 이렇게 하세요’는 아닙니다. 디스어셈블된 코드를 읽고 추측할 수 있는 해커라면 너무나 쉽게 파훼하고 확인가능하기때문입니다. 설사 완벽하게 코드로부터 메타데이터 경로를 숨기거나, 파일을 따로 두지 않고 바이너리에 바로 쑤셔넣어두었더라도 중단점 걸어서 메모리 덤프해버리면 복호화된 메타데이터가 그대로 노출됩니다. 디버거가 붙지 못하도록, 덤프를 방해하도록 또 다른 조치를 병행해서 적용하셔야합니다.

본 섹션은 해킹 원천 봉쇄가 아닌, il2cpp solution 생성 기능을 어떤식으로 활용가능한지에 대한 소개였습니다.

5️⃣ 방어형 코드 작성

보편적인 방어 수단이 모두 적용된 상태에서 가장 효과적이면서 중요하다고 볼 수 있는 장치입니다. 난독화, 암호화, il2cpp빌드 이 삼위일체로 분석을 어렵게 만드는데 성공했다면 자연스러운 코드 흐름 속에 개발자만 아는 규칙을 넣어 문제를 감지해내는 방법론입니다. 이런 스타일을 가장 간편하게 접할 수 있는 것은 fakeValue를 사용하는 자료형입니다. 에셋스토어에서 구매할 수 있는 AntiCheatToolkit에서도 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SecuredInt
{
private int hiddenValue;
private int fakeValue;

public SecuredInt(int value)
{
hiddenValue = Hide(value);
fakeValue = value;
}

int Hide(int value)
{
return value ^ Secure.key;
}

public bool IsFucked { get { return fakeValue != Secure.key ^ hiddenValue; } }
public int Get() { return hiddenValue ^ Secure.key ;}
public void Set(int value) { hiddenValue = value ^ Secure.key; }
}

[간소화된 코드] 실제 자료형에서는 implicit, explicit operator를 구현해 편하게 사용할 수 있도록 장치합니다.

플래그연산으로 값을 바꾸어 저장해둡니다. 실제 밸류는 hiddenValue에 저장되지만, 화면에 표시되는 값은 fakeValue와 동일한 값이 나옵니다. 치트엔진으로 화면에 보이는 값을 찾아 검색해 바꾸게되면, fakeValue만 변경되고 실제 값은 유지됩니다. 이 때 hiddenValue와 비교해 값이 다르면 변조 시도가 있었다고 추측할 수 있게 됩니다. 그러나 이것 역시 쉽게 알 수 있습니다. 치트엔진에서 플래그 연산된 값이나 xor을 거친 값도 추적할 수 있기 때문에, 방법과 숨겨져있다는 점만 안다면 얼마든지 hiddenValue를 조작할 수도 있습니다. 그러지 못하는 해커들을 걸러내는 정도의 수단입니다. 함수를 후킹해 조작하는 경우는 Getter Setter를 바로 사용해버려 감지되지 않게 조작할 수도 있습니다. 큰 효율을 보여주지는 못합니다.

함수 호출 순서 지정에 따라 후킹 함수의 호출을 감지하는 방법도 있습니다. 이 방법은 충분한 코드 분석 없이 함수를 후킹해 사용하는 해커에게 효과적일 수 있습니다. 최근 즐겼던 게임을 예시로 들어보겠습니다. 개발자분이 이 글을 보신다면 ‘아, 우리 게임이구나.’ 하고 아실지도 모르겠네요.

월드를 돌아다니며 캐릭터에게 필요한 아이템을 빠르게 루팅해서 강해지는 목표를 가진 게임입니다. 로직을 확인해보니 간략했습니다. 아이템 박스를 열면 리프레쉬 과정에서 필요한 아이템인지 검사를 하고, 슬롯 UI에서 마크 이미지 컴포넌트의 작동을 시켜주는 방식이었습니다.

ItemWindow.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class ItemWindow : MonoBehaviour
{
private ItemSlot[] slots;

private void OnSlotClick(PointerEventData.InputButton button, ItemSlot slot)
{
if (slots.Contains(slot))
{
PlayerController.instance.TakeItem(slot);
Refresh();
}
}

private void Refresh()
{
// check it need
var needItems = LocalDB.instance.needItem;
foreach (ItemSlot slot in slots)
{
bool need = needItems.Contains(slot.item.itemCode);
slot.MarkAsNeed(need);
}

// repaint
Repaint();
}

private void OnOpen()
{
Refresh();
}

private void Repaint()
{
// ...
}
}
ItemSlot.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ItemSlot : MonoBehaviour
{
private Image isMarked;
public Item item;

public void OnTake()
{

}

public void MarkAsNeed(bool enable)
{
if (isMarked)
{
isMarked.enabled = enable;
}
}
}
PlayerController.cs
1
2
3
4
5
6
7
8
9
10
class PlayerController
{
public static PlayerController instance;

public void TakeItem(ItemSlot itemSlot)
{
// ...
itemSlot.OnTake();
}
}

기타 사용된 클래스는 간단한 싱글턴 패턴으로 구현했고 껍데기만 존재해 크게 중요하지 않습니다. 이런 방식의 코드 동작 구조를 파악했고, 많은 코드를 후킹하고 수정할수록 위험성이 커지므로 최소한의 구성으로 자동 루팅 스크립트를 만들기로 결정합니다.

먼저, Refresh() 함수를 후킹해서, 원본 함수의 호출이 끝난 뒤 ItemSlot의 mark 이미지가 활성화되어있는 슬롯을 캐시하고, OnSlotClick 함수를 호출하기로 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct ui_image {
char pad_0[0x10c]
bool enabled;
}
struct item_slot {
char pad_0[0x18];
ui_image isMarked;
}
struct item_window {
char pad_0[0x82];
il2_cpp_list<item_slot*> slots;

typedef void (__fastcall* t_onslotclick)(item_window*, int, item_slot*);
static t_onslotclick origin_onslotclick;

typedef void (__fastcall* t_refresh)(item_window*);
static t_refresh origin_refresh;
static void Refresh(item_window* instance) {
// call origin
origin_refresh(instance);

// force call onclick cb
for (auto s : slots) {
if (s->isMarked->enabled) origin_onslotclick(instance, 0, s);
}
}
}

Refresh 함수만을 후킹했고, onslotclick 함수는 임의로 호출해버렸습니다. 이제 이 스크립트를 적용한 해커는 월드에서 박스를 열기만 해도 자동으로 아이템을 획득하게 될 것입니다. 마우스 클릭을 하지 않더라도요. 이 경우 로직상 설계에 결함이 있다고 보기는 힘듭니다. 게임상 UI 와 코어로직을 분리하지 않아 다소 난잡하게 막 개발한 케이스이긴 하지만 보안상 특출난 잘못을 하고있지는 않습니다. 그러나 후킹 공격이 생길것을 전혀 고려하지 않은 흐름의 코드인 것입니다.

누군가 이 코드를 후킹할지도 모른다고 가정한 경우 몇 가지 트랩을 생각해 볼 수 있습니다. 예를 들면, OnSlotClick 함수는 마우스 이벤트로부터 시작되어야 할 것입니다. 만약 Refresh 함수가 끝나기 전에 OnSlotClick이 실행된다면 그것은 부정 호출로 간주할 수 있습니다. 모든 Refresh 함수 아래 위로 플래그를 만들어둬 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ItemWindow {
// ...
private void OnSlotClick(PointerEventData.InputButton button, ItemSlot slot)
{
if (flag != 0x0) {
// flag must be 0x0
}

if (slots.Contains(slot))
{
PlayerController.instance.TakeItem(slot);
flag = 0x1;
Refresh();
flag = 0x0;
}
}
// ...
private void OnOpen()
{
flag = 0x1;
Refresh();
flag = 0x0;
}
}

만약 Refresh 함수를 후킹한 곳에서 OnSlotClick을 호출한다면 flag는 0x1 상태이고, 호출되어서는 안 될 때 호출되었음을 확인할 수 있습니다. 이럴 때는 클라이언트를 종료하지 말고 슬그머니 기록해뒀다가 몰래몰래 백그라운드에서 서버로 쏘는 패킷에 섞어 보내세요. 일정 주기로 모아 밴웨이브를 때리면 됩니다.

Q. Refresh에서 호출하지 않고 따로 Update를 만들어 수동으로 호출할 수도 있지 않나요?

A. 맞습니다. 이 경우는 자기의 치트를 자랑하고 싶은 누군가 공유한 스크립트를 토대로 방어적인 코드로직을 설계한 것입니다. 만약 해커가 어떤 방식으로 후킹하는지 모른다면 좀 더 범용적인 방법으로 트랩을 설치해야겠죠.

슬롯을 클릭하는 콜백함수가 발생하려면 마우스 포인터가 슬롯안에 있어야만 한다“ 는 점을 활용해보겠습니다.

ItemSlot.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ItemSlot : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
private int mouseInteracted;
private Image isMarked;
public Item item;

public void OnPointerEnter(PointerEventData data)
{
mouseInteracted = 0x1;
}

public void OnPointerExit(PointerEventData eventData)
{
mouseInteracted = 0x0;
}

public void OnTake()
{
// ...
if (mouseInteracted = 0x0) {
// report!!
}

mouseInteracted = 0x0;
}

public void MarkAsNeed(bool enable)
{
if (isMarked)
{
isMarked.enabled = enable;
}
}
}

유니티에서 제공하는 인터페이스인 IPointer...Handler를 이용했습니다. 마우스 커서가 슬롯 안에 들어있었는지 OnTake 콜백에서 확인하는 방식입니다. 이런 방식의 트랩을 회피하기 위해서는 마우스 커서를 슬롯안에 강제로 넣어주는 스크립트 역시 작성되어야합니다. 번거롭죠. 만약 해커가 난독화된 코드를 제대로 파악하지 못해 이런 제약이 있음을 확인하지 못하면 꼼짝없이 함정을 건드리고 마는 것입니다.

보너스

위 처럼 자동 루팅 스크립트를 제작했을 때, ‘필요한 아이템’이 다수 존재할 경우, Refresh - OnClick - Refresh - OnClick… 이 중첩되어 한 프레임에 여러번 발생합니다. 분명히 아이템을 획득가능한지 판정은 서버에서 할 테고, 언제 아이템 획득 요청을 보냈는지 로깅이 되고 있겠죠. 레이턴시가 있을 수 있으니, 클라이언트 사이드에서도 한 프레임에 몇 번이나 요청 함수가 호출되었는지 로깅해 서버사이드와 비교해 볼 수 있습니다. 로깅은 실시간으로 하되, 데이터 분석 및 비교는 게임 후에 하는 것이 좋겠죠. 그러나 사용자 환경에 따라 얼마든지 false positive가 발생할 여지가 있으니 확실하게 검증할 필요는 있습니다.

다른 예시로, 스킬을 자동으로 사용해 적을 맞추는 코드처럼 주요한 로직에 여러 함정을 설치할 수 있습니다. 코드로 보여드리지는 않겠지만, 복잡한 코드의 경우 CastSpell 처럼 추상적인 함수 아래 여러 델리게이트가 달려 호출되기도 하고, 순서대로 호출되어야만 하는 코드들이 존재 할 것 입니다.

MOBA게임에서 플레이어가 적 캐릭터에 마우스 커서를 대고, 스킬을 사용하려고 Q를 눌렀습니다. 스마트키가 켜져있지 않은 경우, 인디케이터를 먼저 그리고 마우스 왼쪽 클릭을 누르면 스킬이 격발될 것입니다. 반면 스크립트는 매 프레임 적의 위치를 계산하다가, 사거리 안에 들어오면 적의 위치에 스킬 사용 함수를 호출할 것입니다.

이와 같이 실제 플레이어가 정상적인 플레이를 통해 호출될 함수 순서와, 그렇지 않은 경우의 호출 순서를 캡쳐하여 비교하는 식으로 검출할 수도 있습니다.


본 아이디어는 코드가 충분이 난독화되어 한눈에 게임로직이 파악되기 어렵다는 가정을 전제로 한 기법입니다. 사람의 눈으로 코드를 읽고 어떤 기능을 할지 변수 이름으로부터 추적하기 쉽다면 의미 없는 일입니다.

실행된 메서드가 정상적인 곳에서부터 시작했는지 체크하는 여러 기법들이 있는데, 이 방법은 기술적인 접근에서 벗어나 논리상으로 그럴듯한 함정을 파는데 집중했습니다. 리턴 어드레스 확인이나 스레드 ID 확인, 콜스택 추적 같은 기능들은 다른 포스트에서 상세하게 다룰 예정입니다.

마치며

당초 기획했던 분량을 초과하게되어 함께 담으려 했던 일부 내용들을 분리했습니다. 서비스를 방해하고 부정이득을 취하려는 공격자의 입장에서 생각하고 취약점을 찾기 위해 많은 시간을 보냈습니다. 어디를 공격할지 파악해야 효과적인 방어 전략 수립이 가능하기 때문입니다. 소개한 대부분의 방법들은 ‘탐지’ 방법이지 ‘방지’ 방법은 아닙니다. 부정 사용자 탐지를 할 방법이 있다는 것만으로도 안정적인 서비스 유지에는 큰 도움이 될 것입니다.

원천적으로 프로세스를 들여다보고 수정하는 일을 금지하기 위해서는 유저모드보다 더 아래 단계에서의 작업들이 필요합니다. 보통 Ring0라고 불리는 커널모드에서 더 많은 권한을 가진채로 동작합니다. 사용자들은 유저모드에 비해 커널 모드에서의 안티치트 프로그램들에 다소 부정적인 여론을 형성하고 있습니다. 주된 이유는 기술적으로 사생활 침해가 가능할 수도 있다고 믿기 때문입니다. 유저모드에서 해 볼 수 있는 작업들을 마친 후, 여유가 된다면 커널 모드에서 작동하는 솔루션도 소개해보도록 하겠습니다.

앞으로 다룰 내용