The Hidden Originals - How Protection Layers Conceal Apps
How SO Files Work in Software Protection (feat. Vermillion)
Introduction
현대 소프트웨어는 눈에 보이는 기능뿐 아니라, 그 이면에 숨겨진 수십 겹의 방어층을 가진다. 정교한 난독화, 런타임 변조, 무결성 검사, 라이선스 검증 — 그 모든 것은 ‘어떻게 보호해야 하는가’에 대한 설계자의 답이다. 하지만 답은 완전하지 않다. 악성코드와 공격자는 그 틈을 찾아내고, 우리는 그 흔적을 따라가며 설계의 한계를 드러낸다.
오늘은 악성코드를 해체하면서 보이는 보호 메커니즘의 의도와 작동 원리를 체계적으로 분석하고, 그 지식을 다시 제품 설계와 탐지 로직으로 재구성하는 과정을 기록한다. 리버싱은 혐오스러운 행위가 아니라, 더 강한 방어를 설계하기 위한 가장 현실적인 실험실이다. 이 글 끝에서는 실제 위협을 해부한 사례, 보호 메커니즘의 설계 철학을 엿볼 수 있을 것이다.
기술적 통찰과 윤리적 책임 사이의 균형을 잃지 않으며, 우리가 얻은 지식을 어떻게 ‘방어’로 돌려놓을지 함께 고민해본다.
최근 압축된 형태의 설치파일을 많이 보고 있는데 그중 또 재밌는 걸 길에서(?) 주워서 정리해보려고 한다.
파일은 6ded4cf6cc4f4735b954ec4a7becbb99f3d9654710ebc12ffe76c4e0ae396651.apk 이며 손쉽게 디컴파일이 가능하다.
앱의 패키지 이름은 com.tencent.mm 인데 찾아보면 WeChat 이다. WeChat 을 가장한 멀웨어로 보인다.
여기서는 멀웨어의 기능보다 어떻게 자신의 모습을 숨겼는지 그리고 어떤 방식으로 원본 코드를 로드하는지 간략하게 살펴볼 생각이다.
Reverse
자바 단을 먼저 들어간다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package arm;
import android.app.Application;
import android.content.Context;
import android.os.SystemClock;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class StubApp extends Application {
public static String MAIN_APPLICATION;
static {
System.loadLibrary("arm_protect");
}
@Override // android.content.ContextWrapper
protected native void attachBaseContext(Context arg1) {
}
@Override // android.content.ContextWrapper
public native Context createPackageContext(String arg1, int arg2) {
}
@Override // android.content.ContextWrapper
public native String getPackageName() {
}
static void loadDex(List list0, Context context0) {
try {
long v = SystemClock.currentThreadTimeMillis();
StubApp.MAIN_APPLICATION = StubApp.loadMainApplicationFromAssets(context0);
Field field0 = ((Class)Objects.requireNonNull(context0.getClassLoader().getClass().getSuperclass())).getDeclaredField("pathList");
if(!field0.isAccessible()) {
field0.setAccessible(true);
}
Object object0 = field0.get(context0.getClassLoader());
Field field1 = Objects.requireNonNull(object0).getClass().getDeclaredField("dexElements");
if(!field1.isAccessible()) {
field1.setAccessible(true);
}
Object[] arr_object = (Object[])field1.get(object0);
Method method0 = object0.getClass().getDeclaredMethod("makePathElements", List.class, File.class, List.class);
if(!method0.isAccessible()) {
method0.setAccessible(true);
}
Object[] arr_object1 = (Object[])method0.invoke(object0, list0, context0.getDir("arm", 0), new ArrayList());
Object[] arr_object2 = (Object[])Array.newInstance(((Class)Objects.requireNonNull(((Object[])Objects.requireNonNull(arr_object)).getClass().getComponentType())), ((Object[])Objects.requireNonNull(arr_object1)).length + arr_object.length);
System.arraycopy(arr_object, 0, arr_object2, 0, arr_object.length);
System.arraycopy(arr_object1, 0, arr_object2, arr_object.length, arr_object1.length);
field1.set(object0, arr_object2);
System.out.println("Time taken: " + (SystemClock.currentThreadTimeMillis() - v) + " ms");
}
catch(Exception exception0) {
exception0.printStackTrace();
}
}
private static String loadMainApplicationFromAssets(Context context0) {
try {
InputStream inputStream0 = context0.getAssets().open("config.so");
byte[] arr_b = new byte[inputStream0.available()];
inputStream0.read(arr_b);
inputStream0.close();
return new String(arr_b);
}
catch(IOException iOException0) {
iOException0.printStackTrace();
return null;
}
}
@Override // android.app.Application
public native void onCreate() {
}
}
위에 코드가 전부인데 흥미로운건 네이티브 라이브러리(arm_protect)를 로드한다는 것과 native 로 랩핑하여 onCreate 와 attachBaseContext, createPackageContext 그리고 getPackageName 룰 구현하고 있다.
그러면 본격적으로 libarm_protec.so 를 열어본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
jint v2; // w19
__int64 v4; // x20
__int64 v5; // x0
_QWORD v6[2]; // [xsp+0h] [xbp-30h] BYREF
v6[1] = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
v2 = 65542;
if ( (*vm)->GetEnv(vm, (void **)v6, 65542) )
return -1;
v4 = v6[0];
v5 = (*(__int64 (__fastcall **)(_QWORD, const char *))(*(_QWORD *)v6[0] + 48LL))(v6[0], "arm/StubApp");
if ( !v5
|| ((*(__int64 (__fastcall **)(__int64, __int64, char **, __int64))(*(_QWORD *)v4 + 1720LL))(v4, v5, off_3B000, 4)
& 0x80000000) != 0 )
{
return -1;
}
xhook_enable_debug();
xhook_register(".*/libc.so$", "execv", my_execv, &org_execv);
xhook_refresh(0);
return v2;
}
위에 onLoad 코드를 보면 off_3B000 에 JNI 바인딩된 함수를 볼 수 있다.
1
2
3
4
native_attachContextBaseContext(_JNIEnv *,_jobject *,_jobject *) .text 00000000000111F0 000003B0 00000190 R . . . . . B T . .
native_onCreate(_JNIEnv *,_jobject *) .text 000000000001180C 0000009C 00000030 R . . . . . B . . .
native_getPackageName(_JNIEnv *,_jobject *) .text 000000000001207C 00000014 R . . . . . . . . .
native_createPackageContext(_JNIEnv *,_jobject *,_jstring *,int) .text 0000000000012090 000000C0 00000040 R . . . . . B . . .
so 파일 위에서 위에 선언된 네이티브 함수들을 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
__int64 __fastcall native_attachContextBaseContext(__int64 a1, __int64 a2, __int64 a3)
{
__int64 v6; // x20
__int64 v7; // x0
__int64 v8; // x21
__int64 v9; // x0
__int64 v10; // x23
__int64 v11; // x0
__int64 v12; // x0
__int64 v13; // x25
__int64 v14; // x26
__int64 v15; // x28
__int64 v16; // x0
__int64 v17; // x27
__int64 v18; // x0
__int64 v19; // x20
__int64 v20; // x24
__int64 v21; // x28
__int64 i; // x1
__int64 v23; // x0
__int64 v24; // x0
__int64 v25; // x20
__int64 j; // x22
const char *v27; // x0
__int64 result; // x0
__int64 v29; // [xsp+0h] [xbp-180h]
__int64 v30; // [xsp+8h] [xbp-178h]
__int64 v31; // [xsp+10h] [xbp-170h]
char filename[256]; // [xsp+20h] [xbp-160h] BYREF
__int64 v33; // [xsp+120h] [xbp-60h]
v33 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
v6 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(a1, "android/content/ContextWrapper");
v7 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 264LL))(
a1,
v6,
"attachBaseContext",
"(Landroid/content/Context;)V");
v30 = a3;
v31 = v6;
_JNIEnv::CallNonvirtualVoidMethod(a1, a2, v6, v7, a3);
v8 = (*(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)a1 + 248LL))(a1, a2);
v9 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 264LL))(
a1,
v8,
"getFilesDir",
"()Ljava/io/File;");
v10 = _JNIEnv::CallObjectMethod(a1, a2, v9);
v29 = (*(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)a1 + 248LL))(a1, v10);
v11 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 264LL))(
a1,
v29,
"getAbsolutePath",
"()Ljava/lang/String;");
v12 = _JNIEnv::CallObjectMethod(a1, v10, v11);
(*(void (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)a1 + 1352LL))(a1, v12, 0);
VIBE_buildFilePath(filename);
extractDex(a1, a2, filename);
v13 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(a1, "java/io/File");
v14 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 264LL))(
a1,
v13,
"<init>",
"(Ljava/lang/String;)V");
v15 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(a1, "java/util/ArrayList");
v16 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 264LL))(
a1,
v15,
"<init>",
"()V");
v17 = _JNIEnv::NewObject(a1, v15, v16);
v18 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 264LL))(
a1,
v15,
"add",
"(Ljava/lang/Object;)Z");
v20 = dexList;
v19 = qword_3B0C0;
if ( dexList != qword_3B0C0 )
{
v21 = v18;
if ( (*(_BYTE *)dexList & 1) != 0 )
goto LABEL_6;
LABEL_3:
for ( i = v20 + 1; ; i = *(_QWORD *)(v20 + 16) )
{
(*(void (__fastcall **)(__int64, __int64))(*(_QWORD *)a1 + 1336LL))(a1, i);
v23 = _JNIEnv::NewObject(a1, v13, v14);
_JNIEnv::CallBooleanMethod(a1, v17, v21, v23);
v20 += 24;
if ( v19 == v20 )
break;
if ( (*(_BYTE *)v20 & 1) == 0 )
goto LABEL_3;
LABEL_6:
;
}
}
v24 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 904LL))(
a1,
v8,
"loadDex",
"(Ljava/util/List;Landroid/content/Context;)V");
_JNIEnv::CallStaticVoidMethod(a1, v8, v24, v17, v30);
extractDex 하고 자바 단에서 본 loadDex 를 호출하는 것을 볼 수 있다.
아래에서 extractDex 쪽을 봐본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
v7 = (__int64)(*a1)->GetMethodID(a1, (jclass)v6, "getAssets", "()Landroid/content/res/AssetManager;");
v8 = (void *)_JNIEnv::CallObjectMethod(a1, a2, v7);
result = AAssetManager_fromJava(a1, v8);
if ( result )
{
v10 = result;
v11 = AAssetManager_openDir(result, "");
result = (AAssetManager *)AAssetDir_getNextFileName(v11);
if ( result )
{
v12 = (const char *)result;
do
{
if ( strstr(v12, "classes") && strstr(v12, ".dex") )
{
result = AAssetManager_open(v10, v12, 2);
if ( !result )
return result;
{ . . . }
assets 에 있는 classes.dex 를 읽어서 복호화하는 과정을 거친다.
그러면 자바 단에 loadDex 쪽으로 넘어간다.
1
StubApp.MAIN_APPLICATION = StubApp.loadMainApplicationFromAssets(context0);
loadDex 에서 위와 같이 다른 자바 메소드를 호출하는 것을 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
private static String loadMainApplicationFromAssets(Context context0) {
try {
InputStream inputStream0 = context0.getAssets().open("config.so");
byte[] arr_b = new byte[inputStream0.available()];
inputStream0.read(arr_b);
inputStream0.close();
return new String(arr_b);
}
catch(IOException iOException0) {
iOException0.printStackTrace();
return null;
}
}
해당 메소드는 assets 에 있는 config.so 를 읽어서 문자열로 변수에 저장한다.
1
2
3
00000000: 636f 6d2e 7379 7374 656d 2e6d 7961 7070 com.system.myapp
00000010: 6c69 6361 7469 6f6e 2e4a 766d 2e6a 766d lication.Jvm.jvm
00000020: 6472 6564 dred
해당 config.so 를 hex dump 에서 뽑아내면 위와 같은 문자열을 찾아볼 수 있다.
1
Stub.MAIN_APPLICATION = com.system.myapplication.Jvm.jvmdred
다시 정리하면 이렇게 볼 수 있다.
그 다음은 native_onCreate 쪽으로 넘어가본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__int64 __fastcall native_onCreate(__int64 a1, __int64 a2)
{
__int64 v4; // x21
__int64 v5; // x0
__int64 v6; // x4
__int64 result; // x0
v4 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(a1, "android/app/Application");
v5 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 264LL))(
a1,
v4,
"onCreate",
"()V");
result = _JNIEnv::CallNonvirtualVoidMethod(a1, a2, v4, v5, v6);
if ( !isbindRealApplication )
return bindRealApplication(a1);
return result;
}
native_onCreate 를 보면 실제 앱으로 바인딩하는 부분을 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__int64 __fastcall bindRealApplication(__int64 a1)
{
v4 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(
a1,
"android/content/pm/ApplicationInfo");
v5 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(a1, "java/util/List");
v54 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(a1, "android/app/Application");
v6 = (*(__int64 (__fastcall **)(__int64, const char *))(*(_QWORD *)a1 + 48LL))(a1, "android/app/LoadedApk");
v7 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 904LL))(
a1,
v2,
"currentActivityThread",
"()Landroid/app/ActivityThread;");
v11 = _JNIEnv::CallStaticObjectMethod(a1, v2, v7, v8, v9, v10);
v12 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, char *))(*(_QWORD *)a1 + 1152LL))(
a1,
v3,
"MAIN_APPLICATION",
"Ljava/lang/String;");
v56 = v3;
v60 = (*(__int64 (__fastcall **)(__int64, __int64, __int64))(*(_QWORD *)a1 + 1160LL))(a1, v3, v12);
v13 = (*(__int64 (__fastcall **)(__int64, __int64, const char *, const char *))(*(_QWORD *)a1 + 752LL))(
위에서 보면 이전에 아펭서 받은 Stub.MAIN_APPLCIATION 을 호출하는 것을 볼 수 있다.
Call Stack
여기까지 본다면 순서는 아래와 같을 것이다.
native_attachContextBaseContext -> extractDex -> loadDex -> loadMainApplicationFromAssets -> native_onCreate -> MAIN_APPLICATION
그러나 이 파일의 내부 구동 방식은 정확하게 알 필요가 있어서 SO 파일이 로드되기 전에 코드 트래킹을 역으로 추적할 수 있는 Vermillion 을 구현했다.
해당 툴에 대해서는 다른 장에서 상세하게 다뤄볼 생각이다.
SO 파일이 로드되는 시점보다 훨씬 전에 코드 주입을 시작하면 모든 콜스택을 쫓아갈 수 있다.
android_dlopen_ext 를 주목하면 된다. 라이브러리 libdl.so 안에 0x1180 오프셋에 있다.
아래는 현재 보고있는 arm_protect 에 대한 콜스택이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# 0x00001 => JNI_OnLoad
# 0x00002 => .xhook_enable_debug
# 0x00003 => xhook_enable_debug
# 0x00004 => .xh_core_enable_debug
# 0x00005 => xh_core_enable_debug
# 0x00006 => .xhook_register
# 0x00007 => xhook_register
# 0x00008 => .xh_core_register
# 0x00009 => xh_core_register
# 0x00010 => .regcomp
# 0x00011 => .malloc
# 0x00012 => .strdup
# 0x00013 => .pthread_mutex_lock
# 0x00014 => .pthread_mutex_unlock
# 0x00015 => .xhook_refresh
# 0x00016 => xhook_refresh
# 0x00017 => .xh_core_refresh
# 0x00018 => xh_core_refresh
# 0x00019 => .sigemptyset
# 0x00020 => .sigaction
# 0x00021 => sub_1394C
# 0x00022 => .fopen
# 0x00023 => .fgets
# 0x00024 => .sscanf
# 0x00025 => .isspace
# 0x00026 => .strlen
# 0x00027 => .regexec
# 0x00028 => sub_13D20
# 0x00029 => .sigsetjmp
# 0x00030 => .xh_elf_check_elfheader
# 0x00031 => xh_elf_check_elfheader
# 0x00032 => sub_13DC4
# 0x00033 => sub_14064
# 0x00034 => sub_140F8
# 0x00035 => .xh_elf_init
# 0x00036 => xh_elf_init
# 0x00037 => .__android_log_print
# 0x00038 => .strcmp
# 0x00039 => .free
# 0x00040 => .fclose
# 0x00041 => _Z31native_attachContextBaseContextP7_JNIEnvP8_jobjectS2_
# 0x00042 => ._ZN7_JNIEnv24CallNonvirtualVoidMethodEP8_jobjectP7_jclassP10_jmethodIDz
# 0x00043 => _ZN7_JNIEnv24CallNonvirtualVoidMethodEP8_jobjectP7_jclassP10_jmethodIDz
# 0x00044 => ._ZN7_JNIEnv16CallObjectMethodEP8_jobjectP10_jmethodIDz
# 0x00045 => _ZN7_JNIEnv16CallObjectMethodEP8_jobjectP10_jmethodIDz
# 0x00046 => sub_1114C
# 0x00047 => .__vsprintf_chk
# 0x00048 => ._Z10extractDexP7_JNIEnvP8_jobjectPKc
# 0x00049 => _Z10extractDexP7_JNIEnvP8_jobjectPKc
# 0x00050 => .access
# 0x00051 => .mkdir
# 0x00052 => .AAssetManager_fromJava
# 0x00053 => .AAssetManager_openDir
# 0x00054 => .AAssetDir_getNextFileName
# 0x00055 => .strstr
# 0x00056 => .AAssetManager_open
# 0x00057 => ._ZNSt6__ndk16vectorINS_12basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEENS4_IS6_EEE24__emplace_back_slow_pathIJRA256_cEEEvDpOT_
# 0x00058 => _ZNSt6__ndk16vectorINS_12basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEENS4_IS6_EEE24__emplace_back_slow_pathIJRA256_cEEEvDpOT_
# 0x00059 => ._Znwm
# 0x00060 => _Znwm
# 0x00061 => ._ZNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC2IDnEEPKc
# 0x00062 => _ZNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC2IDnEEPKc
# 0x00063 => .memcpy
# 0x00064 => .AAsset_read
# 0x00065 => .fwrite
# 0x00066 => .AAsset_close
# 0x00067 => ._ZN7_JNIEnv9NewObjectEP7_jclassP10_jmethodIDz
# 0x00068 => _ZN7_JNIEnv9NewObjectEP7_jclassP10_jmethodIDz
# 0x00069 => ._ZN7_JNIEnv17CallBooleanMethodEP8_jobjectP10_jmethodIDz
# 0x00070 => _ZN7_JNIEnv17CallBooleanMethodEP8_jobjectP10_jmethodIDz
# 0x00071 => ._ZN7_JNIEnv20CallStaticVoidMethodEP7_jclassP10_jmethodIDz
# 0x00072 => _ZN7_JNIEnv20CallStaticVoidMethodEP7_jclassP10_jmethodIDz
# 0x00073 => .remove
# 0x00074 => _Z21native_getPackageNameP7_JNIEnvP8_jobject
# 0x00075 => _Z27native_createPackageContextP7_JNIEnvP8_jobjectP8_jstringi
# 0x00076 => ._Z19bindRealApplicationP7_JNIEnvP8_jobject
# 0x00077 => _Z19bindRealApplicationP7_JNIEnvP8_jobject
# 0x00078 => ._ZN7_JNIEnv22CallStaticObjectMethodEP7_jclassP10_jmethodIDz
# 0x00079 => _ZN7_JNIEnv22CallStaticObjectMethodEP7_jclassP10_jmethodIDz
# 0x00080 => ._ZN7_JNIEnv14CallVoidMethodEP8_jobjectP10_jmethodIDz
# 0x00081 => _ZN7_JNIEnv14CallVoidMethodEP8_jobjectP10_jmethodIDz
# 0x00082 => _Z15native_onCreateP7_JNIEnvP8_jobject
얼추 비슷하게 맞아보인다. 추후에 dex 파일을 열어서 어떤 내용이 있는지 확인해보면 좋을 것 같다.
Conclusion
다음에는 패킹된 dex 파일을 해제하는 작업을 해보고 어떤 내용이 있는지 확인해보겠다.
If you find any errors, please let me know by comment or email. Thank you.

