Uncrackable level 2 (Frida 활용)
* 준비물 : Jadx, Frida, IDA, Nox, Uncrackable-level2.apk
1. UnCrackable-Level1.apk 설치
Step 1) UnCrackable-Level1.apk 깃허브에서 apk 파일을 다운 받을 수 있습니다.
주소 : https://github.com/OWASP/owasp-mastg/blob/master/Crackmes/Android/Level_02/UnCrackable-Level2.apk
Step 2) 다운 받은 apk 파일을 녹스에 드래그&드롭 하여 설치하고 실행시킨다.
실행하면 루팅 탐지 알림이 뜨며 OK를 누르면 자동으로 앱이 종료된다.
먼저 루팅 탐지 우회부터 실습해 보도록 하겠습니다.
2. 루팅 탐지 우회하기
Step 1) jadx-gui를 실행하고 다운받은 UnCrackable-Level2.apk 파일을 드래그&드롭 한 뒤 메인 액티비티를 찾아간다. (sg.vantagepoint -> uncracakable2 -> MainActivity)
이 중 onCreate 부분을 보면 루팅 탐지하는 알림 창을 띄우는 기능이 존재함을 알 수 있다.
@Override
public void onCreate(Bundle bundle) {
init();
if (b.a() || b.b() || b.c()) {
a("Root detected!");
}
if (a.a(getApplicationContext())) {
a("App is debuggable!");
}
new AsyncTask<Void, String, String>() {
@Override
public String doInBackground(Void... voidArr) {
while (!Debug.isDebuggerConnected()) {
SystemClock.sleep(100L);
}
return null;
}
@Override
public void onPostExecute(String str) {
MainActivity.this.a("Debugger detected!");
}
}.execute(null, null, null);
this.m = new CodeCheck();
super.onCreate(bundle);
setContentView(R.layout.activity_main);
}
루팅 탐지 부분은 level1과 같지만 차이점은 디버거를 탐지하는 부분도 생긴걸 볼 수 있다. 이번 level2는 frida를 이용해 함수를 후킹하여 풀어보도록 하겠습니다.
Step 2) frida를 활용하기 위한 js코드 작성
MainActivity 소스코드에서 생성자인 onCreate()함수를 보면 Rooting 검사와 디버거 검사를 수행합니다. 루팅 탐지 우회를 위해서 함수를 종료시키는 exit() 함수를 후킹하거나, exit 함수를 호출하는 MainActivity.a 함수를 후킹하면 됩니다.
// MainActivity.a 함수를 후킹하는 방법
Java.perform(function(){
var MainActivity = Java.use('sg.vantagepoint.uncrackable2.MainActivity');
MainActivity.a.overload('java.lang.String').implementation = function(param1){
console.log("[+] hooking Mainactivity.a :" + param1);
}
})
// System.exit() 함수를 후킹하는 방법
Java.perform(function(){
var System = Java.use('java.lang.System');
System.exit.overload('int').implementation = function(param1){
console.log("hooking exit function ");
}
})
1. Java.perform(function) {}
이 부분은 Frida가 Java 코드를 실행할 준비를 마치고, 이후의 코드를 실행하도록 하는 코드입니다. 이 코드는 Frida가 실행 중인 애플리케이션의 JVM(Java Virtual Machine)과 연결되어 있다는 것을 보장합니다. 이렇게 하면 Frida 스크립트 내부에서 Java 객체나 메소드를 안전하게 사용할 수 있습니다.
2. var MainActivity = Java.use('sg.vantagepoint.uncrackable2.MainActivity');
이 부분은 'sg.vantagepoint.uncrackable2.MainActivity'라는 Java 클래스를 가져와서 MainActivity라는 변수에 할당합니다. 이 클래스를 통해 클래스의 메소드나 필드에 접근할 수 있습니다. (인스턴스가 아닌 클래스를 반환)
3. MainActivity.a.overload('java.lang.String').implementation = function(param1){}
MainActivity 클래스의 a라는 메소드 중에서 매개변수로 'java.lang.String' 타입을 가지는 메소드의 구현을 재정의하는 코드입니다.
이 코드는 원래의 메소드 구현을 바꾸는 역할을 합니다. 여기서는 이 메소드가 호출될 때마다 console에 로그 메시지를 출력하도록 변경하고 있습니다. 클래스에 정의된 메소드를 재작성한다.
Step 3) 작성한 js코드를 frida로 실행시킨다.
명령어 : frida -U -l [js 파일 경로] -f [실행하고자 하는 프로세스 패키지명]
3. Secret 값 알아내기
Step 1) verify함수 분석하기
Secret값이 무엇인지 알아내기 위해 MainActivity에 검증 메서드를 확인해 보도록 하겠습니다.
public void verify(View view) {
String str;
String obj = ((EditText) findViewById(R.id.edit_text)).getText().toString();
AlertDialog create = new AlertDialog.Builder(this).create();
if (this.m.a(obj)) {
create.setTitle("Success!");
str = "This is the correct secret.";
} else {
create.setTitle("Nope...");
str = "That's not it. Try again.";
}
create.setMessage(str);
create.setButton(-3, "OK", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
}
});
create.show();
}
코드를 보면 this.m.a(obj) 함수가 True를 반환하면 Success가 출력되도록 되어 있습니다. 이때 m 은 onCreate함수를 보면 다음과 같이 CodeCheck()객체임을 알 수 있습니다. 따라서 CodeCheck.a() 함수를 분석해 줍니다.
Step 2) this.m.a(obj) 메서드를 보기 위해 this.m.a메서드를 확인한다.
좀 더 자세히 코드를 분석해보도록 하겠습니다.
package sg.vantagepoint.uncrackable2;
public class CodeCheck {
private native boolean bar(byte[] bArr);
public boolean a(String str) {
return bar(str.getBytes());
}
}
this.m.a을 살펴보면 사용자가 입력한 String 값을 byte형식으로 변환해서 bar 메소드로 넘겨준다.
bar 함수는 바로 위에 정의가 되어있으며 내용은 아무것도 없다. bar 메소드의 modifier을 살펴보면 native라는 것이 보인다. 이는 JNI(Java Native Interface)로 자바코드에서 다른 언어들로 작성된 라이브러리를 호출하거나 반대로 호출되게 하는 프레임워크이다.
※ JNI(Java Native Interface)
JNI는 자바 가상 머신(JVM)위에서 실행되고 있는 자바코드가 네이티브 응용 프로그램(하드웨어와 운영 체제 플랫폼에 종속된 프로그램들) 그리고 C, C++ 그리고 어샘블리 같은 다른 언어들로 작성된 라이브러리들을 호출하거나 반대로 호출되는 것을 가능하게 하는 프로그래밍 프레임워크이다.
※ NDK(Native Development Kit)
NDK는 Android에서 C 및 C++ 코드를 사용할 수 있게 해주는 일련의 도구 모음입니다.
안드로이드에서 NDK를 사용하기 위해서는 so 파일을 load 시켜주는 과정이 꼭 필요합니다. 그럼 네이티브 코드를 불러오는 라이브러리를 찾아보겠습니다.
Step 3) jadx에서 library를 검색하여 so파일을 load시켜주는 라이브러리를 찾는다.
이는 메인 액티비티에 존재하고 있으며 foo라는 라이브러리를 불러오는것을 확인할 수 있다.
즉, libfoo.so 파일에서 this.bar라는 메소드가 정의되어 있으며 해당 메소드에서 이리저리 처리되고 반환 값만 자바단으로 retrun 해주는 형식이다.
so 파일을 분석하기 위해 APK Easy Tool을 이용하여 디컴파일 한다.
Step 4) APK Easy Tool에서 디컴파일을 클릭한다.
Step 5) 디컴파일한 경로에서 lib 디렉터리로 이동한다.
경로 : ~\APK Easy Tool v1.60 Portable\1-Decompiled APKs\UnCrackable-Level2\lib
확인해보면 폴더가 4개나 있다. 이는 사용자가 어떤 OS bit를 가지고 있지 모르니 개발자가 모든 bit에서 실행할 수 있도록 한 것이다. 나에게 맞는 bit를 확인하기 위해 nox_adb를 활용해 안드로이드 OS bit를 확인해 보도록 하겠습니다.
Step 6) 본인이 사용하고 있는 단말기의 정보를 확인한다.
명령어 : getprop ro.product.cpu.abi
Step 7) 버전에 맞는 폴더로 들어가 libfoo.so 파일을 IDA를 이용해 확인한다.
이 중 Java_sg_vantagepoint_uncrackable2_CodeCheck_bar 함수를 확인한다.
그 이유는 Java_sg_vantagepoint_uncrackable2_CodeCheck_bar명에서 알 수 있듯이 bar가 들어가 있기 때문이다.
Step 8) Java_sg_vantagepoint_uncrackable2_CodeCheck_bar를 더블클릭하여 함수에 진입
Java_sg_vantagepoint_uncrackable2_CodeCheck_bar를 더블클릭하면 아래와 같은 화면이 나온다. 전체적인 흐름도를 볼 수 있다.
Step 9) F5를 누르면 읽기 편하게 바꿔준다.
사실 strcpy(s2, "Thanks for all the fish")에 나와있는 문자열이 Secret값이다. 하지만 우린 이를 모른다고 가정했을 때 풀이방법으로 풀어보겠습니다.
_BOOL4 __cdecl Java_sg_vantagepoint_uncrackable2_CodeCheck_bar(int a1, int a2, int a3)
{
const char *v3; // esi
_BOOL4 result; // eax
char s2[24]; // [esp+0h] [ebp-2Ch] BYREF
unsigned int v6; // [esp+18h] [ebp-14h]
v6 = __readgsdword(0x14u);
result = 0;
if ( byte_4008 == 1 )
{
strcpy(s2, "Thanks for all the fish");
v3 = (const char *)(*(int (__cdecl **)(int, int, _DWORD))(*(_DWORD *)a1 + 736))(a1, a3, 0);
if ( (*(int (__cdecl **)(int, int))(*(_DWORD *)a1 + 684))(a1, a3) == 23 && !strncmp(v3, s2, 0x17u) )
result = 1;
}
return result;
}
if문부터 하나씩 분석해보겠습니다.
1. strcpy(s2, "Thanks for all the fish");
- strcpy함수를 이용하여 s2변수에 "Thanks for all the fish"라는 문자열을 저장합니다.
2. v3 = (const char *)(*(int (__cdecl **)(int, int, _DWORD))(*(_DWORD *)a1 + 736))(a1, a3, 0);
- a1에 저장된 주소값에 736을 더한 위치에 있는 함수를 호출하며, 이 함수는 (int, int, _DWORD) 형식의 매개변수를 가집니다. 그리고 그 결과를 v3에 저장합니다.
(1) *(_DWORD *)a1
- a1의 값을 _DWORD 자료형으로 역참조합니다. 이는 a1이 가리키는 메모리 주소에 저장된 값에 접근하는 것을 의미합니다.
(2) (*(_DWORD *)a1 + 736)
- 이전 단계에서 얻은 값을 736만큼 증가시킵니다. 이렇게 얻은 값은 함수 포인터로 취급되며, 해당 함수 포인터에 (a1, a3, 0) 인자를 전달하여 호출합니다. 호출된 함수의 반환값은 const char* 형식으로 캐스팅되어 v3 변수에 저장됩니다.
※ DWORD
- "Double Word"의 약자로, 32비트 정수형을 나타내는 데이터 형식입니다. 정확히는 부호 없는 32비트 정수를 의미하며, 주로 메모리 또는 레지스터에서 데이터를 표현하는 데 사용됩니다.
거창하게 설명되어있지만 결론적으로 사용자가 입력한 값을 의미합니다.
3. if ((*(int (__cdecl **)(int, int))(*(_DWORD *)a1 + 684))(a1, a3) == 23 && !strncmp(v3, s2, 0x17u))
- a1에 저장된 주소값에 684를 더한 위치에 있는 함수를 호출하며, 이 함수는 (int, int) 형식의 매개변수를 가집니다. 그리고 그 결과가 23인지 검사하고, v3와 s2의 문자열을 최대 23글자(0x17u는 16진수로 10진수로 바꾸면 23이 나온다.)까지 비교하여 같으면 result에 1을 저장합니다.
strncmp 함수를 살펴보면 다음과 같다.
즉 str1과 str2를 n의 길이까지 비교하는 함수이다.
위의 정보들을 바탕으로 비교하고자 하는 Secret값에 해당하는 값인 s2 값을 frida를 이용해 로그로 찍어보도록 하겠습니다.
Step 10) frida를 이용해 s2 값을 콘솔 로그로 출력해 Secret값을 찾는다.
Interceptor.attach(Module.findExportByName("libfoo.so","strncmp"),{
onEnter: function(args){
var param1 = Memory.readUtf8String(args[0]);
var param2 = Memory.readUtf8String(args[1]);
if(param1.indexOf('01234567890123456789012')!== -1){
console.log(param1);
console.log(param2);
}
},
onLeave: function(){}
})
1. Interceptor.attach(Module.findExportByName("libfoo.so","strncmp")
Interceptor.attach()는 특정 함수를 후킹하고 그 함수가 호출될 때 실행할 동작을 정의하는 Frida의 함수입니다. Module.findExportByName("libfoo.so","strncmp") 부분에서는 "libfoo.so" 라는 이름의 공유 라이브러리에서 "strncmp"라는 이름의 함수를 찾아서 그 함수의 주소를 반환하게 됩니다.
Module.findExportByname() 혹은 Module.getExportByName() 함수를 이용해서 함수의 메모리 주소값을 가져온 다음 Interceptor를 붙여줍니다.
2. if(param1.indexOf('01234567890123456789012') !== -1) { ... }
우리가 secret String으로 입력할 값인 01234567890123456789012 가 strncmp 함수의 첫번째 인자로 들어오는지 확인합니다.
strncmp 함수의 첫번째 값에서 01234567890123456789012 값이 있다면, 첫번째 인자와 두번째 인자를 출력하는 코드입니다.
이때, 주의해야 할 점은 param1 == ‘01234567890123456789012’ 처럼 한다면 결과가 제대로 나오지 않는 다는 것입니다. 메모리에서 문자열이 있는지 검사할 떄는 정확한 일치인 == 보다, 메모리에 포함되어 있는지 검사하는 indexOf를 사용하는게 훨씬 정확합니다.
최종 js코드
Java.perform(function(){
var MainActivity = Java.use('sg.vantagepoint.uncrackable2.MainActivity');
MainActivity.a.overload('java.lang.String').implementation = function(param1){
console.log("[+] hooking Mainactivity.a :" + param1);
}
Interceptor.attach(Module.findExportByName("libfoo.so","strncmp"),{
onEnter: function(args){
var param1 = Memory.readUtf8String(args[0]);
var param2 = Memory.readUtf8String(args[1]);
if(param1.indexOf('01234567890123456789012') !== -1){
console.log(param1);
console.log(param2);
}
},
onLeave: function(){}
})
})
참조한 블로그
'mobile해킹 > 안드로이드' 카테고리의 다른 글
Uncrackable level 1 (정적분석 Part.2) (0) | 2023.05.24 |
---|---|
Uncrackable level 1 (정적분석 Part.1) (0) | 2023.05.23 |