안드로이드 ARM 후킹 기술 강좌

홈 > 안드로이드 > 안드로이드
안드로이드

안드로이드 ARM 후킹 기술 강좌

M LIN 11 411 4

이번편에는 안드로이드 후킹 기술에 대하여 살펴보려고 합니다. "Hook"이라는 단어만 들었을때에는 갈고리가 먼저 생각이 나는데, 단어 그대로 갈고리로 낚아채는 방법이라고 생각하시면 이해가 쉽습니다. 갈고리 처럼 특정 함수 코드를 가로채서 원하는 행위를 한 뒤 원래의 코드로 돌려주는 기법이 후킹입니다. 예를 하나 들어보겠습니다. 윈도우에서 통신 라이브러리인 "ws2_32.dll"의 함수인 send()를 후킹하면 패킷을 가로채서 인자값을 확인 또는 변조, 전송등을 자기가 하고 싶은 행위들로 직접 조작할 수 있습니다. 또한 send() 함수를 호출되는 코드를 역으로 따라가다보면 암/복호화 로직도 발견할 수 있으며, 패킷을 전송하는 실제 코드까지도 발견할 수 있습니다. 이렇듯 후킹은 다양한 변조가 가능하기 때문에 오래전부터 해커들이 자주 사용하는 기법 중 하나입니다. 사전적인 정의는 위키백과에 다음과 같이 기재되어 있습니다.

후킹(영어: hooking)은 소프트웨어 공학 용어로, 운영 체제나 응용 소프트웨어 등의 각종 컴퓨터 프로그램에서 소프트웨어 구성 요소 간에 발생하는 함수 호출, 메시지, 이벤트 등을 중간에서 바꾸거나 가로채는 명령, 방법, 기술이나 행위를 말한다. 이때 이러한 간섭된 함수 호출, 이벤트 또는 메시지를 처리하는 코드를 후크(영어: hook)라고 한다.

안드로이드 기술을 살펴보기에 앞서, x86 아키텍처에서는 어떠한 방식으로 후킹되는지 간략히 살펴보겠습니다. 인라인 후킹 기준으로 설명 드리겠습니다.

1. 함수의 시작부분 첫 5바이트를 미리 저장 (스택 구성 opcode)
2. JMP(E9 xx xx xx xx)로 변조하고 자기가 만든 코드의 위치를 가리키도록 함
3. 자기가 만든 코드에서 미리 저장해둔 5바이트로 시작하여 원하는 행위를 수행
4. 자기가 만든 함수 수행이 끝났다면 함수 시작부분+5 의 위치로 다시 점프

다음과 같은 순서로 후킹이 진행되며 가장 많이 알려져있는 인라인 후킹 방법입니다. 치트엔진의 Code Injection이 다음과 비슷하게 작동된다고 보시면 됩니다. 이 방법외에도 jmp가 아닌 push~ret opcode를 이용한 인라인후킹도 존재하며 IAT, SSDT 후킹 등 많은 방법들이 있습니다. 이미 해당 자료(문서)등은 인터넷 또는 서적에 공개되어 있으니 직접 확인해보시면 좋습니다.

그렇다면 윈도우(x86)이 아닌 안드로이드(ARM)에서는 어떠한 방식으로 후킹이 작동되는지 살펴보겠습니다. 안드로이드는 우선 리눅스 기반에서 작동이 되기 때문에, 리눅스에서 사용되던 후킹 기술과 유사하다고 보시면 됩니다. 저는 대표적인 두가지 후킹 방법이 어떠한 방식으로 작동되는지 알려드리려고 합니다.

 

1. PLT hook

적용 범위가 제한적이지만 호환성 이슈 없이 안정적으로 후킹할 수 있는 PLT 후킹 기법부터 설명드리겠습니다.

우선 PLT와 GOT가 무엇인지 알고 계셔야 합니다. ELF 프로그램에서 공유 라이브러리 함수를 호출하여 사용할 때 PLT와 GOT를 통해 호출하게 됩니다. 여기서 PLT(Procedure Linkage Table)는 외부 프로시저를 연결하는 테이블로 다른 라이브러리에 포함되어 있는 프로시저를 호출하여 사용할 수 있습니다. GOT(Global Offset Table)는 PLT가 참조하는 테이블이며 실제 사용하는 함수의 주소들을 담는 테이블입니다. 여기서 공유 라이브러리 함수는 libc.so의 함수인 printf(), memcpy()등이라고 생각하시면 됩니다. 컴파일 과정에서 Dynamic Linking 방식을 사용하여 빌드할 경우 실제 라이브러리의 코드들을 포함시키지 않고 공유 라이브러리의 함수들의 실제 함수의 주소를 얻어온 뒤 사용하게 되는 거죠. "함수 포인터를 이용하여 호출한다"라고 생각하시면 이해가 쉬울듯합니다.

