owasp-mastg/Crackmes/Android/Level_02/UnCrackable-Level2.apk at master · OWASP/owasp-mastg

The Mobile Application Security Testing Guide (MASTG) is a comprehensive manual for mobile app security testing and reverse engineering. It describes the technical processes for verifying the contr...

github.com

$ adb install UnCrackable-Level2.apk
 
frida -U -f owasp.mstg.uncrackable2 -l bypass.js

앱을 실행해보니 이전과 다르게 앱이 강제종료됨

 
패키지명 : package="owasp.mstg.uncrackable2"
메인 엑티비티(분석 진입점) : <activity android:name="sg.vantagepoint.uncrackable2.MainActivity">
 
Jadx로 MainActivity 디컴파일 하여 확인

 
18번째 줄을 보니 
접근 제한자 private 으로 MainActivity 외부에서는 접근 불가한 CodeCheck 클래스 타입의 인스턴스 변수 m을 지정
 
20번째 줄을 보니
static 으로 클래스가 처음 로드될때 한번만 실행되도록 하는 정적 초기화를 진행하고서 libfoo.so라는 네이티브 라이브러리 JNI(Java Native Interface)를 로딩함
즉, MainActivity가 메모리에 로드될때 libfoo.so 도 함께 메모리에 로딩된다 (JNI 함수를 사용 할 수 있도록)
 

 
로딩되는 라이브러리를 보기위해서 apk파일 내부 /lib 경로에 접근한다면 사용환경마다 libfoo.so 파일이 존재하는것을 볼 수 있고

  • libfoo.so는 C/C++로 작성된 네이티브 바이너리 라이브러리로, .so는 ELF(Executable and Linkable Format) 포맷이다.
  • jadx는 Java 바이트코드(Android .dex/.jar/.apk) 디컴파일러로, .so 파일을 분석할 수 없다.

따라 Ghidra, IDA Pro, Radare2, Binary Ninja 등을 통해 .so파일을 분석을 진행해야한다
 
Ghidra > import > FileSystem에서 libfoo.so 파일을 분석 시작

 

Funtions를 보면 MainActivity가 존재하고 FUN_00100918(); (로컬함수)가 가장 먼저 실행되는것을 볼수 있다.

FUN_00100918(); 함수를 클릭해서 디컴파일을 진행

 ✅ 기본 원리 정리

Ghidra에서 본 주소:

00100934:    bl <EXTERNAL>::fork

 여기서 00100934는 **해당 함수 또는 명령의 파일 내 오프셋(offset)**입니다. 즉:
libfoo.so 파일 자체에서의 상대적인 위치이지, 실행 중인 메모리 상의 실제 주소는 아닙니다.

✅ 실행 중 메모리 주소로 변환하는 방법
1. 실행 중에 libfoo.so는 메모리에 동적으로 로딩됨
Frida에서 이걸 알 수 있는 코드:

const base = Process.getModuleByName("libfoo.so").base;

2. Ghidra의 오프셋을 base 주소에 더한다

const forkCall = base.add(0x100934);  // 00100934

→ 이 주소가 실제 메모리 상에서 fork를 호출하는 명령이 실행 중 위치하는 곳임

✅ 정리: 후킹 근거 공식
실행 시 메모리 주소 = libfoo.so 로드 base 주소 + Ghidra 오프셋
Ghidra에서 본 주소 00100934는 오프셋이고,
실행 중 주소는 base + 0x100934입니다.

✅ 왜 이렇게 해야 하나?
ELF나 so 파일은 실제 시스템에 따라 다른 주소에 로드되기 때문에,
Ghidra에서 본 raw 오프셋을 그대로 쓰면 동작 중인 프로그램 주소랑 맞지 않습니다.

 

🔧 1. 레지스터의 역할은 왜 중요할까?

ARM64에서는 각 레지스터가 특정 역할을 갖고 있고,
이걸 모르고 보면 w0이 뭔지, x1이 뭔지 감이 안 잡힙니다.

레지스터용도
x0~x7 함수 인자 전달용 (최대 8개까지)
x0 함수 리턴값도 여기 담김
x8 종종 임시 변수나 주소 계산용
x29 프레임 포인터 (fp)
x30 리턴 주소 (lr)
 
