
1. 네이티브 코드(NATIVE CODE)
네이티브 코드(Native Code)는 Java/Kotlin 이 아닌 C/C++ 같은 저수준 언어로 작성된 코드를 말합니다. 일반적으로 JNI(Java Native Interface)를 사용해서 자바/코틀린과 상호작용이 이루어지는데 게임/멀티미디어 처리/보안 모듈 등에서 활용될 수 있습니다. 네이티브 코드는 컴파일 이후 어셈블리어로 변환되기 때문에 분석이 어렵고 더 많은 시간이 소모됩니다. 때문에 최근 많은 앱에서는 보안 기능을 네이티브 코드에 적용하여 사용하고 있습니다. 앱에서 사용되는 네이티브 함수는 APK 디컴파일 시 lib 폴더의 .so 라이브러리 파일에 저장되며 디스어셈블러(Ghidra, IDA, Hopper, etc.)를 통해 분석할 수 있습니다. ANDITER 앱을 통해 네이티브 코드로 보안 기술이 적용되는 방법과 우회 방안에 대해 알아보겠습니다.
APK 디컴파일 후 NativeDetector 클래스를 살펴보면 네이티브 함수들을 확인할 수 있습니다. 어떠한 기능을 수행하는 코드인지 디스어셈블러(IDA)를 통해 확인해보겠습니다.
BYPASS NATIVE (Rooting-Files) : C/C++ 라이브러리 호출을 이용한 루팅 관련 패키지 및 바이너리 파일 탐지

isCheckRooting 함수를 검색해보면 코드를 확인할 수 있는데요. isRooted() 함수 내부에는 루팅 기기에서 발견되는 바이너리·패키지·APK 파일 경로에 대한 문자열들을 확인할 수 있으며 이 값들은 이후에 v1 변수에 할당되어 사용됩니다.

v1 변수는 추후에 서브루틴 함수(sub_61E40)의 인자로 활용되는데요. 함수를 추적해서 분석해보면 fopen 함수의 v4 값으로 사용된다는 것을 알 수 있습니다. 루팅 기기에서 발견되는 파일 경로들을 미리 선언해놓고 fopen 함수로 해당 경로가 열리면 그 단말을 루팅 기기로 판별하는거죠. 네이티브 함수는 자바 코드 후킹과는 조금 다른데요. fopen 함수는 자바 코드에 존재하는 것이 아니라 .so 파일 내부에 존재하므로 해당 파일이 앱 실행 후 메모리에 정상적으로 적재된 후에 후킹이 가능합니다. 따라서 아래와 같이 코드를 작성해 볼 수 있겠습니다.
(FRIDA) 네이티브 함수(fopen) 후킹
앞서 언급했듯이, fopen 함수의 원형은 아래와 같습니다. 함수의 1번째 파라미터가 파일의 절대경로를 의미하므로 후킹 시 1번째 인자 값을 확인한 뒤 이를 변조하는 과정이 필요합니다.
send("[*] Hook Hook Hook !");
// FILE* fopen (const char* fileName, const char* fileMode)
Interceptor.attach(Module.findExportByName(null, "fopen"), {
onEnter: function(args) {
this.path = Memory.readUtf8String(args[0]);
console.log();
console.warn('[*] Native Function - fopen has been called.');
console.log('\tOriginal fileName : ', this.path);
var RootLists = ['su', 'supersu', 'superuser', 'magisk', 'rooting', 'Superuser.apk', 'SuperSU.apk', 'BusyBox.apk', 'com.noshufou.android.su', 'eu.chainfire.supersu', 'com.koushikdutta.superuser']
if (RootLists.includes(this.path.split('/').pop())) {
Memory.writeUtf8String(args[0], 'isNotExist');
console.log('\tModified fileName : ', Memory.readUtf8String(args[0]));
}
//console.log('\t', JSON.stringify(this.context));
},
onLeave: function(retVal) {
return retVal;
}
});다음으로는 단말기 내부에 설치된 패키지 리스트를 검사하는 코드입니다. 아래 이미지를 보면 특정 패키지명 문자열들이 v6에 할당되고 있으며 추후에 system 함수의 인자로 사용되고 있음을 볼 수 있습니다.

