본문 바로가기

Android/Tech

안드로이드 안티디버깅 기술과 우회 기법

디버깅

 안티디버깅 방법을 소개하기 전에 리눅스의 디버깅의 원리 부터 알고 넘어가도록 하자. 리눅스에서는 ptrace 라는 시스템 콜을 이용하여 어태치한 프로세스의 상태와 발생되는 시그널을 추적할 수 있으며 레지스터, 메모리를 읽거나 쓸 수 있다. 주로 개발자가 의도한 대로 정상적으로 작동하는지 흐름을 파악하거나 프로그램 내 버그를 찾는 용도등으로 사용된다.  리눅스 프로그래밍 시 자주 사용하던 디버깅 툴인 GDB 또한 ptrace 시스템 콜을 사용하여 개발되었다.

 하지만 개발자만이 디버깅을 하는게 아닌 해커들 또한 악의적인 의도로 디버깅(일명 동적 분석)하는 경우도 존재 한다. 전체적인 동작 흐름을 분석하여 중요한 핵심 코드들을 찾고 발견한 코드들을 원래의 흐름과 다르게 동작하도록 변경하거나 데이터를 교체하는 등의 방법으로 악용할 수 있다.  이러한 악의적인 디버깅을 방지하기 위해 금융앱 또는 게임앱에서는 안티 디버깅 기능을 사용하여 역공학(리버스 엔지니어링)을 방지한다. 본문에서는 실제 필드에서 사용되는 여러 안티디버깅 기술과 우회방법에 대해서 소개하려고 한다. 

 

안티디버깅 기술

안드로이드는 리눅스 커널 기반이기 때문에 오래전부터 사용하던 리눅스의 안티디버깅 방법과 동일하다.

 

2-1. ptrace 선점

 앞서 서론에서 언급했던것 처럼 안드로이드는 ptrace 함수를 사용하여 디버깅을 하기 때문에 ptrace의 원리를 잘 알고 있다면 쉽게 디버깅을 차단할 수 있다. 원리는 다음과 같다.

A 프로세스가 B 프로세스에게 ptrace 함수를 사용하여 어태치를 하였다면, B 프로세스는 이미 선점 되어 있는 상태이기 때문에 C 프로세스가 B프로세스에게 ptrace 재 어태치를 못한다.  즉, 이미 ptrace 로 점유 되어 있기 때문에 gdb나 ida 같은 ptrace를 사용하는 다른 디버깅툴들이 다시 어태치를 할 수 없게 만드는 원리이다.

 실제 예를 들어서 설명해보겠다. 유명한 게임앱 또는 금융앱을 실행시켜본 뒤에 "ps | grep packageName" 명령어로  프로세스를 검색해보면 두개 혹은 세개의 프로세스가 실행중임을 확인할 수 있다. 이는 앱에 탑재되있는 보호 솔루션들이 fork() 함수를 사용하여 자식 프로세스를 생성하거나, 또는 system()함수를 사용하여 특정 실행 파일을 실행시켜 부모 프로세스에게 ptrace 어태치 하여 부모 프로세스가 선점(점유)한 상태를 만든다. 이렇게 하면 부모프로세스는 점유된 상태가 되어 타 프로세스에 의해 디버거를 붙일 수 없기 때문이다.

 구현 부분에는 "ptrace 안티디버깅 예제"라는 검색어로 인터넷을 검색해보면 수 많은 예제들이 PTRACE_TRACEME 를 사용하여 자신의 프로세스를 차단한다고 있지만, 실제 상용화 제품들은 대부분 PTRACE_ATTACH 를 사용하는 모습을 확인할 수 있다. 이는 부모 프로세스에 생성된 모든 스레드까지 모두 어태치하여 디버깅을 방지할 수 있기 때문이다. 

 아래 링크를 따라 안드로이드 리퍼블릭의 안티디버깅 방법을 참고하면 좋다. 실제 상용화중인 앱 보호솔루션이 사용하는 안티디버깅 방식과 가장 유사하기 때문에 공부에 큰 도움이 될것이다. (직접 AR을 분석해보는것도 좋다. )