bl fork        // fork() 호출
str w0, [x8, #8] // fork 리턴값을 전역변수에 저장

→ 여기서 w0을 리턴값이라는 걸 모르면, 그냥 의미 없는 숫자처럼 보임

 

📦 2. 주소 접근 방식 (adrp + add / str)

ARM은 절대주소를 바로 못 쓰기 때문에 항상 아래처럼 분할합니다.

adrp x8, 0x113000
str w0, [x8, #0x8]
  • 이걸 조합하면 0x113008 주소에 w0 값을 저장한다는 뜻이 됨
  • → 전역 변수 저장 (DAT_00113008 = fork() 와 동일한 의미)

 

✅ 이 방식은 전역변수, 함수 포인터, 라이브러리 주소 등에 모두 쓰이므로 주소 계산 원리를 모르고선 제대로 분석이 불가능함

🔀 3. 조건 분기 명령

조건 분기가 코드 흐름을 바꾸는 핵심입니다.

cbz w0, LAB_00100960  // w0 == 0이면 분기
  • 이거 하나로 "부모냐 자식이냐" 판단해서 fork 결과 분기하는 핵심 로직을 알 수 있음
  • 다른 예:
    • b.eq (equal)
    • b.ne (not equal)
    • cmp x0, #0 + b.eq labe

💡 정리: 꼭 알아야 하는 이유

항목왜 필요한가
레지스터 역할 함수 인자, 리턴값 추적 필수
주소 접근 방식 전역 변수, 함수 주소 추적
조건 분기 명령 프로그램 흐름 판단

 

명령어 의미 설명
bl Branch with Link 함수 호출. 호출 후 LR(링크 레지스터)에 복귀 주소 저장
adrp ADR (Page) 상위 4KB 페이지 주소 계산. 주소 연산 시 base로 사용됨
add Add 레지스터 값에 상수 또는 다른 레지스터 값을 더함
mov Move 레지스터 값을 복사함
str Store Register 레지스터 값을 메모리 주소에 저장
cbz Compare and Branch if Zero 값이 0이면 지정된 주소로 분기함
b Branch 무조건 지정된 주소로 분기함

 

주소 명령어 의미
00100934 bl <EXTERNAL>::fork fork() 함수 호출
00100938 adrp x8, 0x113000 상위주소 0x113000 계산 후 x8에 저장
0010093c str w0, [x8, #0x8] fork 리턴값(w0)을 [0x113008]에 저장
00100940 cbz w0, LAB_00100960 w0이 0이면 → 자식 프로세스 → 감지 루틴 실행
00100944 adrp x2, 0x100000 x2에 0x100000 base 저장
00100948 add x2, x2, #0x8dc x2 = x2 + 0x8dc → 함수 포인터 계산
0010094c mov x0, sp x0에 스택 포인터 저장 (pthread 파라미터)
00100950 mov x1, xzr x1 = 0 (xzr: zero 레지스터)
00100954 mov x3, xzr x3 = 0
00100958 bl pthread_create 감지용 스레드 생성
0010095c b LAB_001009d0 다음 로직으로 분기

 

🔍 어셈블리 분석 요약

00100934: bl     fork                   // fork() 호출 → 결과가 w0에 저장됨
00100938: adrp   x8, 0x113000           // x8 = 0x113000 페이지 상위주소
0010093c: str    w0, [x8, #0x8]         // w0 저장 → 즉 DAT_00113008 ← w0
00100940: cbz    w0, LAB_00100960       // w0 == 0 이면 감지 루틴 진입

 

>>> 00100940 에서 cbz_check: libfooBase.add(0x940),   // CBZ 조건 분기에서 w0 레지스터 값을 조작하여 분기를 우회를 하려했으나 

fork() 호출 시:

  • 부모 프로세스는 그대로 유지
  • 자식 프로세스는 메모리를 복사하고, 새 PID로 분기됨
  • 그런데 Frida는 부모 프로세스에 붙어 있는 상태이므로,
    자식 프로세스에는 후킹이 이어지지 않음

📌 결과적으로:

  • 부모는 우회가 되어도
  • 자식은 ptrace, exit, kill 등의 검사를 다시 타고 → 즉시 종료됨

 

📌 1. Frida의 자바스크립트 기반 후킹의 한계

Frida의 Java.perform()은 현재 attach된 프로세스 내부의 Java 계층만 제어 가능하다.

즉:

  • Java.perform()은 현재 프로세스 안에서만 동작
  • fork() 이후 새로 생성된 자식 프로세스는 새로운 PID와 주소 공간을 가짐
  • Frida는 기존 attach된 세션 안에서 새 PID에 접근할 수 없음

👉 즉, 자식은 별개의 프로세스이므로 기존 JS 스크립트는 자식에 자동 적용되지 않는다

📌 2. fork() 후 자식에 Frida를 붙이려면?

✅ 외부에서 "attach"를 다시 해야 함

  • 자식 프로세스의 PID를 얻은 뒤,
  • frida.attach(PID)로 다시 후킹 스크립트를 주입해야 함
  • 이 작업은 JavaScript가 아니라 Python/CLI에서만 가능
session = frida.attach(child_pid)  # JS에서는 불가능

해결 방법 : fork() 후 자식 프로세스도 후킹 적용 (frida-trace 재어태치)

자식 프로세스를 추적하려면 fork() 반환 후 retval === 0인 경우,
Frida를 자식에 다시 attach하는 로직이 필요합니다.

하지만 JavaScript API 내에서는 직접 attach 불가능하므로, Frida 서버를 사용하고 Python 등 외부에서 attach하는 구조로 구성해야 합니다.

✅ 결론 정리

질문 답변
왜 Java.perform으로 안 되고 Python 써야 함? 자식 프로세스는 기존 Java.perform() 후킹 범위를 벗어나기 때문에
Java에서는 attach(PID) 불가능? 맞다. JS API는 attach 기능 없음
그래서 Python이 필요한 이유는? 자식 프로세스를 탐지하고, PID 기준으로 attach 하기 위해서
import frida
import time

PACKAGE_NAME = "owasp.mstg.uncrackable2"

# libfoo.so 기준 보호 로직 주소 offset
FORK_ADDR_OFFSET = 0x934
CBZ_ADDR_OFFSET = 0x940
EXIT_ADDR_OFFSET = 0x960

# 후킹에 사용할 JavaScript 코드 (libfoo.so 기준 주소 + offset으로 계산)
JS_HOOK_CODE = f"""
console.log("✅ 프리다 후킹 시작됨");

var lib = Process.getModuleByName("libfoo.so");
var fork_call = lib.base.add({FORK_ADDR_OFFSET});
var cbz_check = lib.base.add({CBZ_ADDR_OFFSET});
var exit_path = lib.base.add({EXIT_ADDR_OFFSET});

// fork() 후킹: 자식 생성 후 부모처럼 위장
Interceptor.attach(fork_call, {{
    onLeave: function(retval) {{
        var r = retval.toInt32();
        if (r === 0) {{
            console.log("🔁 자식 프로세스 (retval == 0) → 부모로 위장");
            retval.replace(1);
        }} else {{
            console.log("👨‍👦 부모 프로세스, 자식 PID: " + r);
        }}
    }}
}});

// CBZ 분기 우회: w0 == 0 → 종료 방지
Interceptor.attach(cbz_check, {{
    onEnter: function() {{
        if (this.context.w0.toInt32() === 0) {{
            console.log("⚠️ CBZ: w0 == 0 → 점프 방지 (w0 → 1)");
            this.context.w0 = 1;
        }}
    }}
}});

// 종료 경로 차단
Interceptor.attach(exit_path, {{
    onEnter: function() {{
        console.log("💀 종료 경로 진입 → 건너뜀 (PC += 4)");
        this.context.pc = this.context.pc.add(4);
    }}
}});
"""

# 자식 프로세스에 attach할 때 호출되는 함수
def on_child_added(child):
    print(f"[+] 자식 프로세스 감지됨 (PID={child.pid}) → attach 중...")
    session = frida.get_usb_device().attach(child.pid)
    script = session.create_script(JS_HOOK_CODE)
    script.load()
    session.resume()

def main():
    device = frida.get_usb_device()

    print(f"[*] 앱 spawn: {PACKAGE_NAME}")
    pid = device.spawn([PACKAGE_NAME])

    print(f"[*] attach to PID: {pid}")
    session = device.attach(pid)

    device.on("child-added", on_child_added)  # ✅ 자식 프로세스 이벤트 감지
    session.enable_child_gating()             # ✅ 자식 추적 활성화

    print("[*] 후킹 코드 주입 중...")
    script = session.create_script(JS_HOOK_CODE)
    script.load()

    print("[*] 앱 resume 시작")
    device.resume(pid)

    print("🟢 앱 실행 중, 후킹 활성화됨. Ctrl+C 로 종료")
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("🛑 종료됨")

if __name__ == "__main__":
    main()

 

 

 

Python 3.9.7

https://www.python.org/downloads/release/python-397/

 

Python Release Python 3.9.7

The official home of the Python Programming Language

www.python.org

환경변수 셋팅 

requirements.txt
0.00MB

 

(venv)가 앞에 붙어있으면됨

 

# (1) 가상환경 생성  
python -m venv venv
# → "venv"라는 이름으로 파이썬 가상환경 폴더를 만든다.
#   이 폴더 안에만 독립적으로 패키지가 설치된다.

# (2) 가상환경 활성화 (실행)
venv\Scripts\activate
# → "venv" 가상환경 안으로 진입.  
#   (명령 프롬프트 앞에 (venv) 표시가 붙는다)

# (3) pip 업그레이드
pip install --upgrade pip
# → 패키지 설치관리 도구(pip)를 최신버전으로 만든다.
#오류난다면 아래 명령어로 실행
python -m pip install --upgrade pip

# (4) 필요한 패키지 설치
pip install -r requirements.txt
# → 현재 폴더의 requirements.txt 파일에 적힌 모든 패키지들을 한 번에 설치한다.


-----------------------------------------------------------------------
(1) 명령 프롬프트(CMD) 창을 새로 열었다면
cd 프로젝트폴더경로
# → 다시 작업할 프로젝트 폴더로 이동

venv\Scripts\activate
# → 위와 같이 가상환경을 재활성화(진입)


(2) 가상환경에서 빠져나오는 방법 (비활성화)
deactivate
# → 가상환경에서 나온다. (원래 시스템 파이썬 상태로 돌아감)

https://github.com/OWASP/owasp-mastg/raw/master/Crackmes/Android/Level_01/UnCrackable-Level1.apk

$ adb install UnCrackable-Level1.apk

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    android:versionCode="1"
    android:versionName="1.0"
    package="owasp.mstg.uncrackable1">
    <uses-sdk
        android:minSdkVersion="19"
        android:targetSdkVersion="28"/>
    <application
        android:theme="@style/AppTheme"
        android:label="@string/app_name"
        android:icon="@mipmap/ic_launcher"
        android:allowBackup="true">
        <activity
            android:label="@string/app_name"
            android:name="sg.vantagepoint.uncrackable1.MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

 

패키지명 : package="owasp.mstg.uncrackable1"

메인 엑티비티(분석 진입점) : <activity android:name="sg.vantagepoint.uncrackable1.MainActivity">

 

Jadx로 MainActivity 디컴파일 하여 확인

 

31번째 줄에서

if (c.a() || c.b() || c.c()) {

루팅을 검사하고있음을 확인

 

sg.vantagepoint.a.c.a(), sg.vantagepoint.a.c.b(), sg.vantagepoint.a.c.c()에서 확인할때 루팅이 감지되면 true으로 반환 하고 있는것을 확인 할 수 있음 따라 해당부분은 항상 false으로 반환하도록 하면 루팅이 우회될 것

 

34번째 줄

if (b.a(getApplicationContext())) {

에서는 디버거를 감지중인것을 확인 할 수 있다

// Java 계층에 접근 가능한 시점이 되었을 때 이 함수가 실행됨
Java.perform(function () {

    // 📌 1단계: 루팅 탐지 우회
    // 앱 내부 클래스 sg.vantagepoint.a.c 를 가져옴 (루팅 체크용)
    var RootCheck = Java.use("sg.vantagepoint.a.c");

    // static boolean a() 함수 후킹: 루팅 흔적 (su 바이너리) 존재 여부 판단
    RootCheck.a.implementation = function () {
        console.log("[+] Hooked c.a(): returning false (bypass)");  // 로그 출력
        return false;  // 항상 루팅 아님(false)으로 반환
    };

    // static boolean b() 함수 후킹: 빌드 태그가 test-keys인지 확인 (루팅 가능성)
    RootCheck.b.implementation = function () {
        console.log("[+] Hooked c.b(): returning false (bypass)");
        return false;
    };

    // static boolean c() 함수 후킹: 루팅에 관련된 파일 경로들 존재 여부 판단
    RootCheck.c.implementation = function () {
        console.log("[+] Hooked c.c(): returning false (bypass)");
        return false;
    };

    // 📌 2단계: 디버깅 여부 탐지 우회
    // sg.vantagepoint.a.b 클래스가 존재한다면 후킹 시도
    try {
        var DebugCheck = Java.use("sg.vantagepoint.a.b");

        // b.a(Context) 함수 후킹
        // 이 함수는 앱이 디버깅 가능한 상태인지 판단하는 용도
        DebugCheck.a.overload('android.content.Context').implementation = function (ctx) {
            console.log("[+] Hooked b.a(Context): returning false (bypass)");
            return false;  // 항상 디버깅 불가능하다고 반환
        };

    } catch (e) {
        // 클래스나 함수가 존재하지 않을 경우 에러 발생 → 로그 출력
        console.log("[-] b.a(Context) not hooked: " + e);
    }

});

 

✅ 개념 요약

Java.perform(...) Java 계층에 접근하기 위한 기본 구조
Java.use("...") 내부 Java 클래스를 조작할 수 있도록 Frida에 불러옴
.implementation = ... 해당 메서드를 런타임에서 재정의 (원래 코드 무시)
overload(...) 오버로딩된 메서드 중 정확한 시그니처 지정
결과 앱 내부 보안 로직을 런타임에 우회함

✅ Frida에서 overload(...)가 필요한 이유

Frida는 메서드 이름만으로는 어떤 오버로드된 함수인지 구분 못 해.
그래서 명확하게 어떤 함수 시그니처인지 알려줘야 해.

✅ 예: b.a(...)가 오버로드된 경우

sg.vantagepoint.a.b 클래스가 아래처럼 생겼다고 가정:

public class b {
    public static boolean a(Context ctx) {...}
    public static boolean a(String s) {...}
}

⛔ 잘못된 Frida 후킹 (충돌 발생 가능)

var b = Java.use("sg.vantagepoint.a.b");
b.a.implementation = function(x) { return false; };  // 어떤 a()? 모름

✅ 정확한 후킹

b.a.overload('android.content.Context').implementation = function(ctx) {
    return false;
};

→ 'android.content.Context' 인자를 받는 a() 함수만 정확히 후킹.

✅ 요약 정리

구분설명
a.implementation = ... 오버로딩이 없을 때만 가능
a.overload(...) 오버로딩된 경우 정확한 시그니처 지정 필요
왜 필요함? 이름만 같고 인자 다르면 다른 함수이기 때문
안 쓰면? 여러 오버로드가 있을 때 에러 or 잘못된 함수 후킹

✅ 언제 overload를 반드시 써야 하나?

  • 해당 함수 이름으로 여러 개의 시그니처가 정의된 경우
  • 앱이 a(String)과 a(Context) 같은 함수를 동시에 가지고 있을 때
  • Frida가 "ambiguous overload" 에러를 줄 때

 

MainActivity 루팅 다음단계로 텍스트를 입력받고 확인하는 과정이 존재함

 

받은 텍스트를  17번째 줄에서 bArrA 스트링 문자열을 비교하고있는 과정이 보인다 12번째 줄을 보면 특정 문자열을 base64 디코딩하는 과정을 거치고 sg.vantagepoint.a.a.a 으로 보내는것을 볼수 있다

 

9번 째줄을 확인하면 AES/ECB 모드로 복호화를 진행하는 것을 볼수 있다 (ECB 모드는 IV값 불필요, CBC모드는 IV값 필요)

 

이에따라 CyberChef로 문자열을 집어 넣은 후 base64 , AES Decrypt (ECB) 작업을 진행하여 암호화된 문자열을 복호화 할 수 있다.

1단계 : 패키지 설치

 

2단계 : WireGuard 인터페이스 추가

  1. LuCI 웹 UI → 네트워크 > 인터페이스

  2. "새로운 인터페이스 추가" > 이름 : wg0

  3. 프로토콜 : WireGuard VPN 선택

 

3단계 : WireGuard 인터페이스 (기본설정)

1. Genereate new ke pair 를 클릭 하여 키생성

2. 접근포트 51820 (기본 값)

3. IP Addresses 10.0.0.1/24 (로컬 가상 IP)

 

3단계 : WireGuard 인터페이스 (Peers 설정)

예시로 여러가지 목적을 가진 Peer를 생성(필요한것만 쓰면됨)

목적 Peer Address AllowedIPs
전체 트래픽 VPN 사용 user2 10.0.0.2/32 0.0.0.0/1, 128.0.0.0/1
외부만 VPN사용
(내부제외)
user3 10.0.0.3/32 0.0.0.0/1, 128.0.0.0/1

 

인터페이스 » wg0 » Edit peer

Peer Edit peer → 허용된 IP(Allowed IPs)
서버가 이 피어에게 어떤 IP 대역을 경유시키는지 지정
Generate configuration → 허용된 IP(Allowed IPs)
클라이언트가 어떤 트래픽을 이 서버로 보낼지를 의미
user2 10.0.0.2/32, 0.0.0.0/1, 128.0.0.0/1 예) QR 및 공유시 주소부분에 IP 수정
10.0.0.2/32
0.0.0.0/1 (삭제)
128.0.0.0/1 (삭제)
user3 10.0.0.3/32, 0.0.0.0/1, 128.0.0.0/1

 

4단계 : 방화벽 설정

네트워크 > 방화벽 → 영역 (Zone)

 

5단계 : 방화벽 - 포트 포워드

 

6단계 : 내부망 접근 방지 설정(ip별로 내부망 접근 불가하도록 설정가능)

+ Recent posts