안드로이드 게임 해킹 - 어떻게 함수를 후킹할 것인가

전 장에서, 스피드핵을 구현하기 위해 총 두 가지 일을 해야 하는 것을 알아보았다.


① 어떻게 함수를 후킹할 것인가

② 무슨 함수를 후킹할 것인가


이번 장에서는 '어떻게 함수를 후킹할 것인가'를 다뤄보고자 한다.


안드로이드에서 함수를 후킹하는 방법은 여러 가지가 있다. 하지만 첫 장에서 언급했듯이 내가 하고자 하는 것은 '일반 어플리케이션'에 대한 해킹이 아닌, '보안 솔루션이 적용된 어플리케이션'에 대한 해킹이기 때문에 널리 사용되고 있는 후킹 기법은 사용할 수 없었다. 즉, 해킹을 성공적으로 하기 위해서 아무도 안 쓸 법한, 그리고 보안 솔루션에 의해 막혀있지 않을 법한 아주 마이너한 후킹 기법을 찾아내야만 했다.

(물론 보안 솔루션 자체를 분석하여 이를 우회하는 방법 또한 있지만, 난이도가 너무 높아 공격 방식을 다양화하는 방향으로 연구를 진행하였다)


결론부터 말하자면 하나의 마이너한 후킹 기법을 찾아내는데 성공하였고 이를 구현하여 '보안 솔루션이 적용된 어플리케이션'에 대해 후킹을 성공하였다. 이 후킹 기법을 설명하기 전에, 비록 보안 솔루션에 의해 방어되고 있지만 열심히 찾았기에.. 알아낸 여러 가지 후킹 기법들을 간략하게 언급하고 가겠다.


① 게임 프로세스에 so 파일 인젝션 후, reloc table 변경

https://www.evilsocket.net/2015/05/04/android-native-api-hooking-with-library-injecto/


② 쉘 명령어를 이용한 LD_PRELOAD 환경 변수 설정

https://cedricvb.be/post/intercepting-android-native-library-calls/


③ 게임 프로세스에 attach 후, 함수 시작 코드를 jmp myfunction 로 변경


위 방법들에 대한 자세한 내용은 첨부한 링크를 참조바란다.


이제 '보안 솔루션이 적용된 어플리케이션'에 대해 후킹을 성공한, 아주 마이너한 후킹 기법을 설명하겠다. 


기반 1. LD_PRELOAD 환경 변수


LD_PRELOAD는 리눅스에서 사용되는 환경 변수 중 하나로, so 파일 목록을 정의할 수 있다. 이는 dynamic linker가 제공하는 기능 중 하나로써, dynamic link 과정에서 함수 심볼을 찾을 때 LD_PRELOAD에 정의된 so 파일에서부터 심볼을 찾게 된다. 이러한 특성을 이용하여 LD_PRELOAD는 일반적으로 다른 so 파일에 있는 함수를 덮어씌우기 위해 사용된다. 예를 들어 printf 함수를 호출할 경우, 일반적인 환경에서는 libc.so에 정의된 printf 함수가 실행된다. 하지만 printf 이름의 함수를 가진 fakelibc.so 파일을 만든 후, LD_PRELOAD에 'fakelibc.so'를 정의하면 libc.so에 정의된 printf 함수가 아닌 fakelibc.so에 정의된 printf 함수가 실행된다. LD_PRELOAD에 관련한 자세한 내용은 아래 링크를 참조 바란다.

http://man7.org/linux/man-pages/man8/ld.so.8.html

https://blog.cryptomilk.org/2014/07/21/what-is-preloading/


기반 2. 환경 변수가 설정되는 과정


부모 프로세스는 자식 프로세스를 생성할 때 자신이 가진 환경 변수를 그대로 물려준다. 즉, 자식 프로세스는 부모 프로세스의 환경 변수를 그대로 상속받는다. 이 과정에 대해 좀 더 자세히 알아보도록 하자.

(환경 변수는 프로세스 단위로 관리된다)


리눅스에서 프로세스를 생성할 때 fork 함수를 통해 부모 프로세스를 복제하고, 복제된 프로세스에서 exec 함수를 호출해 생성하고자 하는 프로세스를 로딩한다는 사실은 다들 잘 알고있을 것이다. 여기서 주목해야 할 것은 exec 함수이다.


int execve(const char *filename, char *const argv[], char *const envp[]);


