안드로이드 게임 해킹 - 게임 코드 변조 下

Subtitle : Modify CIL Code during Run-time in Mono


전 장에서는 '보안 솔루션이 적용되지 않은' 유니티 기반의 안드로이드 게임에서의 게임 코드 변조를 다뤘었다. 이번 장에서 다루고자 하는 것은 '보안 솔루션이 적용 된' 유니티 기반의 안드로이드 게임에서의 게임 코드 변조인데, '보안 솔루션이 적용되지 않은' 것과 무슨 차이가 있는지부터 알아보도록 하자.


보안 솔루션이 적용되지 않은? 보안 솔루션이 적용된?


전 장에서는 게임 코드 변조를 위해 dnSpy를 이용하여 DLL 파일 내의 코드를 변조한 후, 변조된 DLL 파일을 원본 DLL 파일과 바꿔치기하는 방법을 사용했었다. 그렇다면 동일한 방법을 '보안 솔루션이 적용된' 것에 적용하면 어떻게 될까?



그림 1. DLL 파일 변조 탐지


내가 의뢰를 맡은 보안 솔루션의 경우, DLL 파일을 변조해서 실행한 결과 그림 1과 같이 변조가 탐지되어 게임이 종료되었다. 이는 비단 내가 맡은 보안 솔루션 뿐만이 아닌, 세상에 존재하는 모든 보안 솔루션이 위와 같은 결과를 내뱉을 것이다. 코드 변조를 탐지하는 것은 보안 솔루션이 해야할 아주 기본적인 기능 중 하나이기 때문에. 그렇다면 '보안 솔루션이 적용된' 유니티 기반의 안드로이드 게임에서의 게임 코드 변조는 어떻게 할 수 있을까?


방법 1. DLL 파일 변조 탐지 로직 제거


내가 의뢰를 맡은 보안 솔루션이 적용된 게임의 경우, apk/lib 폴더에 관련 보안 모듈(.so)이 존재했었다. 분명 해당 보안 모듈에서 DLL 파일 변조를 탐지하는 로직이 있을 것이고, 해당하는 로직을 찾아 동작하지 않게 만들면 그림 1과 같이 DLL 파일 변조가 탐지되지 않을 것이다. 하지만 해당 로직을 찾기에는 보안 모듈이 너무 거대했고, 온갖 난독화와 암호화가 적용되어 있었기에 좀 더 쉬운 방법을 찾아야만 했다.


방법 2. Mono 가상 머신 조작


자바에서 class 파일이 JVM(Java Virtual Machine) 위에서 실행되듯이, C#에서는 DLL 파일이 Mono 가상 머신위에서 실행된다. 좀 더 자세히 설명하면, 전 장에서도 언급했지만 자바나 C#이나 실행 파일을 배포할 때 각 유저의 실행 환경에 맞게 여러 버전의 실행 파일들을 만들어서 배포하는 것이 아닌, 모든 실행환경에서 동작 가능한 '단 하나'의 플랫폼 독립적인 실행 파일을 만들어서 배포한다. 이렇게 배포된 실행 파일들은 플랫폼에 독립적이기 위해 x86 instruction이나 ARM instruction과 같은 네이티브 코드가 아닌, 중간 언어로 구성된다(자바의 경우 bytecode로 구성된 class 파일, c#의 경우 CIL(Commen Intermediate Language)로 구성된 DLL 파일). 가상 머신은 이렇게 배포된 실행 파일을 특정 환경에 맞게 네이티브 코드로 컴파일하고 실행하는 역할을 한다. 그렇다면 가상 머신 위에서 중간 언어가 네이티브 코드로 컴파일되는 과정에 개입할 수 있다면, DLL 파일을 변조하지 않고도 게임 코드를 변조할 수 있지 않을까? 이러한 가정 하에 연구를 진행하였다.


Mono 가상 머신 분석


위에서 설명했다시피 Mono는 CIL로 구성된 DLL 파일을 특정 환경에 맞게 네이티브 코드로 컴파일하고, 실행하는 가상 머신이다. 이러한 가상 머신 위에서 CIL이 네이티브 코드로 컴파일되는 과정에 개입하기 위해, Mono가 내부적으로 어떠한 방식으로 CIL 코드를 컴파일하며, 어떠한 방식으로 컴파일된 네이티브 코드를 실행하는지부터 알아야했다. 이를 위해 Mono를 분석해야했는데, 다행히도 Mono가 오픈소스였기 때문에 비교적 쉽게 분석을 진행할 수 있었다.