링크 : https://www.slideshare.net/ssuser052dd11/igc2018-arandroid-republic

 

2-2. 프로세스 상태값 체크

/proc/(pid)/ 디렉토리안에는 프로세스에 대한 다양한 정보를 알려주는 파일들이 존재한다. 수 많은 파일중에 우리는 프로세스의 상태와 관련있는 파일 3개만 알면 디버깅을 차단할 수 있다.

stat 프로세스 상태
wchan 현재 프로세스에서 사용중인 커널 기능
status 프로세스 상태(알아보기 쉬운 형태)

 위 표에 언급된 3개의 파일은 프로세스의 상태를 나타내며, 디버거에 의해 어태치가 되었을 경우 프로세스의 상태값이 각각 변경이 된다. 이러한 변경되는 필드값을 읽어와서 탐지하는 방식으로 디버깅을 차단할 수 있다.

 

2-2-1. /proc/pid/status

 많은 필드중에서도 StateTracerPid는 디버거가 어태치 했을 경우 상태값이 변경되는데, 이 변경되는 값을 감지하여 디버깅을 확인하는 방식이다. 이중 State는 프로세스의 상태를 나타내고, TracerPid 값은 추적중인(디버깅중인) pid값을 나타낸다.

 탐지 방법은 메인 스레드 외에 백그라운드에서 동작하는 새로운 스레드를 생성하고, 새로운 스레드는 주기적으로 해당 필드를 읽어 변경되어 있는지 확인하는 방식으로 사용된다. 예제 코드를 살펴보자.

int  IsMonitorProcess() {
    FILE * f = fopen("/proc/self/status", "r" );
    int pid = 0;
    char *s = NULL;

    if (f != NULL ) 
    {
        while (__getdelim(s, 0, 0xa, f) >= 0x0) 
        {
            char *temp;
            temp = strstr(s, "TracerPid:");
            pid = 0;
            if (temp != NULL)
                pid = strtol(temp + 0xb, NULL, 10); // Attach pid return
        }

        if (s != NULL) {
               free(s);
        }

        fclose(f);
    }

    return pid;
}

 "/proc/self/status" 파일을 읽고 "TracerPid:" 필드의 값이 0이 아닐경우 디버깅중이라고 간주하는 코드이다. 이를 응용하여 자신(self)의 status 뿐만 아니라 task 디렉토리에 있는 tid값까지 모두 체크하여 스레드에도 디버거가 붙었는지 체크하면 확실히 탐지할 수 있다. 만약 부모프로세스에게 ptrace 로 선점하는 안티디버깅 기능을 사용중이라면 "TracerPid:" 필드의 값이 자식 프로세스의 pid값으로 변경 될 것이다. 이럴 경우 위 소스코드처럼 "0" 으로 비교하는 게아닌 자식 프로세스의 pid값과 비교하여 타 프로세스가 디버깅 중인지 판단하면 된다.

 "State" 필드는 "t (tracing stop)" 으로 변경되었는지 탐지하면 된다. 다만 PTRACE_ATTACH 시에만 다음과 같이 변경되며, 다시PTRACE_CONT로 프로세스를 재개했을 경우에는 "S (sleeping)" 으로 출력된다.

 

2-2-2. /proc/pid/stat

 "/proc/pid/stat" 파일은 status 파일을 간소화 시킨 파일로 보면 된다. 파일을 읽으면 다양한 정보가 나오는데 이 중 3번째에 해당하는 값은 status의 "State" 필드와 똑같다. 위 그림처럼 "S" 일 경우에는 "S (sleeping)" 으로 정상적으로 동작중인 상태이며, "t" 또는 "T"일 경우에는  "t (tracing stop)" 값으로 PTRACE_ATTACH 상태이므로 디버깅중이라고 간주하여 탐지할 수 있다.

 

2-2-3. /proc/pid/wchan

