[Tech Report] 악성 앱, 아는 만큼 막을 수 있다!
DEX 코드 은닉 기법 집중 분석
언제 어디서나 커뮤니케이션이 가능한 모바일 기기는 이용자들에게 점점 매력적인 디바이스가 되고 있다. 이는 해커들에게도 마찬가지이다. 해커들은 악성 앱을 이용해 각종 개인정보와 금융 정보를 담고 있는 모바일 기기를 향해 거센 공격을 준비하고 있다. 최근에 발생한 대부분의 악성 앱을 이용한 공격은 앱 리패키징을 통해 기존 소스코드에 악의적인 코드를 은닉하여 악성 앱이나 위장 앱을 만드는 것이다.
이 글에서는 안드로이드 달빅 실행파일(Dalvik Executable 이하, DEX)에서 코드를 은닉할 수 있는 기법과 동작 원리에 대해 설명한다. 또한 공격자가 이 기법을 악용해 악성코드를 제작할 경우 분석 실무자와 보안제품이 이에 대응할 수 있는 방안, 다시 말해 은닉 코드를 탐지할 수 있는 방법에 대해 자세히 소개하고자 한다.
DEX 파일의 구성
DEX 파일은 안드로이드 애플리케이션의 심장이라고 할 수 있다. DEX 파일은 자바 바이트코드와 유사한 달빅 바이트코드를 사용하여 컴파일된 애플리케이션의 클래스에 대한 정보를 갖고 있다. DEX 파일의 구성은 공식적으로 안드로이드 레퍼런스에 명문화되어 있으며, [그림 1]과 같다.
[그림 1] 달빅 실행파일의 구성
● 헤더 – 파일 해시, 체크섬, 파일을 분석하기 위한 크기, 오프셋 정보 등을 갖는다.
● 배열들 – 문자열 식별자, 타입 식별자, 프로토타입 식별자, 필드 식별자, 메소드 식별자, 클래스 정의와 관련된 배열이 있다. 배열에 기록된 값은 대체로 데이터 섹션에 존재하는 관련 데이터에 대한 오프셋이다.
● 데이터 섹션 – 코드 명령, 문자열, 필드, 디버그 정보 등을 갖는다. DEX 파일의 가장 핵심적인 부분이라 할 수 있다. 이와 비교해 다른 섹션은 데이터를 전혀 갖고 있지 않고 오로지 데이터에 대한 오프셋 정보만 갖기 때문이다.
각 클래스는 클래스에 속한 필드와 메소드에 대한 정보를 가지고 있는 데이터 섹션의 class_data_item 구조체로써 기술된다. 클래스에 속한 전체 메소드는 다이렉트 메소드(direct method)와 버추얼 메소드(virtual method)의 두 가지 배열로 정의되는데, 각 메소드는 class_data_item의 encoded_method 구조체를 통해 선언된다. 구조체의 내용은 다음과 같다.
● method_idx_diff – 이 값은 안드로이드 레퍼런스에 다음과 같이 정의된다. “이름과 기술자를 포함해 메소드를 식별하기 위한 method_ids 인덱스 목록의 이전 요소의 인덱스로부터의 오프셋을 표현한다. 목록의 첫 번째 요소는 인덱스가 직접적으로 표현된다.” 간단히 말해 이 값은 method_ids의 인덱스에 대한 증분값이다. 만약 이전 메소드의 메소드 인덱스가 3이고 현재 메소드의 method_idx_diff가 1이라면 현재 메소드의 메소드 인덱스는 3 + 1 = 4가 된다.
● 접근 플래그(access_flags) – 공용 접근에 대하여 ACC_PUBLIC = 0x1 값을 갖는다. ACC_PRIVATE, ACC_PROTECTED, ACC_STATIC, ACC_FINAL 등이 있다.
● 코드 오프셋(code_off) – 파일의 시작 위치로부터의 오프셋, 메소드의 코드 위치를 나타낸다.
정상적인 DEX 파일에서 메소드가 선언되는 방법을 [그림2]를 통해 더 살펴보자. 메소드 A는 메소드 목록에서 첫 번째로 선언된 메소드이다. 때문에 메소드 A의 method_idx_diff는 직접적으로 메소드 인덱스(method_idx)를 나타낸다. 코드 오프셋은 메소드 A의 코드 위치를 잘 가리키고 있다.
[그림 2] 정상 DEX 파일의 class_data_item 구조체, 메소드 A 선언
이 상태에서 메소드 B가 메소드 목록에 추가되면, 메소드 B의 메소드 인덱스는 이전 메소드인 메소드 A의 메소드 인덱스와 메소드 B의 method_idx_diff를 더한 값으로 계산할 수 있다. 메소드 B의 코드 오프셋은 메소드 A와 다른 개별 위치를 가리키고 있다.
[그림 3] 정상 DEX 파일의 class_data_item 구조체, 메소드 A, B 선언
마지막으로 메소드 목록에 다시 메소드 C가 추가 되면, 메소드 C는 이전 메소드인 메소드 B의 메소드 인덱스와 메소드 C의 method_idx_diff를 더한 값을 메소드 인덱스로 갖는다. 코드 오프셋은 메소드 A, B와는 다른 개별 위치를 가리키고 있다.
[그림 4] 정상 DEX 파일의 class_data_item 구조체, 메소드 A, B, C 선언
DEX 코드 은닉 기법
지금까지 DEX 파일의 구성에 있어 핵심적인 내용을 위주로 특히, 메소드의 선언에 대하여 자세하게 살펴봤다. 이제 본격적으로 DEX 파일에서 코드를 은닉하는 기법을 다뤄보려 한다. 본격적인 설명에 들어가기 전에, 왜 앞서 메소드 선언에 대해서 자세히 살펴봤는지 궁금한 이가 있을 것이다. 이유는 DEX 파일에서 코드를 가질 수 있는 부분이 메소드뿐이기 때문이다. 때문에 DEX 파일에서 코드를 은닉한다는 것은 메소드를 은닉하는 것과 동의어가 된다. 따라서 앞으로 임의의 클래스의 메소드를 은닉하는 기법을 살펴볼 것이다.
DEX 코드 은닉 기법은 몇 가지 서로 다른 방법이 존재하지만, 어떠한 경우에도 다음의 세 가지 단계를 공통적으로 거쳐야만 한다.
● DEX 파일에서 encoded_method로 기술되어 있는 메소드의 선언을 조작한다. 다시 말해 은닉할 메소드의 선언을 수정하여 이미 존재하는 다른 메소드를 가리키도록 만든다.
● DEX 파일을 재계산한다. DEX 파일은 파일의 유효성을 확인하기 위한 값을 파일의 앞부분에 들고 있다. 은닉 기법은 파일의 내용을 변경하는 작업이므로, 파일의 유효성을 확인하기 위해 사용되는 SHA-1 해시와 아들러(adler32) 체크섬을 다시 계산하여 파일 헤더에 유효한 값을 기록해줘야 한다.
● 악의적으로 조작된 DEX 파일을 사용해 APK를 리빌드한다. 리빌드 방법은 뒷부분에서 자세히 소개한다.
실제로 메소드의 선언을 조작하여 메소드를 은닉하는 구체적인 방법을 살펴보자. 총 두 가지 기법을 소개할 것이다.
첫 번째 기법은 은닉된 메소드의 이전 메소드가 두 번 참조되게 하는 방법이다. [그림5]가 이를 간략하게 예시하고 있으며 실질적인 내용은 다음과 같다.
[그림 5] DEX 파일에서 메소드 은닉하기
[그림 5]에서 빨간색 선은 메소드 B를 은닉하는 기법을 나타낸다. 메소드 A의 메소드 인덱스와 코드가 모두 두 번 참조되고 있으며, 실제 메소드 B의 코드는 어디에서도 참조되지 않고, 즉 은닉되었다.
은닉할 메소드의 method_diff_idx를 0으로 변경한다. 참고로 [그림5]에서 은닉할 메소드는 메소드 B로 표현되고 있다. 이렇게 하면 달빅 유효성 검증기가 DEX 파일을 검증할 때 이름이나 디스크립터 등을 모두 포함하여 은닉 메소드를 이전 메소드와 완전히 동일한 메소드로 잘못 인식하게 된다.
● 액세스 플래그(access flag)는 굳이 수정할 필요가 없다. 사실 수정이 가능하지만 여기에서 다루지 않는다.
● 은닉할 메소드의 코드 오프셋을 이전 메소드의 코드 오프셋과 동일한 값으로 설정한다. 당연한 얘기지만, 잘못된 코드 오프셋을 설정하면 달빅 유효성 검증기가 오류를 발생시킬 수 있다.
DEX 파일에서 메소드들은 반드시 서로 연속적으로 형태로 존재하도록 선언되어야만 한다. 이 원칙을 깨지 않기 위해서 은닉 메소드를 뒤따르는 다음 메소드의 선언 또한 변경되어야 한다. 즉 다음 메소드의 메소드 인덱스가 은닉 메소드가 마치 존재하지 않는 것처럼 잘못 계산되도록 다음 메소드의 method_idx_diff 값을 크게 증가시킨다. 이렇게 하면 결국 은닉 메소드가 이전 메소드와 완벽히 동일한 참조를 갖게 되는 동시에 DEX 파일 규약에도 완벽히 부합하게 된다.
[그림 6]을 통해 첫 번째 기법을 다시 살펴보자. 은닉된 메소드 B가 메소드 A와 동일한 메소드 인덱스와 코드를 참조하고 있다. 메소드 B가 메소드 A와 동일한 것으로 잘못 인식되게 함으로써, 결국 메소드 B의 실재 코드가 일반적인 방법으로는 참조될 수 없도록 은닉되었다.
[그림 6] 이전 메소드 두 번 참조 기법
두 번째 기법은 은닉된 메소드의 다음 메소드가 두 번 참조하는 방법이다. 첫 번째 기법과 개념이 거의 동일하므로 간략히 설명한다. 이번에는 은닉할 메소드의 메소드 인덱스가 다음 메소드의 메소드 인덱스와 동일하게 잘못 계산되도록 method_idx_diff 값을 크게 증가시킨다. 코드 오프셋도 다음 메소드와 동일한 코드를 참조하도록 다음 메소드의 코드 오프셋 값으로 설정한다. 그리고 끝으로 다음 메소드의 method_idx_diff를 0으로 설정하게 되면, 은닉 메소드가 다음 메소드와 완벽히 동일한 참조를 갖게 된다.
[그림 7]을 통해 두 번째 기법을 다시 살펴보자. 은닉된 메소드 B가 다음 메소드인 메소드 C와 동일한 메소드 인덱스와 코드를 참조하고 있다. 메소드 B가 메소드 C와 동일한 것으로 잘못 인식시킴으로써 이 기법에서도 결국 메소드 B의 실재 코드가 일반적인 방법으로는 참조될 수 없도록 은닉되었다.
[그림 7] 다음 메소드 두 번 참조 기법
한가지만 덧붙이자면, 두 기법 모두 조작된 클래스는 여전히 은닉 메소드에 대한 정보를 포함하고 있으며 단지 은닉하고 있는 형태라는 것에 주의해야 한다. 즉 클래스의 메소드 개수를 절대 변경하면 안 된다.
유효한 DEX 파일 빌드
앞의 단계에서 DEX 파일의 내용을 변경했으므로 파일의 유효성을 계속 보장하려면, DEX 파일의 헤더를 반드시 수정해야 한다. 수정해야 하는 부분은 헤더에서도 맨 앞에 위치한다. 이 중 처음 값은 DEX 매직 값으로, 절대로 다른 값으로 수정해선 안 된다. 다음으로 아들러(adler32) 체크섬 그리고 SHA-1 해시 두 가지 필드가 따르게 되는데, 이 두 값을 올바른 값으로 변경시켜야 한다. 잘 알려진 바와 같이 체크섬은 전송이나 저장 시 오류를 검출하기 위해 사용되며, 해시는 무결성을 검증하기 위해 사용된다. 두 필드의 값은 모두 DEX 파일의 나머지 부분에 대해 계산된 값이다. SHA-1 해시는 DEX 매직, 체크섬과 자신을 제외한 전체 파일에 대해 계산한 값이고, 아들러 체크섬은 DEX 매직과 자신을 제외한 전체 파일에 대해 계산한 값이다. 그러므로 다음 순서에 맞게 두 값을 차례로 계산해야 유효한 DEX 파일을 빌드할 수 있다.
● DEX 파일의 SHA-1을 계산하여 헤더에 기록한다.
● DEX 파일의 아들러 체크섬을 계산하여 헤더에 기록한다. 아들러 체크섬 계산은 앞서 기록한 SHA-1을 포함하므로 순서에 주의한다.
이 두 단계를 마치면 DEX 파일은 유효성을 갖게 된다. 다음은 위 과정의 코드 구현의 예이다.
[그림 8] 유효한 DEX 파일 코드 구현의 예
APK 파일 리빌드
앞선 과정을 통해 DEX 파일에 대한 준비가 모두 끝났으므로, 안드로이드 애플리케션(.apk)을 리패키징한다. 이 과정은 명령행으로 안드로이드 애플리케이션을 빌드해 본 경험이 있는 사람에게는 전혀 어렵지 않을 것이다. 이클립스를 전용으로 사용하는 개발자에게 다소 생소할 수도 있겠으나 절차는 꽤 간단하다.
● 원본 APK 파일의 압축을 해제한다.
● 해제한 원본 파일들과 조작된 DEX 파일(classes.dex)을 Zip으로 압축한다.
● Jarsigner와 개발자 키로 서명하여 새로운 APK 파일을 빌드한다.
이 과정은 메이크파일(Makefile)을 사용해 쉽게 자동화할 수 있다. 메이크파일의 예는 다음과 같다.
[그림 9] APK 파일 리빌드 메이크파일의 예
DEX 은닉 코드 호출
은닉된 코드를 실행할 수 없다면 은닉 기법은 아무런 가치도 없다. 지금부터 공격자가 악의적으로 조작한 은닉 코드를 애플리케이션 실행시 임의로 실행할 수 있는 방법에 대해 설명한다.
● 현재 애플리케이션의 DEX 파일을 오픈해서 메모리상의 배열에 적재한다. 이것은 android.content.res.AssetManager의 openNonAsset 메소드를 사용한다. 이 메소드는 자산이 아닌 파일들을 자산으로서 열어준다. 하지만 이 메소드는 직접 접근하여 호출할 수 없는 함수이기 때문에 자바 리플렉션 기법을 사용한다.
● 메모리상의 DEX 파일이 적재된 배열을 언패치한다. 말 그대로 코드를 은닉하기 위해 했던 절차를 거꾸로 적용하는 것이다. 조작했던 값을 원본 값으로 모두 되돌린다.
● 언패치된 메모리상의 DEX 파일을 새 클래스로써 오픈한다. 이것은 메모리상의 DEX 파일에 대해 자바 리플렉션 기법을 사용해 openDexFile()을 호출하면 된다. 이 메소드는 쿠키(cookie)를 반환하게 되는데, 실제 이 쿠키의 값은 DEX 파일의 내부 데이터 구조체에 대한 포인터이다. 그리고 defineClass()를 사용하여 언패치된 클래스를 로드한다. 이 메소드는 클래스를 로드하기 위해 openDexFile()이 반환한 쿠키를 이용한다.
● 언패치된 DEX 파일에서 getDeclaredMethods()를 이용하여 은닉된 메소드를 탐색한다.
● 언패치된 DEX 파일로부터 생성한 오브젝트 인스턴스에서 은닉된 메소드를 호출한다.
다음은 위 과정의 코드 구현의 예이다.

