Frida 탐지 방법에 대한 고찰
Frida 탐지와 우회 그리고 탐지 우회와 우회 탐지
Introduction
일반적으로 안드로이드 어플리케이션을 이용하면 앱은 실행 시 라이브러리를 로드하여 실행할 수 있다. 이는 안드로이드에서 정해 놓은 시스템 파티션이나 앱과 함께 제공되는 디렉터리 경로에서만 라이브러리를 가져와 사용할 수 있다는 의미이다.
아래와 같이 정해진 앱의 소스 디렉터리에서 라이브러리를 로드할 수 있다.
1
Loading library : /data/app/~~p-vfdsffewfewg==/com.ruffalo.test-dQ8dewfeffewfef8qA==/lib/arm64/libnative-lib.so
허가되지 않은 혹은 안드로이드 정책에 반하는 위치에 있는 라이브러리가 /proc/self/maps
에서 올라오면 해당 디바이스 환경은 비정상 혹은 위협으로 판단하고서 앱의 실행을 거부하거나 중단하여야 한다.
이와 같은 환경과 행동들은 위협 탐지부터 시작하여 위협 탐지 우회 그리고 위협 우회 탐지라는 끝없는 싸움에 방아쇠가 되었다.
그러한 기술들 중 하나로 루팅이 있는데 이는 대중이 많이 사용하는 Magisk 개발자가 이야기한 것처럼 hide 라는 기능에 끝없는 패치가 이어졌다고 말한다.
MagiskHide Removal; I have lost interest in fighting this battle for quite a while; plus, the existing MagiskHide implementation is flawed in so many ways. Decoupling Magisk from root hiding is, in my opinion, beneficial to the community. Ever since my announcement on Twitter months ago, highly effective “root hiding” modules (much MUCH better than MagiskHide) has been flourishing, which again shows that people are way more capable than I am on this subject. So why not give those determined their time to shine, and let me focus on improving Magisk instead of drowning in the everlasting cat-and-mouse game 😉.
본 글에서는 여러 소스코드와 안드로이드 zygote 가 올라오고 앱이 눈에 보이기까지 따라가보면서 변화한 메모리를 통해 비정상 환경을 감지하는 최첨단 탐지 메커니즘을 살펴보았다.
그중 동적 바이너리 분석 도구에 대한 탐지 시점과 탐지 우회 그리고 그것을 한 번 더 우회 탐지하는 기법을 사용했는데 이는 마치 고양이와 쥐의 게임을 연상시키는 부분이 없지 않아 있을 것이다.
실제 상황을 가정한다면 비정상 환경에서 사용을 막는 앱이 있다고 폈을 때 비정상 단말에서 앱이 실행 상태에 있을 때 앱은 사용을 중단하라고 사용자에게 Alert 를 띄운다.
그러나 누군가는 그럼에도 사용할 수 있게 이를 우회하려고 한다. 그러나 또 누군가는 이를 우회할 경우를 대비하여 한 단계 더 고도화된 탐지 대책을 세우고 있다.
이러한 끝도 없는 공방전이 이어지는 것이라고 볼 수 있다.
Understanding the Core Issue
아래는 현재 공개된 몇가지 탐지 아이디어에 대하여 살펴본 케이스이며 실제 소프트웨어에서도 사용되는 방법이다.
본 글에서는 자체 검파일한 바이너리를 사용하였다.
메모리를 통한 탐지
먼저 비정상 환경이 준비된 모바일이나 그러한 앱이 설치된 모바일을 준비한다.
그 후 모바일에서 탐지 로직이 적용된 어플리케이션을 시작한다.
이때 앱의 pid 를 살펴보면 아래와 같다.
현재 pid 는 20109 이다.
1
2
3
4
5
6
7
8
{
"arch": "arm64",
"codeSigningPolicy": "optional",
"id": 20109,
"pageSize": 4096,
"platform": "linux",
"pointerSize": 8
}
로컬 PC 에서 모바일에 리눅스 쉘처럼 붙을 수 있는데 쉘로 먼저 접근한다.
1
2
3
4
5
6
x1q:/proc/20109 # cat maps | grep ruffalo
6d22b2c000-6d23555000 r--p 00000000 00:01 265762 /memfd:ruffalo-agent-64.so (deleted)
6d23556000-6d2428d000 r-xp 00a29000 00:01 265762 /memfd:ruffalo-agent-64.so (deleted)
6d2428d000-6d2435e000 r--p 0175f000 00:01 265762 /memfd:ruffalo-agent-64.so (deleted)
6d2435f000-6d2437a000 rw-p 01830000 00:01 265762 /memfd:ruffalo-agent-64.so (deleted)
x1q:/proc/20109 #
위에서 볼 수 있는 것처럼 /proc/[pid]
에 접근하여 maps 를 살펴보면 현재 메모리에 올라온 Frida 를 살펴볼 수 있다.
보통 이러한 로직을 작성한다고 하면 아래와 같을 것으로 보인다.
상황에 따라서 다를 수 있지만 이해를 위하여 간단하게 작성해본다.
1
2
3
4
5
char* p_result = strstr(readline_from_maps, "/memfd:ruffalo-agent-64.so");
if (NULL != p_result) {
// Detected
__android_log_print(ANDROID_LOG_VERBOSE, "Sample-Test", "Found");
}
strstr 함수는 아래와 같은 인자를 받고 반환을 한다.
1
char* strstr(const char *string1, const char *string2)
string2 문자열이 string1 에 존재한다면 string2 문자열을 반환하며 존재하지 않으면 0 을 반환한다.
따라서 maps 에서 읽은 문자열에 탐지하려는 문자열이 있는지 확인하는 것이다.
메모리 탐지 우회
이를 우회하는 걸 봐보갰다.
/proc/[pid]/maps
에 올라온 agent 를 탐지하였는데 이를 우회한다면 다음과 같을 것이다.
실제라면 로직을 보호하기 위하여 고난도의 난독화와 암호화 그리고 런타임 탐지로 로직을 들여다보기 힘들텐데 현재는 이 과정을 다 우회했다고 가정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Interceptor.attach(Module.getExportByName('libc.so', 'strstr'), {
onEnter: function (args) {
var str1 = Memory.readCString(args[0]);
var str2 = Memory.readCString(args[1]);
if (str2.indexOf("/memfd:ruffalo-agent-64.so") != -1){
console.log("str1:%s str2:%s\n", str1, str2);
this.hook= true;
}
},
onLeave: function (retval) {
if (this.hook) {
retval.replace(0x0);
console.log("Replace ret value to 0");
}
}
});
위와 같이 우회 스크립트를 작성할 수 있는데 strstr 함수 반환값을 조작할 수 있다.
1
str2.indexOf("string")
해당 함수는 str2
문자열 안에 string
이 있다면 시작하는 그 문자열의 인덱스 번호를 반환하고 없으면 -1
을 반환하는 함수이다.
즉 특정 문자열을 발견하면 strstr 반환값을 0 으로 변조하는 것이다.
strstr(str1, str2)
반환값 0 이 의미하는 것은 str1 안에 str2 이 없다는 의미이다.
실행중인 프로세스 탐지를 통한 우회 탐지
앞에서 메모리를 통한 탐지를 진행했는데 특정 문자열일 때 반환값을 변조하여 우회한 것을 볼 수 있다.
그럼 이번에는 이에 대응하여 프로세스를 통한 탐지를 진행해겠다.
1
2
3
4
5
x1q:/proc/20109 # ps -ef | grep ruffalo
root 21513 30531 19 02:29:32 /debug_ramdisk/.magisk/pts/1 00:00:00 grep ruffalo
root 24938 1 0 01:35:54 ? 00:05:36 ruffalo-server-16.6.3-android-arm64
root 24954 1 0 01:35:56 ? 00:00:00 10 unix:abstract=/ruffalo-b8b5725d-57e7-4c20-b8f7-8e7573912b0d
x1q:/proc/20109 #
위 프로세스 탐지도 비슷하게 진행 할 수 있다.
ps
명령어를 통해 현재 실행중인 프로세스들을 볼 수 있다.
아래와 같이 구현할 수 있을 것이다.
1
2
3
4
5
char* p_result = strstr(readline_from_process, "ruffalo-server-16.6.3-android-arm64");
if (NULL != p_result) {
// Detected
__android_log_print(ANDROID_LOG_VERBOSE, "Sample-Test", "Found");
}
process 를 확인하는 명령어를 통해 해당 문자열을 파싱하고 탐지하려는 문자열이 있는지 확인하는 것이다.
위는 예시이며 여러 방법이 있겠지만 실제론 오탐을 줄이고 정탐을 높게 구현하는 게 중요하다.
실행중인 프로세스 탐지 우회
process 를 확인하고 이때 실행중인 여부를 확인하는 것인데 이를 우회한다면 다음과 같을 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Interceptor.attach(Module.getExportByName('libc.so', 'strstr'), {
onEnter: function (args) {
var str1 = Memory.readCString(args[0]);
var str2 = Memory.readCString(args[1]);
if (str2.indexOf("ruffalo-server-16.6.3-android-arm64") != -1){
console.log("str1:%s str2:%s\n", str1, str2);
this.hook= true;
}
},
onLeave: function (retval) {
if (this.hook) {
retval.replace(0x0);
console.log("Replace ret value to 0");
}
}
});
이도 동일하게 특정 문자열인지 확인하고 반환값을 변조하여 우회하는 스크립트이다.
네트워크 탐지를 통한 우회 탐지
그럼 이번에는 네트워크를 통하여 우회 탐지를 진행해보려고 한다.
netstat 는 어플리케이션이나 디바이스에 네트워크 상태를 보여주는 명령어이다.
명령어를 통해 아래와 같이 현재 연결된 정보를 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
x1q:/proc/21522 # netstat | grep ruffalo
unix 3 [ ] STREAM CONNECTED 29075456 @/ruffalo-87868e60-a33d-415a-ba9e-2718ebaf230d
unix 2 [ ] STREAM CONNECTED 7130091 @/ruffalo-c99f0fff-fcd1-42ff-a321-4e2546485a58
unix 2 [ ] STREAM CONNECTED 3545381 @/ruffalo-6afc2dde-d4d1-4050-8c17-b91aa1a0afae
{ . . .}
unix 2 [ ] STREAM CONNECTED 5332037 @/ruffalo-f561ed25-df09-40c5-b0fe-ee037f88f74a
unix 2 [ ] STREAM CONNECTED 7213926 @/ruffalo-c4e008d7-df55-4d85-a5b0-61ffe3f548d4
x1q:/proc/21522 #
아래와 같이 동일하게 특정 문자열을 탐지할 수 있다.
1
2
3
4
5
char* p_result = strstr(readline_from_process, "ruffalo");
if (NULL != p_result) {
// Detected
__android_log_print(ANDROID_LOG_VERBOSE, "Sample-Test", "Found");
}
netstat 실행을 통해 해당 문자열을 파싱하고 탐지하려는 문자열이 있는지 확인하는 것이다.
명령어 결과에 따라 구현 로직이 튕길 수 있어서 모든 경우의 수에 다 부합하게 작성하는 게 중요하다.
네트워크 탐지 우회
위에서 구현한 netstat 를 확인하고 이때 실행중인 여부를 확인하는 코드를 우회한다면 다음과 같을 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Interceptor.attach(Module.getExportByName('libc.so', 'strstr'), {
onEnter: function (args) {
var str1 = Memory.readCString(args[0]);
var str2 = Memory.readCString(args[1]);
if (str2.indexOf("ruffalo") != -1){
console.log("str1:%s str2:%s\n", str1, str2);
this.hook= true;
}
},
onLeave: function (retval) {
if (this.hook) {
retval.replace(0x0);
console.log("Replace ret value to 0");
}
}
});
위와 같이 특정 문자열을 찾아서 대입해주면 쉽게 우회가 가능하다.
포트 스캔 탐지를 통한 우회 탐지
그럼 이번엔 포트 스캔을 통해 우회 탐지를 진행해보겠다.
netstat
에 -ntlp
를 사용하면 LISTEN 중인 포트 정보 표시한다.
Frida 같은 경우 실행 시 포트를 지정할 수 있어서 손쉽게 포트 변경이 가능하다.
1
2
3
4
x1q:/proc/21522 # netstat -ntlp | grep ruffalo
tcp 0 0 127.0.0.1:27042 0.0.0.0:* LISTEN 24938/ruffalo-server-16.6.3-android-arm64
tcp6 0 0 [::]:34459 [::]:* LISTEN 24938/ruffalo-server-16.6.3-android-arm64
x1q:/proc/21522 #
이는 아래의 옵션들이 조합된 것이다.
1
2
3
4
-l Listening server sockets
-t TCP sockets
-n Don't resolve names
-p Show PID/program name of sockets
이를 통해 열려있는 포트를 검사하거나 문자열을 검사할 수 있을 것이다.
1
2
3
4
5
bool result = strstr(readline_netstat, "27042") && strstr(readline_netstat, "LISTEN");
if (true == result) {
// Detected
__android_log_print(ANDROID_LOG_VERBOSE, "Sample-Test", "Found");
}
위처럼 netstat 결과에서 알 수 있듯이 열려있는 포트 번호와 현재 LISTEN 상태인지 탐지하는 것이다.
포트 스캔 탐지 우회
포트 스캔 방식도 우회한다면 다음과 같을 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Interceptor.attach(Module.getExportByName('libc.so', 'strstr'), {
onEnter: function (args) {
var str1 = Memory.readCString(args[0]);
var str2 = Memory.readCString(args[1]);
if (str2.indexOf("27042") != -1
|| str2.indexOf("LISTEN") != -1){
console.log("str1:%s str2:%s\n", str1, str2);
this.hook= true;
}
},
onLeave: function (retval) {
if (this.hook) {
retval.replace(0x0);
console.log("Replace ret value to 0");
}
}
});
포트 번호와 LISTEN 문자열일 때 반환값을 변조하여 우회한다.
Conclusion
지금까지 Frida 를 탐지할 수 있는 방법들을 살펴보았다. 이외에도 수십가지 방법으로 Frida 를 탐지할 수 있는 방법이 존재한다. 하지만 재밌는 점은 Frida 를 통해 Frida 탐지 기능을 우회할 수 있다는 점이다.
메모리 동작을 통한 탐지 방법은 깊게 알아봐야 하지만 우회는 탐지 기능을 비웃는 것처럼 아주 손쉽게 후킹을 통해 회피할 수 있다.
탐지하는 기능이든 우회든 방도를 알아보기 위하여 깊숙하게 알아봐야 하는 건 동일하다.
위 환경은 사실 일반적인 앱 상테에서 감지해야 하는 것이 조건이므로 좀 더 아이디어를 내야 할 부분이 많을 것이다. 적용할 수 없는 아이디어가 훨씬 많을 것이며 사용할 수 없는 방법들도 많을 것이다.
이와 비슷하게 루팅 기술 또한 비슷한 스토리를 가지고 있다. 루팅은 shamiko 라는 모듈을 통해 추가적으로 zygisk 흔적을 지우는데 사용된다.
수차례 보안개발자들이 탐지 방법을 찾아내면서 Magisk 쪽에서도 몇번 패치를 하는데 이또한 길고 긴 공방전으로 이어지곤 했다.
탐지 방법을 가졌다하더라도 2차, 3차적으로 대책을 세워야 할 것으로 보인다.
단순하게 코드를 작성하면 탐지 로직이 훤히 읽혀 금방 우회가 가능하다.
난독화나 암호화 또는 형식화된 호출을 피하거나 한 단계 낮은 네이티브 레벨의 언어를 사용한다면 우회를 위한 분석 또한 쉽지 않을 것이다.
그래서 금융앱이나 핀테크 앱은 깊은 곳에 보안기술이 꽁꽁 숨겨져있어 공격자들은 이를 파헤쳐 우회할 수 있는 로직을 찾아내기도 한다.
그렇게되면 우회는 이전과 다르게 때론 Direct 하게 접근해야 할 수 있고 탐지 로직을 무력화하기 위하여 기존에 유효했던 탐지 아이디어들을 의미없거나 랜덤한 값으로 바뀐 바이너리를 사용할 수 있다.
이외에도 많은 도구와 환경들이 있는데 이러한 것들은 실행 시 안드로이드 깊숙한 메모리 어딘가에 흔적을 남기며 이를 코드로 로직화하면 정상적으로 탐지할 수 있다.
그러나 최근 코드 Binary instrumentation 기능이 강화되어 앱 시작 전 dynamic hook 을 준비하여 모든 코드 호출부를 추적할 수 있다.
따라서 아무리 완벽한 탐지 시그니처와 수단을 가졌다하더라도 마침내 로직은 쉽게 우회될 것이다.
If you find any errors, please let me know by comment or email. Thank you.