Unity IL2CPP 분석
Unity IL2CPP
Unity는 쉬운 C#의 사용, 써드파티 라이브러리에 대한 접근 등의 이유로 Mono를 사용했습니다. 그러나 Mono에는 몇 가지 난제가 존재합니다.
- C# runtime performance가 C/C++에 비해 여전히 느리다는 점.
- 더 뛰어난 최신 버전의 .NET 언어를 Unity Mono에서 지원하기 힘들다는 점.
- 다양한 플랫폼들의 아키텍처에 맞추려면 포팅, 유지보수 등의 작업이 필요한데 너무 많은 노력이 든다는 점.
- 실행 중에는 Garbage collection이 멈출 수도 있다는 점.
위의 문제점들을 해결할 수 있다며 Unity에서 내세운 기술인 IL2CPP에 대해 살펴보도록 하겠습니다.
Background
IL2CPP의 등장은 기존 Mono 방식의 단점을 개선하기 위함뿐만 아니라 웹과도 관련이 있습니다. Unity에서는 어떤 Plugin의 설치도 없이, 웹 브라우저에서도 Unity Content들을 즐길 수 있도록 하기 위해서, WebGL이라는 3D graphic rendering library를 채택하였습니다. 다만 웹 브라우저에서는 오직 Javascript만을 사용하는 만큼, 해당 라이브러리 역시 Javascript API를 사용하였고, Unity 역시 코드가 Javascript로 구성되어야만 했습니다.
c/c++로 만들어진 Unity runtime code의 경우 emscripten compiler toolchain을 이용하여 Javascript 코드로 cross-compile을 진행할 수 있었지만, 사용자가 작성한 C# 코드나 UnityScript scripts 코드와 같은 .NET game 코드들을 대상으로는 cross-compile이 불가능했습니다. Unity는 IL2CPP라는 신기술을 이용하여 .NET bytecode를 해당하는 c++ source file로 변환해줌으로써 이 문제를 해결하였습니다.
또한, IL2CPP가 많이 사용된 이유 중 IOS와 관련된 이유 역시 존재합니다. Unity Mono는 arm64(armv8)을 지원하지 못하는데, IOS 앱 스토어에 apk 등록 시 armv7과 armv8 두 버전을 모두 등록해야 하는 정책이 생긴 것과, IOS가 JIT 컴파일 방식을 지원하지 않음에 따라서 AOT 컴파일 방식을 사용하는 IL2CPP가 대두되었습니다. 현재 구글 플레이 역시 두 버전 모두 등록해야 하는 정책이 생겨 앞으로 mono는 더욱 더 사용이 되지 않을 것으로 보입니다. 위처럼 IL2CPP은 다양한 곳에서 활용되고 있습니다.
What is IL2CPP?
IL2CPP 방식은 두 개의 구성요소로 나뉩니다. 앞서 언급했던 AOT(Ahead Of Time) 컴파일러와 가상머신을 지원하는 runtime library입니다. AOT 컴파일러는 .NET 컴파일러의 IL을 C++ native code로 변환해주고, runtime library는 garbage collector, 쓰레드 및 파일에 대한 접근, 내부 호출 등에 대한 서비스와 추상화를 제공합니다. 두 가지 구성요소에 대해 알아보며 IL2CPP에 대해 설명하겠습니다.
AOT compiler
IL2CPP AOT 컴파일러의 역할은 il2cpp.exe 유틸리티를 이용함으로써 이루어지며, 해당 파일은 C#으로 작성되고 .NET과 Mono 컴파일러를 이용하여 컴파일된 실행 파일입니다.
해당 유틸리티는 Unity에서 제공하는 Mono 컴파일러로 컴파일된 어셈블리를 input으로 받아들여서 각각의 플랫폼에서 사용되는 컴파일러에게 C++ 코드를 생성하여 전달합니다.
Runtime Library
또 다른 부분은 VM을 지원하기 위한 libil2cpp라는 이름의 runtime library입니다. 우리가 libil2cpp를 호출했을 때, 해당 라이브러리는 player executable에 링크된 정적 라이브러리 형태로 제공됩니다. Mono에서 Il2cpp로 넘어오면서 생긴 가장 큰 feature 중 하나입니다.
Analysis
Summary
Il2Cpp 방식에서는 스트링, 메소드 정보 등을 직접 관리하지 않고, global-metadata.dat이라는 파일에 저장하여 관리합니다. 바이너리 실행 시 해당 파일을 메모리에 적재하여 배열로 관리하게 되는데, 프로세스는 해당 배열을 읽어 들여서 스트링 및 메소드 정보에 접근합니다. 아래의 예시는 함수 내에서 스트링 리터럴에 접근하는 모습입니다.
Analysis Initialization
다음은 Libil2cpp.so 내에서 존재하는 Global-metadata.dat 파일을 메모리에 로드하고, 스트링, 메소드를 찾아 사용하는 동작에 관하여 서술합니다. Il2cpp 자체가 오픈 소스는 아니지만, Unity 에디터가 켜져있는 상태에서는 프로젝트를 빌드할 때 사용했던 cpp 파일을 유지하기 때문에, 해당 파일을 백업하여 분석을 진행하였습니다.
가장 먼저 Runtime 초기화 작업이 수행됩니다. 해당 작업은 Runtime::Init() 함수를 호출하여 수행되며, Image, Thread 등과 관련된 초기화 작업을 진행합니다. Global-metadata와 관련된 작업은 MetadataCache::Initialize()에서 수행됩니다.
MetadataCache::Initialize() 함수에서는 위의 그림과 같이 global-metadata.dat 파일을 메모리에 로드하여 인덱스 기반으로 메소드 및 스트링 데이터를 사용할 수 있도록 준비과정을 수행합니다. 다음의 MetadataLoader::LoadMetadataFile()을 이용하여 지정한 위치에 존재하는 global-metadata.dat 파일을 메모리에 할당시킵니다.
이 후, 시그니처와 버전 정보를 확인한 다음 스트링, 메소드 등의 정보를 다음 그림과 같은 Global-metadata 파일 포맷을 사용하여 앞서 선언해 놓은 테이블들에 저장하는 과정을 수행합니다.
Il2CppGlobalMetadataHeader에 대한 정보는 il2cpp-metadata.h에서 찾을 수 있습니다.
String 데이터를 가져오는 과정
Global-metadata.dat 파일의 데이터에서 스트링 리터럴(string literal) 데이터를 읽어오는 과정은 다음의 GetStringLiteralFromIndex() 함수를 통해 이루어집니다.
이 함수는 인덱스를 기반으로 동작하며, 스트링 포인터를 관리하는 배열인 s_StringLiteralTable을 사용합니다. 테이블에 등록된 스트링이 존재한다면 바로 리턴을 수행하고, 그렇지 않을 경우 s_GlobalMetadata를 참조하여 스트링을 읽어 들인 뒤 s_StringLiteralTable에 저장합니다.
s_GlobalMetadata는 g_MetadataUsages[]를 나타냅니다.
앞서 언급했다시피, 프로젝트를 빌드할 때, Unity에서는 에디터를 닫기 전까지는 tmp 디렉토리에 il2cpp.exe를 이용하여 변환한 cpp파일을 저장하고 있습니다. Mono와 마찬가지로 il2cpp에서도 역시 사용자의 코드는 Bulk_Assembly-CSharp_0.cpp 내에 저장됩니다. 다음은 Kart의 움직임 시작을 담당하는 부분의 함수입니다.
함수가 호출되면 가장 먼저 메소드가 초기화가 되었는지 확인합니다. 처음 호출되었을 경우 il2cpp_codegen_initialize_method()를 호출하여 초기화 과정을 한번 수행합니다.
해당 함수 역시 index(=id) 기반으로 동작하며, 각 메소드에 대한 index 값은Il2CppMetadataUsage.cpp에 정의되어 있습니다.
해당 함수는 MetadataCache::InitalizeMethodMetadata()를 호출합니다.
다음의 함수들은 MetadataCache.cpp에 정의되어 있습니다. InitializeMethodMetadata()에서 전달받은 index를 이용하여 Metadata 내에서의 시작 offset과 길이를 구하고, IntializeMethodMetadataRange()를 호출하여 해당 데이터가 어떻게 사용되는지(ex. 스트링 리터럴인지, 메소드 정의인지… 등) 판단하여 s_Il2CppMetadataRegistration->metadataUsages[]에 저장합니다.
s_Il2CppMetadataRegistration->metadataUsages[]와 g_MetadataRegistration->g_MetadataUsages[]는 다음 함수들의 과정 속에서 이름만 바뀌는 것이기 때문에 실제로 동일한 배열을 나타냅니다.
s_Il2CppCodegenRegistration() -> il2cpp_codegen_register() -> MetadataCache::Register() |
따라서 IL2CPP에서는 스트링 리터럴 데이터를 g_MetadataUsages[] 배열에 저장하여, 사용 시 지정된 index(id)를 통해 불러오는 것을 알 수 있습니다.
클래스 및 메소드 데이터를 읽어오는 과정
해당 과정은 MetadataCache::GetMethodInfoFromMethodDefinitionIndex()를 통하여 이루어집니다. 먼저 GetMethodDefinitionFromIndex()와 GetTypeInfoFromTypeDefinitionIndex()를 이용하여 클래스 정보를 구하고, Class::SetupMethods()를 호출합니다.
Class::SetupMethods()는 SetupMethodsLocked()를 호출하는데, 이 함수 내에서 GetStringFromIndex(), GetMethodPointerFromIndex() 등의 함수가 호출되어 메소드의 이름, Pointer 주소 값 등을 가져오는 것을 알 수 있습니다.
MetadataCache::GetMethodPointerFromIndex() 함수에서는 s_Il2CppCodeRegistration->methodPointers[] 테이블을 이용하여 포인터를 반환하게 되는데, 이 테이블의 경우에도 앞서 스트링 데이터를 가져오는 과정과 비슷하게, 다음의 과정은 분석하면 s_Il2CppCodeRegistration->methodPointers[]가 g_CodeRegistration->g_MethodPointers와 동일하다는 사실을 알 수 있습니다.
s_Il2CppCodegenRegistration() -> il2cpp_codegen_register() -> MetadataCache::Register() |
g_MethodPointers[] 배열은 실행 파일이 생성되었을 때, 함수들의 주소를 갖는 테이블입니다. 이를 이용하여 함수의 이름과 주소를 매칭시키는 것이 가능합니다.
Ref.
https://docs.unity3d.com/kr/2018.1/Manual/IL2CPP.html
https://blogs.unity3d.com/kr/2015/05/06/an-introduction-to-ilcpp-internals/
https://www.nevermoe.com/2016/08/10/unity-metadata-loader/
https://www.nevermoe.com/2016/09/08/%e8%bf%98%e5%8e%9f%e4%bd%bf%e7%94%a8il2cpp%e7%9a%84symbol%ef%bc%88%e4%ba%8c%ef%bc%89/
https://github.com/nevermoe/unity_metadata_loader/blob/v24/unity_decoder/main.cpp
'좀 열심히 쓴 글' 카테고리의 다른 글
Format String field width (0) | 2020.04.09 |
---|---|
[Project Zero] Bad Binder: Android In-The-Wild Exploit 분석글 (0) | 2020.03.07 |
IL2CPP 메타데이터 노출 취약점 대응 방안 (4) | 2020.03.06 |
시스템 수준 입출력(I/O) (0) | 2019.05.17 |
free.c 상세 분석일지 2 (glibc-2.25) (0) | 2019.04.16 |