안드로이드 애플리케이션에서 사용되는 안티-디버깅 방법 중에는 ptrace 시스템 콜을 이용한 프로세스 선점 방법이 있습니다. 일반적으로 리눅스 기반 시스템(안드로이드 포함)에서는 하나의 프로세스에 대해 오직 하나의 디버거(debugger server)만 attach 할 수 있습니다. 이러한 특징을 이용해서 네이티브 코드에 ptace 로 부모 프로세스(실행중인 애플리케이션)를 선점하는 코드를 작성하면 공격자는 IDA, gdb 와 같은 도구를 이용하여 동적 디버깅을 하는 것이 불가능해집니다. 이미 ptrace 에 의해 앱 프로세스가 선점되었기 때문에 IDA, gdb 와 같은 도구로 디버깅이 불가능한거죠. 특정 프로세스(실행중인 애플리케이션)의 디버깅 여부를 확인하려면 /proc/self/status 파일의 TracerPid 값을 확인하면 되는데요. 디버깅 중이지 않으면 0, 디버깅 중이라면 attach 중인 부모 프로세스의 pid 값이 설정됩니다.
린포럼 블로그에서는 감사하게도 안티 디버깅 기능이 적용된 APK를 제공하고 있습니다. 이번 포스팅에서는 이 앱에 적용된 안티 디버깅 로직을 분석해보고 이를 우회하는 과정을 기록해보겠습니다.
사전준비 (anti_debug_ptrace1.apk)
아래 링크에서 anti_debug_ptrace1.apk 파일 다운로드
린포럼 블로그
코드 분석
먼저 APK 파일 설치 후 앱을 실행하면 아래와 같은 화면을 확인할 수 있는데요.
JADX로 디컴파일하여 코드를 분석하면 stringFromJni 네이티브 함수를 확인할 수 있습니다.
이 함수는 ptrace 와는 연관이 없고 그저 문자열을 출력하는 함수이기 때문에 분석 과정에서는 생략하겠습니다.

IDA에서 어태치 할 프로세스 이름(linforum)을 검색하면 2개의 프로세스(14479, 14523)를 확인할 수 있습니다.
동일한 이름의 패키지가 2개 실행되고 있는 이유에 대해서는 조금 후에 다시 알아보도록 하고
14479 프로세스에 어태치를 시도하면 아래와 같이 오류가 발생하며 디버깅이 불가능한 것을 볼 수 있습니다.

왜 디버깅이 불가능한 것인지는 라이브러리(.so) 파일을 분석해 볼 필요가 있습니다.
지난 [포스팅]((AOS) Uncrackable-Level3 IDA DEBUGGING)에서 언급한 것처럼 안드로이드 앱을 실행하면 .init_array, JNI_OnLoad 가 우선적으로 실행됩니다.
따라서, .init_array 세그먼트와 JNI_OnLoad 함수에서 실행되는 코드를 먼저 살펴봐야 합니다.
.init_array 에서는 딱히 중요한 코드가 실행되고 있지 않았으므로 제외하고, JNI_OnLoad 함수를 살펴보면
”JNI_OnLoad - Start!!” 로그를 출력한 후에 “readStatus” 함수를 호출하고 있는 것을 볼 수 있습니다.

readStatus(void) 함수 내부를 살펴보면 fork() 시스템 콜을 통해 자식 프로세스를 생성하고 있는데요.
이전에 IDA를 통해 앱에 붙으려고(ATTACH) 했을 때 동일한 프로세스명이 2개 존재했던 이유가 바로 이 때문입니다.

readStatus(void) 함수의 19라인 fork() 함수가 실행된 후 프로세스 리스트를 확인해보면 동일한 애플리케이션에 대하여 프로세스가 2개(부모; 14479, 자식; 14523) 존재하는 것을 볼 수 있습니다. 그리고 부모 프로세스의 TracerPid 값을 확인하면 자식 프로세스의 pid 값(14523)으로 설정되어 있습니다. 이 정보를 토대로 현재 자식 프로세스가 ptrace를 통해 부모 프로세스를 선점하고 있음을 알 수 있습니다. (자식 프로세스가 부모 프로세스에게 붙어있는(ATTACH) 상태)
c1q:/ $ ps -ef | grep linforum
u0_a336 14479 961 1 12:33:35 ? 00:00:00 kr.linforum.anti_debug_crack_me
u0_a336 14523 14479 0 12:33:35 ? 00:00:00 kr.linforum.anti_debug_crack_me
shell 14559 14363 23 12:34:05 pts/3 00:00:00 grep linforum
c1q:/ $ cat /proc/14479/status | grep TracerPid
TracerPid: 14523
c1q:/ $ cat /proc/14523/status | grep TracerPid
TracerPid: 0부모 프로세스(14479) 실행 코드 분석
부모 프로세스에서는 pthread_create 로 스레드를 생성한 후에 해당 스레드에서 checkChildProcess 함수를 호출하는데요. checkChildProcess 함수는 다음의 과정으로 동작합니다.
- (1) 변수 s의 메모리 주소에 “/proc/14479/status” 문자열 값을 할당
- (2) “/proc/14479/status” 파일 읽기 성공 시 해당 파일을 가리키는 파일 포인터를 v3에 할당
- (3) v3 주소로부터 128 바이트만큼 읽고 v4에 저장
- (4) v4와 “TracerPid”를 9바이트만큼 비교
- (5) TracerPid 값을 v1에 저장
- (6) TracerPid 값이 0이 아니라면 자식 프로세스 중지(kill -6)

