Level 3 앱을 설치한 후 실행해보자.
이번에는 Level 1~2와는 다르게 문구가 다르게 보인다.
빠르게 디컴파일 툴로 해당 문구를 출력해주는 부분을 보자.
그림 부분에서는 잘렸지만 이번에도 루팅 체크를 하는 것 같다.
다시 if문 조건절에 있는걸 보면 총 5개의 메소드로 루팅을 체크하는 로직이 존재한다.
모두 OR로 되어있기 때문에 하나 하나 분석해봐야 한다.
먼저 Level 1~2에서 한 것처럼 RootDetection.checkRoot1()을 먼저 살펴보자.
아래의 그림은 checkRoot1() 메소드이다.
해당 메소드를 보면 이름만 바뀐 것을 볼 수 있다.
즉, 로직은 Level 1~2와 같다는 말이다.
이 부분을 빠르게 후킹 해서 루팅 우회를 진행해보자.
Frida가 실행된 후 앱은 바로 꺼졌다.
따라서 뒤의 IntegrityCheck.isDebuggable(this.getApplicationContext()와
MainActivity.tampered != 0도 추가적으로 우회를 해보았다.
하지만 똑같이 우회가 되지 않았다.
그래서 앱이 문제인지 아니면 문제에서 의도한 것인지 파악하기 위해 로그를 분석해보았다.
위의 그림은 앱이 실행될 때 발생되는 로그를 캡처한 것이다.
처음 시작하자마자 Tampering detected! Terminating...라는 문구가 나온다.
뭔가 탐지됐다는 말이다.
따라서 코드단에서 분석을 진행할 때 놓친 부분이 있나 싶어서 디컴파일 툴에서
"Tampering detected! Terminating..." 문구를
검색해보았다.
나오지 않았다.
이는 앱단(java)에서 처리하는 것이 아니다고 판단할 수 있다.
100%로는 아니다.
저 문자열이 동적으로 생성되거나 문자열이 난독화되어있으면
원하는 문자인 "Tampering detected! Terminating..."을 검색해도 나오지 않는다.
따라서 어딘가에서 루팅 혹은 디버거 탐지 등 어떤 것인지 모르는 탐지 로직이 실행되고 있으며
탐지 로직에 걸려 로그가 나왔다는 것만 알 수 있다.
따라서 우리가 Secret String을 맞춰 성공하는 alert가 있는 곳으로 가보자.
if문 앞의 this.check.check_code(v4)가 실행되면서 true을 return 해주면
우리는 성공한 메시지를 볼 수 있다.
this.check.check_code 메소드를 살펴보자.
살펴보면 check_code 메소드에서 this.bar을 호출하고 this.bar에서 return 된 값을 return 한다.
this.bar 함수는 위에 정의가 되어있으며 이 역시 native이다.
native를 분석하기 전에 컨셉을 잡아보자.
아직 루팅 우회가 되지 않았다.
앱단에서는 해당 로직을 찾을 수 있었지만 후킹 한 결과 앱이 종료가 되는 현상이 있었다.
또한 log에는 Tampering detected! Terminating...라는 문구가 나왔었다.
따라서 첫 번째로는 native에서 루팅 우회를 하는 로직을 찾아봐야 한다.
두 번째로는 Secret String과 관련된 key를 찾는 것이다.
먼저 루팅 우회를 먼저 해야 다음 과정을 진행할 수 있기 때문에 루팅 탐지 로직을 찾아보자.
먼저 해당 문구를 IDA에서 찾아보자.
찾아본 결과 sub_30D0에서 Tampering detected! Terminating... 문구가 사용되고 있다.
그렇다면 바로 sub_30D0 함수를 살펴보자.
살펴보면 우리가 원했던 문구인 Tampering detected! Terminating... 를 찾아볼 수 있다.
또한 log에서 볼 수 있었던 TAG가 UnCrackable3, 내용은 Tampering detected! Terminating... 인
log_print 하는 부분도 찾았다.
따라서 여기에서 탐지한다는 의미이다.
천천히 코드를 살펴보자.
v0 = fopen("/proc/self/maps", "r");
먼저 fopen 함수를 사용해서 /proc/self/maps를 읽어온다.
fopen 함수는 원형은 다음과 같고 하는 역할은 파일을 읽어 FILE 구조체 포인터 넘겨주는 함수이다.
따라서 v0에는 /proc/self/maps 파일의 포인터가 담겨있다.
/proc/self/maps는 무슨 역할을 하는 파일일까?
먼저 /proc 디렉터리는 리눅스 계열에서 사용되는 디렉터리이고 실행되는 프로세스 정보를 담고 있다.
여기서 의미하는 self는 자신의 pid를 의미한다.
maps은 현재 실행되고 있는 프로세스의 주소 맵 또는 프로세스의 메모리 주소 공간을 보여주는 파일이다.
종합해보면 현재 실행하고 있는 프로세스(Level 3 앱)의 메모리 주소 맵을 담고 있는
파일이라고 생각하면 된다.
실제 테스트 단말기에서 maps을 확인해보면 다음과 같은 정보를 볼 수 있다.
jackpotlteks:/ # cat /proc/27212/maps
.....
12c00000-13cc0000 rw-p 00000000 00:01 33530 /dev/ashmem/dalvik-main space (region space)_4735_4735 (deleted)
13cc0000-13ec0000 rw-p 010c0000 00:01 33530 /dev/ashmem/dalvik-main space (region space)_4735_4735 (deleted)
13ec0000-14140000 ---p 012c0000 00:01 33530 /dev/ashmem/dalvik-main space (region space)_4735_4735 (deleted)
14140000-2ac00000 rw-p 01540000 00:01 33530 /dev/ashmem/dalvik-main space (region space)_4735_4735 (deleted)
6f13f000-6f41a000 rw-p 00000000 103:11 228934 /data/dalvik-cache/arm64/system@framework@boot.art
6f41a000-6f430000 r--p 002db000 103:11 228934 /data/dalvik-cache/arm64/system@framework@boot.art
6f430000-6f565000 rw-p 00000000 103:11 228943 /data/dalvik-cache/arm64/system@framework@boot-core-libart.art
6f565000-6f577000 r--p 00135000 103:11 228943 /data/dalvik-cache/arm64/system@framework@boot-core-libart.art
6f577000-6f5b7000 rw-p 00000000 103:11 228949 /data/dalvik-cache/arm64/system@framework@boot-conscrypt.art
6f5b7000-6f5ba000 r--p 00040000 103:11 228949 /data/dalvik-cache/arm64/system@framework@boot-conscrypt.art
......
그다음 sub_30D0 함수의 코드를 분석해보자.
v0 = fopen("/proc/self/maps", "r"); // v0에 /proc/self/maps 파일 포인터 저장
if ( v0 )
{
......
}
else
{
LABEL_7:
v1 = "Error opening /proc/self/maps! Terminating...";
}
만약 if문에서 v0이 null이면 즉, 파일을 열었을 때 열지 못했다면
LABEL 7:로 가면서 "Error opening /proc/self/maps! Terminating..."라는 문구와 종료가 된다.
하지만 v0에 파일 포인터가 잘 넘어왔으면 if문 내부로 들어가게 된다.
...
if ( v0 )
{
do
{
while ( !fgets(&v3, 512LL, v0) ) // /proc/self/maps 512 길이만큼 가져와 v3에 담는다.
{
fclose(v0);
usleep(500LL);
v0 = fopen("/proc/self/maps", "r");
if ( !v0 )
goto LABEL_7;
}
}
while ( !strstr(&v3, "frida") && !strstr(&v3, "xposed") );
v1 = "Tampering detected! Terminating...";
}
...
if문 내부로 들어와서 처음 실행되는 곳이 바로 do while문이다.
while문 안에는 fgets라는 함수가 존재한다.
fgets 함수의 원형은 다음과 같다.
쉽게 설명해서 파일 스트림(stream)에서 해당 길이(num)만큼 문자열을 받는다(str).
정리하면 /proc/self/maps의 파일 정보를 길이만큼 읽어와서 str에 넣어주는 함수이다.
여기까지 정리하면 코드는 다음과 같다.
v0 = fopen("/proc/self/maps", "r"); // v0에 /proc/self/maps 파일 포인터 저장
if ( v0 )
{
do
{
while ( !fgets(&v3, 512LL, v0) ) // /proc/self/maps 512 길이만큼 가져와 v3에 담는다.
{
fclose(v0);
usleep(500LL);
v0 = fopen("/proc/self/maps", "r");
if ( !v0 )
goto LABEL_7;
}
}
while ( !strstr(&v3, "frida") && !strstr(&v3, "xposed") );
v1 = "Tampering detected! Terminating...";
}
else
{
LABEL_7:
v1 = "Error opening /proc/self/maps! Terminating...";
}
이제 if문안의 2번째 while을 보자.
strstr 함수가 2개가 있고 첫 번째 인자에는 v3
즉, 위의 fgets 함수에서 /proc/self/maps의 파일 정보를 v3에 담았기 때문에
strstr 함수로 파일 정보을 한 줄 한 줄 읽어 오면서 중 frida나 xposed가 들어가 있는지 확인하는 것이다.
그렇다면 /proc/self/maps에 frida나 xposed가 있는지 확인해보자.
.........
7d7844e000-7d7844f000 ---p 00000000 00:00 0
7d7844f000-7d78553000 rw-p 00000000 00:00 0
7d78553000-7d78bdc000 r--p 00000000 103:11 972986 /data/local/tmp/re.frida.server/frida-agent-64.so
7d78bdc000-7d78bdd000 ---p 00000000 00:00 0
7d78bdd000-7d799da000 r-xp 00689000 103:11 972986 /data/local/tmp/re.frida.server/frida-agent-64.so
7d799da000-7d79a74000 r--p 01485000 103:11 972986 /data/local/tmp/re.frida.server/frida-agent-64.so
7d79a74000-7d79a8a000 rw-p 0151e000 103:11 972986 /data/local/tmp/re.frida.server/frida-agent-64.so
7d79a8a000-7d79af5000 rw-p 00000000 00:00 0 [anon:.bss]
7d79b23000-7d79b24000 ---p 00000000 00:00 0 [anon:thread stack guard]
......
이렇게 maps 파일에 /data/local/tmp/re.frida.server/frida-agent-64.so 가 존재한다.
따라서 Tampering detected! Terminating... 문구가 log에 남은 것이다.
이제 후킹 포인트를 잡았다.
바로 strstr 함수이다.
첫 번째 인자를 바꾸거나 두 번째 인자인 frida를 다른 문자열로 바꾸면 되는 것이다.
밑의 그림은 strstr 함수를 후킹 하여 첫 번째 인자를 출력해보았다.
정상적으로 후킹이 되고 frida가 출력된 것으로 보아 frida가 포함된 문자열을 다른 문자열로 바꿔보자.
후킹 한 결과 원래 봤던 로그도 없으며 정상적으로 앱이 실행된다.
이렇게 루팅 또는 디버깅 우회가 끝이 났다.
다시 돌아와서 이제 Level 3에서 원하는 Secret String을 찾아야 한다.
위의 bar 메소드가 native 메소드이면 Level 2에서처럼 so 파일을 분석해보아야 한다.
so 파일을 IDA로 열어보자.
Level 2에서 분석한 것과 마찬가지로 CodeCheck_bar 함수를 클릭해서 분석을 해보자.
그 이유는 Level 2에 설명되어있다.
retrun 하는 result 변수가 처음에는 0으로 설정되어있다.
이렇게 0으로 설정되어있으면 Secret String을 맞췄다는 문구를 볼 수가 없다.
따라서 result 값이 1이 되도록 만들어줘야 한다.
물론 Level 2에서도 bar 함수의 return 값을 1로 설정해주면 되지만 이는 출제의도가 아니다.
따라서 코드를 분석해서 Secret String을 알아내야 한다.
Level 2를 제대로 이해를 했다면 Level 2에서와 마찬가지로 해당 코드를 보고 생각나는 것이 있을 것이다.
총길이가 24인 것을 생각할 수 있다.
그다음은 while 문을 살펴보자.
while 문에서 == 연산이 보인다.
== 기준으로 왼쪽 값과 오른쪽 값이 같냐라는 것이다.
같지 않은 경우 while문이 끝이 나며 result는 그대로 0으로 return 되기 때문에 문제를 해결하지 못한다.
따라서 while문이 이 문제의 핵심이다.
먼저 왼쪽을 보자.
먼저 v7이 뭐고 v8이 먼지 알아야 한다.
v8은 코드에서 그냥 0이다.
v7을 알아야 하는데 v7은 IDA에서 다음과 같이 정의되어있다.
따라서 v5+1472 이가 어떤 함수인지 어떤 역할을 하는지 알아봐야 한다.
이를 frida를 활용해서 찍어보면 GetByteArrayElements라는 함수가 나온다.
GetByteArrayElements 함수의 정의는 다음과 같다.
GetByteArrayElements 함수는 java의 byte 배열을 JNI로 전달하는 함수이다.
즉 앱단에서 어떤 값을 so 파일로 전달하는 함수이다.
어떤 값을 전달할까???
GetByteArrayElements가 실행 후 return 값이 담긴 v7를 출력해보자.
이렇게 앱단에서 입력한 "test123456781264864984" 값이
GetByteArrayElements 함수가 실행된 후 v7에 저장이 된다.
따라서 while 문의 왼쪽은 사용자가 입력한 Secret String이라는 의미이다.
그렇다면 이제 ==을 기준으로 오른쪽의 값이 바로 문제에서 원하는 Secret String이라는 말이다.
따라서 우리는 알아야 할 것이 3가지이다.
&qword_15038 + v8, &v9, v8이다.
먼저 &qword_15038을 알아보자.
먼저 &연산은 주소 값을 가져오는 의미이다.
qword_15038에 어떠한 값이 저장되어있는데 그 메모리 주소값을 가져오는 의미이다.
그 후 v8을 더해준다.
처음에 v8은 0이다.
하지만 바로 밑에서 ++연산을 통해 v8을 1씩 증가시켜주고 있다.
이는 어떠한 문자열이나 값이 배열에 저장되어있는데 index를 한 개씩 증가시킨다는 의미이다.
즉, 24자리인 문자열이면 한 문자씩 가져온다는 의미이다.
그렇다면 qword_15038에 어떤 값이 들어있는지 살펴보자.
qword_15038은 Java_sg_vantagepoint_uncrackable3_MainActivity_init 함수에서 사용되었다.
따라서 Java_sg_vantagepoint_uncrackable3_MainActivity_init 함수를 분석해보자.
Java_sg_vantagepoint_uncrackable3_MainActivity_init에서 strncpy 함수에서
qword_15038으로 어떤 값을 copy하는데 무슨 값을? v5에 저장되어있는어떤 값을
얼만큼? 24자리 만큼 copy 하는 것이 보인다.
이를 frida를 이용해서 strncpy 함수의 두 번째 인자인 v5을 출력해보자.
출력해보니 "pizzapizzapizzapizzapizz683"라는 문자열이 나왔다.
24자리를 qword_15038에 copy하니 최종적으로 qword_15038에는
"pizzapizzapizzapizzapizz"가 저장된다.
이는 어디서 나온 것일까?라고 생각을 해보자.
qword_15038는 Java_sg_vantagepoint_uncrackable3_MainActivity_init 함수에서 초기화되었다.
따라서 함수명에서 추측할 수 있듯이 다시 입단으로 돌아가 MainActivity에 선언되어있는
native init 메서드가 있는지 확인해보자.
이렇게 pizzapizzapizzapizzapizz가 init 메소드의 인자로 넘겨주었다.
따라서 현재까지 다음과 같은 값을 도출할 수 있다.
v8은 앞에서도 설명했듯이 0~24까지 증가한다.
따라서 pizzapizzapizzapizzapizz라는 문자열에서
v8가 0일 때 p,
v8가 1일 때 i... v8이 24일 때 z
이렇게 한 문자씩 처리하겠다는 의미이다.
v8가 0일 때를 보자면 문자 'p'와 xor연산을 한다.
^가 xor을 의미하는데 p와 &v9+v8 값을 xor 한다는 의미이다.
&v9+v8에서도 앞서 설명한 것처럼 한 문자씩 가져와서 p와 xor 하겠다는 것이다.
뒤에서 그림으로 설명되어있다.
그렇다면 v9에는 어떤 값이 들어있는지 확인해보자.
v9는 sub_10E0 함수에서 처리된다.
sub_10E0 함수에서 v9의 인자 값을 넘겨주고 sub_10E0 함수가 끝나면 v9에는
어떤 값이 저장되어있을 것이다.
sub_10E0 함수가 끝날 때 v9에는 어떤값이 들어있는지 확인해보자.
sub_10E0 함수가 끝날때 v9에는 이렇게 값이 들어가 있다.
여기까지 정리하자면 다음과 같이 도출할 수 있다.
이렇게 xor을 하는 코드를 작성해서 실행해보면 우리가 원하던 Secret String이 나온다.
이를 Level 3에 입력해보자.
정답이다.
이번 문제는 조금 설명이 많이길었다.
최대한 자세히 step by step으로 설명하려고 노력했다.
후킹 코드나 프리다 코드는 일부러 올리지 않았다.
적어도 후킹 코드나 프리다 코드는 직접 작성해보길 바란다.
검색하면 많이 나온다.