정확히는 execve 함수인데, 위의 함수 원형에서 세 번째 인자를 보면 envp 이름의 문자열 배열을 받고 있는 것을 알 수 있다. 부모 프로세스는 바로 이 세 번째 인자, envp 문자열 배열을 통해 자식 프로세스에게 환경 변수를 물려준다.


기반 3. 안드로이드에서의 프로세스 구조


안드로이드에서 실행되는 모든 어플리케이션은 zygote 프로세스를 부모로 가진다. 그리고 zygote 프로세스는 안드로이드에서의 가장 최상위 부모 프로세스인 init 프로세스를 부모로 가진다. 이를 도식화하면 아래와 같다.




그림 1. 안드로이드 프로세스 구조


여담인데, 리눅스에서는 쉘에서 export 명령어를 사용해 쉘 프로세스의 LD_PRELOAD 환경 변수를 설정한 후($ export LD_PRELOAD=fakelibc.so), 해당 쉘에서 프로세스를 실행하면 생성된 프로세스는 LD_PRELOAD 환경 변수가 설정된 채로 실행된다. 즉, 아주 쉽게 후킹을 할 수 있다. 하지만 안드로이드의 경우 우리가 후킹을 하고자 하는 프로세스는 zygote 프로세스의 자식 프로세스로써 존재하기 때문에 단순히 쉘에서 export 명령어를 사용하는 방법은 사용할 수 없다. 쉘에서 암만 환경 변수를 설정해봤자, 후킹하고자 하는 프로세스는 쉘 프로세스가 아닌 zygote 프로세스 밑에서 태어나니까.


기반 4. zygote 프로세스가 종료될 때 생기는 현상


안드로이드가 정상적으로 실행되고 있다면, zygote 프로세스가 종료될 일은 없다. 하지만 버그 등의 이유로 zygote 프로세스가 종료될 경우, 어디선가 이를 탐지하여 zygote 프로세스를 다시 실행한다. 


zygote 프로세스의 경우 위에서 설명했다시피 init 프로세스를 부모 프로세스로 가진다. 즉, zygote 프로세스가 종료되면 init 프로세스에서 fork 함수가 호출되고, 복제된 init 프로세스에서 execve 함수를 호출하여 zygote 프로세스를 로딩한다.


후킹 과정 및 구현 방법


위에서 설명한 4개의 기반지식을 활용하여, 후킹을 진행한다. 후킹 과정은 아래와 같다. 이탤릭체로 쓰여진 부분은 구현과 관련된 내용이기 때문에 구현을 해보고 싶지 않다면 굳이 읽어 볼 필요는 없다.

(ptrace 함수가 자주 사용되기 때문에 구현하고자 한다면 다음 링크를 한 번 읽어보기바란다)

http://man7.org/linux/man-pages/man2/ptrace.2.html


① init 프로세스에 attach 후 (루트 권한 필요),


이 때 사용되는 함수는 ptrace 이며, 사용되는 ptrace 함수의 인자는 PTRACE_ATTACH 이다.


② zygote 프로세스를 죽인다.


이 때 사용되는 함수는 kill 이며, 사용되는 kill 함수의 인자는 SIGKILL 이다.


③ init 프로세스에서 호출되는 시스템 콜들을 보다 보면, zygote 프로세스를 생성하기 위해 fork 시스템 콜을 호출하는 순간을 포착하여, 생성되는 pid를 알아낼 수 있다.


ptrace 함수에 PTRACE_SYSCALL 인자를 주면 디버기 프로세스에서 시스템 콜을 호출할 때 마다( + 호출이 끝날 때 마다) 디버거로 제어권이 넘어오기 때문에, 이를 이용하여 fork 시스템 콜이 호출되는 순간을 포착할 수 있다. 좀 더 자세히 설명하자면, 디버기에서 시스템 콜이 호출되어 디버거로 제어권이 넘어올 때 디버기의 레지스터를 확인해보면 어떠한 시스템 콜이 어떠한 인자를 가지고 호출되었으며, 어떠한 결과값을 가지는지 알 수 있다. x86의 경우 eax 레지스터에 실행하고자 하는 시스템 콜 번호가 저장되고, ebx, ecx, edx, esi, edi, ebp 레지스터에 차례대로 인자들이 저장된다. 그리고 결과값은 eax 레지스터에 저장된다. 관련하여 좀 더 자세하게 알고싶으면 system call convention 을 키워드로 검색해보기 바란다(calling convention과는 다르다).


