(PRACTICE) Uncrackable-Level 3

앱 실행 시 Rooting or tampering detected. 알림창이 발생합니다.   디컴파일 해서 소스코드를 확인해봅시다.


정보수집

앱을 실행하면 onCreate 함수 안에서 verifyLibs, init(xorkey.getBytes()) 함수가 실행됩니다. 이 때, AsyncTask 에 의해서 디버깅 모드가 활성화(AndroidManifest > android:debuggable="true") 됐는지 체크하고 있습니다. 또, 그 아래의 조건문에서는 루팅/디버깅 가능 여부/무결성 변조 여부를 탐지하고 있습니다.


개략적으로 코드를 분석한 이후에 (AOS) Uncrackable-Level1 의 프리다 스크립트를 실행해보면 아래와 같은 에러 메시지가 발생하는데요. backtrace 출력 정보를 통해 어느 구간에서 오류가 발생하였는지 확인할 수 있습니다. goodbye() 함수로부터 12바이트가 지난 구간에서 오류가 발생하고 있네요.


기드라/아이다를 활용해서 goodbye() 함수가 실행되는 구간으로 넘어가보겠습니다.
FUN_001030d0 함수 내부를 살펴보면 goodbye() 를 발견할 수 있는데요.
함수의 실행 로직을 분석해보면 (1) fopen 함수로 /proc/self/maps 파일을 열어 frida, xposed와 같은 문자열이 존재하면 goodbye() 함수를 호출하여 프로세스를 종료시키고 있습니다. (2) 만약, /proc/self/maps 파일이 정상적으로 열리지 않아도 goodbye() 함수를 실행하여 프로세스를 종료시켜 버립니다. (3) 또, 반복문 밖으로 나가게 되는 순간에도 goodbye() 함수가 실행되어 프로세스가 종료됩니다. 이를 우회하기 위해서는 fopen 함수로 특정 파일을 열 때 /proc/self/maps가 아닌 임의의 파일을 열도록 설정하거나, strstr 함수로 특정 문자열을 검색할 때 인자 값 혹은 리턴 값을 조작하거나, fgets 함수로 한 줄을 읽고 버퍼에 “frida, xposed” 문자열이 있는지를 검사할 때 이 값을 다른 값으로 덮어씌우는 방식으로 우회해 볼 수 있습니다.


필자는 문자열 비교 함수 strstr 의 인자 값을 변조하여 우회를 시도해보았습니다.

send("[*] hook hook hook !");
 
Interceptor.attach(Module.findExportByName(null, "strstr"), {
    onEnter: function(args) {
        console.warn('\n[*] strstr function has been called !');
        var args0 = Memory.readUtf8String(ptr(args[0]));
        var args1 = Memory.readUtf8String(ptr(args[1]));
        
        // 특정 메모리 주소의 RWX 권한 설정 (rw-, r-x, -wx, etc.)
        // ACCESS VIOLATION 에러가 발생하면, 아래와 같이 메모리 보호 설정을 변경해줘야 합니다.
        Memory.protect(ptr(args[1]), 64, "rwx");
        if (args1 == "frida") {
            console.log('\targs0 => ', args0);
            console.log('\targs1 => ', args1);
 
            Memory.writeUtf8String(args[1], "dumpdumpdumpdump");
        }
    }
})


만약, 메모리 값을 덮어씌우는 과정에서 아래와 같은 오류가 발생한다면 Memory.protect(address, size, protection) 코드를 통해 특정 메모리 주소에 대한 RWX 권한을 재설정하면 해결됩니다.


앞선 과정에서 이미 JADX로 자바 코드를 확인해봤는데요. Rooting or tampering detected 메시지가 발생하였으니 verifyLibs(), init(...), Debug.isDebuggerConnected() 함수는 별도 우회과정이 필요하지 않습니다. (궁금해서 확인해보니 무결성 탐지와 디버깅 탐지 로직이었습니다.)
이제 메인 액티비티로 접근하기 위해서는 아래 코드에서 검증하는 구간을 우회하면 됩니다.