void be_attached_check_wchan() {
    try {
        const int bufsize = 256;
        char filename[bufsize];
        char line[bufsize];
        int pid = getpid();
        sprintf(filename, "/proc/%d/wchan", pid);
        FILE *fd = fopen(filename, "r");
        if (fd != NULL) { //nullptr
            while (fgets(line, bufsize, fd)) {
                if (strstr(line,"ptrace_stop") != NULL){
                    LOGD("be attached !! kill %d", pid);
                    fclose(fd);
                    int ret = kill(pid, SIGKILL);
                    break;
                }
            }
            fclose(fd);
        } else {
            LOGD("open %s fail...", filename);
        }
    } catch (...) {
    }
}

주기적으로 파일을 읽어 'ptrace_stop' 값일 경우에 디버깅이라고 간주하여 탐지한다.

 

2-3. 시간 차이 체크

 윈도우에서도 자주 사용하던 안티디버깅 방식으로 초기 시점에서 시간 함수를 사용하여 시간값을 구하고 초기화 작업 및 어떠한 함수가 실행이 된 이후의 시간을 구하여 마지막 시간값과 초기 시간값의 시간차를 구하여 시간이 얼만큼 지났는지 확인하는 방법이다. 아무리 하드웨어 성능이 좋지 않은 노후화된 단말기라고 하더라도 최대 1~2초 내에 수행되는  함수가 30초 이상의 시간차가 발생했다면 디버깅 중이라고 간주할 수 있다.

void check_time(){
    time(&start_time);
    // 초기화 작업 등...
    time(&end_time);

    if(end_time - start_time > 10){
        LOGD("time over!!!");
    }
}

2-4. 매니페스트 파일 내부 디버깅 값 체크

 APK 파일 내부 매니페스트 파일에 "android:debuggable" 속성이 "true" 로 되었는지 확인하여 디버깅을 위해 변조했는지 확인하는 방법이다. 이는 디버깅을 차단하기 보다는 DEX 디버깅을 하기 전 사전작업으로 보면 된다. 이를 탐지하는 방법이다.

 

2-5. IsDebuggerPresent?

 윈도우에서 디버깅중임을 확인할 때 사용하는 API는 IsDebuggerPresent 함수가 있다. 마찬가지로 안드로이드에서도 디버깅중임을 확인해주는 "isDebuggerConnected" 함수가 존재한다.

void detectOsDebug(){
    boolean connected = android.os.Debug.isDebuggerConnected();
    Log.d(TAG, "debugger connect status:" + connected);
}

 

2-6.  네트워크 포트 확인

 안드로이드에서 열려있는 포트를 확인하는 방법은 "proc/net/tcp" 파일을 확인하면 된다. 위 파일을 확인하여 특정한 디버거의 포트가 열려있는지 확인하여 디버거를 탐지하는 방법이다.  아래 예제 코드는 IDA의 기본 포트인 23946을 탐지하는 소스 코드이다. 하지만 IDA의 경우 서버 포트를 다르게 변경하여 실행할 수 있기 때문에 효과적인 안티디버깅 방법은 아니다. 

void check_tcp_port(){
    char buff[BUFF_LEN];

    FILE *fp;
    const char dir[] = "/proc/net/tcp";
    fp = fopen(dir, "r");
    if(fp == NULL){
        LOGE("failed... errno : %d, desc : %s", errno, strerror(errno));
        return;
    }

    while(fgets(buff, BUFF_LEN, fp)){
        if(strstr(buff, "5D8A") != NULL){
            LOGI("detected IDA port");
            fclose(fp);
            return;
        }
    }
}

 

