[Uncrackable] Android : level 1

·2025년 1월 17일

https://velog.io/@jnsnoir/frida-%EC%84%A4%EC%B9%98
프리다 사용을 전제로 함.


Uncrackable

https://mas.owasp.org/crackmes/
위 링크에서 L1부터 다운&실행.

<사진>
L1 앱은 루팅을 탐지한다. 앱에 들어가면 바로 앱이 종료된다.

jdx-gui

https://java-decompiler.github.io/

apk 파일 소스코드 확인을 위해 다운받는다.

apk 소스코드 분석

uncrackable L1 앱에 들어가자마자 루팅을 탐지하기 때문에, 최초 앱을 작동시키면 나오는 액티비티를 분석해야 한다.
왼쪽 리소스 탭에 들어가서 AndroidManifest.xml을 확인한다.



안드로이드에서 AndroidManifest.xml 파일의 첫 번째 activity가 최초 실행되는 액티비티다.
MainActivity는 왼쪽 탭 소스코드➡️sg.vantagepoint 패키지➡️uncrackable1 패키지에 존재한다.
ctrl+클릭하면 바로가기 가능.


MainActivity 소스코드 확인 (1)


해당 액티비티가 생성될 때 실행되는 함수 OnCreate()를 확인한다.

if (c.a() || c.b() || c.c()) {
            a("Root detected!");
        }

c 클래스의 a() b() c() 메소드 중 하나라도 true를 리턴할 경우 루팅이 탐지된다.

if (b.a(getApplicationContext())) {
            a("App is debuggable!");
        }

앱의 context를 b클래스의 a() 메소드에 파라미터로 설정하고, a() 메소드가 true를 리턴할 경우 앱 디버깅이 탐지된다.


C 클래스 확인

우선 루팅 탐지부터 확인해보자. c클래스를 확인한다.

package sg.vantagepoint.a;

import android.os.Build;
import java.io.File;

/* loaded from: classes.dex */
public class c {
    public static boolean a() {
        for (String str : System.getenv("PATH").split(":")) {
            if (new File(str, "su").exists()) {
                return true;
            }
        }
        return false;
    }

    public static boolean b() {
        String str = Build.TAGS;
        return str != null && str.contains("test-keys");
    }

    public static boolean c() {
        for (String str : new String[]{"/system/app/Superuser.apk", "/system/xbin/daemonsu", "/system/etc/init.d/99SuperSUDaemon", "/system/bin/.ext/.su", "/system/etc/.has_su_daemon", "/system/etc/.installed_su_daemon", "/dev/com.koushikdutta.superuser.daemon/"}) {
            if (new File(str).exists()) {
                return true;
            }
        }
        return false;
    }
}

a() 메소드부터 확인한다.

public static boolean a() {
        for (String str : System.getenv("PATH").split(":")) {
            if (new File(str, "su").exists()) {
                return true;
            }
        }
        return false;
    }

PATH 환경변수를 콜론(:)을 기준으로 나눠 하나씩 반복문으로 가져오고 있다.
만약 PATH 환경변수에 su 파일이 등록되어있다면 true를 리턴한다.
안드로이드에서 단말기가 루팅될 때, 자동으로 PATH 환경변수에 su 파일이 등록된다고 한다.


public static boolean b() {
        String str = Build.TAGS;
        return str != null && str.contains("test-keys");
    }

b()메소드는 현재 빌드 태그 정보가 null이 아니고 test-keys가 포함되어있으면 true를 리턴한다.

빌드 정보는 안드로이드 디바이스의 펌웨어나 소프트웨어가 어떻게 컴파일되고 배포되었는지에 대한 메타데이터다.

일반적으로 정식 버전에는 빌드 태그에 release-keys가 포함되고, test-keys가 포함되어있으면 루팅된 기기로 판단할 수 있다.


