문제분석
UnCrackable-Level2에 이어 Level3를 풀어보자.
UnCrackable-Level3.apk를 다운받고 [Android Studio Emulator]를 이용해서 App을 분석했다.
탐지 우회
frida로 앱을 실행하면 프로세스가 강제종료되는 것을 확인할 수 있다.
프로세스가 종료되는 이유를 파악하기위해 apk를 디컴파일하고 코드를 확인했다. MainActivity의 oncreate()에서 처음에 verifyLibs()를 실행하는 것을 파악할 수 있었고 이 함수 내부에서 프로세스 종료와 관련된 로직이 있을 것이라고 생각했다.
○ verifyLibs()
private void verifyLibs() {
this.crc = new HashMap();
this.crc.put("armeabi-v7a", Long.valueOf(Long.parseLong(this.getResources().getString(0x7F0B002B)))); // string:armeabi_v7a "881998371"
this.crc.put("arm64-v8a", Long.valueOf(Long.parseLong(this.getResources().getString(0x7F0B0029)))); // string:arm64_v8a "1608485481"
this.crc.put("x86", Long.valueOf(Long.parseLong(this.getResources().getString(0x7F0B0034)))); // string:x86 "1618896864"
this.crc.put("x86_64", Long.valueOf(Long.parseLong(this.getResources().getString(0x7F0B0035)))); // string:x86_64 "2856060114"
try {
while(true) {
Object v3_1 = v2.next();
Map.Entry v3_2 = (Map.Entry)v3_1;
String v8_1 = "lib/" + (((String)v3_2.getKey())) + "/libfoo.so";
ZipEntry v9 = v1.getEntry(v8_1);
Log.v("UnCrackable3", "CRC[" + v8_1 + "] = " + v9.getCrc());
Log.v("UnCrackable3", v8_1 + ": Invalid checksum = " + v9.getCrc() + ", supposed to be " + v3_2.getValue());
}
Log.v("UnCrackable3", "CRC[classes.dex] = " + v1_1.getCrc());
}
catch(IOException unused_ex) {
Log.v("UnCrackable3", "Exception");
System.exit(0);
}
}
코드가 너무 긴 관계로 로그와 관련된 부분만 작성했다.
하지만 verifyLibs()를 분석해도 딱히 프로세스가 종료되는 로직은 찾을 수 없었고 의문과 함께 logcat으로 실시간 로그를 확인해보기로 했다.
logcat | grep "UnCrackable3"
로그를 보면, verifyLibs()에서 봤던 "CRC[] = ???" 형태의 로그가 찍힌 것을 확인할 수 있다. CRC 체크를 수행하는 과정인 것 같다.
이때, [그림 1]를 보면 CRC 로그 전에 "Tampering detected! Terminating..."이라는 로그가 나오는 것을 확인할 수 있다.
"Tampering detected! Terminating..."라는 로그가 찍힌 것을 봐서는 정체모를 탐지 로직에 걸려서 프로세스가 종료됐다는 의미지만, Java 코드에서는 도저히 찾을 수가 없었다.
따라서 Native 코드에서 "Tampering detected! Terminating..." 로그가 찍히는 부분이 존재하는지 확인해보기로 했다.
○ start_routine()
void __fastcall __noreturn start_routine(void *a1)
{
FILE *v1; // rbp
const char *v2; // rdx
char v3[568]; // [rsp+0h] [rbp-238h] BYREF
v1 = fopen("/proc/self/maps", "r");
if ( v1 )
{
do
{
while ( !fgets(v3, 512, v1) )
{
fclose(v1);
usleep(0x1F4u);
v1 = fopen("/proc/self/maps", "r");
if ( !v1 )
goto LABEL_7;
}
}
while ( !strstr(v3, "frida") && !strstr(v3, "xposed") );
v2 = "Tampering detected! Terminating...";
}
else
{
LABEL_7:
v2 = "Error opening /proc/self/maps! Terminating...";
}
__android_log_print(2LL, "UnCrackable3", v2);
goodbye();
}
libfoo.so 파일을 ida로 분석해보니, start_routine()에서 "Tampering detected! Terminating..." 로그가 찍히는 것을 확인할 수 있었다.
start_routine()를 분석해보면 "/proc/self/maps"에서 "frida" 또는 "xposed"가 검색되는 경우에 프로세스가 종료되는 방식이라는 것을 알 수 있다.
즉, UnCrackable3 App을 실행할때 "frida"를 사용하면 탐지하고 프로세스를 종료한다. 따라서 frida를 사용하기위해서는 해당 로직을 우회해야한다.
○ frida detect 우회
Interceptor.attach(Module.getExportByName('libc.so', 'strstr'), {
onEnter: function (args) {
var haystack = Memory.readUtf8String(args[0]);
if(haystack.indexOf('frida')!==-1 || haystack.indexOf('xposed')!==-1){
this.frida = Boolean(1);
}
},
onLeave: function (retval) {
if(this.frida){
retval.replace(0);
}
}
});
로직 우회 코드를 작성했다.
start_routine()에서 검증하는 로직을 보면 strstr()를 사용해서 "/proc/self/maps"에서 "frida"가 검색되는지 확인하고 있다. 따라서 strstr() 함수의 리턴값을 0으로 바꿔서 탐지를 우회하도록 strstr()를 후킹했다.
원래라면 "frida"가 발견됐을 때, strstr()은 1을 리턴한다. 하지만 위 코드를 실행하면 0을 리턴하므로 탐지에 걸리지않고 우회를 성공할 수 있다.
frida 코드를 작성하고 실행해보자.
실행하면 App이 정상적으로 실행되는 것을 확인할 수 있다.
하지만 이전 문제와 마찬가지로 디버깅을 탐지하는 것을 확인할 수 있다. 다시 Java 코드를 분석해서 디버깅 & 루팅 탐지를 우회해야한다.
○ onCreate()
new AsyncTask() {
protected String doInBackground(Void[] arg3) {
while(!Debug.isDebuggerConnected()) {
SystemClock.sleep(100L);
}
return null;
}
protected void onPostExecute(String arg2) {
MainActivity.this.showDialog("Debugger detected!");
System.exit(0);
}
}.execute(new Void[]{null, null, null});
if((RootDetection.checkRoot1()) || (RootDetection.checkRoot2()) || (RootDetection.checkRoot3()) || (IntegrityCheck.isDebuggable(this.getApplicationContext())) || MainActivity.tampered != 0) {
this.showDialog("Rooting or tampering detected.");
}
onCreate()의 일부분을 보면 AsyncTask()로 테스크를 생성해서 디버깅을 탐지하는 것을 확인할 수 있다.
Debug.isDebuggerConnected()를 통해서 디버깅을 감지하고 있으며, 리턴 값이 true가 나오도록 후킹하면 우회할 수 있다.
추가로 if에서도 루팅과 디버깅에 대한 탐지를 진행하는 것을 확인할 수 있다.
○ showDialog()
private void showDialog(String arg4) {
AlertDialog v0 = new AlertDialog.Builder(this).create();
v0.setTitle(arg4);
v0.setMessage("This is unacceptable. The app is now going to exit.");
v0.setButton(-3, "OK", new DialogInterface.OnClickListener() {
@Override // android.content.DialogInterface$OnClickListener
public void onClick(DialogInterface arg1, int arg2) {
System.exit(0);
}
});
v0.setCancelable(false);
v0.show();
}
if에서 발생하는 루팅 & 디버깅 탐지 로직은 showDialog()를 통해서 처리된다. 코드를 보면 버튼이 화면에 출력되고 버튼을 클릭하면 System.exit(0)을 실행하는 순서다.
그렇다면, System.exit()을 후킹해서 프로세스가 종료되지않도록 우회하면 된다.
루팅과 디버깅을 우회하는 후킹 코드는 다음과 같다.
Java.perform(function() {
var bypassExit = Java.use("java.lang.System"); // system Class Hook
bypassExit.exit.implementation = function (){ // exit() overwrite
console.log("[*] System.exit() is called"); // "Root Detect" 우회
}
var bypassDebugging = Java.use("android.os.Debug"); // Debugging Check
bypassDebugging.isDebuggerConnected.implementation = function (){
console.log("[*] isDebuggerConnected() is called");
return true;
}
});
코드를 실행하면 [그림 3]과 같이 App이 정상적으로 실행된다.
문제풀이
○ verify()
public void verify(View arg4) {
String v4 = ((EditText)this.findViewById(0x7F070036)).getText().toString(); // id:edit_text
AlertDialog v0 = new AlertDialog.Builder(this).create();
if(this.check.check_code(v4)) {
v0.setTitle("Success!");
v0.setMessage("This is the correct secret.");
}
else {
v0.setTitle("Nope...");
v0.setMessage("That\'s not it. Try again.");
}
v0.setButton(-3, "OK", new DialogInterface.OnClickListener() {
@Override // android.content.DialogInterface$OnClickListener
public void onClick(DialogInterface arg1, int arg2) {
arg1.dismiss();
}
});
v0.show();
}
본격적인 풀이를 위해 MainActivity 코드를 전체적으로 분석했다. verify()를 이용해서 Secret String에 대한 검증을 수행하는 것을 알 수 있다.
특히, if를 보면 this.check.check_code()를 통해서 입력값에 대한 검증을 수행하고 Secret String이 맞는 경우에 "Success!"를 화면에 출력한다.
○ check_code()
public class CodeCheck {
private static final String TAG = "CodeCheck";
private native boolean bar(byte[] arg1) {
}
public boolean check_code(String arg1) {
return this.bar(arg1.getBytes());
}
}
입력값에 대한 검증을 수행하는 check_code()를 확인했고 this.bar()를 실행해서 결과를 받고 리턴하는 것을 알 수 있다.
bar()는 native 함수로 정의됐고 libfoo.so에 정의된 함수이므로 ida를 이용해서 분석했다.
○ bar()
char __fastcall Java_sg_vantagepoint_uncrackable3_CodeCheck_bar(JNIEnv *a1, __int64 a2, __int64 a3)
{
__int64 v4; // rbx
unsigned __int64 v5; // rcx
char result; // al
char v7[40]; // [rsp+0h] [rbp-48h] BYREF
unsigned __int64 v8; // [rsp+28h] [rbp-20h]
v8 = __readfsqword(0x28u);
memset(v7, 0, 25);
if ( dword_705C == 2 )
{
sub_12C0(v7);
v4 = ((__int64 (__fastcall *)(JNIEnv *, __int64, _QWORD))(*a1)->GetByteArrayElements)(a1, a3, 0LL);
if ( ((unsigned int (__fastcall *)(JNIEnv *, __int64))(*a1)->GetArrayLength)(a1, a3) == 24 )
{
v5 = 0LL;
while ( *(_BYTE *)(v4 + v5) == ((unsigned __int8)v7[v5] ^ (unsigned __int8)dest[v5])
&& *(_BYTE *)(v4 + v5 + 1) == ((unsigned __int8)v7[v5 + 1] ^ (unsigned __int8)dest[v5 + 1])
&& *(_BYTE *)(v4 + v5 + 2) == ((unsigned __int8)v7[v5 + 2] ^ (unsigned __int8)dest[v5 + 2]) )
{
v5 += 3LL;
if ( v5 >= 0x18 )
{
result = 1;
if ( (_DWORD)v5 == 24 )
return result;
return 0;
}
}
}
}
return 0;
}
bar()를 분석해보니 코드가 생각보다 복잡해서 해석하는데 힘들었다.
○ 코드 분석
1) sub_12C0(v7)으로 v7을 특정 스트링배열로 초기화
2) if를 통해서 v7[i] ^ dest[i] 를 연산하고 결과는 v4[i] 와 비교
3) 2)번 과정에서 모든 v4[]의 인덱스에 대한 검증을 확인하고 true이면 리턴을 true로 전송
GetByteArrayElements()를 통해 Java에서 받은 사용자 입력을 스트링배열로 생성해서 v4[]에 저장한다.
여기서 GetByteArrayElements()가 ida로 안보이는 독자는 ida jni 세팅이 이뤄지지않아서 그런것이다. "ida jni"라고 구글에쳐서 ida jni 세팅을 완료하면 위와 같이 jni 함수들까지 디컴파일된 코드를 볼 수 있다.
즉, v4[]는 사용자의 입력값이므로 비교하는 값인 v7[i] ^ dest[i]는 Secret String이라고 추측할 수 있다.
그렇다면, v7[]과 dest[]를 알아내서 XOR 연산을 수행하면, Secret String을 알아낼 수 있다.
○ v7 array
먼저, v7부터 알아보자.
v7의 경우에는 bar()에서 sub_12C0(v7)를 통해 초기화된다는 사실을 알 수 있다.
즉, sub_12C0()를 후킹해서 v7에 대한 데이터를 조회하면 v7[]을 알아낼 수 있다. 하지만 여기서 문제는 sub_12C0()는 정상적으로 디컴파일되지 않은 함수라서 frida로 후킹하려고 시도하면 함수를 찾을 수 없다.
따라서 sub_12C0()를 후킹하기위해서는 libfoo.so의 베이스로부터 오프셋을 알아내고 직접 주소를 지정해서 후킹해야한다.
그렇다면 sub_12C0() 오프셋을 어떻게 알아낼까?
sub_12C0()의 디컴파일 모드를 풀고(TAB)나서 Text View로 코드를 확인하면 [그림 4]와 같이 sub_12C0() 함수의 오프셋을 알아낼 수 있다. (노란색 부분 == 오프셋)
오프셋을 알아냈으니 후킹코드를 작성해보자.
○ sub_12C0() 후킹
Interceptor.attach(Module.findBaseAddress('libfoo.so').add(0x12C0), {
onEnter: function(args) {
console.log("Secret generator on enter, address of secret: " + args[0]);
this.answerLocation = args[0];
console.log(hexdump(this.answerLocation, {
offset: 0,
length: 0x20,
header: true,
ansi: true
}));
},
onLeave: function(retval) {
console.log("Secret generator on leave");
console.log(hexdump(this.answerLocation, {
offset: 0,
length: 0x20,
header: true,
ansi: true
}));
}
});
sub_12C0()를 후킹하는 코드다.
인자인 args[0]는 v7[]를 가리키는 포인터이며, 전역변수(this.answerLocation)에 담아서 저장하고 함수 실행 전후(onEnter, onLeave)에 각각 args[0]가 가리키는 데이터를 출력하여 비교할 수 있도록 코드를 구성했다.
코드를 실행해보면
[그림 5]와 같이 sub_12C0() 실행 전, 후의 v7[]에 대한 메모리 데이터를 출력한다.
우리가 원하는 데이터는 v7[]이 초기화된 데이터이므로 sub_12C0() 실행 후의 데이터다.
메모리로 출력된 16진수 데이터를 모아보면 "1d0811130f1749150d0003195a1d1315080e5a0017081314"가 v7[]이라는 것을 알아낼 수 있다.
○ dest array
Secret String을 알아내려면 v7[]과 연산할 dest[]도 알아내야한다.
○ init()
__int64 __fastcall Java_sg_vantagepoint_uncrackable3_MainActivity_init(JNIEnv *a1, __int64 a2, __int64 a3)
{
const char *v4; // r15
__int64 result; // rax
sub_3910(a1, a2);
v4 = (const char *)((__int64 (__fastcall *)(JNIEnv *, __int64, _QWORD))(*a1)->GetByteArrayElements)(a1, a3, 0LL);
strncpy(dest, v4, 0x18uLL);
result = ((__int64 (__fastcall *)(JNIEnv *, __int64, const char *, __int64))(*a1)->ReleaseByteArrayElements)(
a1,
a3,
v4,
2LL);
++dword_705C;
return result;
}
libfoo.so의 다른 함수들을 확인해보니 init()이라는 함수가 선언되어있었으며, strncpy()로 dest에 v4를 복사하는 식으로 dest[]가 초기화되는 것을 알 수 있다.
v4가 무엇인지 확인해보니 GetByteArrayElements()로 Java에서 받은 인자값이었다.
GetByteArrayElements()와 같은 JNI 함수를 통해 Java단에서 입력값을 받는다?
→ Java에서 호출되는 Native 함수다.
그렇다면 Java 코드에서도 init()을 사용하는 부분이 있을 것이다.
찾아보자.
private native void init(byte[] arg1) {
}
@Override // android.support.v7.app.AppCompatActivity
protected void onCreate(Bundle arg5) {
this.verifyLibs();
this.init("pizzapizzapizzapizzapizz".getBytes());
}
정말 허무하게도 MainActivty의 onCreate()에서 init()이 사용되고 있다.
인자를 보면 "pizzapizzapizzapizzapizz"라는 데이터가 들어가는 것을 확인할 수 있다.
init()를 보면 인자 값인 "pizzapizzapizzapizzapizz"가 v4이고 v4는 dest[]에 복사된다.
즉, dest[] == "pizzapizzapizzapizzapizz" 다.
○ Secret String
secret = bytes.fromhex("1d0811130f1749150d0003195a1d1315080e5a0017081314").decode("utf-8")
xorkey = "pizzapizzapizzapizzapizz"
password = ""
for i in range(24):
password += chr((ord(secret[i])^ord(xorkey[i])))
print("[!] Found flag: " + password)
v7과 dest을 알아냈으니 XOR 연산을 통해 Secret String을 출력해보자.
Secret String을 생성하고 출력하는 코드이다.
실행해보자.
exploit
import frida, sys
def on_message(message, data):
if message['type'] == 'send':
print("[*] {0}".format(message['payload']))
else:
print(message)
PACKAGE_NAME = "owasp.mstg.uncrackable3"
jscode = """
console.log("[*] Start Hooking");
Interceptor.attach(Module.getExportByName('libc.so', 'strstr'), {
onEnter: function (args) {
var haystack = Memory.readUtf8String(args[0]);
if(haystack.indexOf('frida')!==-1 || haystack.indexOf('xposed')!==-1){
this.frida = Boolean(1);
}
},
onLeave: function (retval) {
if(this.frida){
retval.replace(0);
}
}
});
Java.perform(function() {
var bypassExit = Java.use("java.lang.System"); // system Class Hook
bypassExit.exit.implementation = function (){ // exit() overwrite
console.log("[*] System.exit() is called"); // "Root Detect" 우회
}
var bypassDebugging = Java.use("android.os.Debug"); // Debugging Check
bypassDebugging.isDebuggerConnected.implementation = function (){
console.log("[*] isDebuggerConnected() is called");
return true;
}
});
Interceptor.attach(Module.findBaseAddress('libfoo.so').add(0x12C0), {
onEnter: function(args) {
console.log("Secret generator on enter, address of secret: " + args[0]);
this.answerLocation = args[0];
console.log(hexdump(this.answerLocation, {
offset: 0,
length: 0x20,
header: true,
ansi: true
}));
},
onLeave: function(retval) {
console.log("Secret generator on leave");
console.log(hexdump(this.answerLocation, {
offset: 0,
length: 0x20,
header: true,
ansi: true
}));
}
});
"""
try:
device = frida.get_usb_device(timeout=10)
pid = device.spawn([PACKAGE_NAME])
print("App is starting ... pid : {}".format(pid))
process = device.attach(pid)
device.resume(pid)
script = process.create_script(jscode)
script.on('message',on_message)
print('[*] Running Frida')
script.load()
sys.stdin.read()
except Exception as e:
print(e)