기본적인 리눅스 프로그램에서의 호출 순서에 대해서 알아보겠습니다. Import 함수는 plt를 먼저 참조하고 got에 쓰여져 있는 주소값을 호출하게 되는데, 이 때 첫번째 호출이라면 GOT 에는 값이 쓰여져 있지 않습니다. 쓰여져 있지 않다면 _dl_runtime_resolve() 함수를 호출하게 됩니다. 이 함수는 실제 함수(BaseAddress+Offset)의 주소를 얻어오고 GOT에 실제 주소를 적어줍니다. 그 이후 GOT에 실제 주소가 쓰여져 있다면 바로 해당 주소로 호출하여 사용하게 되는거죠.

안드로이드 네이티브 라이브러리의 경우에는 dlopen() 수행시에 해당 함수에서 Relocation Table을 참조하여 GOT에 값이 쓰여지게 됩니다. 이 부분은 글을 따로 작성하여 자세하게 설명드리도록 하겠습니다. (http://linforum.kr/bbs/board.php?bo_table=android&wr_id=6)http://linforum.kr/bbs/board.php?bo_table=android&wr_id=6://linforum.kr/bbs/board.php?bo_table=android&wr_id=6a

외부 라이브러리 함수 호출의 동작방식을 이해하셨다면 GOT 후킹에 대해서 감이 좀 오실거 같습니다. 여기서 GOT에 쓰여져 있는 주소값을 내가 만든 함수의 위치 주소값으로 교체해버리면 어떻게 될까요? 실제 함수 호출이 아닌 내가 만든 함수의 위치로 이동되어 실행되기 때문에 원래의 동작과 다르게 조작할 수 있게 됩니다.

그러나 GOT후킹은 import함수만 후킹이 가능하기 때문에 사용이 매우 제한적입니다. 특정 라이브러리 내부 함수에는 조작을 못하고 memcpy(), strstr(), fork()등과 같은 시스템 함수(ex:libc 내 함수 등)에만 후킹이 가능하기 때문입니다. 또한 import함수는 내가 원하는 시점에만 후킹이 되는게 아니라 프로그램 내 해당 모듈이 함수를 사용하기 때문에 빈번하게 호출이 되어 성능저하가 발생할 수 있습니다. 실제 자주 사용되는 사례로는 해킹(변조)를 목적으로 사용하기 보다는 방어 행위 목적으로 자주 사용 되곤합니다.

 

2. Inline Hook (Trampoline)

 

x86과 달리 ARM 기반에서 인라인 후킹을 어떠한 방식으로 수행되는지 설명 드리겠습니다. 사실 안드로이드 앱 설치파일(APK)에 보시면 lib 폴더안에 x86, x64, arm, mips 등 다양한 네이티브 라이브러리를 포함시킬 수 있습니다. 여기서 x86 아키텍처는 윈도우 에뮬레이터(또는 atom을 사용하는 태블릿)에서 작동시킬때 주로 사용하고 있습니다. 마찬가지로 위에 설명드린 x86 아키텍처 인라인 후킹 방법과 동일하다고 보시면 됩니다. 다만 APK 내부에 어떠한 아키텍처의 라이브러리를 포함시킬지는 개발자의 선택이며, 우리가 흔히 사용하는 휴대폰 단말기는 arm기반으로 작동된다고 보시면 됩니다. 그렇기 때문에 개발자들은 arm 라이브러리만 포함시켜 배포하는 경우도 있구요. 또한 요즘 윈도우 에뮬레이터(momo, nox 등)는 x86 뿐만 아니라 arm 아키텍처도 호환이 가능하기 때문에 arm을 주로 사용한다고 보시면 됩니다. 실제로 arm 네이티브 라이브러리만 존재하는 apk 파일을 momo AppPlayer 에서 설치할 경우 정상적으로 실행되는것을 확인할 수 있습니다.

그럼 본격적으로 ARM 기준으로 후킹하는 방법을 알려드리겠습니다.

 

 

img.png

 

 

 

먼저 ARM에 대해서 간단히 설명드리면 RISC 머신으로 CPU가 한번에 처리할 수 있는 크기(word)가 32bit입니다. 4바이트가 1word로 Cycle(fetch -> decode -> execute)과정을 거치게 되는거죠(arm7기준). 여기서 ARM은 Thumb모드라는것도 존재하는데 1word가 16bit입니다. 한번에 처리할 수 있는 크기가 16bit로 줄어들게 되는거죠. 이 정도의 지식만 알고계시면 인라인 후킹을 이해하는데에는 충분합니다. ARM 아키텍처에 대하여 더욱 자세한 구조를 알고싶다면 "임베디드 레시피"라는 책을 추천드립니다 :)

