Uncrackable 1~3 문제 풀이

minmoong07·2023년 6월 17일
1

Frida 시리즈

목록 보기
4/4
post-thumbnail

Uncrackable 소개 👋

Uncrackable는 정보보안을 연구하는 그룹인 OWASP에서 모바일 리버스 엔지니어링 연습용으로 개발한 앱입니다.

총 4개의 앱으로 구성되어 있지만 인터넷에 level 4 풀이에 대한 정보가 많이 없어서 저는 3번 문제까지만 풀이하겠습니다. (실력이 된다면 직접 풀어서 올려볼게요!)

우리의 목표는 Uncrackable앱을 각각 Jadx로 분석하여, 문제를 해결하는 flag 문장을 찾아내면 됩니다.

Uncrackable Level 1

https://github.com/OWASP/owasp-mastg/tree/master/Crackmes/Android/Level_01

우선 위 링크에 들어가서 APK 파일을 다운로드 받습니다.

그리고 녹스 앱플레이어에 드래그 앤 드롭으로 설치해주세요.

루팅 탐지 우회

앱을 열어보면 Root detected 라는 문구가 뜨고 OK 버튼을 누르면 앱이 종료됩니다.
디바이스가 루팅을 했는지 여부를 감지해서 루팅을 했다면 앱을 종료시키는 것 같네요.

따라서 이 루팅 감지 부분을 우회해야 합니다.

Jadx로 UnCrackable-Level1.apk 파일을 열어서 분석을 하겠습니다.

MainActivity 클래스를 찾아서 보면 루팅를 탐지하는 것으로 의심되는 부분이 있습니다.

c.a() 함수, c.b() 함수, c.c() 함수 중 하나만 true를 반환하면 루팅이 탐지된것으로 간주하네요.

이때 c 클래스에 있는 세 함수를 분석하려 하지 말고, 우리의 목표는 앱이 종료되는 것을 막는 것이기 때문에 앱을 종료하는 부분만 찾으면 됩니다. 일단 c 클래스에 앱을 종료하는 부분이 있을 수도 있으니 없는 것만 확인하고 넘어갑시다.

딱히 없는 것 같으니 이제 'Root detected!' 메시지를 인자로 호출하는 a() 함수에 대해 분석해보겠습니다.

필요한 부분만 봅시다. 알림창에 넣을 메시지를 세팅하고 알림창에 OK 버튼을 만들고 있네요.

그리고 OK 버튼을 눌렀을 때 실행할 함수를 new DialogInterface.OnclickListener() 함수로 등록하고 있습니다.

onClick() 함수를 등록하는데 해당 함수에서는 앱을 종료한다는 의미인 System.exit(0) 명령을 실행합니다.

따라서 System.exit() 함수를 Frida로 후킹하여 해당 함수가 정상적인 작동을 하지 않도록 해야합니다.

다음은 후킹 코드입니다.

// uncrackable1.js
setImmediate(function() {
  Java.perform(function() {
    let System = Java.use('java.lang.System');
    System.exit.implementation = function() {
      console.log('[*] System.exit function hooked!');
    }
  });
});

System 클래스는 자바 내부 클래스입니다. java.lang.System 에 있습니다.

이어서 System.exit() 함수를 implementation을 통해 새로운 함수로 덮어씌워줍니다.

후킹이 완료되었다는 메시지를 출력하는 함수로 덮어씌웠습니다.

녹스 앱플레이어에서 Uncrackable1 앱을 실행시키고 cmd에 돌아와 다음 명령을 입력하면 후킹 코드가 실행됩니다.

> frida -U Uncrackable1 -l uncrackable1.js

이제 앱이 꺼지지 않습니다!

Secret String 찾기

앱을 보면 'Enter the Secret String' 이라는 문구가 적힌 입력창이 있습니다. 숨겨진 문자열을 찾아서 입력창에 입력하면 문제를 풀 수 있습니다.

그럼 이제 그 문자열을 Jadx로 분석하여 찾아봅시다.

verify() 함수를 보겠습니다.

우리가 입력창에 입력한 문자열이 obj 라는 변수에 들어가고, if 문에서 a.a() 함수에 obj 변수를 전달하여 true를 반환하면 성공했다는 알림창을 띄워주네요.

a.a() 함수를 분석해보겠습니다.

일단 str 인자로 우리가 입력한 값이 들어가겠네요.

이 함수의 리턴값이 어떻게 만들어지는지 보겠습니다.

return str.equals(new String(bArr));