분석 결과 1. Mono는 처음 실행될 때 DLL 파일에 존재하는 모든 함수들의 CIL 코드를 읽어둔다.


Mono는 처음 실행될 때, DLL 파일에 존재하는 모든 함수들에 대해 MonoMethodHeader 구조체를 생성한 후, 해당 함수의 CIL 코드를 code_size 변수와 code 변수에 저장해둔다.


struct MonoMethodHeader

{

    ...

    guint32      code_size;

    const unsigned char  *code;

    ...

}; 


분석 결과 2. Mono는 컴파일된 각 함수의 네이티브 코드를 해시 테이블 형태로 보관한다.


Mono는 특정 함수의 CIL 코드를 컴파일하면, 컴파일된 네이티브 코드를 jit_code_hash 변수에 해시 테이블 형태로 저장해둔다.


struct _MonoDomain

{

    … 

    MonoInternalHashTable      jit_code_hash;

    …

}; 


분석 결과 3. CIL 코드의 컴파일 및 실행과정


DLL 파일 내에 아래와 같은 함수들이 있다고 하자. caller1 함수와 caller2 함수는 0을 인자로 받았을 때에만 callee 함수를 호출한다. caller1(1), caller1(0), caller2(1), caller2(0) 이 차례대로 호출된다고 가정했을 때, Mono에서 어떠한 방식으로 이들 함수를 컴파일하고, 실행하는지 설명하겠다.


int caller1(int param)

{

    if(param == 0)

        return callee();

    return 0;

}


int caller2(int param)

{

    if(param == 0)

        return callee();

    return 0;

}


int callee()

{

    return 1;

}


① caller1(1) 호출


caller1 함수가 호출되면 Mono는 먼저 jit_code_hash 변수를 확인하여, 해당 해시 테이블에 caller1 함수가 컴파일된 네이티브 코드가 있는지 확인한다. 현재 caller1 함수는 처음 호출된 상태이므로 해시 테이블에는 caller1 함수가 컴파일된 네이티브 코드가 없을 것이고, Mono는 이를 확인한 후 caller1 함수를 컴파일하여 네이티브 코드를 생성한다. Mono는 생성된 네이티브 코드를 해시 테이블에 저장한 후 실행한다. 이 때까지 생성된 네이티브 코드와 해시 테이블을 메모리 상에 나타내면 그림 2와 같다. 그림 2에서 특히 주목해야 할 것은, caller1 함수가 컴파일된 네이티브 코드다. callee 함수를 호출하는 부분이 call callee의 형태가 아닌, call trampoline(callee)의 형태로 컴파일된 것을 볼 수 있다.



그림 2. caller1(1)이 호출된 후 메모리 상황


② caller1(0) 호출


Mono는 jit_code_hash 변수를 확인하여, 해당 해시 테이블에 caller1 함수가 컴파일된 네이티브 코드가 있는지 확인한다. ① 에서 caller1 함수를 한 번 호출했기 때문에 해시 테이블에는 caller1 함수가 컴파일된 네이티브 코드가 있는 상태고, Mono는 이를 확인한 후 해당 네이티브 코드를 실행한다. 다만 이번에는 인자 0을 가지고 호출되었으므로, callee 함수가 실행된다. 즉, 그림 2에서의 call trampoline(callee) 가 실행된다. 여기에서 trampoline은 Mono 모듈(libmono.so) 내에 구현된 함수인데, trampoline 은 인자로 넘어온 함수를 네이티브 코드로 컴파일한 후에 그림 2에서의 call trampoline(callee)를 그림 3에서의 call 0x2000 으로 바꿔주는 역할을 한다(0x2000은 callee 함수가 컴파일된 네이티브 코드의 위치다). 물론 trampoline을 통해 callee 함수가 컴파일될 때에도 Mono는 해시 테이블에 callee 함수가 컴파일된 네이티브 코드가 있는지 확인하고, 없다면 컴파일 후 생성된 네이티브 코드를 해시 테이블에 저장해둔다. 이 때까지 생성된 네이티브 코드와 해시 테이블을 메모리 상에 나타내면 그림 3과 같다.



