가. UnCrackable 정의 및 설치
1. UnCrackable 란?
- MAS Crackmes의 UnCrackable Apps는 모바일 리버스 엔지니어링을 실습할 수 있는 앱으로, OWASP MSTG(Mobile Security Testing Guide)로 활용할 수 있다.
- 쉽게 말해 런타임 조작 또는 탈옥탐지 우회 등과 같은 안드로이드 모의해킹 실습을 할 수 있는 앱이다.
2. UnCrackable 설치 방법
- 아래의 링크에 접속하여 "UnCrackable-Level3.apk" 파일을 다운로드한다.
https://mas.owasp.org/crackmes/
- adb를 이용하거나 본인이 알고 있는 다양한 방법으로 모바일 디바이스에 다운로드한 APK파일을 설치한다.
//명령어 형식
adb install [options] [filepath]
//명령어 예시
adb install -r UnCrackable-Level3.apk
나. Frida Hooking을 통한 문제 풀이
1. 루팅탐지 우회
- 앱 설치 후 실행하면 "Rooting or tampering detected."라는 오류 메시지가 출력되며, OK를 누를 경우 앱이 종료된다.
- 앱 실행 시 루팅탐지 메시지가 출력되었으므로, 앱 실행 시 먼저 호출되는 이벤트 함수 onCreate()의 내용을 JADX를 통해 확인한다.
※ JADX다운로드 및 소스코드 확인방법은 아래의 게시글을 참고하길 바란다.
[AOS App 취약점 진단 · 모의해킹] - [AOS 취약점 진단] 05강 - 하드코딩된 중요정보 확인(실습 1)
- onCreate() 함수의 소스를 보니 Level 1, 2와 다르게 세개의 함수와 한 개의 스레드를 사용하는 것을 볼 수 있다.
※ 아래 사진에서의 ①, ②, ③은 무결성 검증, 디버깅 탐지 기능이나 우회하지 않아도 문제를 푸는데 지장이 없으므로 본문 하단에서 따로 설명하겠다.
- RootDetection클래스 checkRoot1(), checkRoot2(), checkRoot3() 함수의 소스를 확인해 보니 각기 다른 방식으로 루팅 된 환경을 탐지하고 있고, 탐지가 되었을 때 TRUE를 반환하는 것을 알 수 있다.
*a 함수 내용 : 환경변수에 등록된 파일 중 su가 존재하는지 확인하여 루팅 여부를 판단
*b 함수 내용 : 테스트키가 등록되어져 있는지 확인하여 루팅 여부를 판단
*c 함수 내용 : 루팅된 환경에서만 설치되는 패키지 또는 파일들이 존재하는지 확인하여 루팅 여부를 판단
- IntegrityCheck 클래스의 isDebuggable() 함수의 소스를 확인해 보니 디버깅을 탐지하는 기능인 것 같으나, 제대로 구현이 되어있지 않아 우회하지 않아도 문제를 푸는데 지장이 없으므로 넘어가겠다.
- ①번 verifyLibs() 함수의 무결성 검증과정에서 무결성에 이상이 없을 경우 tampered 변수에 0이, 이상이 있을 경우 31337이 저장된다. 해당 기능도 제대로 구현이 되어있지 않아 우회하지 않아도 문제를 푸는데 지장이 없으므로 넘어가겠다.
- 결론적으로 RootDetection클래스 checkRoot1(), checkRoot2(), checkRoot3() 함수의 루팅탐지 기능만 우회하면된다.
- 함수의 반환값을 모두 FALSE로 변조하는 아래와 같은 후킹 코드를 작성한다.
Java.perform(function () {
var java_lang_System = Java.use('sg.vantagepoint.util.RootDetection');
java_lang_System.checkRoot1.implementation = function () {
if (this.checkRoot1()) { console.log("[*] CheckRoot1 is bypassed"); } return false;
}
java_lang_System.checkRoot2.implementation = function () {
if (this.checkRoot2()) { console.log("[*] CheckRoot2 is bypassed"); } return false;
}
java_lang_System.checkRoot3.implementation = function () {
if (this.checkRoot3()) { console.log("[*] CheckRoot3 is bypassed"); } return false;
}
});
- 위에서 작성한 코드를 프리다로 실행하니 "Process crashed: Trace/BPT trap"이라는 오류가 발생하며 후킹이 실패한다.
- 프리다 코드가 실행되다가 중지되었으니 어딘가에서 후킹을 탐지하는 로직이 있다고 의심해볼 수 있다.
- *backtrace에 표시되는 내용을 확인하여 어디서 후킹을 탐지하였는지 확인하여야 한다.
*backtrace : 앱 실행의 흐름을 표시한것으로 강제종료 되기까지의 과정을 알아낼 수 있다.(역순으로 표시됨)
※ 프리다를 탐지하는 로직을 찾는 방법은 본문 하단에서 따로 설명되어 있으니 참고하길 바란다.
- backtrace 내용을 보니 /lib/x86_64/libfoo.so 파일의 0x0389a에서 goodbye() 함수를 실행시킨 것으로 보인다.
- libfoo 파일의 0x398a에 무슨 내용이 있는지 알아내기 위해 UnCrackable-Level3.apk 파일을 디컴파일 한다.
※ APK 파일 디컴파일 방법은 아래의 게시글을 참고하길 바란다.
[AOS App 취약점 진단 · 모의해킹] - 안드로이드 APK 파일 디컴파일/리패키징 방법
- 디컴파일 폴더에서 "/lib/x86_64/libfoo.so" 파일을 찾아 기드라(Ghidra) 또는 아이다(IDA)를 이용하여 읽어드린다.
※ 기드라를 통한 분석 및 수정한 파일을 저장하는 방법은 아래의 게시글을 참고하길 바란다.
[iOS App 취약점 진단 · 모의해킹] - [iOS App 진단] 12강 - 탈옥 탐지 우회(실습3)
- libfoo.so 파일의 0x389a부분을 확인해 보니 FUN_001037c0 함수에서 goodbye() 함수를 호출하는 것을 볼 수 있다.
- FUN_001037c0 함수의 내용을 자세히 살펴보니 *maps 파일에서 "frida"나 "xposed" 문자열이 있는지 검사한 뒤, 문자열이 발견된다면 goodbye() 함수를 호출하여 프로그램을 종료시키는 것을 알 수 있다.
*/proc/self/maps : 해당 프로세스의 메모리 공간이 어떻게 구성되어 있는지 나타내는 파일
※ 아래의 소스코드에 대한 라인 별 설명
15 라인 : 변수 __stream에 /proc/self/maps 파일을 읽기 권한으로 읽어드린다.
18 라인 : 파일을 읽어드리는데 실패하면 "Error opening /proc/self/maps! Terminating..." 문구를 pcVar1에 저장한다.
24 라인 : __stream에서 최대 0x200(512)바이트까지의 문자열을 읽어서 acStack_238 버퍼에 저장한다.
25 라인 : fget() 함수로 문자열을 성공적으로 읽은 경우 반복문을 중단한다.
36 라인 : acStack_238 문자열에서 "frida" 패턴을 찾은 경우 반복문을 종료한다.
39 라인 : acStack_238 문자열에서 "xposed" 패턴을 찾은 경우 더이상 반복하지 않는다.
40 라인 : "Tampering detected! Terminating..." 문구를 pcVar1에 저장한다.
43 라인 : pcVar1에 저장된 문구를 "UnCrackable3" 태그로 "INFO" 레벨의 로그로 출력한다.
45 라인 : 프로그램을 종료시키기위해 _exit(0) 코드를 사용하는 goodbye() 함수를 호출한다.
- 위 프리다 탐지 코드를 우회하기 위해 아래의 코드 중 하나를 작성한다.
*예제 1 : libc.so 파일의 strstr 함수에 후킹을 걸어 frida, xposed 문자열을 찾았을 때 0을 반환하여 탐지 기능 우회
*예제 2, 3 : fopen 함수에 후킹을 걸어 "/proc/self/maps" 파일대신 "/proc/self/stat" 파일을 분석하도록 하여 탐지 기능 우회
//예제 1
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);
}
}
});
//예제 2
var fopen_ = Module.getExportByName(null, "fopen")
var func = ptr(fopen_);
Interceptor.attach(func, {
onEnter: function (args) {
if (args[0].readUtf8String() == "/proc/self/maps") {
// access violation 방지 - Memory 접근 권한 설정
Memory.protect(args[0], 16, 'rwx');
args[0].writeUtf8String("/proc/self/stat");
}
}
});
//예제 3
function nativeTrace(nativefunc) {
var nativefunc_addr=Module.getExportByName(null, nativefunc)
var func=ptr(nativefunc_addr);
Interceptor.attach(func, {
// set hook
onEnter: function (args) {
console.warn("\n[+] " + nativefunc + " called"); // before call
if (nativefunc == "fopen") {
if(args[0].readUtf8String() == "/proc/self/maps"){
args[0].writeUtf8String("/proc/self/stat");
}
console.log("\n\x1b[31margs[0]:\x1b[0m \x1b[34m" + args[0].readUtf8String() + ", \x1b[32mType: ");
}
},
onLeave: function (retval) {
if(nativefunc == "fopen"){
console.warn("[-] " + nativefunc + " ret: " + retval.toString() ); // after call
}
}
});
}
nativeTrace("fopen");
- 위 코드를 실행하면 프리다를 통한 후킹 시, 종료되지 않고 정상 실행되는 것을 확인할 수 있다.
- 프리다 탐지 우회 코드와 ④번 루팅 탐지 기능을 우회하는 코드를 합친 후 실행한다.
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 java_lang_System = Java.use('sg.vantagepoint.util.RootDetection');
java_lang_System.checkRoot1.implementation = function () {
if (this.checkRoot1()) { console.log("[*] CheckRoot1 is bypassed"); } return false;
}
java_lang_System.checkRoot2.implementation = function () {
if (this.checkRoot2()) { console.log("[*] CheckRoot2 is bypassed"); } return false;
}
java_lang_System.checkRoot3.implementation = function () {
if (this.checkRoot3()) { console.log("[*] CheckRoot3 is bypassed"); } return false;
}
});
- 앱이 종료되지 않고 정상적으로 실행되는 것을 확인할 수 있다.
2. Secret String(Key) 우회
- verify 함수를 보니 입력한 암호를 check_code 함수를 이용해 값을 비교하여 성공과 실패를 반환하는 것을 알 수 있다.
※ CodeCheck 클래스의 check_code 함수의 내용을 모르더라도 우회하는데 문제가 없으므로 본문 하단에서 따로 설명하겠다.
- 여기서 프리다를 통해 check_code 함수의 반환값을 true로 변조시켜 주기 위해 아래의 코드를 작성한 뒤 실행한다.
Java.perform(function(){
var class_a = Java.use("sg.vantagepoint.uncrackable3.CodeCheck");
class_a.check_code.implementation = function (arg){
return true;
}
})
- 프리다 후킹 코드 실행 후 어떤 값을 입력하여도 정확한 값을 입력했다는 성공 메시지가 출력된다.
다. Secret String(Key) 값 찾기
1. 검증 로직 확인
- MainActivity 클래스에서 입력한 값이 사전에 입력된 값과 같은지 check 객채의 check_code 메소드로 검증하는 것을 볼 수 있다.
- check 객체가 속해있는 CodeCheck 클래스 내용을 보니, 라이브러리의 bar 함수로 데이터를 넣고 값을 리턴 받는 것을 알 수 있다.
- 기드라(Ghidra)를 통해 libfoo.so 라이브러리의 bar 함수의 내용을 살펴본다.
- while 반복문에서 XOR 연산을 통한 데이터 검증로직이 존재하는 것을 확인할 수 있다.
2. XOR 연산 데이터 확인 - 1
- XOR연산에 필요한 값 중 하나인 DAT_00107040을 더블클릭한다.
- DAT_00107040가 MainActivity 클래스의 init 함수를 통해 호출되는 것을 알 수 있다.
- 그리고 문자열 복사 strncpy 함수를 이용해 입력된 값으로부터 24글자를 DAT_00107040로 저장하는 것을 알 수 있다.
- MainActivity 클래스를 보니 사전에 입력된 "pizzapizzapizzapizzapizz" 문자열이 init 함수 호출 시 입력되는 것을 볼 수 있다.
3. XOR 연산 데이터 확인 - 2
- 다음으로 검증로직에 필요한 local_48 값을 알아내기 위해 FUN_001012c0을 더블클릭한다.
- FUN_001012c0의 코드 중 제일 아랫부분을 확인해 보니 *param_1을 ZEXT816(0)으로 초기화한 후, *param_1과 *param_1[1]에 기록하는 것을 알 수 있다.
※ ZEXT816은 일반적으로 "Zero-extend 8-bit to 16 bytes (128-bit)"의 줄임말로 사용되며, 8비트 값을 128비트로 확장한다는 의미이다. ZEXT816(0)은 0값(8비트)을 128비트로 확장한다는 것으로, 16바이트 크기의 메모리 영역이 모두 0으로 설정된다는 것을 의미한다.
- *param_1에 쓰여지는 값(16바이트)에 의해 최종적으로 메모리에 저장되는 값은 다음과 같다.
*(undefined4 *)*param_1 = 0x1311081d;
*(undefined4 *)(*param_1 + 4) = 0x1549170f;
*(undefined4 *)(*param_1 + 8) = 0x1903000d;
*(undefined4 *)(*param_1 + 0xc) = 0x15131d5a;
위 값들은 다음과 같이 메모리에 배치된다.
1D 08 11 13 0F 17 49 15 0D 00 03 19 5A 1D 13 15
- param_1[1]에 쓰여지는 값(8바이트)에 의해 최종적으로 메모리에 저장되는 값은 다음과 같다.
*(undefined8 *)param_1[1] = 0x14130817005a0e08;
이 값은 다음과 같이 메모리에 배치된다.
08 0E 5A 00 17 08 13 14
- 코드의 메모리 배치에 따라 *param_1과 param_1[1]의 데이터를 이어 붙이면 다음과 같은 바이트 배열이 생성된다.
1D 08 11 13 0F 17 49 15 0D 00 03 19 5A 1D 13 15 08 0E 5A 00 17 08 13 14
4. XOR 연산
- 위에서 확인한 두 개의 값을 XOR 연산해 주는 프리다 스크립트를 작성한다.
function solve() {
var key = "pizzapizzapizzapizzapizz";
var a1 = [0x1D,0x08,0x11,0x13,0x0F,0x17,0x49,0x15,0x0D,0x00,0x03,0x19,0x5A,
0x1D,0x13,0x15,0x08,0x0E,0x5A,0x00,0x17,0x08,0x13,0x14];
var str = "";
for(var i=0; i<24; i++) {
str += String.fromCharCode(key.charCodeAt(i%24) ^ a1[i]);
}
console.warn('Secret String: ' + str);
}
- "making owasp great again"이라는 비밀키 값이 xor 연산을 통해 출력되는 것을 알 수 있다.
- 위에서 출력된 값을 넣으니 제대로 된 값을 넣었다는 것을 확인할 수 있는 문구가 출력된다.
다. 참고설명
1. verifyLibs() 함수 설명
①번 verifyLibs() 함수의 코드를 보니 libfoo.so 파일과 classes.dex 파일의 *CRC값을 가져와 시스템 로그에 출력하는 것을 알 수 있다.
*CRC : Cyclic Redundancy Check의 약자로, 전송된 데이터에 오류가 있는지를 확인하기 위한 체크값을 결정하는 방식을 말함
2. init(xorkey.getBytes()) 함수 설명
② init(xorkey.getBytes()) 함수에 들어가는 인자 xorkey변수의 값은 MainActivity 클래스 상당에서 확인할 수 있다.
- init 함수의 선언부에 native가 적힌 것을 보아 외부 라이브러리 파일에서 함수를 호출하는 것 같다.
- 외부라이브러리 파일은 MainActivity 클래스 최하단에서 호출하는 것을 확인할 수 있다.
- libfoo.so 라이브러리의 init 함수를 보니 FUN_00103910()이라는 함수를 호출하는 것을 알 수 있다.
- 그 후 인자값으로 받은 문자열 중 18바이트(24문자)를 전역변수 DAT_00107040으로 복사하고, 입력받은 문자열 중 1을 더한 값을 _DAT_0010705c에 저장한다.
- FUN_00103910() 함수의 내용은 디버그 모드로 연결되어 있다면 프로그램을 종료하라는 내용이다.
※ 해당 소스코드는 Level2 문제풀이 시 설명하였으므로 자세한 내용은 이전글을 참고하길 바란다.
[AOS App 취약점 진단 · 모의해킹] - Android UnCrackable Level 2 문제풀이
3. check_code() 함수 설명
- 앱이 실행될 때 네이티브 함수 init()를 통해 xorkey에 저장된 문자 "pizzapizzapizzapizzapizz"가 호출되는 것을 볼 수 있다.
- 라이브러리 libfoo.so 파일의 init() 함수의 내용을 기드라로 확인해 보니 xorkey의 값이 DAT_00107040에 저장되는 것을 볼 수 있다.
- CodeCheck 클래스의 check_code 함수의 내용을 보면 입력받은 문자열을 네이티브 함수 bar를 이용하여 비교한 뒤 결과값을 반환하는 것을 알 수 있다.
- bar 함수의 내용을 확인하기 위해 libfoo.so 라이브러리파일을 기드라를 통해 분석한다.
- Symbol Tree에서 "bar"를 검색하면 아래와 같은 코드가 출력된다.
- 사용자가 입력한 값과 시스템에서 원하는 값을 비교하여 반환하는 값이 달라지는 것을 확인할 수 있다.
※ 아래의 소스코드에 대한 라인 별 설명
25 라인 : 사용자로부터 받은 문자열을 lVar2에 저장한다.
26-27 라인 : 사용자로부터 받은 문자열의 길이가 0X18(24)인지 확인한다.
29-35 라인 : 사용자로부터 입력받은 값과 *DAT_00107040의 값과 stored_secret을 xor한 값을 비교한다.
*DAT_00107040 : init()함수를 통해 불러온 xorkey 변수의 값이 저장되어 있음
36-37 라인 : 결과가 일치할 경우 uVar3에 0x107001을 저장 및 반환한다.
41-44 라인 : 결과가 일치하지 않을 경우 uVar3에 0을 저장 및 반환한다.
4. 앱에서 프리다를 탐지하는 부분을 찾기
- 프리다를 탐지하는 부분을 찾기 위해 "frida"문구를 비교하는 프리다 코드를 작성하여 실행한다.
Interceptor.attach(Module.findExportByName(null, "strstr"), {
onEnter: function(args) {
this.file_name = args[1].readCString();
if (/frida/i.test(this.file_name)){
console.warn("str: ", this.file_name);
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n\t'));
}
}
});
- libfoo.so 라이브러리 파일의 0x3830 부분에서 "frida"문구를 비교하는 것을 알 수 있다.
- 00103830 주소의 Decompile을 보니 FUN_001037c0 함수에서 "frida"문구를 비교하는 것을 알 수 있다.
'Mobile App 취약점 진단 · 모의해킹 > AOS App 취약점 진단 · 모의해킹' 카테고리의 다른 글
파이썬으로 프리다 코드 실행하기(Frida Python Binding) (0) | 2025.01.02 |
---|---|
[AOS 취약점 진단] 05강 - 하드코딩된 중요정보 확인(실습 4) (0) | 2024.07.16 |
Android UnCrackable Level 2 문제풀이 (0) | 2024.03.02 |
Android UnCrackable Level 1 문제풀이 (0) | 2024.02.25 |
[AOS 취약점 진단] 16강 - 루팅 탐지 우회 취약점 점검 (0) | 2024.02.25 |