추가로, init 프로세스에서 fork 시스템 콜이 호출되었다고 그게 항상 zygote 프로세스 생성만을 위한 것은 아니기 때문에 이를 구분할 필요가 있다. 현재 Bluestacks 2.6 환경에서 작업하고 있는데, 여기서는 zygote 프로세스가 생성될 때 fork 시스템 콜이 호출되기 전에 항상 '/system/bin/app_process' 문자열을 인자로 가지는 stat64 시스템 콜이 먼저 호출된다. 나의 경우 이를 단서로 구분하였는데, 환경에 따라 다를 수 있으니 zygote 프로세스를 죽인 후 init 프로세스에서 호출되는 시스템 콜들을 모두 확인하여 해당 환경에서 사용할 수 있는 단서를 찾아보기 바란다.


마지막으로, 구현에 있어 내가 실수했던 부분이 있어 짧게 적어둔다. 자식 프로세스는 죽을 때 부모 프로세스에게 자기가 죽었다는 의미의 SIGCHLD 신호를 보내고, 부모 프로세스는 이렇게 보내진 SIGCHLD 신호를 받아 해당하는 핸들러를 호출해 관련된 리소스를 정리한다. 하지만 부모 프로세스가 디버깅되고 있을 경우, SIGCHLD 신호를 받으면 부모 프로세스 본인이 이를 처리하지 않고 디버거에게 제어를 넘겨버린다(디버거-디버기 관계에서 디버기에 예외가 발생하면 디버기는 예외를 직접 처리하지 않고 디버거에게 제어를 넘겨버린다). 즉, 부모 프로세스인 init 프로세스가 디버깅되고 있을 때 자식 프로세스인 zygote 프로세스가 죽으면 SIGCHLD 신호를 부모 프로세스인 init 프로세스가 처리하지 않고 디버거에게 제어를 넘겨버린다. 나의 경우 이렇게 넘어온 제어를 무시한 채 구현하였는데, 이렇게 하니 SIGCHLD 신호가 적절히 처리되지 못해서 죽어 사라져야할 zygote 프로세스가 사라지지 않고 좀비 상태로 남아 새로운 zygote 프로세스가 생성되는 것을 막는 문제가 발생하였다. 이를 염두에 두고 구현하기 바란다.


④ 생성된 zygote 프로세스에 attach 후,


이 때 사용되는 함수는 ptrace 이며, 사용되는 ptrace 함수의 인자는 PTRACE_ATTACH 이다. pid 값은 과정 ③에서 얻은 pid 값을 사용하면 된다.


⑤ execve 시스템 콜이 호출되는 순간을 포착하여, execve 시스템 콜의 세 번째 인자인 문자열 배열, 즉 환경 변수 배열에 LD_PRELOAD=fakelibc.so 문자열을 추가한다.


execve 시스템 콜이 호출되는 순간을 포착하는 방법은 과정 ③에서 사용한 방법과 동일하다. 세 번째 인자는 edx 레지스터를 통해 전달되고, esp 레지스터를 확인하면 스택의 위치를 알 수 있으므로 스택의 빈 공간에 문자열 값을 넣고 그 주소를 가리키도록 인자 값을 잘 조작하면 된다.

(나는 편의를 위해 esp - 2048 주소에 문자열과 배열 값들을 넣어두었는데, 좀 더 정확하게 하고 싶다면 /proc/pid/maps 파일을 참조하여 스택 공간을 계산한 후 구현해도 좋을 것이다)


⑥ zygote 프로세스는 LD_PRELOAD=fakelibc.so 환경 변수를 가지고 태어나게 되고, 그 이후에 생성되는 어플리케이션들 또한 부모 프로세스인 zygote 프로세스의 환경 변수를 그대로 물려받게된다.


일반적으로 보안 솔루션이 적용된 어플리케이션에 Attach를 할 순 없다. 하지만 보안 솔루션이 init 프로세스와 zygote 프로세스에 Attach 하는 것 까지 막아줄 수는 없다. 적어도 내가 연구를 맡은 보안 솔루션은 그랬다. 그 덕분에 위에서 서술한 후킹 기법을 성공적으로 사용할 수 있었다.


* 추가로 위 기법의 한계를 짧게 덧붙이자면, LD_PRELOAD 환경 변수를 이용한 후킹 방식은 so 파일이 export하고 있는 함수에 대해서만 후킹을 할수 있고, dynamic link 방식으로 링크되어 호출되는 함수에 대해서만 후킹이 되는 단점이 있다.