equals() 함수로 우리가 str 변수의 값이 bArr 변수의 값과 같은지 확인하여 같다면 true를, 같지 않다면 false를 반환합니다.

그럼 bArr 변수는 어떻게 만들어지는지 보겠습니다.

bArr = sg.vantagepoint.a.a.a(b("8d127684cbc37c17616d806cf50473cc"), Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0));

sg.vantagepoint.a.a.a() 함수에 인자 세 개를 전달하여 호출한 반환값으로 bArr 변수를 할당하고 있네요.

그럼 sg.vantagepoint.a.a.a() 함수를 분석해보겠습니다.

import javax.crypto.Cipher;

암호화/복호화에 사용되는 클래스인 Cipher 클래스를 추가한 것을 보니 암호화/복호화와 관련된 작업을 진행하나 봅니다.

a.a() 함수에서 우리가 입력한 값과 이 함수의 리턴값이 같은지 확인하니 이 작업은 복호화 작업입니다. (우리가 암호화된 텍스트를 입력해야하는것은 아닐테니까요)


public static byte[] a(byte[] bArr, byte[] bArr2) {
	// ...
}

a() 함수에서는 두 개의 인자 bArrbArr2를 받습니다.


SecretKeySpec secretKeySpec = new SecretKeySpec(bArr, "AES/ECB/PKCS7Padding");

그리고 복호화에 사용될 키를 secretKeySpec 변수에 할당하고 있네요.


Cipher cipher = Cipher.getInstance("AES");

Cipher 클래스로 cipher 변수에 AES 인스턴스를 할당하고 있습니다. AES 암호화를 사용하나 보네요.


cipher.init(2, secretKeySpec);

cipher.init() 함수의 첫 번째 인자에는 암호화 모드를 사용할 것인지, 복호화 모드를 사용할 것인지 설정합니다.

원래 Cipher.DECRYPT_MODE 와 같은 인자를 전달해야 하는데 2라는 숫자가 인자로 들어가있네요.

안드로이드 Cipher 공식 문서(링크)를 찾아보면 Cipher.DECRYPT_MODE는 2라는 값을 가진다고 하네요.

따라서 2를 cipher.init() 함수에 전달한다는 것은 복호화 모드를 사용하겠다는 의미입니다.

두번째 인자에는 복호화에 사용될 키값인 secretKeySpec 변수가 들어가네요.


return cipher.doFinal(bArr2);

cipher.doFinal() 함수에 암호화된 텍스트(bArr2 변수값)를 전달하여 복호화한 값을 리턴하고 있습니다.

따라서 cipher.doFinal() 함수를 후킹하여 cipher.doFinal() 함수가 리턴하는 값을 알아내보도록 하겠습니다.

// uncrackable1.js
setImmediate(function() {
  Java.perform(function() {
    // ...

    let Cipher = Java.use('javax.crypto.Cipher');
    Cipher.doFinal.overload('[B').implementation = function(encryptedBytes) {
      let bytes = this.doFinal(encryptedBytes);

      let decryptedText = '';
      for (let i=0; i<bytes.length; i++) {
        decryptedText += String.fromCharCode(bytes[i]);
      }
      console.log('[*] Secret string: ' + decryptedText);

      return bytes;
    }
  });
});
let Cipher = Java.use('javax.crypto.Cipher');

Cipher 클래스를 Cipher 변수에 할당합니다.


Cipher.doFinal.overload('[B').implementation = function(encryptedBytes) {
	// ...      
}

그리고 doFinal() 함수를 overload() 를 통해 특정합니다.