자식 프로세스(14559) 실행 코드 분석
- (1) (line 28) 자식 프로세스 PID 가져오기
- (2) (line 29) 부모 프로세스(v0; ppid)에 어태치 시도
- (3) (line 39) 어태치 후 중지된 부모 프로세스 재개(PTRACE_CONT)
- (4) (line 51) “/proc/
[부모 프로세스 PID]/status” 파일 열람 후 파일 포인터 v6에 할당 - (5) (line 54) v6에서 128 바이트만큼 복사 후 newThread에 저장
- (6) (line 61) newThread 에 저장된 값과 “TracerPid” 이 일치하면 반복문 탈출
- (7) (line 62) /proc/
[부모 프로세스 PID]/status 의 TracerPid 값(정수형) 추출하여 v5에 할당 - (8) (line 64) 자식 프로세스 PID와 v5 값이 같으면 디버깅으로 탐지하여 부모 프로세스 종료

이러한 안티-디버깅 로직을 우회하기 위해서는 readStatus 함수가 호출되기 전에 Frida Spawn + IDA Attach 조합으로 프로세스를 선점하거나, 프리다 스크립트를 작성하여 getpid 함수를 후킹하여 변조하면 해당 로직을 우회할 수 있을 것으로 보입니다. 먼저 프리다와 IDA를 이용하여 후킹을 시도해보겠습니다. 후킹 코드는 아래와 같습니다. 임의의 시간이 지난 후에 libnative-lib.so 파일이 메모리에 적재되도록 설정하면 IDA가 먼저 앱(프로세스)에 붙어 프로세스를 선점(디버깅)하도록 할 수 있습니다.
FRIDA SPAWN + IDA ATTACH
Interceptor.attach(Module.findExportByName(null, 'android_dlopen_ext'), {
onEnter: function(args) {
this.path = args[0].readUtf8String();
if(this.path.indexOf('libnative-lib.so') != -1) {
Thread.sleep(10);
}
},
onLeave: function(retval) {
if(this.path.indexOf('libnative-lib.so') != -1) {
// hook function
}
}
});libnative-lib.so 파일의 readStatus 함수가 호출되지 않으므로 자식 프로세스도 생성되지 않으며 ptrace를 통한 프로세스 선점도 진행되지 않습니다. 따라서, IDA로 21874 프로세스에 어태치를 시도하면 정상적으로 디버깅이 가능합니다.

다음으로는 프리다 후킹 스크립트를 활용한 안티 디버깅 우회 방법입니다.
FRIDA HOOKING - BYPASS ANTI-DEBUGGING
아래 코드를 실행하면 아래와 같이 getpid() 함수가 후킹되는 것을 확인할 수 있는데요. 0x5c3b는 부모 프로세스(앱 프로세스)에 해당하는 23611 입니다. v0 = getpid() 에서 v0 변수는 이후에 ptrace 함수로 앱에 어태치할 때 사용되는 PID이므로 이 값을 앱 PID가 아닌 값으로 설정하면 IDA에서 정상적으로 디버깅 할 수 있습니다.
Java.perform(function() {
console.error('\n[!] Hook Hook Hook !\n')
Interceptor.attach(Module.findExportByName(null, "getpid"), {
onEnter: function(args) {
console.warn('getpid function has been called !');
},
onLeave: function(retVal) {
console.log('Original Return Value : ', retVal);
retVal.replace(0x0);
console.log('Modified Return Value : ', retVal);
return this.retVal;
}
})
})

sprintf, fopen 함수 후킹을 통해서도 안티 디버깅 우회가 가능할 것으로 보입니다. fork() 함수가 호출된 후에 자식 프로세스에서는 v6 = fopen(s, "r"); 코드가 실행되는데 이 때 사용되는 변수 s는 sprintf(s, "/proc/%d/status", v0);에 의해 값이 할당됩니다. 이 때, fopen 함수의 인자로 사용되는 s로 유효하지 않은 값이 사용되면 해당 함수가 실행될 때 NULL 포인터 역참조가 발생하여 프로세스가 종료됩니다. 자식 프로세스가 종료되면 ptrace에 의한 앱 프로세스 선점이 해제되기 때문에 IDA를 통해 어태치하는 것이 가능해집니다.
후킹 스크립트를 작성하여 실행해보면 아래 이미지와 같이 안티 디버깅 우회가 가능합니다. fopen 함수의 인자로 사용되는 /proc/self/status를 존재하지 않는 임의의 값으로 덮어씌워 오류를 유발시키고 자식 프로세스가 종료되도록 하는 것이죠.
Java.perform(function() {
console.error('\nhook hook hook !')
Interceptor.attach(Module.findExportByName(null, "fopen"), {
onEnter: function(args) {
var path = Memory.readUtf8String(args[0])
if((path.indexOf("/proc/") !== -1) && (path.indexOf("/status") !== -1)) {
console.warn(`\nfopen('/proc/self/status', 'r') has been called !`);
console.log(`Original Path => ${path}`);
Memory.writeUtf8String(args[0], "dumpdumpdumpdump");
console.log(`Modified Path => ${Memory.readUtf8String(args[0])}`);
}
},
onLeave: function(retval) {
}
})
})
IDA 어태치 시 부모 프로세스 1개만 남아있는 것을 볼 수 있고 (자식 프로세스는 Cut!)
IDA로 앱 프로세스에 정상적으로 어태치되는 것도 확인 가능합니다.

이렇게 린님의 안티-디버깅 앱을 이용하여 안티 디버깅 원리와 우회 방법에 대해 알아봤습니다.
REFERENCE
- 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?category=779680
- https://linears.tistory.com/entry/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%95%88%ED%8B%B0%EB%94%94%EB%B2%84%EA%B9%85
- https://blog.naver.com/koo__oo/222005876208