public static boolean c() {
        for (String str : new String[]{"/system/app/Superuser.apk", "/system/xbin/daemonsu", "/system/etc/init.d/99SuperSUDaemon", "/system/bin/.ext/.su", "/system/etc/.has_su_daemon", "/system/etc/.installed_su_daemon", "/dev/com.koushikdutta.superuser.daemon/"}) {
            if (new File(str).exists()) {
                return true;
            }
        }
        return false;
    }

c()메소드는 String 타입 배열을 선언하고 있다.
배열의 요소들은 파일의 경로를 나타내고 있는데, 이 파일들은 루팅된 장치에서 일반적으로 발견되는 루트 권한 관리와 관련된 파일들이다. ( 루팅도구 (Magisk, SuperSU)가 설치될 때 생성된다고 함 )

이 경로들에 들어가서 파일이 실제로 존재하는지 확인하고, 존재하면 true를 리턴한다.


private void a(String str) {
        AlertDialog create = new AlertDialog.Builder(this).create();
        create.setTitle(str);
        create.setMessage("This is unacceptable. The app is now going to exit.");
        create.setButton(-3, "OK", new DialogInterface.OnClickListener() { // from class: sg.vantagepoint.uncrackable1.MainActivity.1
            @Override // android.content.DialogInterface.OnClickListener
            public void onClick(DialogInterface dialogInterface, int i) {
                System.exit(0);
            }
        });
        create.setCancelable(false);
        create.show();
    }

결론적으로 c클래스의 a(), b(), c() 모두 루팅을 탐지하는 메소드이다. 셋 중 하나라도 true를 리턴하면, MainActivity의 a()메소드를 통해 팝업창이 나타난다.

frida 후킹 스크립트 작성(1)

아래 블로그를 참고했다.
https://mingzz1.github.io/pentesting/android/2020/09/09/frida_install_for_android.html/


후킹

특정 함수의 코드를 가로채서 원하는 행위를 한 뒤 원래의 코드로 돌려주는 기법
http://linforum.kr/bbs/board.php?bo_table=android&wr_id=731#google_vignette

루팅 탐지를 우회하는 방법은 여러가지가 있다.

  • a(), b(), c() 메소드에서 false를 리턴하도록 변경
  • 팝업창에서 확인 버튼을 눌러도 시스템이 종료되지 않게 변경
  • 혹은 아예 팝업창이 나타나지 않게 MainActivity의 a() 메소드를 변경할 수도 있겠다.

다양한 방법으로 우회가 가능할 것 같다.

import frida, sys

def on_message(message, data):
	print(message)


# Hooking 할 어플리케이션의 package 명
PROCESS_NAME = "owasp.mstg.uncrackable1"

 
jscode = """
# 이 안에 후킹 스크립트를 작성한다.
"""

# 연결된 장치 애플리케이션 프로세스에 연결
process = frida.get_usb_device(1).attach(PROCESS_NAME)

# 프로세스에 후킹할 자바스크립트 코드를 포함하는 객체를 생성
script = process.create_script(jscode)

# 자바스크립트 코드로 후킹한 결과를 파이썬에 전달
script.on('message', on_message)

print('[+] Running Hook')

# 자바스크립트 실행
script.load()

# 무한 대기 상태로 진입해 프로그램이 종료되지 않도록 한다.
sys.stdin.read()

기본적인 frida 후킹 스크립트 틀은 다음과 같다.
PROCESS_NAME에 들어갈 내용은 아래 명령어를 이용해 확인한다.

frida-ps -Uia

해당 부분의 프로세스 Name을 작성한다.

에러

타 블로그를 살펴보면 모두 위 명령어에서 출력된 패키지 네임을 이용했는데, 실제로 스크립트를 돌려보니 다음과 같은 에러가 발생했다.

frida.ProcessNotFoundError: unable to find process with name 'owasp.mstg.uncrackable1'

해당 에러는 패키지 이름 대신 프로세스 이름을 사용하니 정상적으로 실행됐다.