if (RootDetection.checkRoot1() || RootDetection.checkRoot2() || RootDetection.checkRoot3() || IntegrityCheck.isDebuggable(getApplicationContext()) || tampered != 0)

여기서 우회가 필요한 함수는 checkRoot1() 1가지인데요.  checkRoot2는 Build.TAGS 값이 “release-keys” 이고, checkRoot3에서 탐지하는 경로들은 매지스크로 루팅한 단말이기 때문에 존재하지 않았습니다. 그리고 IntegrityCheck.isDebuggable(getApplicationContext())도 AndroidManifest 파일에 디버깅 설정이 없었기 때문에 해당사항이 없고 tampered 값 또한 앱 리빌드를 하지 않았으므로 무결성이 침해된 바가 없어 탐지될 일이 없습니다. checkRoot1() 의 탐지 로직을 우회한 스크립트는 아래와 같습니다. level1, level2와 동일하게 “java.io.File(args0, args1)” 클래스의 인스턴스가 생성될 때 인자 값을 변조하는 방식으로 우회했습니다.

send("[*] hook hook hook !");
Java.perform(function () {
    console.log();
 
    var File = Java.use('java.io.File');
    File.$init.overload('java.lang.String', 'java.lang.String').implementation = function (str, su) {
        var stackTrace = Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new());
 
        if (stackTrace.includes("sg.vantagepoint.util.RootDetection.checkRoot1")) {
            console.warn('File.$init(str, "su") in sg.vantagepoint.util.RootDetection.checkRoot1 has been called !')
            console.log(`\tFile.$init(str, "su") => File.$init(${str}, "su")`);
            return this.$init("dumptrashdumptrash", su);
        }
 
        return this.$init(str, su);
    }
})
 
Interceptor.attach(Module.findExportByName(null, "strstr"), {
    onEnter: function(args) {
        var args0 = Memory.readUtf8String(ptr(args[0]));
        var args1 = Memory.readUtf8String(ptr(args[1]));
        
        Memory.protect(ptr(args[1]), 64, "rwx");
        if (args1 == "frida") {
            console.warn('\n[*] strstr function has been called !');
            console.log('\targs0 => ', args0);
            console.log('\targs1 => ', args1);
 
            Memory.writeUtf8String(args[1], "dumpdumpdumpdump");
        }
    }
})

위 스크립트를 실행하면 이제 메인 액티비티로 진입이 가능한데요.
메인 화면에서 임의의 값을 입력하고 “VERIFY” 버튼을 클릭하면
That's not it. Try again 알림창이 발생합니다. 이제 이 부분을 분석해봐야 합니다.


this.check.check_code(obj) 함수의 실행 결과에 따라서 성공/실패 화면이 출력되는 것을 확인할 수 있는데요. check_code(obj) 함수 내부를 살펴보니 네이티브 함수(bar)를 호출하여 사용하고 있습니다. 다시 기드라로 이동해서 bar 함수가 어떻게 동작하는지 확인해보겠습니다.


기드라에서 Java_sg_vantagepoint_uncrackable3_CodeCheck_bar 함수를 살펴보면 DAT_00115054, DAT_00115038 값에 따라 코드가 분기되고 있는데요.

위 변수들에 어떤 값이 들어있는지 확인해보겠습니다.
”DAT_00115054, DAT_00115038” 값은 “libfoo.so” 라이브러리가 메모리에 적재된 이후 0x15054, 0x15038 바이트만큼 이동한 시점에 존재하므로 이 구간을 확인해야 합니다.


DAT_00115054, DAT_00115038

프리다 스크립트를 이용해서 “libfoo.so”이 메모리에 로드되는 BASE 주소에
0x15054, 0x15038 만큼 더한 주소를 덤프해서 훑어보면 “pizzapizza…” 문자열을 확인할 수 있습니다.
그럼 여기서 “pizza..pizza”는 어디서 온 값일까요?


아래 이미지를 보면 자세히 알 수 있습니다. 네이티브 함수 “init(…)”의 인자로 xorkey(“pizzapizzapizzapizzapizz”) 값이 사용되는데 기드라에서 코드를 다시 확인해보면 해당 값을 “DAT_00115038” 변수에 복사하여 저장하고 있습니다. 때문에 “DAT_00115038” 의 메모리 주소로 접근해서 값을 확인해보면 피자피자피자피자피자가 나왔던겁니다.



