문제풀이
App을 실행하면, "Root detected!"라고 뜨는 것을 확인할 수 있다.
[OK] 버튼을 누르면 App이 종료된다.
실제 APK 리버싱을 해보면 루팅폰에서 App에 접근하는 것을 [그림 1]과 같이 감지해서 사전차단하는 경우를 많이 볼 수 있다. 이런 유형을 우회하는 방법에 대해 공부하는 의도인 것 같다.
APK 코드를 파악하기 위해서 JEB라는 디컴파일 툴을 이용해서 코드를 분석했다.
(JEB가 없는 독자는 JADX를 사용하면된다.)
○ MainActivity → onCreate()
@Override // android.app.Activity
protected void onCreate(Bundle arg2) {
if((c.a()) || (c.b()) || (c.c())) {
this.a("Root detected!");
}
if(b.a(this.getApplicationContext())) {
this.a("App is debuggable!");
}
super.onCreate(arg2);
this.setContentView(0x7F030000); // layout:activity_main
}
if문을 보면 c.a(), c.b(), c.c()를 통해서 root 인지 확인하고 검증한다는 것을 확인할 수 있다.
c 클래스에 root 검증을 하는 함수들이 정의된 듯하다.
문제를 해결하는데 있어서 반드시 C 클래스를 분석할 필요는 없지만, 공부를 위해서는 C 클래스를 분석해보고 넘어가는 것이 좋다고 판단해서 분석해보기로 했다.
○ C Class
package sg.vantagepoint.a;
import android.os.Build;
import java.io.File;
public class c {
public static boolean a() {
String[] v0 = System.getenv("PATH").split(":");
int v3;
for(v3 = 0; v3 < v0.length; ++v3) {
if(new File(v0[v3], "su").exists()) {
return 1;
}
}
return 0;
}
public static boolean b() {
if(Build.TAGS != null && (Build.TAGS.contains("test-keys"))) {
return 1;
}
return 0;
}
public static boolean c() {
String[] v0 = new String[]{"/system/app/Superuser.apk", "/system/xbin/daemonsu", "/system/etc/init.d/99SuperSUDaemon", "/system/bin/.ext/.su", "/system/etc/.has_su_daemon", "/system/etc/.installed_su_daemon", "/dev/com.koushikdutta.superuser.daemon/"};
int v3;
for(v3 = 0; v3 < v0.length; ++v3) {
if(new File(v0[v3]).exists()) {
return 1;
}
}
return 0;
}
}
○ 코드 분석
1. a() → 환경변수를 분석하여 "su"라는 글자가 포함되어있으면 root로 취급
2. b() → App이 "test-keys"로 sign 되어있으면 root로 탐지
3. c() → 시스템 내에 String[]에 포함되는 경로의 파일이 하나라도 존재하면 root로 취급
a(), b(), c() 함수를 통해 root인지 감지하는 것을 확인할 수 있다.
실제 android 기기 대상으로 루팅하는 경우에는 superSu, magisk등의 프로그램을 이용해서 루팅하는 경우가 많다. a(), c()는 superSu 프로그램을 통해 루팅한 기기를 탐지하는 함수들로 su, superuser.apk 등의 키워드를 이용하여 루팅 감지를 시도한다.
b()는 "test-keys"로 sign 되어있는 경우에 루팅을 감지한다는 뜻이다.
즉, apk 코드를 조작하고 "test-keys"라는 keystore로 signing을 수행해서 기기에서 실행한 조작된 App을 감지하는 것이다.
쉽게 설명하면 공격자가 App 코드를 변조하고 APK로 생성해서 설치하는 경우를 막는 것이다.
하지만 b()처럼 "test-keys"라는 키워드로만 필터링하는 경우라면 다른 keystore를 활용하여 signing하는 경우에는 우회가 된다. 즉, 현재 APK는 검증이 취약하다.
○ MainActivity → onCreate()
@Override // android.app.Activity
protected void onCreate(Bundle arg2) {
if((c.a()) || (c.b()) || (c.c())) {
this.a("Root detected!");
}
if(b.a(this.getApplicationContext())) {
this.a("App is debuggable!");
}
super.onCreate(arg2);
this.setContentView(0x7F030000); // layout:activity_main
}
문제 풀이를 위해서 MainActivity로 돌아가보자.
c 클래스 함수들을 통해 루팅이 감지되면 this.a("Root detected!")를 실행하는 것을 확인할 수 있다.
그렇다면 a()를 분석하는 것이 문제 해결의 키포인트가 될 것이다.
○ MainActivity → a()
private void a(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();
}
MainActivity의 a()는 rooting 탐지 시에 실행되는 함수다.
System.exit(0)을 실행하여 App 실행을 종료하는 것을 확인할 수 있다.
그렇다면 어떻게 루팅 감지를 우회할 수 있을까?
○ Root detect Bypass
var bypassExit = Java.use("java.lang.System"); // system Class Hook
bypassExit.exit.implementation = function (){ // exit() overwrite
console.log("[*] System.exit() is called"); // "Root Detect" 우회
}
java.lang.System.exit()를 원하는 코드가 실행되도록 후킹하면된다.
exit()의 원래 코드를 실행하지않고 내부적으로 console.log()만 실행하도록 후킹한 코드다. 루팅 탐지되도 정상적인 exit()은 실행되지 않기 때문에 프로그램은 종료되지 않는다.
후킹 코드를 실행하고 app에서 [OK] 버튼을 클릭해보자.
App이 종료되지 않고 정상적으로 실행되는 것을 확인할 수 있다.
또한 [그림 2]와 같이 System.exit()을 호출했다는 log도 남는 것을 확인할 수 있다.
루팅 감지를 우회했으니 문제를 풀어보자.
[그림 3]을 보면 Secret String을 입력하고 검증화면이 나온다.
검증에 성공하는 Secret String을 구하는 것이 목적이다.
○ MainActivity → verify()
public void verify(View arg4) {
String v4_1;
String v4 = ((EditText)this.findViewById(0x7F020001)).getText().toString(); // id:edit_text
AlertDialog v0 = new AlertDialog.Builder(this).create();
if(a.a(v4)) {
v0.setTitle("Success!");
v4_1 = "This is the correct secret.";
}
else {
v0.setTitle("Nope...");
v4_1 = "That\'s not it. Try again.";
}
v0.setMessage(v4_1);
v0.setButton(-3, "OK", new DialogInterface.OnClickListener() {
@Override // android.content.DialogInterface$OnClickListener
public void onClick(DialogInterface arg1, int arg2) {
arg1.dismiss();
}
});
v0.show();
}
검증은 위 함수에서 진행된다.
v0.setTitle("Success!")를 보면 입력값이 정답인 경우에 실행되는 코드다.
v0.setTitle("Success!")가 실행되려면 if문에서 a.a(v4)를 실행한 결과가 true 반환되야한다.
정리하자면, a.a()가 Secret String을 검증하는 함수다.
○ a.a()
public class a {
public static boolean a(String arg5) {
byte[] v0_1;
byte[] v1 = Base64.decode("5UJiFctbmgbDoLXmpL12mkno8HT4Lv8dlat8FxR2GOc=", 0);
byte[] v2 = new byte[0];
try {
v0_1 = sg.vantagepoint.a.a.a(a.b("8d127684cbc37c17616d806cf50473cc"), v1);
}
catch(Exception v0) {
Log.d("CodeCheck", "AES error:" + v0.getMessage());
v0_1 = v2;
}
return arg5.equals(new String(v0_1));
}
public static byte[] b(String arg7) {
int v0 = arg7.length();
byte[] v1 = new byte[v0 / 2];
int v2;
for(v2 = 0; v2 < v0; v2 += 2) {
v1[v2 / 2] = (byte)((Character.digit(arg7.charAt(v2), 16) << 4) + Character.digit(arg7.charAt(v2 + 1), 16));
}
return v1;
}
}
a()를 보면 base64로 인코딩된 값을 디코딩한다.
디코딩 값과 "8d127684cbc37c17616d806cf50473cc"를 이용해 AES 복호화를 하는 것을 알 수 있다.
(sg.vantagepoint.a.a.a()의 코드를 보면 자세히 알 수 있다.)
그렇다면, Secret String을 얻기위해서는 AES 복호화를 직접해서 수행해야할까?
아니다.
a.a()를 보면 최종적으로 sg.vantagepoint.a.a.a() 의 return 값을 v0_1 변수에 넣고 arg5와 비교한다는 것을 알 수 있다.
arg5는 입력값이고 v0_1과 arg5를 비교한다는 것을 토대로 v0_1이 Secret String이라는 것을 알 수 있다.
풀이방법은 간단하다.
sg.vantagepoint.a.a.a()의 return 값을 후킹해서 알아내면된다.
○ Output `Secret String`
var aClass = Java.use("sg.vantagepoint.a.a"); // a Class Hook
aClass.a.implementation = function (a, b){ // a() overwrite
console.log("[*] aClass.a function is called");
var findCode = this.a(a, b); // return secretkey
var secret = "";
for (var i=0; i<findCode.length; i++){
secret = secret + String.fromCharCode(findCode[i]);
}
console.log("[*] SecretKey = " + secret); // secretkey output
return findCode;
}
secret string을 후킹하는 코드다.
this.a(a,b)의 return 값인 secret string을 findCode 변수에 저장하고 for문 작업으로 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.uncrackable1"
jscode = """
console.log("[*] Start Hooking");
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 aClass = Java.use("sg.vantagepoint.a.a"); // a Class Hook
aClass.a.implementation = function (a, b){ // a() overwrite
console.log("[*] aClass.a function is called");
var findCode = this.a(a, b); // return secretkey
var secret = "";
for (var i=0; i<findCode.length; i++){
secret = secret + String.fromCharCode(findCode[i]);
}
console.log("[*] SecretKey = " + secret); // secretkey output
return findCode;
}
});
"""
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)
작성한 코드를 실행해보자.
실행하면 루팅 감지가 우회된다.
후킹한 함수는 sg.vantagepoint.a.a.a()고 해당 함수가 실행되려면 a.a()가 실행되야한다.
a.a()가 실행되려면, verify()가 실행되야한다. 따라서 [VERIFY] 버튼을 클릭했다.
App에서 Nope이라고 뜬다.
하지만?
console에서는 SecretKey가 출력된 것을 확인할 수 있다.