이제 루팅 탐지 우회를 위해 jscode 함수에 들어갈 자바스크립트문을 작성해보자.

<나중에 다른 방식 스크립트 추가>


System.exit(0) 후킹

console.log("[+] Start Script");

Java.perform(function() {
	console.log("[+] Hooking System.exit");
    
    # System 클래스를 사용한다.
	var exitClass = Java.use("java.lang.System");
    
    # System 클래스의 exit 함수에 대한 구현을 새로 정의한다.
	exitClass.exit.implementation = function() {
		console.log("[+] System.exit called");
	}
});

대표적으로 잘 알려진 exit() 메소드를 후킹하는 스크립트.
exit() 메소드 내부 구현을 단지 콘솔 출력만 하는 것으로 변경했다.

import frida, sys

def on_message(message, data):
	print(message)


PROCESS_NAME = "Uncrackable1"

 
jscode = """
console.log("[+] Start Script");

Java.perform(function() {
	console.log("[+] Hooking System.exit");
    
	var exitClass = Java.use("java.lang.System");
	exitClass.exit.implementation = function() {
		console.log("[+] System.exit called");
	}
});
"""

process = frida.get_usb_device(1).attach(PROCESS_NAME)
script = process.create_script(jscode)
script.on('message', on_message)

print('[+] Running Hook')

script.load()
sys.stdin.read()

uncrackable_L1.py

전체 스크립트는 다음과 같다. 단말기에서 uncrackable을 실행하고, 해당 파이썬 스크립트를 cmd에서 실행한다.

uncrackable_L1.py
혹은
python uncrackable_L1.py
python3 uncrackable_L1.py 라고 입력해야 실행되는 경우도 있다 ㅠ.ㅠ

<단말기 사진첨부>
스크립트가 정상적으로 실행되며, 팝업창에 OK 버튼을 눌러도 앱이 종료되지 않는다.


MainActivty 소스코드 확인 (2)

이제 Secret Code를 알아내야 한다. 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 (a.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() { // from class: sg.vantagepoint.uncrackable1.MainActivity.2
            @Override // android.content.DialogInterface.OnClickListener
            public void onClick(DialogInterface dialogInterface, int i) {
                dialogInterface.dismiss();
            }
        });
        create.show();
    }

EditText는 사용자의 입력을 받는 안드로이드 요소이다. EditText에 사용자가 입력한 값을 getText()toString()을 통해 String 타입으로 받아오고 있다.

만약 a클래스의 a()메소드가 true면 정답을 입력한 것이고, 옳지 않다면 오답을 입력한 것이다.

if문 이하는 설정한 팝업창을 보여주는 코드이므로, 넘어가자.

그럼, a 클래스를 확인해보자.
이때, MainActivity에서 선언한 a클래스는 sg.vantagepoint.uncrackable1 패키지의 a 클래스이다.
sg.vantagepoint.a 클래스도 있으니 헷갈리지 말자.

