유니티 파일시스템에서 메타데이터 숨기기

배포될 유니티 앱에서 파일시스템에 존재하는 메타데이터를 숨기는 튜토리얼

본 작업은 기본적인 il2cpp 작업 흐름에 대해 이해하신 다음 확인하시는 편이 좋습니다.
Windows 배포를 위한 체크리스트에서 이어지는 글입니다.

본 튜토리얼은 2019.4.14f1 버전으로 진행되었습니다. 다른 버전의 유니티인 경우 작업 파이프라인이 상이할 수 있습니다. 윈도우즈 빌드 기준으로 작성되었지만, 안드로이드 플랫폼에서도 동일하게 작업할 수 있습니다. .so 파일에서도 유사한 기법을 사용할 수 있습니다.

시작하며

유니티는 스크립팅 언어로 C#을 채택하였고, 사용자는 엔진에서 제공하는 API를 이용해 게임 로직을 구현합니다. 네이티브 플러그인이 아닌 경우, 대체로 C# 코드를 사용하며 JIT 컴파일 과정을 거쳐 메모리에 로드되어 동작이 수행됩니다. 에디터에서는 항상 JIT 컴파일 방식으로 동작하며, 빌드 후에는 JIT 컴파일 방식(mono)과 AOT 컴파일 방식(IL2CPP) 중에 선택해 바이너리를 추출할 수 있습니다.

이 때, AOT 컴파일 방식에서는 Literal String, Method, Struct 정보들을 직접 관리하지 않고 global-metadata.dat라는 파일에 따로 저장하여 보관합니다. GameAssembly.dll이 로드되면서 직접 파일 시스템에 접근하여 메모리에 로드하고 파싱하여 배열로 관리합니다.

윈도우 빌드한 애플리케이션 기준으로는 [게임이름]_Data\il2cpp_data\Metadata\ 경로에 위치합니다.

모 상용 게임에서 노출되어 있는 글로벌 메타데이터 파일

텍스트 에디터로 열어보시면 바이너리 포맷의 파일임을 확인하실 수 있습니다. 그러나 IL2CPPDumper를 이용하시면 쉽게 데이터를 파싱해 볼 수 있습니다.

Il2CppDumper를 실행하고, GameAssembly.dllglobal-metadata.dat를 차례로 선택해주면, 구조체 데이터가 담긴 헤더, 스크립트 심볼 정보들이 담긴 json 데이터, String Literal이 담긴 json, CPP 코드로 변환되기 전의 구조였을것으로 예상되는 C# 신택스의 클래스 구조, 그리고 이를 묶은 더미 DLL들을 생성해줍니다.

이렇게 생성된 파일들에서 dump.cs를 열어보면 C# 문법의 클래스구조가 노출되고 이를 이용해 클래스를 재구성할 수 있습니다.

img

위는 global-metadata.dat에서 추출한 클래스구조, 아래는 그를 기반으로 생성한 구조체입니다.

이렇게 재구성된 클래스로 게임을 공격하는 방법에 대해서는 이 글에서 다루었습니다.

대응

누차 말씀드리지만 완벽한 방법은 아니란 것을 항상 기억해주세요.

저희는 IL2CPP 의 코드를 수정해 이 global-metadata.dat 파일을 숨길 예정입니다. 어떤 작업을 할 지만 읽고도 어떻게 작업할지 감이오시는 분들을 위해 먼저 작업 요약을 작성했습니다.

  1. Project Settings - Player - Other Settings - Configuration - Scripting BackendIL2CPP로 선택
  2. File - Build Settings - Create Visual Studio Solution 옵션을 키고 빌드
  3. 생성된 global-metadata.dat 파일을 char array로 변환
  4. 생성된 솔루션의 libil2cpp\vm\MetadataLoader.cpp 수정해서 변환된 char array 하드코딩
  5. Master Configuration 옵션으로 빌드

1. 프로젝트 추출 옵션으로 빌드

빈 프로젝트로 진행하기엔 약간 심심하니 에셋 스토어의 무료 에셋을 하나 다운로드 받아 빌드에 사용합니다.

