Frida로만 우회할 것이기 때문에, 일단 frida spawn으로 앱에 붙여봅니다.
앱 크래시가 발생하는데, backtrace에 "/data/app/owasp.mstg.uncrackable3-Hq5BUb-FTx24k8x76LLLog==/lib/arm64/libfoo.so (goodbye()+12)" 정보가 찍혀 있습니다.
arm64 "libfoo.so" 파일을 열어서 goodbye() 함수 정보를 확인합니다. goodbye 함수는 sub_30D0 함수에서 호출하고 있습니다.
sub_30D0 함수를 확인합니다. fopen 함수를 이용하여 "/proc/self/maps" 파일을 열어서 frida 정보를 확인하고 있는것을 확인할 수 있습니다.
"/proc/self/maps" 파일에 "frida" 문자열이 있거나, fopen함수가 파일을 제대로 열지 못한 경우에는 goodbye() 함수를 호출하여 앱을 종료시키고 있습니다.
fopen 함수에 대한 후킹코드를 작성하여, "/proc/self/maps"가 아닌 다른 파일(ex. "/proc/self/stat")을 열어보도록 하면 frida 탐지 우회가 가능합니다.
function nativeTrace(nativefunc) {
var nativefunc_addr=Module.getExportByName(null, nativefunc)
var func=ptr(nativefunc_addr);
Interceptor.attach(func, {
// set hook
onEnter: function (args) {
console.warn("\n[+] " + nativefunc + " called"); // before call
if (nativefunc == "fopen") {
if(args[0].readUtf8String() == "/proc/self/maps"){
args[0].writeUtf8String("/proc/self/stat");
}
console.log("\n\x1b[31margs[0]:\x1b[0m \x1b[34m" + args[0].readUtf8String() + ", \x1b[32mType: ");
}
},
onLeave: function (retval) {
if(nativefunc == "fopen"){
console.warn("[-] " + nativefunc + " ret: " + retval.toString() ); // after call
}
}
});
}
nativeTrace("fopen");
그러나 access violation이 발생하면서 앱이 죽습니다. 이는 frida가 메모리에 접근하지 못하여 발생하므로 Memory의 접근권한을 우선적으로 설정해줄 필요가 있습니다.
function nativeTrace(nativefunc) {
... 생략 ...
if (nativefunc == "fopen") {
if(args[0].readUtf8String() == "/proc/self/maps"){
// 메모리 권한 설정
Memory.protect(args[0], 16, 'rwx');
args[0].writeUtf8String("/proc/self/stat");
}
console.log("\n\x1b[31margs[0]:\x1b[0m \x1b[34m" + args[0].readUtf8String() + ", \x1b[32mType: ");
}
},
... 생략 ...
}
nativeTrace("fopen");
이제 fopen함수에 "/proc/self/maps"가 아닌 "proc/self/stat" 파일이 계속해서 들어가고 있으며, frida 탐지가 우회된 결과 앱은 죽지 않는것을 확인할 수 있습니다.
이제 루팅탐지 우회를 위해 apk파일을 까보면 RootDetection 클래스의 checkRoot1, checkRoot2, checkRoot3 메서드가 루팅 탐지 역할을 하는것을 알 수 있습니다.
RootDetection 클래스를 살펴보면 다음과 같은데, 실제 단말기(갤럭시 s7)의 build 키값은 "release-keys" 이므로 checkRoot2() 메서드는 무시할 수 있습니다. checkRoot3() 메서드 역시 매지스크로 루팅한 단말기여서 해당 디렉터리에 명시된 파일들이 존재하지 않으므로 무시할 수 있습니다. 즉, 신경써야 할 루팅탐지 메서드는 오직 checkRoot1() 뿐입니다.
checkRoot1() 메서드는 여러가지 방법으로 우회가 가능한데, checkRoot1() 메서드의 반환 타입이 boolean이므로 return 값을 true로 변경시키면 제일 간단합니다.
여기서는 System.getenv 메서드를 후킹하여 루팅 탐지를 우회해보겠습니다. 우선 System.getenv 메서드를 후킹하여 반환값을 살펴봅니다.
function nativeTrace(nativefunc) {
... 생략 ...
}
nativeTrace("fopen");
Java.perform(function(){
var java_lang_System = Java.use('java.lang.System');
java_lang_System.getenv.overload('java.lang.String').implementation = function(argv0){
var retval = this.getenv(argv0);
console.log("retval: " + retval);
return retval;
}
});
다음과 같이 PATH 환경변수가 반환값에 찍히는 것을 확인할 수 있습니다.
반환된 PATH 환경변수는 .split(":") 에 의해 "/sbin", "/system/sbin", ... 등으로 쪼개져서 v0 array에 할당되며, for 문에서 각 디렉터리에 "su"파일이 존재하는지 여부를 .exists() 메서드로 확인하고 있습니다.
루팅 탐지에서 문제되는 파일은 "/sbin/su" 이므로 System.getenv 메서드를 후킹하여 retval의 "sbin"을 "sban"으로 바꿔주면 우회가 가능하겠습니다.
function nativeTrace(nativefunc) {
... 생략 ...
}
nativeTrace("fopen");
Java.perform(function(){
var java_lang_System = Java.use('java.lang.System');
java_lang_System.getenv.overload('java.lang.String').implementation = function(argv0){
var retval = this.getenv(argv0);
console.log("original retval: " + retval);
var newretval = "/sban:/system/sban:/product/bin:/apex/com.android.runtime/bin:/system/bin:/system/xbin:/odm/bin:/vendor/bin:/vendor/xbin"
console.log("newretval: " + newretval);
return newretval;
}
});
이제 화면에 아무값이나 입력해서 반응을 살펴봅니다. "Nope..." 문구가 출력되는데 해당 문자열은 MainActivity에서 찾을 수 있습니다. 또한 if 문의 this.check.check_code(v4) 값이 true가 되면 "Success!" 문구가 출력되는것을 알 수 있습니다.
check_code 메서드를 따라 들어가면 결국 native library의 함수와 연결되어 있는것을 확인할 수 있습니다.
"libfoo.so" 파일을 열어서 보면 친절하게 함수이름이 "...Codecheck_bar"로 명시되어 있어서 쉽게 찾을 수 있습니다.
저는 분석전에 가능한 변수를 줄이고 가는 걸 좋아해서 우선 위 그림에서 "dword_15054", "qword_15038"에 어떤값이 들어있는지 확인해보겠습니다.
function nativeTrace(nativefunc) {
... 생략 ...
}
function hexDump(dump_addr){
var module_base = Module.findBaseAddress("libfoo.so"); // get base addr from module
var dump_realaddr = module_base.add(dump_addr); // add function offset
console.log("hexdump start at " + ptr(dump_addr));
console.log(hexdump(dump_realaddr,{offset:0, length:32}));
}
nativeTrace("fopen");
hexDump(0x15054);
hexDump(0x15038);
Java.perform(function(){
... 생략 ...
});
다음과 같이 "dword_15054" 에는 0x02가 들어있고, "qword_15038" 에는 24자리의 문자열 "pizzapizzapizzapizzapizz" 이 들어있습니다.
이번에는 "sub_10E0(v8);"의 인자값 변화를 살펴보겠습니다.
왜냐하면 뒤의 while문("while ( *(v6 + v7) == (qword_15038[v7] ^ *(v8 + v7)) )")에서 xor 연산을 수행하기 때문에, v8에 어떤 값이 들어있는지 살펴볼 필요가 있겠죠.
function nativeTrace(nativefunc) {
... 생략 ...
}
function hexDump(dump_addr){
... 생략 ...
}
function traceSub(sub_addr){
var module_base = Module.findBaseAddress("libfoo.so"); // get base addr from module
var sub_realaddr = module_base.add(sub_addr); // add function offset
console.log("Tracing " + ptr(sub_addr));
Interceptor.attach(sub_realaddr, { // set hook
onEnter: function (args) {
console.warn("[+] " + ptr(sub_addr) + " called");
if(sub_addr == "0x10e0"){
console.log("v8: " + args[0]); // v8 인자값 출력
}
},
onLeave: function (retval) {
console.warn("[-] " + ptr(sub_addr) + " Exiting"); // after call
}
});
}
nativeTrace("fopen");
// hexDump(0x15054);
// hexDump(0x15038);
traceSub(0x10e0);
Java.perform(function(){
... 생략 ...
});
화면에 아무값이나 입력해서 실행결과를 살펴보면 v8 인자는 메모리 주소값으로 보입니다.
그렇다면 "sub_10E0()" 함수로 들어갈때의 v8의 주소값에 들어있는 값과, 'sub_10E0()" 함수를 나올때의 v8의 주소값에는 어떤값이 들어있는지도 살펴보겠습니다.
function nativeTrace(nativefunc) {
... 생략 ...
}
function hexDump(dump_addr){
... 생략 ...
}
function traceSub(sub_addr){
var module_base = Module.findBaseAddress("libfoo.so"); // get base addr from module
var sub_realaddr = module_base.add(sub_addr); // add function offset
console.log("Tracing " + ptr(sub_addr));
Interceptor.attach(sub_realaddr, { // set hook
onEnter: function (args) {
console.warn("[+] " + ptr(sub_addr) + " called");
if(sub_addr == "0x10e0"){
console.log("v8: " + args[0]); // v8 인자값 출력
this.temp = args[0];
console.log("onEnter sub_10e0:\n" + hexdump(ptr(this.temp),{offset:0, length:32}));
}
},
onLeave: function (retval) {
console.log("onLeave sub_10e0:\n" + hexdump(ptr(this.temp),{offset:0, length:32}));
console.warn("[-] " + ptr(sub_addr) + " Exiting"); // after call
}
});
}
nativeTrace("fopen");
// hexDump(0x15054);
// hexDump(0x15038);
traceSub(0x10e0);
Java.perform(function(){
... 생략 ...
});
실행결과 sub_10e0함수를 들어오고 나갈때 v8 주소값에 들어있는 값에 분명한 차이가 있습니다.
들어갈때는 *v8에 "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" 이 들어있지만, 나갈때는 *v8에 "1D 08 11 13 0F 17 49 15 0D 00 03 19 5A 1D 13 15 08 0E 5A 00 17 08 13 14" 이 들어있습니다.
따라서, 뒤의 while문("while ( *(v6 + v7) == (qword_15038[v7] ^ *(v8 + v7)) )")에서는 "qword_15038" 에 들어있는 24자리의 문자열 "pizzapizzapizzapizzapizz" 와 *v8에 들어있는 24자리의 hex값 "1D 08 11 13 0F 17 49 15 0D 00 03 19 5A 1D 13 15 08 0E 5A 00 17 08 13 14"에 대하여 xor 연산을 수행한다는 것을 알 수 있습니다.
XOR 연산으로 어떤 값이 출력되는지 Frida로 알아볼 수 있습니다.
function nativeTrace(nativefunc) {
... 생략 ...
}
function hexDump(dump_addr){
... 생략 ...
}
function traceSub(sub_addr){
... 생략 ...
}
function XOR(){
var zzz = "pizzapizzapizzapizzapizz";
var kkk = [0x1D,0x08,0x11,0x13,0x0F,0x17,0x49,0x15,0x0D,0x00,0x03,0x19,0x5A,0x1D,0x13,0x15,0x08,0x0E,0x5A,0x00,0x17,0x08,0x13,0x14];
var secret = "";
for(var i=0; i<=24; i++){
secret += String.fromCharCode(zzz.charCodeAt(i) ^ kkk[i]);
}
console.log(secret);
}
nativeTrace("fopen");
// hexDump(0x15054);
// hexDump(0x15038);
// traceSub(0x10e0);
XOR();
Java.perform(function(){
... 생략 ...
});
위 스크립트의 실행결과로 secret 값이 콘솔에 출력됩니다.
'Information Security > Android' 카테고리의 다른 글
Xposed 간단한 Module 제작 (0) | 2021.09.13 |
---|---|
갤럭시 S9(SM-G960N) Pixel AOS 10 커스텀롬 설치 (1) | 2021.07.10 |
Frida의 ptrace 사용 시점, 그리고 MagiskHide (0) | 2021.06.21 |
갤럭시 S7(SM-G930S) Pixel AOS 10 커스텀롬 설치 (4) | 2021.06.11 |
Android 10 SSH for Magisk (0) | 2021.05.19 |