2-7. 소프트 브레이크 포인트 탐지

 브레이크 포인트 탐지 방법의 원리는 이미 윈도우에서 자주 사용하던 방식 그대로 가져오면 쉽게 구현할 수 있다. 원리는 다음과 같다. 우선 x86 CPU 아키텍처에서 소프트 브레이크 포인트를 설정하려면 디버깅할 위치의 인스트럭션을 "0xCC"로 변경해야 한다. 변경 이후 CPU는 함수 프롤로그 부분을 실행하게 되는데 이때 해당("0xCC") 인스트럭션을 읽고 INT 3 이벤트를 발생시킨 뒤 디버거의 브레이크 포인트 예외 핸들러가 제어권을 받게 된다. 이러한 소프트웨어 브레이크 포인트의 단점은 실행 코드에 인스트럭션이 변경해야 사용할 수 있기 때문에, 실행되는 코드를 무결성 검사하는 방법 또는 "0xCC"로 변경되었는지 탐지하는 방식으로 검출할 수 있다.

 그렇다면 안드로이드에서는 어떻게 탐지를 할까? 우선 기본적인 안드로이드 단말기의 CPU 아키텍처는 ARM이라는 아키텍처를 사용한다. 물론 예외로 에뮬레이터나 x86(Atom) CPU를 사용하는 태블릿도 존재한다. 앞서 설명할 내용은 ARM 기준으로 브레이크 포인트 탐지하는 방법을 소개할것이다. ARM의 경우 ARM모드와 Thumb모드로 나누어지며 이러한 모드까지 고려해야 한다. 

 ARM의 소프트웨어 브레이크 포인트 인스트럭션은 아래표로 확인할 수 있다.

 

ARM 모드 브레이크 포인트 (f0 01 f0 e7) = 0xe7f001f0
Thumb 모드 2바이트 브레이크 포인트 (0x10, 0xDE) = 0xde10
Thumb 모드 4바이트 브레이크 포인트 (f0 f7 00 a0) = 0xa000f7f0

 위 표에 언급된 세개의 인스트럭션이 실행 코드내에 존재하는지 확인하는 방식으로 브레이크 포인트를 탐지할 수 있다. 앞서 설명했던것 처럼 ARM 기준이며 ARM64, x86 등의 아키텍처의 경우 인스트럭션이 모두 다르기 때문에 아키텍처에 맞게 구현해야 한다.

 

2-8. SIGTRAP 시그널 수신기

 시그널은 프로세스가 수신받은 소프트웨어 인터럽트다. 시그널은 프로그램에서 발생한 다양한 예외 신호들을 발생시킨다. 예를 들면 알수 없는 인스트럭션을 실행하거나 존재하지 않는 메모리를 읽는 등의 예외 작업시 시그널이 발생하게 된다. 시그널의 종류는 다음과 같으며 운영체제에 따라 다를 수 있다.

 우리는 위 그림에서 5번 시그널인 SIGTRAP을 이용하여 디버깅을 탐지하려고 한다. 그렇다면 SIGTRAP 시그널은 무엇일까? 위 7번 항목에서 소프트웨어 브레이크 포인트 탐지 방법에 대해 설명했었다. 세개의 소프트웨어 브레이크 포인트 인스트럭션을 CPU가 읽게 되면 SIGTRAP 시그널을 디버거에게 전달하고 프로세스는 멈추게 된다. 즉, 브레이크 포인트 명령어로 발생되는 시그널이다. 

 그렇다면 해당 시그널을 어떻게 사용해서 디버깅을 탐지할까? 

int isDebugger = 0;
static void sigtrap_handler(int signum)
{
  print("good...sigtrap \\n");
  isDebugger = 1;
}

int main(void)
{
  signal(SIGTRAP, sigrap_handler);
  raise(SIGTRAP);

  if(isDebugger == 0)
  {
  	LOGD("Debugger detected.");
  }
  return 0;
}

 위 소스코드는 SIGTRAP 시그널을 사용하여 디버깅을 탐지하는 코드이다. signal()함수를 사용하여 SIGTRAP 시그널이 발생시 처리할 수 있는 핸들러를 등록하고 raise() 함수를 사용하여 임의로 SIGTRAP 시그널을 발생한다. 정상적인 경우라면 SIGTRAP 시그널을 발생할 경우 sigtrap_handler 함수가 수신받아 호출된다. 하지만 디버거가 붙어있을 경우에는 sigtrap_handler 함수가 호출되지 않고 디버거가 수신받아 제어권이 넘어가게 될것이다. 즉, 디버거가 붙은 경우에는 sigtrap_handler 함수가 호출되지 않아 소스 코드처럼 전역 변수인 "isDebugger" 변수를 체크하는 방법으로 디버깅 중임을 탐지할 수 있다. 또한 이 방법은 시그니처 기반의 탐지 방식이 아닌 행위 기반으로 디버거를 탐지하는 방법이다. ( 이말은 반대로 오탐이 발생할 수 도 있다. )

 