ARM도 마찬가지로 x86의 인라인 후킹 방법과 매우 유사하다고 보시면 됩니다. 기존 x86에서 함수 앞 코드 5바이트를 변경시켰다면, ARM에서는 8바이트를 변경시켜주면 됩니다. ARM도 마찬가지로 함수의 시작부분은 스택을 구성하는 opcode로 되어있다고 보시면 됩니다.(스택을 구성하는 함수를 예로 들었으며 LDR 또는 Branch opcode로 시작하는 함수도 존재합니다.)

위 그림을 보시면 STMFD(Push onto a Full Descending Stack)은 r13(sp) 레지스터에 R4, R5, R6, R7, R8, LR를 차곡차곡 메모리번지가 감소하면서 담아준다고 보시면 됩니다. 그리고 SUB opcode를 통해 스택 공간을 할당하였습니다. 여기서 이 첫4바이트를 LDR PC, [PC, #-4] 로 바꾸고 다음 4바이트는 점프시킬 메모리 번지를 적어주시면 됩니다. 이때 branch opcode를 왜 사용안하느냐?라고 의문을 가지실 수 있는데 해당 실행되는 opcode 메모리 번지에서 26bit 이상으로 분기를 할 수 없기때문에 이동 시킬 메모리 번지가 26bit 이상으로 멀어져있다면 해당 주소로 이동을 시킬 수 없습니다. 그렇기 때문에 먼 메모리 번지로 이동시켜주기 위해서는 R15(PC) 레지스터에 앞 4바이트 주소번지를 Load 하도록 opcode를 구성해주는 것이죠. 이렇게 변조하였다면 0x23000000 번지로 점프를 시킬 수 있게됩니다. x86 인라인 후킹과 바이트수가 달라진것 말고는 매우 똑같죠?

0x23000000 번지에서는 기존 8바이트 코드를 복사해온 뒤 원하는 코드를 삽입하여 작동시키고 다시 돌려주면 됩니다.

[1] LDR PC, [PC, #-4] 
[2] 0x0011A3C

 

 

돌려주는 방법은 다음과 같습니다. PC를 다시 4바이트 앞 메모리 번지수를 Load하고 이동시킬 메모리 번지수는 변조시킨 기존 메모리 위치 + 0x08로 이동시켜주면 되는것이죠.

 

img.png

 

 

설명드린 내용을 그림으로 그려보았습니다. 기본적인 후킹 동작 원리는 이렇게 작동된다고 이해하시면 됩니다.

그럼 실제로 사용되는 예제를 살펴보도록 하겠습니다. 깃허브에 안드로이드 인라인 후킹을 검색하시면 스타가 가장 많은 https://github.com/ele7enxxh/Android-Inline-Hook 링크가 나오게 되는데요. 해당 코드를 분석해보겠습니다.

 

 

img.png

 

Android-Inline-Hook/example/hooktest.c 소스코드를 확인해보시면 위의 그림과 같은 코드가 나오는데요. 해당 코드는 제가 설명드린 방법들이 그대로 적용되어 있습니다.

 

img.png

 

코드를 분석해보면 다음과 같은 순서로 진행됩니다. 우선 puts()의 기존 코드의 앞 8바이트를 변조합니다. 변조된 코드는 new_puts()라는 새로운 코드 위치로 이동하게 되죠. 새로운 코드에서는 old_puts()를 호출하게 됩니다. 이 old_puts()는 registerInlineHook()함수에서 기존 8바이트를 복사해오고 다시 기존코드+0x08로 이동시켜주는 코드를 구성한 뒤 mmap()을 통해 메모리에 할당시키게 되는데 이 16바이트 코드가 old_puts()입니다. 이 old_puts()를 호출하게 되면 기존 puts() 코드가 수행되는 것과 동일합니다. 여기까지 이해하셨다면 응용할 포인트가 굉장히 많이 떠오르실것입니다. 첫번째 인자값인 스트링 값을 조작할 수도 있으며, 기존 코드가 동작되기 전과 후에 원하는 코드를 호출 또는 리턴값 변조 등 많은 변조가 가능할것입니다.

실제로 함수의 앞부분 스택을 구성하는 opcode인 8바이트의 코드를 옮겨온다면 인스트럭션 재조합이 필요 없겠지만, 함수 중간에 인라인 후킹코드를 삽입할 경우에는 재조합이 필요합니다. 예를 들면 branch 분기문이 있을경우 기존 메모리 번지와 이동시킨 메모리 번지와의 차이를 계산한 뒤 옮겨줘야 되며 26bit 이상일 경우에는 4바이트 branch opcode를 8바이트로 늘려서 또 다시 LDR PC opocde를 이용해야 됩니다. 또한 bl, blx의 opcode의 경우 LR값도 다시 담아줘야 되기 때문에 12바이트로 늘려줘야 되구요. 이러한 많은 예외상황들이 해당 코드에 포함되어 있으니 직접 분석해보시는것도 공부에 도움이 많이 될것입니다. 나중에 예외상황도 정리하여 글 작성하도록 하겠습니다.

범용 프레임워크인 Frida Framework, Cydia Substrate 등도 이 방법을 이용하여 인라인 후킹을 사용하고 있으며 변조된 코드를 분석해보시면 어떻게 작동되는지 직접 확인하실 수 있습니다.

본문은 ARM 기준으로 설명되어 있으며 Thumb모드가 빠져있는데 12바이트 변조로 이와 유사하니 직접 분석해보시기를 추천드립니다.


--------------------------------



11 Comments
2 nqminhquan 11.04 20:33  
yes , it not work for THUMB
1 캔아이코즈믹 11.05 00:43  
아직은 이해하기에 살짝 어렵네요 ㅠㅠ
3 dldldl 11.05 15:56  
강좌이긴 강좌인데... 암호코드 같은... ㅜㅜ
S 코드몽키 11.05 17:33  
이해는 대충 했지만.. 막상 할래면.. 음... 게임하나 예제로 붙잡고 설명을 해주시면 뭔가 확 와닿을거같은느낌이긴한데 ㅠㅠ...
1 쥬스 11.05 19:27  
감사합니다
5 vk2v 11.06 00:16  
역시 어렵네요 ㅎㅎ
1 dasdaff 11.06 13:54  
오우.. 좋은글감사합니다
3 뾰족 11.06 16:24  
오 개념은 이해가 되는 군요. ^^
3 Haclthesoul 11.08 11:24  
임의의 함수로 점프하기전에 LDR PC, [PC, #-4]의 실제 코드는 다음과 같네요
static void doInlineHook(struct inlineHookItem *item)
{
mprotect((void *) PAGE_START(CLEAR_BIT0(item->target_addr)), PAGE_SIZE * 2, PROT_READ | PROT_WRITE | PROT_EXEC);

if (item->proto_addr != NULL) {
*(item->proto_addr) = TEST_BIT0(item->target_addr) ? (uint32_t *) SET_BIT0((uint32_t) item->trampoline_instructions) : item->trampoline_instructions;
}

if (TEST_BIT0(item->target_addr)) {
int i;

i = 0;
if (CLEAR_BIT0(item->target_addr) % 4 != 0) {
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = 0xBF00;  // NOP
}
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = 0xF8DF;
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = 0xF000; // LDR.W PC, [PC]
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = item->new_addr & 0xFFFF;
((uint16_t *) CLEAR_BIT0(item->target_addr))[i++] = item->new_addr >> 16;
}
else {
((uint32_t *) (item->target_addr))[0] = 0xe51ff004; // LDR PC, [PC, #-4]
((uint32_t *) (item->target_addr))[1] = item->new_addr;
}

mprotect((void *) PAGE_START(CLEAR_BIT0(item->target_addr)), PAGE_SIZE * 2, PROT_READ | PROT_EXEC);

item->status = HOOKED;

cacheflush(CLEAR_BIT0(item->target_addr), CLEAR_BIT0(item->target_addr) + item->length, 0);
}

Thumb모드의 경우에는 TEST_BIT0(item->target_addr) TEST_BIT0으로 구분을 하는듯 보입니다
1 정배다 11.09 03:10  
오.. 해봐야겠는데요
1 치킨한마리 11.12 14:34  
좋은 정보 공유 감사드립니다.