Create Visual Studio Solution 옵션으로 빌드하면 아래처럼 파일시스템이 생성됩니다.

솔루션에는 4개의 프로젝트가 솔루션 안에 생성됩니다.

간단하게 콘솔을 띄우는 코드로 확인해볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nShowCmd)
{
AllocConsole();
FILE* file;

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

return UnityMain(hInstance, hPrevInstance, lpCmdLine, nShowCmd);
}

여기에서 return 하는 UnityMain(...)UnityPlayerStubMain입니다. 그러니까 프로그램 실행 순서는, exe - UnityPlayer.dll - GameAssembly.dll이 됩니다. 이를 이용해서 어느 타이밍에 어떤 장치를 심을지 결정하시면 됩니다.

2. 메타데이터를 바이너리로 변환

빌드에서 생성되는 global-metadata.dat 파일을 로드해 임의의 byte array로 변환을 거칩니다. 저는 빌드 이벤트에서 진행하는 방식을 택했습니다.

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
private const string BIN = "build\\bin\\";
private const string METADATA = "il2cpp_data\\Metadata\\global-metadata.dat";
private const string HEADER = "Il2CppOutputProject\\IL2CPP\\libil2cpp\\vm\\SafeMetadata.h";

[PostProcessBuild(Order.IL2CPP)]
public static void OnPostProcessBuild(BuildTarget target, string pathToBuildProject)
{
switch (target)
{
case BuildTarget.StandaloneWindows:
case BuildTarget.StandaloneWindows64:
OnWindows(pathToBuildProject);
break;
default:
break;
}
}

private static void OnWindows(string pathToBuildProject)
{
if (!UnityEditor.WindowsStandalone.UserBuildSettings.createSolution)
return;

pathToBuildProject = pathToBuildProject.Replace($"/{PlayerSettings.productName}.exe", "");

string metadataPath = $"{pathToBuildProject}\\{BIN}{PlayerSettings.productName}_Data\\{METADATA}";
string outputHeaderPath = $"{pathToBuildProject}\\{HEADER}";

MetadataHeader header = new MetadataHeader(metadataPath);
File.WriteAllText(outputHeaderPath, header.ToString());
}

간략하게 빌드 직후에 호출되는 콜백을 이용했습니다. metadata.dat 파일이 있을 것으로 예상되는 경로에서 직접 로드해, header 파일을 생성해줍니다. 전체 소스는 여기에서 보실 수 있습니다. 생성된 메타데이터 파일은 아래와 같습니다.

global-metadata.dat 파일은 2.25MB 였던 반면 이 헤더 파일은 13MB정도로 커졌습니다. 이 헤더를 IL2CPP 에서 제공하는 메타데이터 로드 부분 코드에서 직접 인클루드하고, 로드할 것입니다.

3. 로드 부분 코드 수정

생성된 솔루션을 열고, Il2CppOutputProject\IL2CPP\libil2cpp\vm 경로에서 MetadataLoader.cpp 소스 파일을 열어주세요. 메타데이터를 로드하는 부분의 코드를 확인할 수 있습니다.

간략히 코드를 설명드리면, 매개변수로 파일이름을 받았고 Metadata 폴더와 함께 경로를 Resolve 한 뒤, 파일 버퍼에 올리고 리턴해줍니다. 파일시스템에서 주어진 파일 이름으로 데이터를 로드하는 간단한 구현입니다.

이 부분을 아래처럼 수정해줍니다.

우선, 앞서 생성했던 SafeMetadata.h를 인클루드 해 줍니다. 2에서 진행한 빌드 이벤트가 제대로 수행되었다면 vm 폴더 경로에 해당 헤더가 존재하고 있을 것입니다. 코드에 그대로 데이터가 정의되어있으므로, 메모리에 데이터를 카피해주고 원래 로직이 하던 것 처럼 리턴해줍니다. 테스트를 빌드해주세요.

빌드 테스트 방법

bin\Architecture\Build Config\ 경로에 exe 파일이 생성되었을 것입니다.

실행하기 전에, bin...Data\il2cpp_data\Metadata\global-metadata.dat 파일의 이름을 다른 것으로 바꿔주세요. 이 데이터가 없어도 제대로 게임이 동작하는지 확인해야하기 때문입니다.

