이번 글에서는 암호화된 sqlite DB를 이용해, 악성 행위를 진행하는 코드를 로딩하는 악성 APK를 간단하게 분석해보았습니다. 해당 샘플은 abuse.ch의 MalwareBazaar를 통해 입수하였습니다. (https://bazaar.abuse.ch/sample/68ca3a0c39b6216579779e66a524a08836c7e99dd1b4603881c803948a38aa0a/)
APK의 악성 행위 및 C2 서버나 데이터 유출 방식 등의 파악보다는 DB를 이용한 dex를 숨기는 방식을 간단하게 분석해보고, 숨겨진 dex 파일을 정적 분석만으로 확보해보는게 목적이었으므로 별도로 동적 분석이나 악성 행위의 종류 파악 등은 진행하지 않았습니다. (혹여나 추후 이러한 분석을 추가로 진행한다면 새로운 아티클로 작성해볼 생각입니다.)
Java에서 ClassLoader에 추가로 .jar 파일을 등록하여 동적으로 클래스 로딩을 진행하는 방식과 유사하게, Android에서도 ClassLoader를 이용해 동적으로 .dex 파일을 로딩할 수 있습니다.
Android 앱 형태로 배포되는 악성 코드의 경우, 악성 행위를 하는 코드나 관련된 API 사용, 문자열 등을 포함하는 dex 파일을 사전에 암호화하여 APK 내에 보관한 이후 앱 실행 시 이를 복호화하여 동적으로 로딩하는 방식으로 분석을 저해하는 동작을 하는 경우가 있습니다. (몇 년 전에 작성한 글 내의 악성 APK도, assets 디렉토리 내에 암호화된 dex 파일을 보관하고, 앱 진입 시 이를 복호화하여 로딩하는 방식을 채택하고 있습니다.)
(물론 이러한 방식으로 분석을 저해하거나 탐지를 회피하려고 해도, APK 내에 암호화된 것으로 보이는 애셋이 존재하는 경우 탐지하거나, 애셋을 복호화 후 클래스 로더에 등록하는 코드 패턴 등을 인식하는 식으로 이를 실행이나 다른 동적 분석 없이 사전에 탐지할 수 있습니다.)
분석 대상은 단순히 암호화된 dex를 애셋으로 저장하는 형태가 아니라, 암호화된 sqlite DB 내에 이를 blob으로 저장하는 형태로 탐지를 회피하는 기능을 가지고 있습니다. (물론 샌드박스를 이용한 자동화된 분석이나, 사람이 직접 여러 기법을 이용하여 분석한다면 쉽게 파악할 수 있지만, 샌드박스 환경을 탐지하면 악성 행위를 진행하지 않는 방식으로 이러한 자동화된 분석에서 탐지를 회피하는 기법을 적용한 악성 코드도 다수 존재합니다. 현재 분석 대상도 이러한 코드를 가지고 있으며, 이는 아래에서 추가로 조금 다루겠습니다.)
APK 내의 애셋 디렉토리를 확인해보면, imhaa.db라는 파일이 있습니다. macOS의 file 유틸리티를 사용해보면 해당 파일을 단순 data로 판단하는 점과, 파일 컨텐츠가 암호화된 것으로 추정되는 형태인 점을 보면 APK 내에 이 파일을 복호화하여 동작하는 코드가 존재할 것으로 추측할 수 있습니다. (파일 내에 ASCII string이 없는 점이나, 반복되는 패턴 등이 별도로 없는 점 등으로 암호화 혹은 그와 유사한 처리가 되어있다고 추정했습니다.)

해당 클래스 내의 코드를 살펴보면, DB를 열어서 "nehDvuNTT"라는 테이블의 "rusPKYTRQ" 컬럼의 데이터를 로딩하는 코드를 확인할 수 있습니다.
this.dgWr1h = SQLiteDatabase.openOrCreateDatabase(str, tMX8Ms(E8vhtS(getContext())), (SQLiteDatabase.CursorFactory) null);

imhaa.db 파일을 여는 코드를 보면, SQLiteDatabase.openOrCreateDatabase 메서드를 이용하는 것을 확인할 수 있습니다. 이때 해당 메서드의 시그니쳐를 보면 String, String, CursorFactory라는 점을 확인할 수 있습니다. Android에서 제공하는 android.database.sqlite.SQLiteDatabase에서는 이러한 시그니쳐의 openOrCreateDatabase 메서드를 제공하지 않습니다. (사실 smali를 확인해서 관련 클래스의 fully qualified name을 확인하거나, jadx의 decompiled view에서 import 구문을 유심히 확인하기만 해도 해당 클래스가 안드로이드의 SQLiteDatabase 클래스가 아니라는 점을 확인할 수 있습니다.)
net.sqlcipher 라는 패키지 이름 등을 통해, 관련 클래스는 android-database-sqlcipher 라이브러리에서 제공하는 클래스임을 알 수 있습니다. SQLCipher 프로젝트는 SQLite를 포크하여, AES-256 암호화를 제공하는 프로젝트입니다.
샘플에서 사용하는 openOrCreateDatabase 메서드의 경우, 첫 번째 스트링이 DB path, 두 번째 스트링이 암호화에 사용된 키임을 알 수 있습니다. tMX8Ms(E8vhtS(getContext()) 함수를 분석하면 PackageManager를 이용해 자기 자신의 signatures[0]을 추가로 처리해, 키로 사용하고 있음을 알 수 있습니다.
Android API 문서를 확인해보면 signatures 필드는 APK의 서명 관련 정보를 담고 있다는 점을 알 수 있습니다.
여러 인증서를 사용하여 서명한 경우라면 signature[0]이 어떤 인증서 정보를 담고 있는지 파악을 해야겠으나 샘플 APK는 1가지 인증서로 서명되어 있으므로 서명된 인증서의 Signature 객체에 toByteArray 메서드를 호출한 결과만 파악하면, 동일한 방식으로 키를 계산할 수 있습니다.
APK 서명에 사용된 인증서와 관련된 파일은 META-INF/ 디렉토리 내에 저장됩니다.
openssl pkcs7 -in path_to_unzipped_dir/META-INF/I8PJV8MY.RSA -inform DER -print_certs
위처럼 openssl 명령어를 이용해, .RSA 파일의 인증서 정보를 출력할 수 있습니다. 해당 출력의 base64 인코딩된 인증서 정보를 다시 디코딩하고 이를 Java ByteArray의 형태로 만들면, 샘플과 동일한 코드를 사용하여 키 획득이 가능합니다.
https://gist.github.com/dustty0/b7a9c04ac8ec48998624a39068e593f1#file-testa-java
import java.util.HexFormat;
public class TestA {
public static void main(String[] args) {
String hexString = "3082034b30820233a0030201020205008d38d6fb300d06092a864886f70d01010b050030673110300e06035504030c07556e6b6e6f776e3110300e060355040b0c07556e6b6e6f776e3110300e060355040a0c07556e6b6e6f776e3110300e06035504070c07556e6b6e6f776e3110300e06035504080c07556e6b6e6f776e310b3009060355040613024e55301e170d3234303730383037313430335a170d3234303731313232353030335a30673110300e06035504030c07556e6b6e6f776e3110300e060355040b0c07556e6b6e6f776e3110300e060355040a0c07556e6b6e6f776e3110300e06035504070c07556e6b6e6f776e3110300e06035504080c07556e6b6e6f776e310b3009060355040613024e5530820122300d06092a864886f70d01010105000382010f003082010a02820101008cfc78c835bcb84ef2cde709f13ba5b3135946258b859bda800e62d3dac0c2957b9a2f94c49ae80572b9e59e6709392db698317187e5c0356bec937442f217e0c991ab0f4b5621cded0d24ea49246a5cd82645c774cf96f480b00de62626f872a538c2838ebad087a55b4b79da7984a2cd6ecb354840f4f8ac9c51b07d0ccc1fda8c6aa91c2d2f4011be7246320c3e3d21511fd87fae69c836c7b21fd03737dce0c30b56bc32a0b713a8a624690047715b9bfe3f6b3c0efa8225901d75b333e68418c04c88b286747fdfdbcead450e2b08ca39de8ea0bcffe650712e7ca6e445fcea38b63cf75177450def046ccaa321af7f21cae7d9f44d2d6506739831a2eb0203010001300d06092a864886f70d01010b050003820101000b813772da8c93ec1824b21b783f014f3421c0d19c45e0e49de1f582891c617092d721361752f38ea16a346b34c0e88776a41372b2f77ac475ffe87fdf7899d01f0cbb3e4072d0dd1e536fe592e240952ce01386f1edb8b8756757cb4967ed7acac9e5222d1b435bfdaf50213b81d429df93c4a7ab24434c313974465a2e5cbf4f0066b3477fd8530bbc1764e8c4f53c8d315938a169e2cdd730f038a1f512b8cde175131fc6bc06252fd29a0fd899ec5c5f3a265b31cd66368af808cd5f3a806b2901303c0682695f4cfd41e807b2261c608de1cdaf7d39bdf30d90e48a973319a96243668b1724bf628884b1ae944bcd59ccaf166a762ff16d0c5f512bed92";
HexFormat hf = HexFormat.of();
byte[] bArr = hf.parseHex(hexString);
int res = 0;
int i = 1;
for (byte b : bArr) {
i = (i * 31) + b;
}
res = (-664904760) ^ i;
System.out.println("" + Integer.toString(res)); // -116909577
}
}
출력의 문자열을 디코딩하는 부분부터 자바로 처리해도 되지만, CyberChef를 이용해 hex string으로 처리하고, 이를 HexFormat을 이용하여 바로 ByteArray로 변환하는 방식으로 간단하게 코드를 작성했습니다. (HexFormat은 Java 17 이상에서 추가되었고, 이 점 때문에 그냥 파이썬으로 샘플 내의 키 관련 코드를 다시 구현할까 고민을 정말 조금 했지만, 어차피 로컬에 asdf 사용해서 JDK 17이 설치되어 있어서 그냥 자바로 진행했습니다...)
위 스니펫을 실행해보면, 키로 사용되는 문자열은 -116909577임을 알 수 있습니다.

이제 imhaa.db 파일을 sqlitebrowser를 이용해서 열거나, 각 환경에 맞는 sqlcipher를 설치해서 cli를 통해 복호화 후, 내용을 확인할 수 있습니다. sqlitebrowser를 사용하는 경우 위와 같이 SQLCipher 4 defaults 설정을 이용해서 복호화 가능하고, sqlcipher cli를 사용하는 경우, db 오픈 이후 PRAGMA key="-116909577"; 명령어를 입력하여 복호화가 가능합니다.
sqlite> .tables
nehDvuNTT
sqlite> .schema nehDvuNTT
CREATE TABLE nehDvuNTT (ylpIJpprw TEXT, rusPKYTRQ BLOB);
sqlite> select * from nehDvuNTT;
KMQYMfhOb|dex
035
imhaa.ext.jar|뉋��s�[
imhaa.dat.jar|��C@��
P�
imhaa.uni.jar|(�eK����&}
imhaa.stp.jar|�"7a���
�mhaa.irs.jar|��)�
�0
코드 내용과 동일하게 해당 DB 내에는 1개의 테이블이 존재하고, 테이블은 TEXT 컬럼 1개와 BLOB 컬럼 1개로 구성되어 있습니다.
SELECT WRITEFILE(ylpIJpprw, rusPKYTRQ) FROM nehDvuNTT; 쿼리를 통해, 각 blob을 저장할 수 있습니다.
저장된 파일을 살펴보면, KMQYMfhOb 파일은 정상적인 dex 파일입니다. sqlcipher cli에서 알 수 있듯 dex 035로 시작하는 dex 헤더를 가지고 있고, jadx 등을 이용하여 해당 dex를 디컴파일 하는데에도 문제가 없습니다.
그러나 .jar 파일들은 file 유틸리티를 사용해보면 단순 data로 결과가 출력되고, 파일 자체를 확인해봐도 일반적인 .jar 파일과 다르게 PK로 시작하는 ZIP 헤더 등을 전혀 확인할 수 없습니다.
KMQYMfhOb 파일을 jadx로 확인해보면, 동일한 imhaa.db 파일과 동일한 키를 이용하여 jar 파일을 추가로 복호화한 다음, 이를 ClassLoader에 등록하는 동작을 하는 것을 확인할 수 있습니다.


DB 내에서 .jar 파일들을 복호화하는 코드를 보면, 파일의 첫 10바이트를 키로 하고 이후 8바이트를 생략한 이후 65536 바이트 씩 파일을 끊어서, 10 바이트의 키로 복호화하는 점을 알 수 있습니다.
https://gist.github.com/dustty0/b7a9c04ac8ec48998624a39068e593f1#file-testb-java
import java.io.InputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.Arrays;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
public class TestB {
private static byte[] decode(byte[] buf, byte[] key) {
byte[] res = new byte[buf.length];
for (int i = 0; i < buf.length; i++) {
res[i] = (byte) (buf[i] ^ key[i % key.length]);
}
return res;
}
public static void main(String[] args) {
String[] fileToDec = {"imhaa.ext.jar", "imhaa.dat.jar", "imhaa.uni.jar", "imhaa.stp.jar", "imhaa.irs.jar"};
for (String fileName : fileToDec) {
try {
InputStream istream = new FileInputStream(fileName);
FileOutputStream fostream = new FileOutputStream(fileName + "-dec.jar");
byte[] key = new byte[10];
byte[] dummy = new byte[8];
byte[] buf = new byte[65536];
istream.read(key, 0, 10);
istream.read(dummy, 0, 8);
while (true) {
int sz = istream.read(buf, 0, 65536);
if (sz <= 0) break;
fostream.write(decode(buf, key), 0, sz);
}
// close streams
} catch (Exception e) {
System.out.println("error while processing " + fileName);
e.printStackTrace();
}
}
}
}
샘플을 디컴파일한 코드를 그대로 사용한다면 발생하지 않을 문제이지만, 키가 10 바이트이고 복호화는 65536 (=16의 배수) 단위로 진행한다는 점을 주의해야 합니다. 저는 별 생각 없이 nio.Files의 readAllBytes를 이용해서 한 번에 전체를 복호화한 후, 결과가 이상해서 잠깐 고민한 다음... 뒤늦게 눈치채고 코드를 동일하게 수정해서 .jar 파일들을 복호화했습니다.
각각의 .jar 파일은 dex 파일을 하나씩 포함하고 있습니다.


dex 내에는 su 바이너리나, 기타 루팅 여부와 연관된 패키지 등을 탐지하는 코드가 존재합니다.

이외에도 AVD나 vbox 기반의 에뮬레이터 등 탐지와, USB 디버깅 여부 등을 검사하는 등의 코드가 존재합니다.

샘플은 이러한 코드를 통해 일반적인 사용자 단말기가 아닌 환경에서 앱을 종료하는 방식으로 분석을 저해하거나, 샌드박스 등을 이용한 탐지를 회피하는 동작을 구현하고 있습니다.


이외에도 샘플은 ssh와 연관된 문자열을 코드 내에 포함하고 있습니다. jsch 프로젝트의 0.1.54 라이브러리와 유사한 코드로 보이며, C2와의 통신이나 기타 통신에 ssh를 활용하고 있지 않는지 의심해볼 수 있습니다.


이외에도 특정 뷰를 캡쳐하여 .jpg 파일로 저장하거나, 웹뷰의 URL이 구글 플레이 스토어인지 확인하고, 필요한 경우 추가 동작을 진행하는 코드 또한 존재합니다. 추후 기회가 된다면 해당 앱의 정확한 악성 행위 및 C2 통신 방식 등을 파악하여 분석해보는 것도 좋은 경험이 될 것 같습니다.
암호화된 dex 등과는 무관하여 본문에서 다루지는 않았지만, 샘플은 실제로 플레이 스토어에 존재하는 특정 앱의 코드를 다수 포함하고 (com.digital.libecricketapp 패키지 등) 있습니다.
단순히 분석을 저해하거나 혼란스럽게 하려는 시도인지, 아니면 다른 의도가 있는지 등을 파악할 수 있으면 좋을 것 같은데... 유사한 동작을 하는 다른 샘플을 조금 더 입수해서 확인해보거나, 추가 분석을 진행하지 않으면 당장은 파악하기 어려울 것 같아 그 점이 아쉽습니다.

샘플은 Facebook-Mod-457_0_0_54_84.apk라는 파일로, apkmb[.]com이라는 사이트에서 다운로드 된 것으로 나옵니다.
일반적으로 Mod 앱이라 함은, 공식 앱에 없는 기능을 추가하거나, 유료로만 사용 가능한 기능을 무료로 쓸 수 있게 수정해서 배포한다던가, 임의로 광고를 제거해서 배포한다던가 하는 앱을 말합니다.
파일 이름만 보고 단순히 페이스북 앱을 수정해서 악성 행위를 하는 코드를 주입하거나, 써드파티 모드 앱으로 위장하여 페이스북 계정이나 기타 사용자 정보를 탈취하는 InfoStealer 형태일 것이라고 짐작했습니다. 하지만 암호화된 dex 내에 Play Store URL인 경우 별도의 동작을 진행하는 코드 등이 있는 것을 보면, 설치 이후 추가적인 악성 앱 등을 설치하도록 유도하는 일종의 dropper 역할 등을 겸할 가능성도 있어 보입니다.