본문 바로가기

카테고리 없음

Android UnCrackable L2

 

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()