우회 방법

 앞서 안티디버깅의 구현 방법에 대해 살펴봤다면 이를 우회하여 동적 분석(디버깅)을 할 수 있도록 하는 방법들을 소개하겠다. 여기서 우회의 뜻은 안티디버깅 기능을 동작하지 않도록 패치하는 작업을 뜻한다. 안티디버깅이 동작하지 않는다면 IDA, GDB 같은 도구를 붙여서 동적 분석이 가능하기 때문이다. (참고로 린엔진은 안티디버깅 기능이 동작하더라도 이를 무시하고 동작 한다. 즉, 보호솔루션이 탑재되어 있더라도 이를 우회할 필요 없이 무시하고 분석이 가능하다. 악성코드, 모드앱, 악성코드앱등을 쉽게 분석할 수 있다. )

 그럼 본론으로 들어가서 다양한 우회 방법들을 살펴보도록 하자.

 

3-1. 코드 패치

 가장 기본적인 ptrace 점유 안티디버깅을 우회해보겠다. ptrace를 사용하여 안티디버깅 기능을 활성화 시키는 로직은 보호솔루션 모듈안에 내장되어 있는 경우가 가장 많다. 보호 솔루션의 기능으로 보면 되겠다. 이러한 보호 솔루션은 대부분 프로텍터 방식으로 APK를 보호하는데, 프로텍터 방식은 빌드 후 나온 릴리즈 버전의 APK 파일을 전달하여 보호 솔루션 서버에서 자동으로 보호기능을 추가하여 APK를 개발자에게 전달하는 방식이다. ( SDK 방식은 라이브러리를 직접 연동하여 함수 호출 부분을 개발자가 추가하는 방식으로 구현된다. 즉, 프로텍터 방식은 개발자가 번거로운 연동 작업 없이 자동으로 보호작업을 해주는 장점이 있다. )

 프로텍터 방식의 경우 매니페스트 파일을 변경하여 최초 실행 시점(Entry Point)을 보호 솔루션의 Applcation 클래스로 변경(교체)하고 초기화 작업을 수행하게 되는데, 보통 이때 보호솔루션의 SO(Shared Object) 파일을 로드하게 된다. ELF구조의 SO파일은 먼저 DT_INIT -> INIT_ARRAY 순으로 실행하게 되며, 초기 로직은 SO 파일에 걸려있는 언패킹 작업을 하고 안티디버깅 로직을 동작시키는 경우가 많다. (물론 보안성이 높은 제품을 예로 들었다.)

 자바의 System.loadLibrary("xxxx") 함수로 호출할 경우 INIT -> INIT_ARRAY -> JNI_OnLoad 순서로 호출하게 된다. 가장 마지막의 호출 시점인 JNI_OnLoad() 함수에 안티디버깅 로직을 넣는 보호 솔루션 제품도 있다. 이러한 동작 로직은 각각 보호솔루션마다 다르며 직접 분석해봐야 정확히 알 수 있다. 안티디버깅은 동적 분석 차단이 목적이니 대부분 초기에 동작하게 된다.

 안티디버깅의 동작 호출 시점을 알았다면 분석 방법은 다음과 같다. 우선 가장 기초적이고 정석인 분석 방법인 동적 분석 방법이다. IDA로 최초 실행 시점에 어태치한 다음 dlopen()이라는 함수에 브레이크 포인트를 걸고 한줄한줄 따라 올라가는 방식이다. 이 방법은 모든 동작 로직을 파악할 수 있어 정확히 동작 로직을 분석할 수 있다. 다만 시간이 오래 걸린다는 단점이 있다. 이렇게 동적 분석으로 안티디버깅 로직을 발견하는 방법도 있으나 필자가 사용하는 방법을 소개하겠다. 후킹을 사용하여 빠르게 안티디버깅 함수를 찾는 방법이다. 

 우리는 이전에 2-1에서 ptrace 함수를 사용하여 안티디버깅 동작 로직을 구현해보았다. 핵심 함수는 ptrace 함수로 안티디버깅을 사용하기 위해서는 반드시 ptrace() 함수를 사용해야 한다. (syscall 방식과 직접 svc 명령어로 호출하는 명령어도 있으며 3-3을 참고하면 된다. ) 여기서 포인트는 libc.so 라이브러리의 ptrace 함수를 사용해야 한다는 점이다. 감이 오지 않는가? 디버거를 붙여서 ptrace 함수를 브레이크 포인트 걸어서 추적하거나 ptrace 함수에 후킹하여 호출 하는 위치를 파악하면 안티디버깅의 호출 함수를 찾을 수 있다. 브레이크 포인트를 설정하는 방법은 이미 수 많은 예제가 있기 때문에 설명은 패스하겠다.

 그럼 후킹(링크)은 어떻게 할까? 필자가 만든 린엔진이라는 툴을 사용하면 빠르게 호출 지점을 찾을 수 있다. 