[그림 10] DEX 은닉 코드 호출 구현의 예
DEX 은닉 코드 탐지
DEX 코드 은닉 기법은 다양한 분야에 활용될 수 있다. 예를 들어, 앱 마켓 플레이스의 필터링을 우회하는 데 이용될 수 있다. 또한 역분석을 어렵게 하기 위한 안티-리버싱 기법으로 활용될 수도 있다. 더욱이 악성코드 제작자가 악성 앱 제작 시 악의적인 코드를 은닉하기 위하여 본 기법을 악용할 수 있다. 따라서 지금부터 은닉 코드 탐지에 활용할 수 있는 탐지 방법 몇 가지를 소개한다.
● encodeded_method에서 method_idx_diff가 0이나 0보다 작은 값을 갖는 경우 혹은 method_idx_diff로 계산한 method_idx가 중복 사용된 경우
[그림 11] 은닉 코드 탐지 구현의 예: method_idx_diff가 <=0 또는 methdod_idx 중복 사용
● encodeded_method에서 code_off가 중복 사용된 경우
[그림 12] 은닉 코드 탐지 구현의 예: code_off 중복 사용
● method_ids의 인덱스 중에서 사용되지 않은 method_idx 추출
[그림 13] 은닉 코드 탐지 구현의 예: 사용되지 않은 method_idx 추출
지금까지 안드로이드에서 DEX 파일의 유효성 검증 시 암호화된 메소드(encoded_method)를 완벽하게 검증하지 못하는 취약점을 이용, 임의의 코드를 은닉하고 또 실행할 수 있는 공격 방법에 대해 살펴봤다. 은닉된 코드는 DEX 파일에 그대로 남아 있었으며 일반적인 분석도구나 분석가에게 보이지 않게 되었다. 그러나 다행히도 DEX 코드 은닉 기법을 탐지하는 것 또한 가능하였다. @
- AhnLab분석팀 박준용 수석연구원