system 함수는 입력받은 문자열을 커맨드 창에서 실행하는 기능을 가지는데요. 명령어 실행 시 패키지명이 존재하면 아래와 같은 메시지를 반환하게 됩니다.
c1q:/ $ pm list packages com.playground.rooting
package:com.playground.rooting따라서, 안드로이드 기기 내부에 특정 패키지명이 존재하는지 검사하고 존재한다면 이를 루팅 기기로 탐지하는 로직입니다. fopen과 동일하게 system 함수 실행 시 사용되는 인자 값을 필터링하여 임의의 값으로 변환하면 우회가 가능할 것으로 보입니다.
(FRIDA) 네이티브 함수(system) 후킹
// int system(const char *string);
Interceptor.attach(Module.findExportByName(null, "system"), {
onEnter: function(args) {
this.cmd = Memory.readUtf8String(args[0]);
console.log();
console.warn('[*] Native Function - system has been called.');
console.log('\tOriginal fileName : ', this.cmd);
if (this.cmd.includes('pm list packages')) {
Memory.writeUtf8String(args[0], 'isNotExist');
console.log('\tModified fileName : ', Memory.readUtf8String(args[0]));
}
//console.log('\t', JSON.stringify(this.context));
},
onLeave: function(retVal) {
return retVal;
}
});BYPASS NATIVE (Rooting-Files)
Rooting-Files 실습에서는 fopen, system 함수가 함께 적용되어 있기 때문에 fopen 탐지 로직을 우회해야 system 함수가 실행됩니다. 따라서 루팅 탐지 우회를 위해서는 각각의 우회 코드를 함께 실행해줘야 합니다. 작성한 코드를 실행해보면 아래와 같이 우회가 가능합니다.

BYPASS NATIVE (Rooting-Excution) : C/C++ 라이브러리 호출을 이용한 Which 명령어를 이용한 SU 바이너리 탐지
- (1)
isCheckExecution → isCheckSUExecution네이티브 함수 호출 - (2)
Java_com_playground_anditer_NativeDetector_isCheckSUExecution함수 분석 - (3)
findExecutable → popen('which su 2>/dev/null')호출

popen 함수를 통해 단말기 내부에 su 바이너리 존재 여부를 통해 루팅 탐지를 하고 있는데요. ADB SHELL에서 which su 명령어를 입력해보면 su 바이너리의 위치가 반환되는 것을 볼 수 있습니다. 루팅 기기이기 때문에 해당 바이너리가 존재하는 것이죠. 바이너리(실행) 파일의 존재 여부도 이전과 동일하게 네이티브 함수 후킹을 통해 우회가 가능합니다.
c1q:/ $ which su
/system/bin/su(FRIDA) 네이티브 함수(popen) 후킹
send("[*] Hook Hook Hook !");
// FILE *popen(const char *command, const char *open_mode)
Interceptor.attach(Module.findExportByName(null, "popen"), {
onEnter: function(args) {
this.binary = Memory.readUtf8String(ptr(args[0]));
console.log();
console.warn('[*] Native Function - fopen has been called.');
if (this.binary.includes('which su')) {
console.log('\tOriginal binary : ', Memory.readUtf8String(ptr(args[0])));
Memory.writeUtf8String(ptr(args[0]), 'dumptrashdumptrash')
console.log('\tModified binary : ', Memory.readUtf8String(ptr(args[0])));
}
},
onLeave: function(retVal) {
return retVal;
}
});
BYPASS NATIVE (Debug-Debuggable) : C/C++ 라이브러리 호출을 이용한 ro.debuggable 이상 값 탐지
안드로이드 디버깅이 가능하기 위해서는 AndroidManifest.xml 파일에 android:debuggable="true"로 설정되어 있거나 안드로이드 단말에 자체적으로 getprop ro.debuggable 값이 1로 설정되어 있어야 합니다. 처음 앱(ANDITER)을 설치하면 매니페스트 파일에 디버깅 설정이 존재하지 않으며 단말 자체 설정을 살펴봐도 ro.debuggable 값이 0으로 설정되어 있기 때문에 MagiskHidePropsConf를 이용하여 ro.debuggable 값을 1로 변경한 뒤에 진행하였습니다.

위 이미지와 같이 Java_com_playground_anditer_NativeDetector_isCheckDebuggable 네이티브 함수를 살펴보면 isDebuggable 함수 내부에서 popen("getprop ro.debuggable", "r")가 실행되고 있는 것을 볼 수 있습니다. 이전과 동일하게 함수의 인자 값을 덤프 값으로 덮어씌워 우회가 가능합니다.
(FRIDA) 네이티브 함수(popen) 후킹
send("[*] Hook Hook Hook !");
// FILE *popen(const char *command, const char *open_mode)
Interceptor.attach(Module.findExportByName(null, "popen"), {
onEnter: function(args) {
this.binary = Memory.readUtf8String(ptr(args[0]));
console.log();
console.warn('[*] Native Function - popen has been called.');
if (this.binary.includes('getprop ro.debuggable')) {
console.log('\tOriginal binary : ', Memory.readUtf8String(ptr(args[0])));
Memory.writeUtf8String(ptr(args[0]), 'dumptrashdumptrash')
console.log('\tModified binary : ', Memory.readUtf8String(ptr(args[0])));
}
},
onLeave: function(retVal) {
return retVal;
}
});
BYPASS NATIVE (Debug-TracerPID) : C/C++ 라이브러리 호출을 이용한 TracerPid 이상 값 탐지
Java_com_playground_anditer_NativeDetector_isCheckTracerPID 네이티브 함수를 확인해보면 /proc/self/status 파일의 TracerPid: 값을 가져와 디버깅 여부를 판단하고 있습니다.

