본문 바로가기

Mobile

[Mobile] Uncrackable_Level3

반응형

💡Introduction

OWASP Mobile Application Security Testing Guide(MASTG) 저장소는 모바일 앱 리버스 엔지니어링 및 보안 테스트 실습을 위한 훈련용 크랙미 앱들이 저장되어 있다.

 

owasp-mastg/Crackmes/Android/Level_03/UnCrackable-Level3.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


📌Step-By-Step

Step1) 앱 실행시 Rooting or tampering detected 다이얼로그 창이 떴다.

 

Step2) frida 동적 분석을 하기 위해 frida 코드 실행을 시도했지만, Process crashed: Trace/BPT trap 에러가 발생했다.

Process crashed: Trace/BPT trap 에러는 앱이 Frida 탐지를 감지하여 크래시해서 에러가 발생한다고 한다.

 

 

Step3) Frida를 탐지하고 있는거 같아 탐지 우회부터 진행하기 위해 IDA로 libfoo.so 파일을 열어 분석을 진행했다.

우리가 현재 알고 있는 단서는 Rooting or tampering detected 문자 밖에 없기 때문에 해당 문자열을 토대로 찾아봤다. "tampering" 문자열과 관련된 코드를 확인했고, 아래 사진들은 IDA에서 해당 함수 접근 과정을 쭉 작성해봤다.

tampering 검색
C언어 소스코드로 변환된 sub_30D0() 메서드

 

 

Step4) 이렇게 sub_30D0() 메서드에서 해당 문자열이 존재하는 것을 확인했고, 해당 코드 분석 결과 frida와 xposed가 발견되면 탐지를 하는 것으로 추측했다.

{
  FILE *v0; // 텍스트 파일을 읽을 때 사용하는 변수 (파일 핸들러)
  char v1[512]; // 파일에서 읽어온 한 줄의 문자열을 저장할 512바이트 공간

  v0 = fopen("/proc/self/maps", "r");
  if ( v0 )
  //파일 열기 성공했을 때, 성공하면 v1에 저장됨
  {
    do
    {
      while ( !fgets(v1, 512, v0) )
      {
        fclose(v0);
        usleep(0x1F4u);
        v0 = fopen("/proc/self/maps", "r");
        if ( !v0 )
          goto LABEL_7;
      }
    }
   
    //파일 열기 실패했을 때, 실패하면 false → 루프 진입 
    while ( !strstr(v1, "frida") && !strstr(v1, "xposed") );
    
    // 위에서 둘 중(frida, xposed) 하나라도 발견되면 → 조건은 false가 되어 → 루프 종료하고 아래 실행
    __android_log_print(2, "UnCrackable3", "Tampering detected! Terminating...");
  }
  else
  {
LABEL_7:
    __android_log_print(2, "UnCrackable3", "Error opening /proc/self/maps! Terminating...");
  }
  goodbye();
}

 

 

Step5) 이걸 토대로 탐지 우회 코드를 작성하였다

//level_3.js
Java.perform(function () {
	// 1. Frida detection bypass!
    const strstrPtr = Module.findExportByName(null, "strstr");
    let logged = false;

    Interceptor.attach(strstrPtr, {
        onEnter: function (args) {
            this.haystack = args[0].readCString();
            this.needle = args[1].readCString();
        },
        onLeave: function (retval) {
            if (this.needle === "frida" || this.needle === "xposed") {
                if (this.haystack.indexOf("frida") !== -1 || this.haystack.indexOf("xposed") !== -1) {
                    retval.replace(ptr("0")); // 탐지 우회
                    if (!logged) {
                        console.log("[+] 탐지 우회: → NULL 반환");
                        logged = true;
                    }
                }
            }
        }
    });
});

 

 

Step6) 동적 분석을 통해 확인한 결과, 루팅 체크는 checkRoot1() 메서드만 실행되는 것을 확인했다. 따라서 이 메서드가 false를 반환하도록 Firda를 이용해 후킹 코드를 작성했고, 루팅 우회가 정상적으로 이루어졌다