LinEngine - Hook Helper 세팅
앱 실행 후 로그

 ptrace 함수에 후킹을 걸고 실행을 시키면 다음과 같이 호출하게된 스택을 트레이싱할 수 있다. 실제 가리키는 위치를 직접 따라가보면 ptrace 함수를 실행시키는 로직을 확인할 수 있다. 실제 호출하는 함수 위치를 찾았으니 인스트럭션을 nop로 변경한다면 ptrace가 호출되지 않고 우회 될 것이다. 정말 간단하지 않은가? 물론 단순한 보호솔루션 또는 무결성 검사를 하지 않는다면 이 방법으로 우회가 가능하다. 이렇게 SO 파일을 변조하고 APK를 리패키징하면 무결성이 깨지기 때문에 앱 실행시 위변조 행위가 감지되어 종료될 것이다. 그렇다면 이를 어떻게 우회해야할까? 3-2에서 살펴보자.

 

3-2. ptrace 후킹

 우리는 린엔진 훅헬퍼 타입 "BackTracer" 를 사용하여 호출 지점을 찾았었다. 린엔진은 함수 호출을 추적할 뿐만 아니라 ptrace 함수의 인자값을 변경하거나 리턴값 등을 조작할 수 있고 아예 함수가 동작하지 않도록 하는 기능들이 있다. 훅헬퍼의 타입을 "NOP" 로 변경하여 앱을 실행해보자. (NOP는 후킹한 함수를 호출하지 않고 바로 리턴한다. )

 NOP로 세팅한 뒤 앱을 실행하면 그림과 같이 TracerPid값이 0으로 ptrace 함수가 호출되지 않은것을 확인할 수 있다. 보호솔루션이 해당 함수의 후킹 여부를 감지하지 않는다면 이렇게 쉽게 우회할 수 있다. 물론 ptrace 함수 호출이후 디버거가 제대로 붙었는지 체크하는 함수등에서 우회에 실패할 수도 있다. 이때는 린엔진 타입 "BackTracer" 를 사용하여 호출 지점을 파악하고 직접 분석 한 뒤 우회하면 되겠다.

 

3-3. LKM 구현

 LKM(loadable kernel module)은 실행되고 있는 커널에 동적으로 Load/unload 할 수 있는 커널 프로그램이다. 보통 디바이스 드라이버를 로드하는데 사용되는데 새로운 디바이스를 시스템에 연결할 때마다 커널을 다시 컴파일하고 재 부팅해야 하는 불편함을 없애기 위한 것이다. 즉, 커널 재 컴파일 필요 없이 작동중인 운영체제 커널에 기능을 추가하기 위해 사용된다. 해커들은 LKM을 이용하여 커널을 조작할 수 있는데 분석에 활용할 수 있는 방법들을 소개하려고 한다.

 가장 먼저 프로세스에서 발생하는 시스템 콜에 대해서 추적하는 방법에 대해서 알아보자. 

