Dynamic Code Loading in Android
안드로이드에서의 동적 코드 로딩(Dynamic code loading)은 악성 행위를 숨기기 위해 악성 코드가 사용하는 방법이었다. 하지만, 최근에는 일반적인 앱에서도 많이 사용된다. 앱에 대한 리버싱 및 디버깅을 방해하는데 매우 효과적이기 때문이다.
이 글에서는 런타임 시 안드로이드 애플리케이션에 코드를 동적으로 로드하는 데 사용할 수 있는 3가지 방법을 살펴볼 것이다. 우선, 이 글은 Play Feature Delivery
1PFD는 Google Play 기능의 하나로, 앱의 기능을 여러 개의 동적 모듈로 나누고, 선택적으로 다운로드하고 업데이트할 수 있다.에 대한 내용이 아니다. 우리가 살펴볼 방법들은 BaseDexClassLoader
의 직접적인 하위 클래스들이며, 악성 코드는 종종 이러한 방법들을 이용하여 자신의 실체를 숨기기 위해 사용한다.
그렇다면, 왜 이러한 것을 만드는 방법을 설명하고 있는지 궁금할 수도 있다.
답은 간단하다. 만들어봐야 어떻게 동작하는지 완전히 이해할 수 있다. 그리고, 완전히 이해한 후에야 탐지하는 방법을 찾을 수 있다.
모든 세 가지 방법을 사용한 예시를 여기에서 확인할 수 있다.
1. SETUP
세 가지 다른 방법은 로드할 수 있는 파일 형식들이 서로 다르다. 따라서, 일관성을 유지하기 위해 dex
파일을 로드하는데 사용할 것이다. 모든 방법들이 dex
파일은 로드할 수 있기 때문이다.
비어있는 액티비티와 RandomNumber
라는 새로운 클래스를 가진 매우 간단한 안드로이드 애플리케이션을 만들었다. 다음은 해당 클래스에 있는 코드이다.
1 2 3 4 5 6 7 8 |
package com.erev0s.randomnumber import kotlin.random.Random class RandomNumber { fun getRandomNumber(): String { return Random.nextInt(0, 1000).toString() } } |
보다시피, 위의 코드는 많은 일을 하지 않는다. 랜덤한 숫자를 반환하는 함수만을 가지고 있다.
APK
를 빌드한 후, 해당 APK
를 추출하여 루트 폴더에 있는 classes.dex
파일을 가져온다. 이 파일에는 우리가 작성한 클래스가 포함되어 있다. JEB
등의 도구로 해당 dex
파일이 예상한 코드를 가지고 있는지 확인할 수 있다. 이제 목표는 이 dex
파일을 새로운 앱에 애셋(asset)으로 추가하고, 런타임 시 동적으로 로드하여 getRandomNumber()
함수를 사용하는 것이다.
2. DexClassLoader
DexClassLoader
는 apk
또는 jar
파일에서도 dex
파일을 로드할 수 있다. DexClassLoader
의 생성자는 총 네 개의 매개변수를 요구하지만 그 중 두 개만 중요하다. 바로 dex
파일의 경로와 부모 클래스로더이다. 따라서 작업은 간단하다. 먼저 애셋 폴더에서 dex
파일을 가져와 내부 저장소에 넣은 다음 DexClassLoader
의 생성자에 해당 경로를 지정하면 된다. 다음 코드를 통해 이 내용을 확인할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
fun cl(dexFileName: String): DexClassLoader { val dexFile: File = File.createTempFile("pref", ".dex") var inStr: ByteArrayInputStream = ByteArrayInputStream(baseContext.assets.open(dexFileName).readBytes()) inStr.use { input -> dexFile.outputStream().use { output -> input.copyTo(output) } } var loader: DexClassLoader = DexClassLoader( dexFile.absolutePath, null, null, this.javaClass.classLoader ) return loader } |
처음에는 createTempFile
을 사용하여 임시 파일을 생성하고, 이 파일은 3번째 줄에서 로드되는 dex
파일의 내용을 가진다. 마지막으로 9번째 줄에서 DexClassLoader
를 호출하면서, 바로 위에서 생성한 임시 파일의 절대 경로와 부모 클래스로더를 제공한다.
다음 단계는 적절한 파일 이름으로 위의 함수를 호출한 다음 getRandomNumber
함수를 호출하는 것입니다. 다음 코드는 이를 수행하는 방법을 보여준다.
1 2 3 4 5 |
var loader = cl(dexfilename) // get the DexClassLoader val loadClass = loader.loadClass("com.erev0s.randomnumber.RandomNumber") // get the class val checkMethod = loadClass.getMethod("getRandomNumber") // get the method val cl_in = loadClass.newInstance() // instantiate the class checkMethod.invoke(cl_in) as String // invoke the method |
3. PathClassLoader
PathClassLoader
는 클래스로더를 매우 간단하게 구현한 것이다. PathClassLoader
는 로컬 파일 시스템의 파일과 디렉토리 목록을 기반으로 동작합니다. 문서에 명시된 대로, PathClassLoader
는 네트워크에서 클래스를 로드하지 않는다. DexClassLoader
와 비교하여 구현 부분에는 차이가 적다. 이를 자세히 살펴보자.
1 2 3 4 5 6 7 8 9 10 11 |
fun path_cl(dexFileName: String): PathClassLoader { val dexFile: File = File.createTempFile("pref", ".dex") var inStr: ByteArrayInputStream = ByteArrayInputStream(baseContext.assets.open(dexFileName).readBytes()) inStr.use { input -> dexFile.outputStream().use { output -> input.copyTo(output) } } var loader: PathClassLoader = PathClassLoader(dexFile.absolutePath, this.javaClass.classLoader) return loader } |
중요한 차이점은 9번째 줄에서 PathClassLoader
의 생성자가 두 개의 인수를 받는다는 것이다. 바로 dex
파일의 경로와 부모 클래스로더이다. 함수를 호출하고 메서드를 호출하는 부분은 이전에 살펴본 DexClassLoader
와 동일하다.
4. InMemoryDexClassLoader
InMemoryDexClassLoader
는 이름에서 알 수 있듯이 dex
파일이 포함된 버퍼에서 클래스를 로드할 수 있는 클래스로더이다. 여기서 주목해야 할 중요한 점은 dex
파일이 로컬 파일 시스템에 기록되지 않고 버퍼에만 있음을 의미한다. 이는 인터넷에서 dex
파일을 다운로드하여 직접 로드해야 할 경우에 유용한 시나리오일 수 있다. 그러나 InMemoryDexClassLoader
를 사용하여 구현한 코드 중에서 “로컬 파일 시스템에 접근하지 말라”는 원칙을 따르는 것을 찾지 못했다. 대신 파일을 로컬 파일 시스템에 다운로드한 다음 InMemoryDexClassLoader
를 사용하여 로드하는 방식으로 구현되는 것을 찾을 수 있었다. 이는 사실상 처음부터 InMemoryDexClassLoader
를 사용하는 의도를 무색하게 하는 것이므로 의미가 없다. 다음 예제는 dex
파일을 다운로드하고 버퍼에 저장하여 클래스로더에 직접 공급하는 방법을 보여준다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
private fun downloadFile(url: String) { val thread = Thread { try { val u = URL(url) val conn: URLConnection = u.openConnection() val contentLength: Int = conn.getContentLength() val stream = DataInputStream(u.openStream()) buffer = ByteArray(contentLength) stream.readFully(buffer) stream.close() Log.d("seccheck", "Success of download to buffer") } catch (e: Exception) { Log.e("seccheck", e.message!!) } } thread.start() } |
다음 코드는 이를 앱 내에서 사용하는 예제를 보여준다.
1 2 3 4 5 6 |
btBuffer = ByteBuffer.wrap(buffer) val lder = InMemoryDexClassLoader(btBuffer, this.javaClass.classLoader) val mt = lder.loadClass("com.erev0s.randomnumber.RandomNumber") val checkMethodInMemory = mt.getMethod("getRandomNumber") val newcl = mt.newInstance() checkMethodInMemory.invoke(newcl)!!.toString() |
5. 예제
다음 애플리케이션은 세 가지 메서드가 사용된 예제이다. 세 개의 다른 dex
파일이 생성되는데, 그들 사이의 유일한 차이점은 포함된 클래스의 이름(RandomNumber
, RandomNumber2
, RandomNumber3
)이다. 각 클래스에 포함된 함수는 완전히 동일하다. 각 클래스의 이름이 다른 것은 올바른 dex
파일이 각각 로드되었음을 증명한다. 이 애플리케이션은 Github에서 확인할 수 있으며, 위에서 설명한 단계를 더 자세히 확인할 수 있다.
InMemoryDexClassLoader
를 사용하는 경우에는 버튼을 클릭할 때 해당 dex
파일을 제공해야만 로드할 수 있다는 내용의 토스트 메시지가 나타나는 것을 주목하자. dex
파일을 가져와 로드하기 위해 다운로드 버튼을 누른 후에야 버튼이 작동한다.