이제 루팅 우회를 진행해야한다. 동적 분석으로 확인 시 checkRoot1 메서드만 실행되는 것을 확인했고, 해당 부분만 false를 출력하게 코드를 추가로 작성했다.

//level_3.js
Java.perform(function () {
    // 1. Frida detection bypass!
    const strstrPtr = Module.findExportByName(null, "strstr");
    let logged = false;
    Interceptor.attach(strstrPtr, {
        onEnter: function (args) {
            this.haystack = args[0].readCString();
            this.needle = args[1].readCString();
        },
        onLeave: function (retval) {
            if (this.needle === "frida" || this.needle === "xposed") {
                if (this.haystack.indexOf("frida") !== -1 || this.haystack.indexOf("xposed") !== -1) {
                    retval.replace(ptr("0")); // 탐지 우회
                    if (!logged) {
                        console.log("\n[+] 탐지 우회: → NULL 반환");
                        logged = true;
                    }
                }
            }
        }
    });

    //2 rooting detection bypass!
    var hookClass = Java.use('sg.vantagepoint.util.RootDetection');
    hookClass.checkRoot1.implementation = function(str) {
        console.log('[+] Hooked hookClass!');
        return false;
    };
});

 

 

Step7) 이제 Success를 띄우는 조건에 알맞는 Flag 값을 찾아야한다. 아래 조건이 true일 때 Success 메시지가 뜨는 것을 확인할 수 있다. 즉, 사용자가 입력한 값이 check.check_code(obj) 메서드에서 올바른 값으로 판단되면 Success가 뜨는 구조이다.

 

 

Step8) check_code() 메서드를 따라가보니 내부에서 호출되는 bar() 메서드는 native 메서드였다. 이는 Java 코드가 아닌 libfoo.so 네이티브 라이브러리 내에서 처리된다는 뜻이다.

그래서 .so 파일을 추출하고 IDA를 이용해 Java_sg_vantagepoint_uncrackable3_CodeCheck_bar() 함수를 분석해봤다.

libfoo.so

 

Step9) 분석 결과 핵심 로직은 아래 부분에서 이루어지고 있었다.

bool __fastcall Java_sg_vantagepoint_uncrackable3_CodeCheck_bar(__int64 a1, __int64 a2, __int64 a3)
{
...
  if ( dword_15054 == 2 )
  {
    sub_10E0(v8); //v8에 뭔가 정답 키 같은 걸 채우는 것으로 추측됨
    ...
    if ( (*(unsigned int (__fastcall **)(__int64, __int64))(*(_QWORD *)a1 + 1368LL))(a1, a3) == 24 ) //입력값이 24바이트 인경우
    // 사용자가 입력한 값이 24바이트일 때
    {
      while ( *(unsigned __int8 *)(v6 + v7) == (qword_15038[v7] ^ *((unsigned __int8 *)v8 + v7)) )
      {
        ...
      }
    }
  }
}

 

정리하면,

  • v6: 사용자가 입력한 값
  • qword_15038: 앱 내부에서 저장된 암호화된 값
  • v8: sub_10E0() 함수에서 생성된 XOR 키

즉, 사용자가 입력한 값이 qword_15038 XOR v8 결과와 동일해야 Success가 출려되는 구조이다. 다시 말해 우리가 구해야할 FLAG 값은 다음과 같다.

FLAG = qword_15038 ^ v8

 

 

Step10) 먼저 v8의 값을 구해봤다. sub_10E0() 함수에서 생성된 XOR 키이니 sub_10E0() 함수가 어떤식으로 되어 있는지 IDA를 통해 분석해봤다.

 

🔎sub_10E0() 함수 분석

앱 내부에서 sub_10E0() 라는 함수에서 v8이라는 XOR 키 버퍼를 직접 만들고 있다. 이 값은 우리가 입력하는 값과 비교할 때 사용된다. IDA로  sub_10E0() 코드를 확인해보면 아래와 같은 코드가 보인다.

*(_OWORD *)a1 = xmmword_34B0;
*(_QWORD *)(a1 + 16) = 0x14130817005A0E08;

 

