배포될 유니티 앱에서 파일시스템에 존재하는 메타데이터를 숨기는 튜토리얼
본 작업은 기본적인
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.dll
과 global-metadata.dat
를 차례로 선택해주면, 구조체 데이터가 담긴 헤더, 스크립트 심볼 정보들이 담긴 json
데이터, String Literal
이 담긴 json
, CPP
코드로 변환되기 전의 구조였을것으로 예상되는 C#
신택스의 클래스 구조, 그리고 이를 묶은 더미 DLL
들을 생성해줍니다.
이렇게 생성된 파일들에서 dump.cs
를 열어보면 C#
문법의 클래스구조가 노출되고 이를 이용해 클래스를 재구성할 수 있습니다.
위는 global-metadata.dat
에서 추출한 클래스구조, 아래는 그를 기반으로 생성한 구조체입니다.
이렇게 재구성된 클래스로 게임을 공격하는 방법에 대해서는 이 글에서 다루었습니다.
대응
누차 말씀드리지만 완벽한 방법은 아니란 것을 항상 기억해주세요.
저희는 IL2CPP
의 코드를 수정해 이 global-metadata.dat
파일을 숨길 예정입니다. 어떤 작업을 할 지만 읽고도 어떻게 작업할지 감이오시는 분들을 위해 먼저 작업 요약을 작성했습니다.
Project Settings
-Player
-Other Settings
-Configuration
-Scripting Backend
를IL2CPP
로 선택File
-Build Settings
-Create Visual Studio Solution
옵션을 키고 빌드- 생성된
global-metadata.dat
파일을char array
로 변환 - 생성된 솔루션의
libil2cpp\vm\MetadataLoader.cpp
수정해서 변환된char array
하드코딩 Master Configuration
옵션으로 빌드
1. 프로젝트 추출 옵션으로 빌드
빈 프로젝트로 진행하기엔 약간 심심하니 에셋 스토어의 무료 에셋을 하나 다운로드 받아 빌드에 사용합니다.
Create Visual Studio Solution 옵션으로 빌드하면 아래처럼 파일시스템이 생성됩니다.
솔루션에는 4개의 프로젝트가 솔루션 안에 생성됩니다.
Il2CppOutputProject
: 생성된 코드 프로젝트입니다. 대부분의 작업은 여기에서 진행됩니다.IL2CPP
: 에디터 버전에 종속되어 설치된il2cpp
모듈의 일부를 그대로 복사해옵니다.Source
: 작성한C#
코드가CPP
코드로 생성되어 추가됩니다.
UnityPlayerStub
:UnityPlayer.dll
이 됩니다. 하지만 코드를 수정해도 반영되지 않습니다. 이미 빌드되어있는DLL
파일을 카피해옵니다.SafeMetadata
:프로젝트명.exe
으로 컴파일됩니다.wWinMain
에 코드를 작성하면 컴파일되어 반영됩니다.
간단하게 콘솔을 띄우는 코드로 확인해볼 수 있습니다.
1 | int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nShowCmd) |
여기에서 return
하는 UnityMain(...)
은 UnityPlayerStub
의 Main
입니다. 그러니까 프로그램 실행 순서는, exe
- UnityPlayer.dll
- GameAssembly.dll
이 됩니다. 이를 이용해서 어느 타이밍에 어떤 장치를 심을지 결정하시면 됩니다.
2. 메타데이터를 바이너리로 변환
빌드에서 생성되는 global-metadata.dat
파일을 로드해 임의의 byte array
로 변환을 거칩니다. 저는 빌드 이벤트에서 진행하는 방식을 택했습니다.
1 | private const string BIN = "build\\bin\\"; |
간략하게 빌드 직후에 호출되는 콜백을 이용했습니다. 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.dll
의 EntryPoint
부터 메타데이터 로드하는 곳 까지는 그렇게 복잡하지 않습니다. 초기화 흐름대로 함수를 따라가다보면 금방 발견하기 때문에 큰 의미를 가지지는 못합니다.
한 가지 배리에이션의 예시로 제가 작업했던 프로젝트를 소개하겠습니다.
xor
암복호화를 이용해, 한 번 더 꼬아 작업했습니다. 보안상의 이유로 코드는 따로 작성하지 않겠습니다. global-metadata.dat
를 매개변수로 받는 부분을 수정하지 않고 그대로 받았습니다. 그리고 파일을 열고, 메모리에 로드했다가 언로드하는 부분의 코드도 주석처리 하지 않았고, xor
연산의 키로, global-metadata.dat
라는 스트링을 사용했습니다.
정적 분석 도구에서 보면 매개변수로 받고도 사용하지 않았다는 점은 쉽게 파악이 가능합니다. 이와 같은 흐름을 파악하게 되면 공격자는 무언가 다른 로드 방식이 숨겨져있을 거라고 생각할 것입니다. 이를 이용해 정상적인 메타데이터 로드를 하는 것처럼 파라미터를 사용하면서도, 실제로 앱과 함께 배포되는 메타데이터 파일은 완전히 더미 데이터입니다. 디버거를 붙인다고 해도, IO도 발생했고 포인터로 액세스하며 메모리에 올라갔다 내려가는 걸 확인할 수 있기 때문에 신경써서 살펴보지 않으면 껍데기일 뿐이라는 것을 눈치채지 못하고 넘어가는 경우가 많습니다. 마지막으로는 상용 패킹 도구를 이용, DLL을 한 번 더 패킹해주면서 어리숙한 정적 분석으로부터 보호받습니다.
5. 자동화
방법 1 - il2cpp 빌드 모듈을 수정
사용중인 에디터 버전의 설치경로에는 il2cpp
라는 이름의 폴더에 빌드 모듈이 그대로 위치해있습니다. 여기 있는 코드를 미리 수정해두어 빌드 때 마다 여기에서 헤더와 소스 파일을 카피해도록 하는 것입니다. 해당 버전을 사용하는 모든 프로젝트가 수정된 빌드 모듈을 공유해야한다는 단점(장점일 수도 있지요)이 있습니다. 빌드슬레이브에서는 사용해 볼 만한 선택지입니다.
방법 2 - 스크립트로 오버라이드
권장하는 선택지입니다. 포스트 빌드 콜백에 정의했던 메타데이터 컨버터 스크립트처럼, 빌드 이벤트를 받아 오버라이드하는 방식입니다.
1 | private static void CopyCustomScripts_Windows(string pathToBuiltProject) |
미리 수정한 헤더와 소스 파일을 프로젝트에 포함시켜두고, 빌드 이벤트로 카피해 덮어쓰기하는 방식입니다.
여기까지가 메타데이터를 숨기기위해 사용되는 필수적인 수정사안들에 대한 자동화 제안입니다. 이외에 추가로 구현할 수 있는 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.dll
와 UnrealEngine.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#
레이어에서 실행이 되는 경우라거나 쉽게 그 사용처와 로딩 방식이 드러나기도 합니다. 어떤 안티치트를 사용하고 어떻게 로드되는지 알려진다면 그 비싼 로열티 지출이 있으나 마나한 소비가 됩니다.
게임을 개발하시는 모든 분들이 많은 시간 고생해서 만들어낸 소중한 작업물들을 스스로 보호할 수 있으면 좋겠습니다.