다시 기드라로 돌아와서 Java_sg_vantagepoint_uncrackable3_CodeCheck_bar 함수를 분석해보겠습니다. 1번째 파라미터인 long *param_1는 사용자가 입력한 값(바이트 배열)을 가리키는 포인터입니다. 이 값은 추후에 if (*(byte *)(lVar4 + uVar5) != (*(byte *)((long)&DAT_00115038 + uVar5) ^ *(byte *)((long)&local_68 + uVar5))) 조건문의 lVar4 값에 활용됩니다. 따라서, 사용자가 입력한 값은 *(byte *)(lVar4 + uVar5) 이고 코드에서 정의된 임의의 문자열은 (*(byte *)((long)&DAT_00115038 + uVar5) ^ *(byte *)((long)&local_68 + uVar5)))라고 유추할 수 있습니다. &DAT_00115038는 “pizzapizzapizzapizzapizz” 이고 uVar5 값은 0으로 정의되어 있기 때문에 “&local_68” 값만 알 수 있다면 XOR 연산을 직접 수행하여 임의의 문자열을 구할 수 있을 것이고 이 값이 바로 “Success!” 화면을 띄우는 문자열에 해당되겠죠!


 “&local_68” 값을 확인하려면 FUN_001010e0(&local_68) 함수를 후킹해서 인자를 확인해보면 됩니다.    아래의 스크립트를 활용하여 빠르게 구해보겠습니다. (func_addr → 0x10e0)
 


“&local_68” 의 메모리 주소는 0x7ff8368fb8 이고 0x7ff8368fb8 부터 32바이트의 값을 출력해보면 아래와 같습니다.

00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 90 36 f8 7f 00 00 00

다음으로 FUN_001010e0(&local_68) 함수 내부에서 어떤 코드가 실행되는지 확인해보겠습니다.
인자로 사용된 &local_68 는 함수 내부에서 param_1 라는 이름으로 활용되고 있으며 param_1 배열에 임의의 값들이 할당되고 있는 것을 볼 수 있습니다. 때문에 FUN_001010e0(&local_68) 함수가 실행된 이후에 &local_68 값을 확인해보면 이전과는 다른 값이 출력될 것을 예상할 수 있습니다.


실제로 함수 실행 전/후 결과를 비교해보겠습니다.
&local_68 에 저장된 값이 아래와 같이 변경된 것을 볼 수 있습니다.
앞 24바이트만 값이 다르고 뒤 8바이트는 동일하므로 &local_68은 24바이트 크기를 가진 배열입니다.   혹은 “pizzapizzapizzapizzapizz” 문자열이 24바이트이므로 이를 통해 유추해도 됩니다.

1d 08 11 13 0f 17 49 15 0d 00 03 19 5a 1d 13 15
08 0e 5a 00 17 08 13 14 00 90 36 f8 7f 00 00 00

그럼 이제 아래 XOR 문에 대한 계산을 직접 해보겠습니다.

if (*(byte *)((long)&DAT_00115038 + uVar5) ^ *(byte *)((long)&local_68 + uVar5))

[ . . . ]
 
function XOR() {
    var pizza = "pizzapizzapizzapizzapizz";
    var hexData = [
        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 answer = "";
    for (var i = 0; i <= 24; i++) {
        answer += String.fromCharCode(pizza.charCodeAt(i) ^ hexData[i]);
    }
 
    console.log(`Flag : ${answer}`);
}
 
bypassFrida();
bypassCheckRoot1();
hexDump(0x15054);
hexDump(0x15038);
traceFunc(0x10e0);
 
XOR();

위 스크립트를 실행하면 “making owasp great again” 을 확인할 수 있습니다.


이제 이걸 메인 화면에서 입력해주면 “Success!” 메시지를 확인할 수 있습니다.


REFERENCE

  1. https://mas.owasp.org/crackmes
  2. https://hackcatml.tistory.com/105
  3. https://blog.naver.com/koo__oo/222437620168