쉽게 풀어서 설명하면,

  • a1은 메모리 주소다. 함수에서는 이 주소에 어떤 값을 쓰기(write)를 하고 있다.
  • *(_OWORD *)a1 = xmmword_34B0; : a1 주소부터 시작해서 16바이트(xmmword) 길이의 데이터를 xmmword_34B0 라는 곳에서 복사해서 저장하라는 의미이다. 즉, v8 앞 16바이트는 xmmword_34B0 값으로 채워진다.
  • *(_QWORD *)(a1 + 16) = 0x14130817005A0E08; : a1에서 16바이트 떨어진 위치에 8바이트(qword) 값을 저장한다. 즉, v8의 뒤 8바이트는 해당 상수값이 들어간다.

 

🔎 xmmword_34B0 값은?

IDA로 확인해보면 아래와 같은 데이터로 정의되어 있다.

.rodata:00000000000034B0 xmmword_34B0    DCB 0x1D, 8, 0x11, 0x13, 0xF, 0x17, 0x49, 0x15, 0xD, 0
.rodata:00000000000034B0                                         ; DATA XREF: sub_10E0+1DEC↑o
.rodata:00000000000034B0                                         ; sub_10E0+1DF4↑r
.rodata:00000000000034B0                 DCB 3, 0x19, 0x5A, 0x1D, 0x13, 0x15

 

이게 바로 앞 16바이트 값이 된다.

xmmword_34B0 = 
[0x1D, 0x08, 0x11, 0x13, 0x0F, 0x17, 0x49, 0x15,
 0x0D, 0x00, 0x03, 0x19, 0x5A, 0x1D, 0x13, 0x15]

 

 

나머지 8바이트 상수 값은 아래값인데,

0x14130817005A0E08

 

컴퓨터는 리틀 엔디언(Little Endian) 방식으로 숫자를 저장하므로, 이 값을 바이트 단위로 바꾸면 아래와 같다.

이게 뒤쪽 8바이트가 된다.

[0x08, 0x0E, 0x5A, 0x00, 0x17, 0x08, 0x13, 0x14]

 

 

이렇게 해서 총 24바이트로 이루어진 XOR 키 v8이 만들어졌다.

v8 = [
    0x1D, 0x08, 0x11, 0x13, 0x0F, 0x17, 0x49, 0x15,
    0x0D, 0x00, 0x03, 0x19, 0x5A, 0x1D, 0x13, 0x15,  # ← xmmword_34B0
    0x08, 0x0E, 0x5A, 0x00, 0x17, 0x08, 0x13, 0x14   # ← 리틀 엔디언 해석된 상수값
]

 

 

Step11) 이제 qword_15038 값을 추출해보자, Frida를 이용해서 qword_15038가 실제로 출력하는 값을 출력하기 위해 일단 IDA에서 qword_15038의 위치를 확인했다. 이후 Frida 코드를 작성했다. (3. Check qword_15038!) 

qword_15038 위치 확인

// level_3.js
Java.perform(function () {
    // 1. Frida detection bypass!
    const strstrPtr = Module.findExportByName(null, "strstr");
    let logged = false;
    Interceptor.attach(strstrPtr, {
        onEnter: function (args) {
            this.haystack = args[0].readCString();
            this.needle = args[1].readCString();
        },
        onLeave: function (retval) {
            if (this.needle === "frida" || this.needle === "xposed") {
                if (this.haystack.indexOf("frida") !== -1 || this.haystack.indexOf("xposed") !== -1) {
                    retval.replace(ptr("0")); // 탐지 우회
                    if (!logged) {
                        console.log("\n[+] 탐지 우회: → NULL 반환");
                        logged = true;
                    }
                }
            }
        }
    });

    //2. rooting detection bypass!
    var hookClass = Java.use('sg.vantagepoint.util.RootDetection');

    hookClass.checkRoot1.implementation = function(str) {
        console.log('[+] Hooked hookClass!');

        return false;
    };

    // 3. Check qword_15038!
    setTimeout(function () {
        const base = Module.findBaseAddress("libfoo.so");
        if (!base) {
            console.error("XX libfoo.so not loaded yet");
            return;
        }

        const offset = 0x15038;  // IDA에서 확인한 qword_15038 위치
        const addr = base.add(offset);
        const buf = Memory.readByteArray(addr, 24);

        console.log("[+] 암호문 (24바이트):");
        console.log(hexdump(buf, { length: 24 }));

        // 바이트 값을 프린트용 배열로 출력
        const bytes = Array.from(new Uint8Array(buf))
            .map(b => "0x" + b.toString(16).padStart(2, "0"))
            .join(", ");
        console.log("\n[+] Python용 바이트 배열:");
        console.log("[" + bytes + "]");

    }, 3000);  // 라이브러리 로딩 시간 고려
});

 