위 코드를 살펴보면 TracePid 값을 가져오기 위해 compare 함수의 4번째 인자 값에 TracerPid: 문자열이 사용되는 것을 확인할 수 있는데요. 다음과 같이 TracerPid 값(PID)을 추출한 뒤에 디버깅 여부를 판단하고 있는 것으로 추측이 가능합니다.
c1q:/ $ cat /proc/17315/status | grep -i TracerPid
TracerPid: <PID>따라서, “TracerPid:“를 인자 값으로 사용하는 _ZNKSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE7compareEmmPKcm 함수를 후킹하여 우회를 시도해 볼 수 있겠습니다. 코드에서는 “TracerPid:” 문자열과 맵핑되는 값을 가져오도록 하고 있는데 “TracerPid:” 가 아닌 임의의 덤프 값을 사용하면 서버에서 디버깅 여부를 판단할 수 없도록 하는 것이 가능해집니다.
여기서 저도 몰랐던 사실이 있는데요. 인자 타입이 포인터(ex; void *)인 경우에는 Memory.writeUtf8String() 함수 사용이 불가하다고 합니다. 인자에 새로운 값을 재할당하고 싶다면 Memory.allocUtf8String("String") 함수를 사용해야 한다고 하네요. 처음 우회 코드를 작성할 때 Memory.writeUtf8String(args[0], "string") 와 같은 코드를 사용하면 Error: access violation accessing 0x743464a39b 오류가 발생했는데 아래의 원인 때문이지 않을까 싶습니다.
- (1) args[0]이 가리키는 메모리가 읽기 전용(read-only) 영역일 수 있음
- (2) 해당 주소가 유효한 메모리인지 확신할 수 없음
- (3) 원래 args[0]이 가리키는 데이터의 크기가 String을 담기엔 부족할 수 있음 (버퍼 오버플로우 발생 가능)
어찌됐건 allocUtf8String 함수를 이용하면 새로운 메모리 영역에 원하는 값(문자열)을 동적으로 할당할 수 있습니다. 이렇게 특정 값(문자열)을 새로운 메모리 주소에 할당한 뒤에 이를 가리키는 메모리 주소(포인터)를 함수의 인자 값으로 재할당하면 위 오류들이 발생하지 않고 안전하게 값을 변조해볼 수 있다는 것이 핵심입니다.
writeUtf8String vs allocUtf8String
Memory.writeUtf8String(ptr, str)→ 이미 유효한 메모리 주소(ptr이 가리키는 곳)가 있고, 그곳을 안전하게 수정할 수 있을 때만 사용Memory.allocUtf8String(str)→ 새로운 문자열을 저장할 메모리를 할당해야 할 때 사용
(FRIDA) 네이티브 함수(_ZNKSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE7compareEmmPKcm) 후킹
최종 우회 코드는 아래와 같습니다.
send('[*] hook hook hook !');
var didHookApis = false;
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {
onEnter: function(args) {
this.path = Memory.readUtf8String(args[0]);
console.log(this.path)
},
onLeave: function(retVal) {
if(!retVal.isNull() && this.path.indexOf('libanditer.so')!== -1 && !didHookApis) {
didHookApis = true;
console.log("File loaded hooking");
so_hook();
}
}
})
function so_hook() {
var so_target = Module.findExportByName("libanditer.so", "_ZNKSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE7compareEmmPKcm");
Interceptor.attach (so_target, {
onEnter(args) {
console.log();
console.warn('[*] Native Function - compare has been called');
console.log('\tOriginal args[3] value : ', Memory.readUtf8String(args[3]));
var dump = Memory.allocUtf8String("dumpdumpdump");
args[3] = dump;
console.log('\tModified args[3] value : ', Memory.readUtf8String(args[3]));
},
onLeave: function (retVal) {
return retVal;
}
})
}
BYPASS NATIVE (Frida-Files) : C/C++ 라이브러리 호출을 이용한 Frida 관련 파일 탐지
BYPASS NATIVE (Frida-Port) : C/C++ 라이브러리 호출을 이용한 Frida 리스닝 포트 탐지
Frida-Files, Frida-Port도 위와 동일하게 네이티브 함수에서 프리다 관련 파일/포트를 다루는 코드를 분석한 뒤에 함수를 후킹하면 우회가 가능합니다. 쓰다보니 양이 너무 많아서 이 파트는 생략..하도록 하겠습니다 (__)