a 클래스 확인

 public static boolean a(String str) {
        byte[] bArr;
        byte[] bArr2 = new byte[0];
        try {
            bArr = sg.vantagepoint.a.a.a(b("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
        } catch (Exception e) {
            Log.d("CodeCheck", "AES error:" + e.getMessage());
            bArr = bArr2;
        }
        return str.equals(new String(bArr));
    }

a 클래스의 a() 메소드는 다음과 같다.
다른 패키지의 a() 메소드를 호출하고 있다. 해당 메소드를 파라미터를 2개 받는다.

하나씩 살펴보자.


b("8d127684cbc37c17616d806cf50473cc")
public static byte[] b(String str) {
        int length = str.length();
        byte[] bArr = new byte[length / 2];
        for (int i = 0; i < length; i += 2) {
            bArr[i / 2] = (byte) ((Character.digit(str.charAt(i), 16) << 4) + Character.digit(str.charAt(i + 1), 16));
        }
        return bArr;
    }

b() 메소드를 호출하고 있다.
b() 메소드는 16진수 문자열을 바이트 배열로 변환하는 동작을 한다.
즉, 8d127684cbc37c17616d806cf50473cc을 바이트 배열로 변환한다.

Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0)

다음 메소드는 Base64로 인코딩된 문자열 5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc을 디코딩한다. 뒤에 0은 플래그 값으로. 줄바꿈, 패딩 문자 등... 디코딩 옵션을 지정한다. (0일 경우 기본 동작)

이제 a 패키지의 a() 메소드를 살펴보자.

public static byte[] a(byte[] bArr, byte[] bArr2) {
        SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES/ECB/PKCS7Padding");
        Cipher cipher = Cipher.getInstance("AES");
        cipher.init(2, secretKeySpec);
        return cipher.doFinal(bArr2);
    }

a() 메서드는 AES 대칭 암호화 알고리즘을 이용해 데이터를 복호화하는 함수다.
첫 번째 파라미터 bArr은 비밀키로 이용되고, Base64로 디코딩한 데이터, bArr2가 복호화할 데이터다.

다시, uncrackable1의 a클래스로 넘어와보자.

public static boolean a(String str) {
        byte[] bArr;
        byte[] bArr2 = new byte[0];
        try {
        # 암호화된 정답을 복호화
            bArr = sg.vantagepoint.a.a.a(b("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));
        } catch (Exception e) {
            Log.d("CodeCheck", "AES error:" + e.getMessage());
            bArr = bArr2;
        }
        
        # 사용자의 입력값과 복호화한 값을 비교
        return str.equals(new String(bArr));
    }

즉, a패키지에서 복호화한 값이 사용자가 입력한 값과 일치하는지 비교한 결과를 리턴한다.


frida 후킹 스크립트 작성(2)

루팅 탐지와 마찬가지로 다양한 방법을 통해 우회가 가능할 것 같다.

  • 복호화된 값 출력하기
  • uncrackable1.a 클래스의 a() 메소드가 true를 리턴하도록 변경하기

복호화된 값 출력하기

정답이 뭔지 궁금하니까 출력해보자. 앞서 작성한 스크립트에 추가한다.

...

jscode = """

...

console.log("[+] Hooking decrypt");

var decryptClass = Java.use("sg.vantagepoint.a.a");
decryptClass.a.implementation = function(args1,args2) {
	var decryptValue = this.a(args1,args2);

	var str="";
	for(var i=0; i<decryptValue.length; i++){
		str+=String.fromCharCode(decryptValue[i]);
	}

	console.log("[*] SecretCode: "+str);
	return decryptValue;
}

"""

...

sg.vantagepoint.a 패키지의 a 클래스를 후킹한다.
a클래스의 a 메소드에 대한 내용을 변경한다.

this.a는 변경하지 않은 원래 소스코드의 결과를 리턴한다. 정상적인 동작에 오류가 없게 하기 위해 변수에 따로 저장해놓고, 결과값으로 리턴한다.

원본 소스코드의 결과는 복호화된 바이트 타입 값이기 때문에 console.log로 출력할 수 없다. 때문에 바이트 값을 문자열로 출력해주는 함수 fromCharCode() 메소드를 이용한다.

String.fromCharCode()는 JavaScript의 표준 내장 메서드로, 유니코드(Unicode) 값들을 문자열로 변환하는 기능을 수행합니다. 주어진 하나 이상의 UTF-16 코드 유닛을 기반으로 문자열을 생성합니다.

반복문으로 바이트 배열의 값을 하나하나 변환하고, str 변수에 저장해서 str 변수를 출력해준다.

마찬가지로 앱을 실행하고, 아무 값이나 입력해보면 console 창에 복호화된 정답이 나타난다!
출력된 정답을 입력하면 문제 해결~!


이렇게 하지 않고 단순하게 입력값과 정답을 비교하는 a() 메소드를 수정해서 무조건 true를 리턴하도록 변경할 수도 있다.
profile
🔥

0개의 댓글