결과값을 아래와 같이 얻었다.

[+] qword_15038 주소:
           0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  		0123456789ABCDEF
00000000  70 69 7a 7a 61 70 69 7a 7a 61 70 69 7a 7a 61 70  		pizzapizzapizzap
00000010  69 7a 7a 61 70 69 7a 7a                          		izzapizz

[+] Python용 바이트 배열:
[0x70, 0x69, 0x7a, 0x7a, 0x61, 0x70, 0x69, 0x7a, 0x7a, 0x61, 0x70, 0x69, 0x7a, 0x7a, 0x61, 0x70, 0x69, 0x7a, 0x7a, 0x61, 0x70, 0x69, 0x7a, 0x7a]

 

 

이제 qword_15038의 실질적인 값(24바이트)을 확보했으니, 이전에 확인한 v8 값과 XOR하여 최종 정답을 복원할 수 있다.

파이썬을 이용하여 XOR 복호화 스크립트를 작성해서 최종 Flag 값을 얻었다.

qword_15038 = [
    0x70, 0x69, 0x7a, 0x7a, 0x61, 0x70, 0x69, 0x7a,
    0x7a, 0x61, 0x70, 0x69, 0x7a, 0x7a, 0x61, 0x70,
    0x69, 0x7a, 0x7a, 0x61, 0x70, 0x69, 0x7a, 0x7a
]

v8 = [
    0x1D, 0x08, 0x11, 0x13, 0x0F, 0x17, 0x49, 0x15,
    0x0D, 0x00, 0x03, 0x19, 0x5A, 0x1D, 0x13, 0x15,
    0x08, 0x0E, 0x5A, 0x00, 0x17, 0x08, 0x13, 0x14
]

decoded = bytes([a ^ b for a, b in zip(qword_15038, v8)])
print("[+] FLAG:", decoded.decode(errors="ignore"))

🚩Flag Revealed

making owasp great again

💡 Reference & Notes

❓어떻게 16바이트인지, 8바이트인지를 알까

 

1. *(_OWORD *)a1 = xmmword_34B0;

  • 여기서 _OWORDOcta-word의 줄임말로 이건 128비트, 즉 16 바이트를 의미합니다.
  • 따라서 *(_OWORD*)a1는 a1 주소부터 16바이트 크기의 공간을 차지하는 변수(또는 버퍼)로 간주하겠다는 의미입니다.
  • 그래서 xmmword_34B0도 16바이트 떨어진 위치부터 8바이트를 쓰겠다는 뜻이 되죠

2. *(_QWORD *)(a1 + 16) = 0x14130817005A0E08;

  • 여기서 _QWORDQuaid-word, 즉 64비트 = 8바이트를 의미합니다
  • 이건 a1 주소에서 16바이트 떨어진 위치부터 8바이트를 쓰겠다는 뜻이 되죠

 

 

반응형

'Mobile' 카테고리의 다른 글

[Mobile] Crackmes_iOS_Level_1  (1) 2025.06.13
[Mobile] Uncrackable_Level2  (3) 2025.06.10
[Mobile] Uncrackable_Level1  (2) 2025.06.09
[iOS] Sileo 필수 트윅 리스트  (0) 2024.08.01
[Mobile] iOS 탈옥(Jailbreak) 방법  (2) 2024.07.28