테스트에 성공했다면, 배포될 파일 구조에서는 메타데이터를 제외해도 됩니다. 번거롭게 숨겨놓고 메타데이터를 그대로 배포해버리면 전혀 의미 없는 일이 되니 주의해주세요. 2에서 생성했던 빌드 스크립트에서 자동으로 다른 위치로 옮겨두는 로직을 추가하는 것을 권장합니다.

4. 배리에이션

위 스텝들을 통해 메타데이터를 파일시스템 상에 남기지 않을 수 있는 방법을 터득했습니다. 조금 심리적인 방법을 시도해겠습니다. MetadataLoader::LoadMetadataFile 메서드가 호출되는 곳은 MetadataCache::Initialize 함수입니다. 정직하게 리터럴 스트링을 이용해 로드하고 있으며, 이는 여러 정적 분석 도구에서 쉽게 확인이 가능합니다. 참고

우리는 이미 메타데이터를 DLL에 박아 넣어버렸기 때문에 파일 이름을 매개변수로 전달할 필요가 없습니다. 헤더를 수정해 파라미터를 아예 받지 않도록 바꾸거나, 쓸데없는 파일 이름을 넘겨주며 공격자를 짜증나게 할 수 있습니다. 이것은 작업하시는 분의 선택입니다. 스트링을 남겨두면 메타데이터를 로드하는 곳의 위치를 쉽게 파악할 수 있어 취약점이 될 수도 있지만, GameAssembly.dllEntryPoint부터 메타데이터 로드하는 곳 까지는 그렇게 복잡하지 않습니다. 초기화 흐름대로 함수를 따라가다보면 금방 발견하기 때문에 큰 의미를 가지지는 못합니다.

한 가지 배리에이션의 예시로 제가 작업했던 프로젝트를 소개하겠습니다.

xor 암복호화를 이용해, 한 번 더 꼬아 작업했습니다. 보안상의 이유로 코드는 따로 작성하지 않겠습니다. global-metadata.dat를 매개변수로 받는 부분을 수정하지 않고 그대로 받았습니다. 그리고 파일을 열고, 메모리에 로드했다가 언로드하는 부분의 코드도 주석처리 하지 않았고, xor 연산의 키로, global-metadata.dat라는 스트링을 사용했습니다.

정적 분석 도구에서 보면 매개변수로 받고도 사용하지 않았다는 점은 쉽게 파악이 가능합니다. 이와 같은 흐름을 파악하게 되면 공격자는 무언가 다른 로드 방식이 숨겨져있을 거라고 생각할 것입니다. 이를 이용해 정상적인 메타데이터 로드를 하는 것처럼 파라미터를 사용하면서도, 실제로 앱과 함께 배포되는 메타데이터 파일은 완전히 더미 데이터입니다. 디버거를 붙인다고 해도, IO도 발생했고 포인터로 액세스하며 메모리에 올라갔다 내려가는 걸 확인할 수 있기 때문에 신경써서 살펴보지 않으면 껍데기일 뿐이라는 것을 눈치채지 못하고 넘어가는 경우가 많습니다. 마지막으로는 상용 패킹 도구를 이용, DLL을 한 번 더 패킹해주면서 어리숙한 정적 분석으로부터 보호받습니다.

5. 자동화

방법 1 - il2cpp 빌드 모듈을 수정

사용중인 에디터 버전의 설치경로에는 il2cpp라는 이름의 폴더에 빌드 모듈이 그대로 위치해있습니다. 여기 있는 코드를 미리 수정해두어 빌드 때 마다 여기에서 헤더와 소스 파일을 카피해도록 하는 것입니다. 해당 버전을 사용하는 모든 프로젝트가 수정된 빌드 모듈을 공유해야한다는 단점(장점일 수도 있지요)이 있습니다. 빌드슬레이브에서는 사용해 볼 만한 선택지입니다.

방법 2 - 스크립트로 오버라이드

