1. 개요
안녕하세요. 지난 시간 다뤘던 [Java 난독화 이야기 1탄] Control Flow 난독화에 이어 오늘은 문자열 난독화에 대해서 집중적으로 살펴볼 예정입니다.
앞서 다뤘던 난독화 이야기가 궁금하신 분들은 아래 링크를 참고해 주세요.
시작에 앞서
안드로이드 앱을 디컴파일 해보면 평문으로 남아 있는 문자열들이 은근히 눈에 띄입니다. 공격자는 이걸 보고 “아, 여기 API 키가 있네?”, “저 URL로 통신하네?” 하고 한눈에 알아챌 수 있습니다. 문자열 난독화(string obfuscation)는 이런 위험을 막기 위해 코드에 박혀 있는 문자열을 이해하기 어렵게 바꾸는 방법입니다. 문자열 난독화가 적용되면 디바이스 화면에서는 평소처럼 제대로 보이지만, DEX 파일 내부 코드에서는 쉽게 내용을 파악하기 어려운 상태가 됩니다.
보통 ProGuard나 R8으로 클래스·메소드 이름을 난독화하지만, 문자열까지 처리하는 것은 쉽지 않습니다. 그래서 DEX 단계에서 직접 문자열을 숨기면
- 일반 난독화 도구가 놓치는 부분까지 안전해지고
- smali 패치나 후처리 스크립트로 원하는 타이밍에 복호화 로직을 짤 수 있으며
- 앱 실행 속도에 큰 영향을 주지 않으면서도 보안을 한층 끌어올릴 수 있습니다.
이 글에서는 문자열 노출이 왜 위험한지에서 시작해, DEX 단계에서 문자열을 어떻게 숨기는지, 그리고 실제로 숨겨진 문자열이 어떻게 보이는지 차근차근 살펴봅니다.
2. 배경
2.1. 안드로이드 DEX 파일 구조
DEX(Dalvik Executable) 파일은 안드로이드 런타임에서 애플리케이션 코드를 실행하기 위해 사용하는 바이너리 형식입니다. 주요 섹션은 다음과 같습니다.
- Header
- DEX 파일에 대한 메타데이터를 담고 있습니다. magic(파일 식별자), checksum(무결성 검증용), file_size, header_size, endian_tag 등의 정보를 포함합니다.
- string_ids
- 애플리케이션에서 사용되는 모든 문자열 상수를 가리키는 인덱스를 저장합니다. 각 entry는 Data 섹션에 있는
string_data_item
의 오프셋(offset)을 참조하며, 실제 UTF-8 문자열은string_data_item
에 저장되어 있습니다.
- 애플리케이션에서 사용되는 모든 문자열 상수를 가리키는 인덱스를 저장합니다. 각 entry는 Data 섹션에 있는
- type_ids
- 문자열 ID 중 클래스, 배열, 기본 타입 등을 나타내는 타입 식별자를 저장합니다. 각 타입은
string_ids
테이블의 인덱스를 참조합니다.
- 문자열 ID 중 클래스, 배열, 기본 타입 등을 나타내는 타입 식별자를 저장합니다. 각 타입은
- proto_ids
- 메소드 시그니처(반환 타입, 파라미터 타입들)를 정의합니다. 이를 통해 메소드 호출 시 타입 일관성을 확인할 수 있습니다.
- field_ids
- 클래스의 필드 정보를 저장합니다. (예: 클래스 타입, 필드 이름, 필드 타입)
- method_ids
- 클래스의 메소드 정보를 저장합니다. (예: 클래스 타입, 메소드 이름, 메소드 시그니처)
- class_defs
- 애플리케이션에 포함된 각 클래스의 정의를 담습니다. 클래스가 상속하는 부모, 인터페이스, 접근 제어자, static 필드/메소드 오프셋 등이 여기에 포함됩니다.
- Data
- 실제 코드(DEX 바이트코드), 초기화 값, 디버깅 정보, annotation 등 다양한 데이터를 저장하는 영역입니다.
- string_data_item:
string_ids
가 가리키는 실제 문자열이 저장된 곳입니다. 길이(prefix)와 UTF-8 인코딩된 바이트 시퀀스로 구성됩니다. - code_item: 메소드 바이트코드, 레지스터 수, try/catch 블록 등 실행 정보를 담습니다.
- 그 외: 초기값(encoded_array), 디버깅(debug_info_item), 어노테이션(annotation_item) 등
- string_data_item:
- 실제 코드(DEX 바이트코드), 초기화 값, 디버깅 정보, annotation 등 다양한 데이터를 저장하는 영역입니다.
우리가 궁금한 문자열은 DEX 파일 내부에 존재하고 있는 것을 확인할 수 있었습니다.
이어서 빌드된 앱에서 어떻게 문자열을 확인할 수 있는지 알아보겠습니다.
2.2. 문자열 노출 위험
다음은 테스트를 위해 작성한 간단한 네트워크 통신 로직입니다.
class HttpClient {
private val baseUrl = "https://httpbin.org"
private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun get(
endpoint: String = "/get",
callback: (Result<String>) -> Unit
) {
mainScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
(URL(baseUrl + endpoint).openConnection() as HttpURLConnection).run {
requestMethod = "GET"
connectTimeout = 5_000
readTimeout = 5_000
inputStream.bufferedReader().use { it.readText() }
}
}
}
callback(result)
}
}
}
코드 상 baseUrl 값이 문자열로 그대로 작성되어 있는 것을 볼 수 있습니다.
위 소스를 포함한 앱을 빌드하고, jadx 디컴파일 툴로 확인을 해보겠습니다.
위 이미지에서 확인 할 수 있듯이 디컴파일 툴을 사용하면 빌드한 앱이라도 원본 소스를 그대로 확인이 가능하고, 코드 상 구현되어 있는 문자열 값도 곧바로 확인할 수 있는 것을 볼 수 있습니다.
3. 문자열 난독화란?
앞서 살펴볼 수 있듯이 앱 내부에 민감한 정보가 남아 있으면 공격자는 정적 분석만으로도 쉽게 노출된 데이터를 꺼내 쓸 수 있습니다. 문자열 난독화(string obfuscation)는 이런 위험을 줄이기 위해 하드코딩된 문자열을 사람이 읽기 어려운 형태로 변환하는 기법입니다. 단순히 코드 흐름을 숨기는 것에서 한 걸음 더 나아가, 문자열 자체를 암호화하거나 인코딩해 정적 분석 도구에 걸러지지 않도록 만드는 것이 핵심입니다.
대표적으로 사용하는 Proguard의 경우 클래스, 메서드 난독화 기능을 제공하지만 문자열 난독화 기능은 제공하지 않지 않기 때문에 문자열은 기본 난독화 단계에서 놓치기 쉽습니다.
3.2. 식별자 난독화 vs. 문자열 난독화의 차이점
구분 | 식별자 난독화 | 문자열 난독화 |
---|---|---|
대상 범위 | 클래스·메소드·필드 이름 | 하드코딩된 문자열 리터럴 |
도구 | ProGuard, R8등 | 별도 후처리 스크립트나 DEX 레벨 패치 필요 |
분석 난이도 | 식별자만 숨김 | 문자열 자체가 암호화되어 정적 분석 도구에 포착되지 않음 |
적용 시점 | 컴파일 / 패키징 단계 | 컴파일 후(post-build)에 삽입 |
식별자 난독화가 누가 호출하는지를 숨기는 반면, 문자열 난독화는 무엇이 담겨 있는지를 직접 감춥니다.
4. DEX 대상 문자열 난독화의 필요성
4.1. 안드로이드 앱 보안 위협 모델
안드로이드 앱은 APK 형태로 배포되며, 내부에 포함된 컴파일된 DEX 파일이 기기에서 바로 실행됩니다. 그러나 공격자는 APK를 쉽게 열어보고 classes.dex에 담긴 바이트코드를 디컴파일하거나 smali로 변환해 분석할 수 있습니다.
이로 인해 다음과 같은 보안 위험이 발생할 수 있습니다:
- 정적 분석을 통한 정보 수집
- 문자열 상수에 API 키, 서버 엔드포인트, 권한 확인 로직 등이 하드코딩되어 있으면 공격자는 decompiler나 grep 같은 도구만으로 민감 정보를 빠르게 추출할 수 있습니다.
- 재배포 및 변조 공격
- 추출한 문자열을 바탕으로 악성 서버 통신 코드를 삽입하거나 인증 우회 로직을 심어 변조 APK로 재배포할 수 있습니다.
- 자동화된 도구 활용
- jadx, JADX-GUI, apktool 같은 도구를 사용하면 수많은 클래스와 문자열을 순식간에 검색·분석할 수 있습니다.
이러한 위협 모델 하에서 문자열이 평문으로 노출되어 있다는 것은 곧 공격의 발판을 제공하는 것과 같습니다.
4.2. 난독화가 제공하는 방어 계층
DEX 단계에서 문자열 난독화를 적용하면, 위 위협 모델을 다음과 같은 방어 계층으로 보강할 수 있습니다:
- 정적 분석 난이도 증가
- 문자열이 암호화·인코딩되어 있기 때문에 단순 검색(grep)으로는 유의미한 결과를 얻기 어렵습니다. 공격자는 복호화 로직을 분석해야 하므로 시간과 노력이 크게 증가합니다.
- 자동화 스캐닝 회피
- 많은 보안 스캐너는 평문 키워드 기반 탐지를 사용합니다. 난독화된 문자열은 스캐너 룰과 일치하지 않기 때문에 보안 스캐너 검사에 통과할 수 있습니다.
- 재배포 변조 방지
- 문자열 복호화 로직에 키 관리나 무결성 검증을 추가하면, 변조된 APK에서는 복호화가 실패해 정상 동작을 방해할 수 있습니다.
- 방어 심층화(Defense-in-Depth)
- 다른 코드 난독화와 결합해 여러 계층에서 공격 난이도를 높일 수 있습니다. 이름·제어 흐름·문자열이 모두 난독화되면 공격자는 각 계층마다 다른 복호화 과정을 거쳐야 하므로 분석 파이프라인이 복잡해집니다.
이처럼 DEX 대상 문자열 난독화는 안드로이드 앱 보안의 중요한 방어 축으로, 다층 보안 전략의 핵심 요소입니다.
5. 주요 문자열 난독화 기법
안드로이드에서 문자열 난독화를 적용하는 대표적인 방법으로 정적 인코딩 방식이 있습니다.
5.1. 정적 인코딩 (Static Encoding)
정적 인코딩은 문자열을 미리 암호화·인코딩해서 DEX에 기록해 두고, 런타임에 디코딩하는 방식입니다. 빌드 후에 smali 패치나 후처리 스크립트를 통해 인코딩된 문자열로 교체합니다.
Base64 예제
public class StaticEncodingExample {
// 인코딩된 문자열 (원본: "https://api.example.com/endpoint")
private static final String ENC_URL = "aHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vZW5kcG9pbnQ=";
public static String getUrl() {
byte[] decoded = android.util.Base64.decode(ENC_URL, android.util.Base64.NO_WRAP);
return new String(decoded, StandardCharsets.UTF_8);
}
}
XOR 예제
object XorEncodingExample {
// 단순 XOR 키
private val XOR_KEY: Byte = 0x5A.toByte()
// “SECRET_KEY” 각 문자에 XOR 키를 적용한 결과
private val ENC_KEY = byteArrayOf(
0x09.toByte(), // 'S' ^ 0x5A = 0x53 ^ 0x5A = 0x09
0x1F.toByte(), // 'E' ^ 0x5A = 0x45 ^ 0x5A = 0x1F
0x19.toByte(), // 'C' ^ 0x5A = 0x43 ^ 0x5A = 0x19
0x08.toByte(), // 'R' ^ 0x5A = 0x52 ^ 0x5A = 0x08
0x1F.toByte(), // 'E' ^ 0x5A = 0x45 ^ 0x5A = 0x1F
0x0E.toByte(), // 'T' ^ 0x5A = 0x54 ^ 0x5A = 0x0E
0x05.toByte(), // '_' ^ 0x5A = 0x5F ^ 0x5A = 0x05
0x11.toByte(), // 'K' ^ 0x5A = 0x4B ^ 0x5A = 0x11
0x1F.toByte(), // 'E' ^ 0x5A = 0x45 ^ 0x5A = 0x1F
0x03.toByte() // 'Y' ^ 0x5A = 0x59 ^ 0x5A = 0x03
)
fun getSecretKey(): String {
val decoded = ByteArray(ENC_KEY.size) { i ->
(ENC_KEY[i].toInt() xor XOR_KEY.toInt()).toByte()
}
return String(decoded, Charset.forName("UTF-8"))
}
}
Custom 테이블 인코딩 예제
object TableEncodingExample {
private val TABLE = charArrayOf(
'Q','W','E','R','T','Y','U','I','O','P',
'A','S','D','F','G','H','J','K','L','Z',
'X','C','V','B','N','M','1','2','3','4'
)
// "HELLO" → H(15), E(2), L(18), L(18), O(8)
private val ENC = byteArrayOf(
15.toByte(), 2.toByte(), 18.toByte(), 18.toByte(), 8.toByte()
)
fun decode(): String {
val sb = StringBuilder()
for (idx in ENC) {
// Byte → Int로 변환하여 TABLE에서 문자 가져오기
sb.append(TABLE[idx.toInt()])
}
return sb.toString()
}
}
여기서 아쉬운 점은 정적 인코딩 방식을 쓰면, 애플리케이션에서 보호가 필요한 모든 문자열을 개발 단계에서 일일이 골라 인코딩해야 한다는 점입니다.
반면 NHN AppGuard의 문자열 난독화 기능은 이미 빌드된 DEX 파일을 분석해 앱 내부의 모든 문자열을 자동으로 찾아 난독화하기 때문에, 수작업의 번거로움을 없애면서도 보안 수준을 높일 수 있습니다.
6. NHN AppGuard 문자열 난독화
NHN AppGuard의 문자열 난독화 기능은 보호 작업 단계에서 빌드된 DEX 파일을 분석해 내부에 있는 모든 문자열을 찾아냅니다. 이후 해당 문자열이 런타임 환경에서 복호화될 수 있도록 Dalvik Executable 포맷의 코드 조각을 삽입합니다. 아래 이미지는 앞서 살펴본 예제 코드에 NHN AppGuard의 문자열 난독화를 적용했을 때의 모습입니다. 기존에 평문으로 노출되었던 `baseUrl` 문자열이 런타임 시 복호화 로직을 통해 복원되는 것을 확인할 수 있습니다.
7. 결론
안드로이드 앱 내에 하드코딩된 문자열은 리버스엔지니어링 시 핵심 단서가 되므로 반드시 보호해야 합니다. 정적 인코딩 방식(Base64, XOR, 테이블 인코딩 등)은 구현이 간편하지만, 개발 단계에서 수작업으로 모든 문자열을 찾아 인코딩해야 하는 번거로움이 있습니다. 따라서 DEX 단계에서 후처리 도구(예: NHN AppGuard)를 사용해 빌드된 DEX 파일을 분석하고 모든 문자열을 자동으로 찾아 난독화하면, 수작업 부담 없이도 강력한 보안 계층을 추가할 수 있습니다.
다음 시간엔 함수 호출 과정을 분석하게 어렵게 만드는 난독화 기술인 Hide Call(함수 호출 숨김)에 대해서 알아보겠습니다.
앞으로 이어질 난독화 시리즈도 많은 관심 부탁드리겠습니다(__).
'BLOG > 인사이드' 카테고리의 다른 글
[Java 난독화 이야기 1탄] Control Flow 난독화 (2) | 2025.04.23 |
---|---|
iOS 앱 개발자가 알아야 할 iOS탈옥의 위험성 (0) | 2025.03.20 |
모바일 앱 난독화란? (0) | 2025.03.11 |
GPS 조작 어뷰징 탐지/차단 (0) | 2024.07.03 |
2024년 상반기 결산 및 로드맵 (0) | 2024.06.26 |