doFinal() 이라는 이름의 함수는 여러개가 정의되어 있기 때문에, doFinal() 함수가 어떤 인자를 받는지로 구분해야 합니다. [B 의 의미는 'Byte 배열 형' 이고 따라서 Byte 배열 형의 인자를 받는 doFinal() 함수로 정하겠다는 뜻입니다.

implementation 을 통해 doFinal() 함수를 encryptedBytes 인자를 받는 새로운 함수로 덮어씌워줍니다.


let bytes = this.doFinal(encryptedBytes);

bytes 변수에 this(Cipher 클래스를 의미함)의 doFinal() 함수에 암호화된 텍스트를 전달한 리턴값을 할당합니다.

이 값은 복호화된 값이지만 Byte 형태입니다.


let decryptedText = '';
for (let i=0; i<bytes.length; i++) {
  decryptedText += String.fromCharCode(bytes[i]);
}
console.log('[*] Secret string: ' + decryptedText);

doFinal() 함수의 리턴값은 Byte 형태이기 때문에 실제 텍스트를 얻기 위해서는 변환 작업이 필요합니다.

bytes 변수의 값을 하나씩 꺼내 우리가 읽을 수 있는 문자로 변환한뒤 decryptedText 변수에 추가합니다.

그리고 decryptedText 를 출력하고 있습니다.


return bytes;

후킹 당한 doFinal() 함수가 다시 정상적인 작동을 할 수 있도록 bytes 변수에 담긴 복호화값을 리턴해줍니다.


파일을 저장하고 앱에서 아무 문자나 입력한뒤 VERIFY 버튼을 눌러보면 앱 내에서 doFinal() 함수가 호출되어 후킹 작업이 실행됩니다.

Secret String이 출력된 것을 확인할 수 있습니다.

이것을 앱에서 입력창에 입력하면

성공 알림창이 뜹니다!

Uncrackable Level 1을 풀어보면서 어떻게 문제를 해결해야 할지 알았습니다.

함수의 입력(전달받은 인자값)과 출력(리턴값)을 중심으로 따라가면서 분석해야 빠르고 정확하게 핵심만 분석할 수 있습니다. 필요한 부분만 봅시다!

Uncrackable Level 2

https://github.com/OWASP/owasp-mastg/tree/master/Crackmes/Android/Level_02

우선 위 링크에 들어가서 APK 파일을 다운로드 받습니다.

그리고 녹스 앱플레이어에 드래그 앤 드롭으로 설치해주세요.

루팅 탐지 우회

앱을 열어보면 Uncrackable Level 1와 마찬가지로 루팅을 탐지해서 OK 버튼을 누르면 앱이 종료되네요.

루팅 탐지 우회 코드는 레벨1에서 작성했기때문에 생략하겠습니다.

Secret String 찾기

레벨1과 동일하게 Secret String 을 찾아 입력하면 문제가 해결될 것입니다.

그럼 Jadx로 APK를 분석하여 찾아보도록 하겠습니다.

MainActivity 클래스를 보면 외부 라이브러리를 로드하고 있습니다.

이 외부 라이브러리는 리소스 > lib > x86 에 들어가면 libfoo.so 라는 이름으로 저장되어 있습니다.

이 파일을 분석해야 할 수도 있겠군요.

verify() 함수를 보겠습니다.

우리가 입력한 값이 obj 변수에 들어가고 if문에서 m.a() 함수에 obj 변수를 인자로 전달하여 호출하네요.
이 호출값이 true라면 성공입니다.

위 사진은 m 인스턴스가 선언되는 부분입니다.

그리고 CodeCheck 클래스의 인스턴스로 할당하고 있네요

CodeCheck 클래스로 들어가보니 a() 함수가 있네요. 우리는 이걸 분석하면 됩니다.

아까 m.a() 함수에 우리가 입력한 값이 담겨있는 obj 변수가 인자로 전달된다고 했었습니다.

a() 함수를 보면 그 인자를 str 이라는 변수로 받고 있네요.

그리고 bar() 함수에 str 변수를 인자로 전달해 호출한 리턴값을 반환하고 있네요.

그래서 bar() 함수를 봤더니 native 키워드로 선언되었네요. 자바에서 native 키워드는 외부 모듈에서 가져온 함수를 선언할 때 사용됩니다.

아까 외부 모듈인 libfoo.so를 불러오는 부분이 있었죠. 그 모듈 안에서 선언된 함수를 사용하는 것 같습니다.

그럼 이제 libfoo.so를 분석해보도록 하겠습니다.

libfoo.so 파일은 UnCrackable-Level2.apk 파일을 압축을 해제한 뒤 lib 폴더 밑에 있는 x86 폴더에 들어가면 있습니다.

그리고 libfoo.so 파일의 바이너리 분석을 위해 IDA가 필요합니다. IDA를 설치(링크)하고 libfoo.so 파일을 엽니다.

Exports 탭을 열면 해당 파일에서 선언하는 함수들을 볼 수 있습니다. 우리는 bar 함수 분석이 필요하므로 'bar' 이라는 글자가 포함된 이름의 함수를 보겠습니다.

더블클릭을 하면 어셈블리어로 작성된 플로우차트를 볼 수 있는데 이것을 보기 좋게 C언어로 변환해야 합니다.
F5키를 누르면 변환을 해줍니다.

이 함수는 result 변수를 통해 true(1) 혹은 false(0)를 반환하네요.

12번째 줄에서 v5 변수에 "Thanks for all the fish" 라는 문자열을 strcpy() 함수를 통해 넣고있네요. 이 문자열이 Secret String인 것을 직관적으로 알 수 있지만(실제로 Secret String입니다.), 공부를 위해 Secret String인 것을 모르고 있다고 가정하고 진행하겠습니다.

13번째 줄에서 인자로 받은 a1a3 를 통해 뭔가 복잡해보이는 작업을 거친 뒤 v3변수에 할당하네요.
이 함수가 인자로 받는 값은 우리가 입력한 값이기 때문에 아마 v3의 값은 우리가 입력한 값일 것입니다.

그리고 14번째 줄의 if문에서 (첫번째 조건)무언가가 23인지 확인하고 (두번째 조건)strncmp() 함수로 v3v5이 23자까지 똑같은지 확인하네요.

두번째 조건을 보아 첫번째 조건에서 무언가가 23인지 확인한것은 입력받은 문자열의 길이가 23인지 확인하는것 같네요. 그래야 두번째 조건으로 넘어가 v3v5가 23자까지 같은지 확인할테니까요.

확인 후 result 변수의 값을 1(true) 로 바꾸고 있습니다.

따라서 strncmp() 함수를 후킹하여 v5 의 값을 얻으면 됩니다. (v3 변수에는 우리가 입력한 값이 들어있으니 비교하는 나머지 하나는 Secret 문자열이겠지요.)

후킹 코드를 작성해보겠습니다.

// uncrackable2.js
setImmediate(function() {
  Java.perform(function() {
    // ... (루팅 우회 코드 생략됨)

    Interceptor.attach(Module.getExportByName('libfoo.so', 'strncmp'), {
      onEnter(args) {
        let string = Memory.readUtf8String(args[0]);
        let secret = Memory.readUtf8String(args[1]);

        if (string.includes('AAAAAAAAAAAAAAAAAAAAAAA')) {
          console.log('[*] Secret string: ' + secret);
        }
      }
    });
  });
});

코드를 설명하겠습니다.

Interceptor.attach(Module.getExportByName('libfoo.so', 'strncmp'), {
  // ...
});

Interceptor.attach() 함수는 인자로 들어온 메모리 주소에 있는 함수를 감지합니다. 메모리주소를 통해 함수를 찾으면 두 번째 인자에 있는 onEnter() 함수를 호출합니다.
Module.getExportByName('libfoo.so', 'strncmp') 을 통해 strncmp() 함수의 메모리 주소를 가져옵니다.


onEnter(args) {
  let string = Memory.readUtf8String(args[0]);
  let secret = Memory.readUtf8String(args[1]);

  if (string.includes('AAAAAAAAAAAAAAAAAAAAAAA')) {
    console.log('[*] Secret string: ' + secret);
  }
}

strncmp() 함수가 받는 인자들을(v3, v5, 23) args 인자로 받는 함수입니다.

각각의 값은 메모리주소값으로 저장되어있기때문에 Memory.readUtf8String() 함수를 통해 읽을 수 있는 값을 얻습니다.

그리고 if문에서 사용자가 입력한 값이 'A' 가 23개 있는 값인지 확인하는 부분이 있는데, strncmp() 함수는 여기저기에서 많이 쓰이고있기 때문에 strncmp() 함수에 특정 값이 들어올 때만 코드를 실행하겠다는 것입니다.

여기서 string == '문자열' 대신 string.includes() 함수를 사용한 이유는 메모리에서 문자열을 가져올 때는 메모리의 여러 문자가 섞여서 가져와지는 경우가 종종 있습니다. 따라서 일치가 아니라 포함하는지 여부를 확인해야 정확합니다.

'A'가 23개 있는 값이 사용자로부터 들어올 경우 호출되는 strncmp() 함수는 아까 분석했던 libfoo.so 파일 안에서 호출되는 strncmp() 함수이기 때문에 그때의 두번째 인자(secret 변수)는 Secret String 일 것입니다.

이제 아래 명령어를 통해 후킹 코드를 실행합니다.

> frida -U "Uncrackable Level 2" -l uncrackable2.js

그리고 앱에서 A가 23개 있는 문자열을 입력하여 strncmp() 함수가 호출되도록 유도하면 Secret String을 얻을 수 있습니다.

이제 이 문자열을 입력하면 문제가 해결됩니다!

사실 문제를 해결하는 방법은 정말 많습니다. 그냥 성공 알림창을 띄우는 if문의 조건에 들어간 함수를 후킹해 반환값을 true로 지정해주면 어떤 문자열을 입력하든 성공 알림창이 띄워질 것입니다.

하지만 우리는 공부를 위해 직접 Secret String을 찾아내는 방향으로 분석을 진행했고 새로 알게 된 것들도 많습니다!

Uncrackable Level 3

https://github.com/OWASP/owasp-mastg/tree/master/Crackmes/Android/Level_03

우선 위 링크에 들어가서 APK 파일을 다운로드 받습니다.

그리고 녹스 앱플레이어에 드래그 앤 드롭으로 설치해주세요.

루팅 탐지 우회

앱을 실행하면 예상했던대로 루팅을 감지했다는 알림창이 뜹니다.

그런데 알림창의 제목이 'Root detected!' 에서 'Root or tampering detected.' 으로 바뀌었네요.

그래도 OK 버튼을 누르면 앱이 종료되는것은 레벨1, 레벨2와 같으니 마찬가지로 System.exit() 함수를 후킹해보도록 하겠습니다.

// uncrackable3.js
setImmediate(function() {
  Java.perform(function() {
    let System = Java.use('java.lang.System');
    System.exit.implementation = function() {
      console.log('[*] System.exit function hooked!');
    };
  });
});

이렇게 후킹 코드를 작성하고 다음 명령어로 실행하겠습니다.

> frida -U "Uncrackable Level 3" -l uncrackable3.js

이상합니다. 몇 번을 다시 실행해도 후킹 코드가 실행될 때 마다 앱이 강제종료 되네요. 뭔가가 바뀐게 분명합니다.

그럼 이 시점에서 Jadx로 앱을 분석하여 무엇이 바뀌었는지 확인해보겠습니다.

우선 MainActivity클래스를 보면 foo라이브러리를 로드하고 있네요.

그리고 onCreate() 함수를 보면 verifyLibs()라는 함수를 호출하고 있습니다.

해당 함수에서는 Log.v()함수(logcat을 출력하는 함수 - 참고(링크))를 통해 logcat에 로그를 출력하고 있습니다.

Log.v() 함수의 첫 번째 인자에는 로그의 고유 이름이 들어가는데, 이는 MainActivity 클래스에서 TAG 라는 변수에 들어가 있는 것을 알 수 있습니다.

따라서 다음 명령으로 어떤 로그가 찍혔는지 확인해보겠습니다.

> nox_adb shell "logcat | grep UnCrackable3"

grep 명령어를 통해 logcat의 Tag가 UnCrackable3인 것만 출력합니다.

위 로그를 보면 아까 Jadx에서 확인한 로그가 찍힙니다.

그런데 조금 수상한 로그들이 보입니다. 변조가 감지되었다는 내용인데, 'Tampering detected! Terminating...' 이라는 텍스트는 Jadx에서 아무리 찾아도 보이지를 않습니다.

따라서 이 logcat을 출력하는 코드는 자바 단에 존재하지 않습니다. 아까 불러왔던 foo 라이브러리 안에서 logcat을 출력한 것이라고 추측할 수 있습니다.

그러면 라이브러리 파일 분석을 위해 레벨2에서 한 것 처럼 libfoo.so 파일을 IDA로 열어줍니다.

우리는 아까 logcat에서 봤던 문자열 'Tampering detected! Terminating...' 을 IDA에서 찾아서 변조를 감지하는 코드가 뭔지 확인해 그 코드를 후킹하는 작업을 진행해야 합니다.

IDA에서 Alt + T 키를 눌러 문자열 'Tampering detected! Terminating...'을 검색합니다.

검색한 문자열이 사용된 함수의 플로우차트가 뜨는데 이것을 보기좋게 C언어 코드로 바꾸기 위해 F5키를 누르겠습니다.

sub_3080()라는 이름의 함수입니다.

7번째 줄에서 v0 변수에 /proc/self/maps 파일의 내용을 읽어들이네요.

/proc/self/maps는 현재 실행중인 프로세스의 디렉토리 이름 등등의 정보를 볼 수 있는 파일입니다.

그리고 8번째 줄의 if문에서 v0 변수의 값이 null이 아니면 if블럭 밑의 코드를 실행하고 null이면 else 블럭으로 이동하여 v1 변수의 값을 'Error opening /proc/self/maps! Terminating...' 로 할당하고 __android_log_print() 함수에 인자로 넘겨 logcat을 출력하고 있습니다. 그리고 goodbye()함수를 실행하여 프로그램을 종료하고 있네요.
/proc/self/maps파일에서 정상적으로 프로세스 정보를 가져왔는지 확인하고 못가져왔으면 종료해버리고 있는 것 같습니다.

그리고 if문 밑의 코드는 do-while 문으로 작성되어 있는데, do문을 보면 v3변수에 v0 변수의 512바이트만큼을 할당하고 있습니다.

while문의 조건을 보면, strstr() 함수가 사용된 것을 확인할 수 있습니다.

strstr() 함수는 첫 번째 인자로 들어간 문자열이 두 번째 인자로 들어간 문자열을 포함하는지 여부를 확인하여 포함한다면 1을, 포함하지 않는다면 0을 반환합니다.

따라서 while문에서 만약 v3변수에 'frida' 라는 문자열 또는 'xposed' 라는 문자열이 발견되면 while문을 탈출하게 됩니다. 'frida' 또는 'xposed' 문자열이 발견되지 않는다면 do-while 문을 계속 돌고 있겠지요.

while문을 탈출하면 22번째 줄에 v1 변수의 값을 우리가 찾는 문자열인 'Tampering detected! Terminating...'로 할당하고 __android_log_print() 함수에 인자로 넘겨 logcat을 출력하고 있습니다. 그리고 goodbye()함수를 실행하여 프로그램을 종료하고 있네요.

그럼 이 중요한 sub_3080() 함수를 어디에서 호출하는지 확인해봅시다.

IDA에서 코드의 함수 이름을 마우스로 클릭한 뒤 X키를 누르면 이 함수를 어디에서 호출하는지 확인할 수 있습니다.

더블클릭하여 해당 주소로 들어가보겠습니다.

sub_3180()함수에서 sub_3080()함수를 호출합니다.

8번째 줄에서 pthread_create() 함수로 sub_3080() 함수를 호출하고 있습니다. 백그라운드에서 변조 탐지를 계속 돌리고 있겠다는 뜻이죠.

그럼 이 sub_3180()함수는 어디에서 호출되는지 알아보기 위해 다시 함수 이름을 클릭하고 X키를 눌러 주소로 찾아가겠습니다.

이해하기 어려운 어셈블리어 코드가 가득합니다.

빨간색 박스를 보면 'ELF Initialization Function Table' 밑에 sub_3180()함수가 있습니다.

'ELF Initialization Function Table'는 프로그램이 실행될 때 처음 실행 할 함수들을 모아놓은 곳입니다.

따라서 이 libfoo.so 라이브러리가 로드되면 sub_3180()함수가 실행되어 결국 백그라운드에서 frida 및 xposed를 감지하게 되는것입니다.

그런데 아까 Jadx에서 MainActivity 클래스에서 이 라이브러리를 로드했기 때문에 앱이 켜지기 전에 후킹을 진행해야 frida 감지를 피할 수 있습니다.

앱이 켜지기 전에 미리 후킹 코드를 삽입하는 것을 Spawn 이라고 하며 frida 명령어의 -f 옵션을 사용하면 됩니다.

우선 다시 sub_3080() 함수로 돌아와 어떻게 후킹 코드를 작성해야 할지 생각해봅시다.

우리의 목표는 do-while 문에서 while 문이 탈출하지 않고 계속 돌면서 검사를 진행하게 하는것이기 때문에 while 문의 조건에 쓰인 strstr() 함수를 후킹하여 항상 0을 반환하게 만들겠습니다.

0을 반환한다는것은 v3 변수에 'frida' 문자열이 존재하지 않는다는 뜻입니다.

다음은 후킹 코드입니다.

//uncrackable3.js
setImmediate(function() {
  Java.perform(function() {
    Interceptor.attach(Module.getExportByName(null, 'strstr'), {
      onEnter(args) {
        let arg2 = Memory.readUtf8String(args[1]);

        if (arg2.includes('frida') || arg2.includes('xposed')) {
          this.isTarget = true;
        }
      },

      onLeave(retval) {
        if (this.isTarget == true) {
          retval.replace(0);
        }
      }
    });

    let System = Java.use('java.lang.System');
    System.exit.implementation = function() {
      console.log('[*] System.exit function hooked!');
    };
  });
});

코드를 설명하겠습니다.

Interceptor.attach(Module.getExportByName(null, 'strstr'), {
  // ...
});

strstr() 함수에 Interceptor를 붙입니다. Module.getExportByName() 함수의 첫 번째 인자에 null이 들어간 이유는, 공식 타입 선언 문서를 확인하면 다음과 같습니다.

frida-gum 타입 선언(링크)

주석을 보면 모듈의 이름을 잘 모르겠으면 null 을 넣되, 성능이 좋지 않으니 가급적이면 피하라고 되어있습니다.

우리는 현재 strstr() 함수가 어디에서 선언되어 있는지 모르기 때문에 null 을 넣었습니다.


onEnter(args) {
  let arg2 = Memory.readUtf8String(args[1]);

  if (arg2.includes('frida') || arg2.includes('xposed')) {
    this.isTarget = true;
  }
},

onLeave(retval) {
  if (this.isTarget == true) {
      retval.replace(0);
  }
}

우선 함수가 호출되었을 때 실행되는 onEnter() 함수입니다.

strstr() 함수로 들어오는 두 번째 인자를 arg2 변수에 넣습니다. 그리고 그것이 만약 'frida' 나 'xposed' 라는 문자열을 포함한다면(메모리에서 문자열을 가져와서 비교할때 일치(==) 비교 연산자를 사용하면 안됩니다. 메모리에는 여러 문자들이 섞여있기 때문에 가져온 문자열에 이상한 값이 들어있을 수 있기 때문입니다.)
포함한다면 우리가 후킹해야 할 strstr() 함수의 호출이므로 this.isTarget 변수의 값을 true로 할당합니다.

그리고 이 함수가 종료될 때 호출되는 함수인 onLeave() 함수입니다.

this.isTarget 변수가 true 로 설정되어 있다면 해당 strstr() 함수의 반환값을 0으로 수정해버리네요.

정리하자면 strstr() 함수의 두번째 인자가 'frida' 또는 'xposed' 라면 그 strstr() 함수의 반환값을 0으로 바꿔버리는 작업입니다.


let System = Java.use('java.lang.System');
System.exit.implementation = function() {
  console.log('[*] System.exit function hooked!');
};

이제 frida 탐지를 우회했으니 알림창의 OK 버튼을 눌렀을 때 호출되는 System.exit() 함수를 후킹할 수 있습니다.


아까 Spawn에 대해 잠깐 설명했었습니다. 다음 명령어를 통해 후킹 코드를 Spawn 합니다.

> frida -U -f owasp.mstg.uncrackable3 -l uncrackable3.js

레벨1과 레벨2와는 다르게 앱 이름이 아닌 패키지 이름으로 들어갔습니다. 이 패키지 이름은 녹스 앱플레이어에서 앱을 실행한 뒤 frida-ps -Ua 명령어로 확인할 수 있습니다.

또한 frida가 자동으로 앱을 실행해주기 때문에 녹스 앱플레이어에서 앱을 실행하지 않은 상태에서 명령어를 입력해도 됩니다.

이제 frida 탐지 우회를 진행했기 때문에 System.exit() 함수의 후킹 작업이 정상적으로 진행되어 알림창의 OK 버튼을 눌러도 앱이 종료되지 않습니다.

Secret String 찾기

시크릿 스트링을 찾는 과정은 설명이 조금 부족할 수 있습니다.

Secret String을 찾기 위해 Jadx로 APK 분석을 진행하겠습니다.

라이브러리 분석을 위해 APK 파일 압축을 해제하고 lib 폴더 밑에 x86 폴더 밑에있는 libfoo.so 파일을 IDA로 열었습니다.

그리고 bar 함수 분석을 위해 Exports 탭에서 Java_sg_vantagepoint_uncrackable3_CodeCheck_bar 함수로 들어가봤습니다.

들어가보니 어셈블리어들이 있는데 메모리 주소의 색이 빨간색입니다.

찾아보니 IDA가 함수의 시작과 끝을 읽지 못했을 때 이렇게 메모리 주소가 빨간색으로 뜬다고 하네요. (원래는 검정색이 정상입니다.)

그래서 F5키를 눌러도 C언어 소스코드로 변환되지도 않고 문제가 있는 것 같아서 라이브러리 정적 분석은 x86_64 폴더 밑에 있는 libfoo.so 파일로 진행하겠습니다. 어차피 작동하는건 똑같으니까요.

x86_64 폴더 밑의 libfoo.so 파일을 IDA로 열어서 bar 함수의 코드를 확인해보면 다음과 같습니다.

sub_12C0 함수에 v7 변수를 넣고있네요. 함수 안에서 아마 v7 변수에 값을 할당하는 것 같습니다. sub_12C0 함수에 들어가보니 코드가 몇 천 줄이라 이 함수를 분석하는것은 어려울 것 같네요.

그리고 밑에 v4 변수에 우리가 입력한 값으로 추정되는것을 할당하고 있습니다.

그다음 if 함수에서 우리가 입력한 값의 길이가 24인지 확인하고 맞다면 v7 변수에 담긴 값과 dest 변수에 담긴 값을 한 바이트씩 xor 해서 우리가 입력한 값과 한 바이트씩 비교해서 v5 변수를 3씩 더하고 v5 변수값이 0x18(24) 가 되면 result를 1로 설정하고 LABEL_10 으로 이동하면 result를 그대로 리턴합니다.

dest 변수를 클릭한다음 X키(변수를 어디에서 참조하는지 확인 할 수 있습니다.)를 눌러보니 init 함수에서 init 함수의 인자로 받는 값으로 할당하고 있습니다.

그럼 jadx에서 init 함수를 호출하는 부분이 있나? 해서 확인해보니

"pizzapizzapizzapizzapizz" 라는 24자의 문자열을 init 함수로 넘기고 결과적으로 init 함수에서는 dest 변수에 그것을 할당하네요.

후킹 코드를 작성하기 전에 우리는 x86_64 폴더의 libfoo.so 파일을 정적분석했지만, 실제 녹스 앱플레이어는 x86 아키텍쳐이기 때문에 후킹할 메모리 주소를 얻을때는 x86 폴더에 있는 libfoo.so 파일에서 찾아야 합니다.

xor하는 부분을 찾고 밑에 비교하는 부분(cmp 명령어) 의 메모리주소를 후킹하여 스택의 esp 레지스터가 가리키는 곳의 값을 확인해볼겁니다.

어셈블리어에서, cmp 명령어를 실행할 때 esp(스택의 맨 위를 가리키는 레지스터)에 비교 대상의 값이 담기는것 같습니다. (어셈블리어는 공부중이라 아직 잘 모릅니다. 설명이 부족할 수 있습니다. 이 부분은 블로그(링크)를 참고하여 작성했습니다.)

후킹 코드는 다음과 같습니다.

//uncrackable3.js
// ... (생략)

function hook() {
  let targetAddress = 0x00003446;
  let moduleBaseAddress = Module.findBaseAddress('libfoo.so');
  let targetRealAddrees = moduleBaseAddress.add(targetAddress);

  Interceptor.attach(targetRealAddrees, {
    onEnter() {
      let esp = this.context.esp;
      console.warn("[*] Hexdump at esp: " + esp);
      console.log(hexdump(esp, { length: 24 }));
    }
  });
}

setTimeout(hook, 1000);

Java.perform(function() {
  // ... (생략)
});

hook 함수는 0x00003446(IDA에서 확인한 cmp 명령어가 실행되는 주소) 주소에 있는 명령어가 실행되었을때(Interceptor.attach) 그때의 esp 레지스터에 있는 값을 hexdump 로 출력하는 함수입니다.

이 hook 함수에 setTimeout을 달아준 이유는, 우리가 frida -U -f owasp.mstg.uncrackable3 -l uncrackable3.js 명령어를 통해 우리가 작성한 코드를 먼저 프로세스에 삽입하고 앱을 spawn 하면, 코드의 hook 함수에서는 Module.findBaseAddress('libfoo.so')를 통해 libfoo.so 파일을 찾고있는데, 코드가 먼저 프로세스에 삽입됐고 libfoo.so 를 로드하는 앱은 코드가 삽입 된 후에 켜지기 때문에 코드가 libfoo.so 파일을 찾을 수 없게됩니다.

따라서 앱이 켜진 다음 hook 함수를 실행하여 libfoo.so 파일을 찾기 위해 1초(setTimeout의 두 번째 인자, 1000ms)의 딜레이를 준 것 입니다.

frida는 아직 앱이 로드되었을때 이벤트를 핸들링하는 기능이 없기 때문에 frida-onload(링크)와 같은 외부 라이브러리를 설치해서 코딩해야 합니다. 하지만 저는 그냥 단순하게 setTimeout 함수를 달아줘서 간단하게 해결할 수 있었습니다.


frida -U -f owasp.mstg.uncrackable3 -l uncrackable3.js 명령어를 실행하고 앱에서 24자의 아무 텍스트나 입력해봅시다.

그러면 esp에 있는 값이 나오게 되는데 이것과 아까 dest 변수에 있는 값과 xor 하여 입력값과 한 바이트씩 비교한다고 했으니 esp에 있는 이 값과 dest 변수에 있는 "pizzapizzapizzapizzapizz" 값을 xor 연산하면 secret string을 찾을 수 있습니다.

이것이 secret string 입니다.

앱에 입력할 때는 hook 함수를 주석처리한다음 입력해야 success 메시지가 뜨네요. (왜인지는 잘 모르겠습니다.)

지금까지 Uncrackable 3문제를 풀어보았습니다!

0개의 댓글