권장하는 선택지입니다. 포스트 빌드 콜백에 정의했던 메타데이터 컨버터 스크립트처럼, 빌드 이벤트를 받아 오버라이드하는 방식입니다.

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
private static void CopyCustomScripts_Windows(string pathToBuiltProject)
{
string loaderHeaderPath =
pathToBuiltProject + "\\Il2CppOutputProject\\IL2CPP\\libil2cpp\\vm\\MetadataLoader.h";
string loaderSourcePath =
pathToBuiltProject + "\\Il2CppOutputProject\\IL2CPP\\libil2cpp\\vm\\MetadataLoader.cpp";

string customizedHeaderPath = $"{Application.dataPath}\\SafeMetadata\\Native\\MetadataLoader.h";
string customizedSourcePath = $"{Application.dataPath}\\SafeMetadata\\Native\\MetadataLoader.cpp";

FileInfo loaderHeader = new FileInfo(loaderHeaderPath);
if (loaderHeader.Exists)
{
loaderHeader.Delete();
}

FileInfo customizedHeader = new FileInfo(customizedHeaderPath);
if (customizedHeader.Exists)
{
customizedHeader.CopyTo(loaderHeaderPath);
}

FileInfo loaderSource = new FileInfo(loaderSourcePath);
if (loaderHeader.Exists)
{
loaderHeader.Delete();
}

FileInfo customizedSource = new FileInfo(customizedSourcePath);
if (customizedHeader.Exists)
{
customizedHeader.CopyTo(loaderSourcePath);
}
}

미리 수정한 헤더와 소스 파일을 프로젝트에 포함시켜두고, 빌드 이벤트로 카피해 덮어쓰기하는 방식입니다.

여기까지가 메타데이터를 숨기기위해 사용되는 필수적인 수정사안들에 대한 자동화 제안입니다. 이외에 추가로 구현할 수 있는 xor 방식 암호화 등도 함께 붙여 사용하시면 됩니다. 단일 빌드에 대해 진행해야할 작업이 많고 번거로운 만큼 자동화 파이프라인을 갖춰두는 것이 생산성에 큰 도움이 됩니다.

의미와 궁금증

Q. 앱이 차지하는 데이터 용량이 늘어나나요?

global-metadata.dat 파일의 용량은 2MB 였던 반면 생성한 헤더는 13MB였습니다. 그러나 이 작업을 진행하기 전 후로 나누어 GameAssembly.dll의 용량을 비교하면, 8MB / 10MB로 사실상 데이터가 커진 것은 아닙니다. 컴파일되면서 데이터영역으로 들어가버려 다시 바이너리 형태로 저장되었기 때문입니다. 용량의 이점이나 손해는 미미합니다.

Q. 전부 다 했어요. 이제 안 뚫리나요?

치트엔진을 설치하고, 빌드한 앱을 켜보세요. 치트엔진에서 유니티 프로세스를 찾아 어태치 하고, 상단 메뉴에서 Mono Dissect 메뉴를 선택해보세요. 메타데이터에 저장되어있던 심볼들이 그대로 노출됩니다. 들인 노력에 비해 너무 간단하게 무력화됩니다. 이런 방식의 접근은 다르게 차단해야합니다. 치트엔진의 이 기능은 DLL을 강제로 로드한 뒤 데이터에 접근하는 방식인데요, 이전 포스트에서 DLL Injection을 막는 방법 중의 하나를 소개했습니다. DLL 인젝션을 막거나, 치트엔진의 데이터 콜렉터 소스를 보고 작동하지 않도록 IL2CPP 모듈을 수정해야합니다. 이 부분에 대해서는 내용 정리 중에 있습니다.

Q. UnityPlayer.dll도 수정하고 싶어요

유니티 엔진 코드 라이센스를 하시면 IL2CPP 빌드 솔루션도 함께 주는 것으로 알고 있습니다. 코드 라이센스 없이 할 수 있는 작업 레벨에서만 도움을 드릴 수 있을 것 같습니다. 얘기가 나온 김에, 조금 재미있는 장난을 쳐보겠습니다.

Hex Editor를 사용해 UnityPlayer.dll을 엽니다.

GameAssembly.dll 을 검색합니다.

적당한 길이의 스트링으로 바꿔줍니다. 저는 언리얼 엔진으로 바꾸었습니다.