그림 3. caller1(0)이 호출된 후 메모리 상황


③ caller2(1) 호출


caller2 함수가 호출되면 Mono는 jit_code_hash 변수를 확인하여, 해당 해시 테이블에 caller2 함수가 컴파일된 네이티브 코드가 있는지 확인한다. 현재 caller2 함수는 처음 호출된 상태이므로 해시 테이블에는 caller2 함수가 컴파일된 네이티브 코드가 없을 것이고, Mono는 이를 확인한 후 caller2 함수를 컴파일하여 네이티브 코드를 생성한다. Mono는 생성된 네이티브 코드를 해시 테이블에 저장한 후 실행한다. 이 때까지 생성된 네이티브 코드와 해시 테이블을 메모리 상에 나타내면 그림 4와 같다. ① 에서와 마찬가지로 그림 4에서 특히 주목해야 할 것은, caller2 함수가 컴파일된 네이티브 코드다. callee 함수를 호출하는 부분이 call callee의 형태가 아닌, call trampoline(callee)의 형태로 컴파일된 것을 볼 수 있다.



그림 4. caller2(1)이 호출된 후 메모리 상황


④ caller2(0) 호출


Mono는 jit_code_hash 변수를 확인하여, 해당 해시 테이블에 caller2 함수가 컴파일된 네이티브 코드가 있는지 확인한다. ③ 에서 caller2 함수를 한 번 호출했기 때문에 해시 테이블에는 caller2 함수가 컴파일된 네이티브 코드가 있는 상태고, Mono는 이를 확인한 후 해당 네이티브 코드를 실행한다. 다만 이번에는 인자 0을 가지고 호출되었으므로, callee 함수가 실행된다. 즉, 그림 4에서의 call trampoline(callee) 가 실행된다. trampoline 은 인자로 넘어온 callee 함수가 이미 컴파일되어서 네이티브 코드로 존재하는지 확인하기 위해 해시 테이블을 확인한다. ② 에서 이미 callee 함수를 한 번 실행했기 때문에 컴파일된 네이티브 코드가 존재하므로 trampoline은 callee 함수를 새로 컴파일 하지 않고, 기존에 컴파일된 네이티브 코드의 위치인 0x2000을 이용해 그림 4에서의 call trampoline(callee)를 그림 5에서의 call 0x2000으로 바꿔주는 역할만 한다. 이 때까지 생성된 네이티브 코드와 해시 테이블을 메모리 상에 나타내면 그림 5와 같다.



그림 5. caller2(0)이 호출된 후 메모리 상황


분석 결과를 활용한 코드 변조


변조하고자 하는 함수 callee 가 한 번도 호출되지 않은 상태라면, 단순히 함수 callee 에 해당하는 code 변수와 code_size 변수를 원하는 CIL 코드로 바꾸면 게임 코드 변조가 그대로 적용이 된다. 하지만 변조하고자 하는 함수 callee가 한 번이라도 호출된 상태라면, 이를 변조하는 것은 꽤나 까다롭다. 이를 위해 우선, callee 함수에 해당하는 code 변수와 code_size 변수를 원하는 CIL 코드로 바꾼 후, callee 함수가 컴파일된 네이티브 코드를 해시 테이블로부터 제거한다. 그 후 callee 함수를 호출하는 caller1 함수가 컴파일된 네이티브 코드를 해시 테이블로부터 제거하면 게임 코드 변조가 적용이 된다. 그림 6을 보면 컴파일된 네이티브 코드가 해시 테이블로부터 지워지고, 새로이 함수가 컴파일되고, 실행되면서, 변조가 적용되는 과정을 좀 더 명확하게 이해할 수 있을 것이다. 



그림 6. 코드 변조 과정


다만, 그림 6을 자세히 보면 caller1 함수로부터 호출되는 callee 함수의 경우 변조된 코드가 적용되었지만, caller2 함수로부터 호출되는 callee 함수의 경우 변조된 코드가 아닌 옛날 코드가 실행되고 있음을 알 수 있다. 즉, 특정 함수의 코드를 변조하고자 한다면 해당 함수를 호출하는 모든 경로에 대해 위 과정을 반복해줘야 한다.