#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/syscalls.h>

void** sys_call_table = (void*)0xd8865180;      // sys_call_talbe address setting
const int ___NR_read = 3;

asmlinkage ssize_t (*org_sys_read)(int fd, char* buf, size_t count);

asmlinkage ssize_t hooked_read(int fd, char* buf, size_t count)
{
  printk(KERN_ALERT "pseudo read(): %s\n", buf);
  return org_sys_read(fd, buf, count);
}

int __init my_init( void )
{
  org_sys_read=*(sys_call_table+___NR_read);
  printk(KERN_ALERT "init sys_read before %p\n", *(sys_call_table+___NR_read));
  *(sys_call_table+___NR_read)=hooked_read;
  printk(KERN_ALERT "init sys_read after %p\n", *(sys_call_table+___NR_read));
  return 0;
}

void __exit my_exit( void )
{
  printk(KERN_ALERT "exit sys_read before %p\n", *(sys_call_table+___NR_read));
  *(sys_call_table+___NR_read)=org_sys_read;
  printk(KERN_ALERT "exit sys_read after %p\n", *(sys_call_table+___NR_read));
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");

 위의 소스코드는 read 시스템 콜 주소를 직접 만든 hooked_read로 교체한다. 이렇게 되면 "dmesg" 를 통해 read 함수가 어떤 시점에 호출되고 인자값이 무엇인지 확인이 가능하다. 보호솔루션이 libc.so의 read() 함수를 import하여 plt -> got를 거쳐서 호출하는 방식이 아닌 직접 시스템 콜을 호출하여 read()를 사용하는 경우 이러한 LKM을 사용하여 추적이 가능하다. ptrace 함수 또한 위와 같이 수정하여 추적을 할 수 있다. 또한 printk 로 로그를 남기는것 뿐만 아니라 조건문을 추가하여 리턴값을 변경하는등의 조작이 가능하다. 커널 레벨에서 후킹한다고 보면 된다. (이말은 보호솔루션이 탐지하기 어렵다. ) 

 위의 인스트럭션은 특정 보호솔루션(잉X)에서 ptrace() 함수를 import 하여 사용하지 않고 곧바로 시스템 콜을 호출하는 모습을 확인할 수 있다. 이를 우회하려면 저 인스트럭션을 찾아 코드 패치를 하거나 LKM을 붙여서 우회하면 된자. ( 코드 가상화가 적용 되어있다면 코드패치는 끔찍할 것이다.. )

 LKM의 장점은 보호솔루션은 루트 권한이 아닌 유저모드에서 탐지해야 되기 때문에 LKM 탐지가 굉장히 어렵고, 만들어두면 분석 업무에 두고두고 사용할 수 있기 때문에 직접 만드는것을 추천한다. 다만, 커널버전을 맞춰서 빌드해야 되기 때문에 환경 세팅이 굉장히 까다롭고 개인 환경(단말기)에서만 사용 가능하다는게 단점이다.

 

Q. "SVC 0" 인스트럭션이 뭐에요? http://recipes.egloos.com/v/5037342

 

3-4. status 파일의 TracerPid 체크

char* strstr_hook(const char *s1, const char *s2)
{
    if(!strcmp(s2, "TracerPid:"))
    {
        return 0;
    }
    .... 
}

 위의 소스코드처럼 점프시킬 네이티브 함수를 만들어서 libc.so의 strstr 함수를 GOT 또는 Inline 후킹하는 방법으로 교체할 수 있을것이다. 하지만 이 방법은 직접 후킹용 SO를 만들어야 되므로 까다로운 부분이 있다. 물론 필자처럼 후킹용 SO 샘플을 만들어두면 좋다. ( 필자의 경우 만들어둔 샘플이 점점 발전하여 린엔진이 탄생하게 되었다. ) 

SO 만들 여건이 안된다면 Frida DBI 프레임워크를 사용하면 간단한 자바스크립트 작성만으로 우회/조작이 가능하다.  

Interceptor.attach(Module.findExportByName(null, "strstr"), {
			onEnter: function(args) {
				this.arg0 = Memory.readUtf8String(args[0]);
				this.arg1 = Memory.readUtf8String(args[1]);
			},
			onLeave: function(retval) {
				if (this.arg1.indexOf("TracerPid") !== -1){ 
					retval.replace(0)
				}
				return retval;
			}
		});

  위 자바스크립트 코드는 C로 작성된 strstr 후킹코드와 동일하다고 보면 된다. 해당 스크립트로 후킹할 경우 TracerPid 탐지 로직을 쉽게 우회할 수 있다. 

 

3-5. 커널 소스 코드 수정

static inline void task_state(struct seq_file *m, struct pid_namespace *ns, ...
		...
        tracer = ptrace_parent(p);
        if (tracer)
         //   tpid = task_pid_nr_ns(tracer, ns);
        	tpid = 0;
 
        tgid = task_tgid_nr_ns(p, ns);
        ngid = task_numa_group_id(p);
		...

 위 소스코드 처럼 커널 코드를 변경한다면 쉽게 안티디버깅 로직을 우회할 수 있다. 소스코드는 TracerPid 값을 무조건 0으로 고정시키도록 수정하는 방법이다. 이런식으로 커널 코드를 변경하여 빌드한 경우 보호솔루션이 탐지할 방법이 있을까? 이런식으로 커널 소스 코드를 커스터 마이징한다면 다양한 탐지 로직을 우회하도록 개발이 가능하다. 단, 앞서 설명했던 모든 기술중에서 가장 난이도가 높다. ( 빌드시간도 굉장히 오래 걸리고 잘못 수정했다가는 부팅조차 안된다. )

 

3-6. mprop

 덱스를 디버깅하기 위해서는 APK 매니페스트 내부 "android:debuggable" 속성을 "true" 로 변경하거나 추가시켜줘야 한다. 매니페스트를 수정하고 리패키징을 한다면 매니패스트의 무결성과 APK의 무결성, 서명값등이 깨지게(변경) 된다. 이를 쉽게 우회하는 방법은 다음과 같다.

- https://github.com/wpvsyou/mprop

링크에서 다운로드 받고 실행시켜주면 android:debuggable="true" 옵션이 없어도 동적 디버깅이 가능하다. ( 추후에 린엔진 플러그인 기능으로 추가해도 좋을것 같다.  )

chmod 755 /data/local/tmp/mprop
./mprop ro.debuggable 1

사용법은 위와 같다.

 

3-7. 유니콘 에뮬레이터 ELF 로드

 유니콘 에뮬레이터를 사용하여 ELF 형식의 SO 파일만 단독적으로 로드가 가능하다. 직접적으로 시스템콜을 호출하는 경우도 트레이싱이 가능하기 때문에 LKM 만들 여건이 안될경우 유니콘 에뮬레이터를 사용하면 빠르게 초동 분석이 가능하다는 장점이 있다. 

- https://github.com/P4nda0s/AndroidNativeEmu

필자는 SO를 분석하기 전에 에뮬레이터로 한번 실행시켜보고 시스템콜 호출 순서를 보고 분석을 시작한다.

mmap, mprotect 시스템 콜이 초기에 호출된다면 언패킹 과정이 있는것이고, ptrace, inotify 시스템 콜이 호출된다면 안티디버깅과 안티 덤프 기능이 탑재되어있는것을 확인할 수 있다. 시스템콜을 확인하면 어떠한 기능을 사용중인지 파악도 되며, 우회할 포인트도 알 수 있다. 

논외로 이러한 호출 순서를 학습시켜 패킹/악성앱 검출하여 머신 러닝을 적용해보는것도 좋을것 같다. 

 

---

 

실습 : https://linears.tistory.com/entry/%EC%95%88%ED%8B%B0%EB%94%94%EB%B2%84%EA%B9%85-%EC%9A%B0%ED%9A%8C-%EC%8B%A4%EC%8A%B5-1