GameAssembly.dll 파일이름도 UnrealEngine.dll로 바꾸어주었습니다. 실행이 잘 됩니다.

로드된 모듈을 확인하니 UnityPlayer.dllUnrealEngine.dll이 공존하고 있습니다. 쉽게 볼 수 없는 진풍경이네요. 이를 잘 이용하면, Op코드를 수정해 GameAssembly.dll을 로드하고 제대로 로드되었는지 확인하는 코드를 스킵해버리도록 변경할 수도 있습니다. 그리고 exe 파일에서 UnityPlayer.dll을 로드하기 전, GameAssembly.dll에 해당하는 데이터를 임의로 메모리에 로드할 수 있게됩니다. 라이엇 게임즈의 레전드 오브 룬테라는 이와 유사한 stub.dll 기법을 이용해 PE영역을 제거해 로드 되기 전에는 정적분석 되기 힘든 DLL을 임의로 로드하고 있습니다.

Q. 병행하면 좋은 다른 기법들이 있나요?

패킹을 고려해보시는 것도 괜찮습니다. 단점으로는 앱 구동 준비 시간이 길어집니다. 실행하고 몇 초 정도 앱 프레임이 뜨지 않고 로드됩니다. 게임 퍼포먼스상으로는 차이가 없습니다. 클래스명, 함수명, 변수명 난독화는 필수적으로 선행되어야합니다.

마치며

치트엔진의 모노데이터콜렉터 기능이 매우 치명적입니다. IL2CPP 방식의 유니티 앱은 여러 모듈을 사용하고 모듈 간 통신이 필수적이기 때문에 허용되지 않은 모듈에서 역시 export된 기능을 쉽게 사용할 수 있습니다. 특히 모노 데이터 콜렉터는 그 점을 확실히 인지하고, 메타데이터 파일과 관계없이 메모리에 로드된 상태의 데이터를 긁어갑니다. 근본적으로 IL2CPP 모듈 부분을 모두 뜯어고치거나, UnityPlayer.dll과 통신하는 방법을 교체 또는 감시하는 방식을 추가해야합니다. 가장 무식하면서도 직관적이라고 생각하는 방법은 Game.exe, GameAssembly.dll, UnityPlayer.dll을 모두 단일 파일로 묶어버리는 것입니다. 코드 라이센스 없이는 힘든 작업이겠죠.

우리가 편의를 위해 상용엔진을 사용하기로 한 이상, 그로부터 발생하는 모든 부분은 트레이드 오프의 연장선에 있습니다. 라이센스를 하지 않으면 코드를 알 수 없는 블랙박스이기 때문에 해킹이 어렵지 않나 생각할 수도 있지만, 코드 라이센스를 하지 않은 모든 게임들은 동일한 파이프라인 위에서 제작되었다는 점을 고려하면 더 취약하다고 볼 수도 있습니다. 특정 유니티 함수의 바이너리 코드 패턴을 찾아내는 방식으로 제작된 치트라면 다른 게임에서도 동작할 수도 있는 여지가 있죠. 그만큼 ‘범용적’으로 공격받기 쉽다는 얘기입니다.

상용 안티치트 솔루션을 사용하지 못하는 이상 저희는 ‘유니티 게임’의 스테레오 타입에서 최대한 벗어나야 합니다. ‘모든 유니티 게임’에서 동작하기위해 설계된 프로그램이 작동하지 않는 것만 해도 공격시도의 90% 이상을 막아낼 수 있다고 생각합니다. 만약 상용 안티치트를 쓰더라도, 이를 게을리해서는 안됩니다. 유니티 특성상 안티치트 역시 C# 레이어에서 실행이 되는 경우라거나 쉽게 그 사용처와 로딩 방식이 드러나기도 합니다. 어떤 안티치트를 사용하고 어떻게 로드되는지 알려진다면 그 비싼 로열티 지출이 있으나 마나한 소비가 됩니다.

게임을 개발하시는 모든 분들이 많은 시간 고생해서 만들어낸 소중한 작업물들을 스스로 보호할 수 있으면 좋겠습니다.