보안정보
전문화된 보안 관련 자료, 보안 트렌드를 엿볼 수 있는
차세대 통합보안관리 기업 이글루코퍼레이션 보안정보입니다.
ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 : PART 4-2 (네이티브 코드)
2023.09.05
7,567
03. 네이티브 코드(Native Code) 개요와 탐지 및 우회 방안
1) 네이티브 코드(Native Code) 개요
이전 호인 PART4의 1편에서는 ‘무결성, 동적로딩’에 대해서 설명한 것에 이어, PART4의 2편에서는 나머지 네티이브 코드를 이용한 탐지방안에 대해 설명하고자 한다. 네이티브 코드는 안드로이드 애플리케이션에서 C/C++ 언어로 작성된 코드를 말한다. 안드로이드 애플리케이션은 주로 Java와 Kotlin 같은 고수준 프로그래밍 언어로 개발되지만, 일부 기능이나 성능 향상, 하드웨어 접근, 호환성을 이유로 네이티브 코드를 활용한다. 특히 게임 분야에서 네이티브 코드가 가장 많이 활용되며, 이는 그래픽 처리, 음성 처리 등과 같은 작업에서 Java 코드 대비 높은 효율을 보이기 때문이다.
또한, C/C++과 같이 저수준 언어로 작성된 코드는 기계어 또는 어셈블리어로 컴파일되기 때문에 가독성이 낮아 코드 분석 및 리버스 엔지니어링에 어려움을 준다. 이는 공격자들에게 앱의 내부 구조와 동작을 파악하는 것을 어렵게 만드는 것과 같아 보안 취약성을 감소시킬 수 있는 이점이 생긴다. 때문에, 예전에는 게임과 같은 특정 분야에서만 네이티브 코드가 활용되었다면 근래에는 일반적인 앱에서도 암호화 및 보안 라이브러리를 사용하여 보안을 강화하고 있는 추세이다.
[그림 75]는 ANDITER 앱의 Java 코드에서 호출되고 있는 네이티브 탐지 코드로 Java 코드 단에서는 함수 이름만 노출되고, 중요한 로직들은 외부로 노출되지 않는 것을 볼 수 있다.
네이티브 코드는 하나의 라이브러리 파일(.so)로 구성되며, 앱에서 이를 사용하고자 하는 경우 [그림 76]과 같이 System.loadLibrary() 함수를 통해 라이브러리 파일을 로드하여 사용할 수 있다.
앱에서 사용되는 라이브러리 파일은 앱 패키지 내의 lib 디렉터리에서 확인할 수 있으며, 디바이스 아키텍처에 따라 각각의 버전에 맞는 라이브러리 파일이 사용된다. 그리고 이는 네이티브 코드 분석 시 꼭 알아야 하는 중요한 정보로 디바이스 아키텍처에 맞지 않는 라이브러리 파일을 분석하게 되면 원하는 결과를 얻지 못하게 된다. 아래는 안드로이드에서 사용되는 아키텍처 종류를 나열한 것이다.
ㆍarm64-v8a : 64비트 ARM 아키텍처
ㆍarmeabi-v7a : 32비트 ARM 아키텍처
ㆍx86 : 인텔(Intel) 및 AMD의 32비트 x86 프로세서 아키텍처
ㆍx86_64 : 인텔(Intel) 및 AMD의 64비트 x86 프로세서 아키텍처
사용 중인 디바이스의 아키텍처 정보는 [그림 78]과 같이 getprop 명령어를 사용하여 확인할 수 있다.
2) Bypass Native(Rooting-Files)
[그림 79]의 Bypass Native(Rooting-Files) 탐지 항목은 'ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 1(루팅)'의 [Bypass Packages], [Bypass Binaries] 항목에서 소개된 내용을 기반으로 네이티브 코드로 구현되었으며, 루팅 디바이스에서 사용되는 바이너리 파일과 패키지를 기준으로 검사하여 루팅 디바이스를 탐지한다.
※ 루팅과 관련된 내용은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 1(루팅)”에서 자세히 다루고 있으니 해당 자료를 참고하면 된다.
[그림 80]의 isCheckRooted() 함수는 Bypass Native(Rooting-Files) 탐지 결과를 반환해주는 역할을 한다. ① 함수 내에서는 ② isCheckRooting() 함수를 호출하여 탐지 작업을 수행하고, 그 결괏값을 반환하는데 [그림 80]을 보면 해당 함수의 내부 로직이 표시되어 있지 않는 것을 볼 수 있다. 이는 함수가 미구현 상태가 아닌 실제로는 네이티브 코드로 작성되어 있기 때문에 함수 로직이 구현되어 있지 않은 것이다. 또한, 이러한 네이티브 코드로 작성된 함수들은“native”라는 함수 수식어를 가지며, 이를 통해 해당 함수가 네이티브로 코드로 작성된 함수임을 알 수 있다. 따라서, 이를 분석하고자 할 때는 먼저 해당 함수를 포함하는 네이티브 코드를 가진 라이브러리 파일을 찾아야 한다.
호출된 라이브러리 파일은 [그림 81]과 같이 System.loadLibrary() 구문을 통해 어떤 라이브러리 파일이 로드되었는지 알 수 있다. 또한, 해당 구문을 통해 라이브러리 파일 로드 시 파일 확장자(.so)와 파일 이름 앞의 “lib“를 생략하여 호출하기에 [그림 81]과 같은 경우 “anditer”가 아닌 “libanditer.so” 파일이 호출된다.
라이브러리 파일 이름을 확인했다면 사용 중인 디바이스의 아키텍처 버전에 맞게 해당 파일을 추출하면 된다. 라이브러리 파일은 [그림 82]와 같이 JEB를 통해 추출하거나 또는 대상 앱을 압축 해제하여 “lib” 디렉터리에 위치한 라이브러리 파일을 가져오면 된다.
C/C++ 라이브러리 파일을 분석하기 위해서는 IDA, Ghidra, RetDec, Snowman 등과 같은 전용 디컴파일러 도구를 사용해야 하며, 해당 문서에서는 분석 작업을 위해 IDA 도구를 활용하고자 한다.
[그림 83]은 IDA에서 불러온 “libanditer.so” 파일의 화면으로 여기서 우리는 가장 먼저 분석할 함수의 엔트리 포인트(Entry Point)를 찾아야 한다. 다만, 라이브러리 파일은 어셈블리어로 구성되어 있고 불필요한 내용들이 다수 포함되어 있어 이를 하나씩 분석하는 것은 쉬운 일이 아니다. 따라서, 분석 시 유용하게 활용될 수 있는 몇 가지 정보를 먼저 살펴보겠다.
안드로이드 애플리케이션에서 NDK를 이용한 네이티브 코드 작성 시에는 몇 가지 지켜야 하는 규칙이 있다. 그중 첫 번째가 Java 코드와 네이티브 코드 간의 연동을 위해 사용되는 JNI(Java Native Interface) 함수명 규칙으로 “Java_[피키지 이름][클래스][함수명]”와 같은 형식을 가지며, [그림 84]에서도 이와 같은 형태로 함수가 선언된 것을 볼 수 있다. 다음으로 두 번째는 Java 코드에서 JNI 함수를 호출할 때는 [그림 84]의 화살표 표시와 같이 동일한 함수명을 사용한다는 것이다. 따라서, 이와 같은 두 가지 정보를 잘 활용한다면 분석에 소요되는 시간을 상당히 단축 시킬 수 있게 된다.
[그림 85]와 [그림 86]은 “java_com”으로 시작하는 이름을 가진 함수와 [그림 84]에서 확인한 함수명(isCheckRooting)을 IDA의 Functions 패널에서 검색한 결과로 분석할 함수를 바로 찾을 수 있다.
[그림 87]은 isCheckRooting() 함수의 코드 중 일부로 루팅 디바이스를 검사하기 위한 “/system/xbin/su”, “/xbin/su” 등의 파일 경로 문자열이 사용되고 있는 것을 볼 수 있다.
그리고 검사 시 사용된 문자열은 “basic_string” 생성자에 의해 호출된 “std::string” 클래스 객체 생성을 통해 “v0” 변수에 할당된다.
“v0” 변수는 sub_5C1B0() 함수의 인자로 전달되어 루팅 디바이스를 검사하게 된다.
sub_5C1B0() 함수 호출 시 전달된 인자는 [그림 90]과 같이 fopen() 함수를 통해 디바이스에 파일이 존재하는지를 검사하게 된다. 이때, 코드에서 사용된 fopen() 함수는 C/C++ 언어에서 파일을 열어 내용을 확인하는 데 사용된다.
isCheckRooting() 함수는 루팅 디바이스를 탐지하기 위해 파일 검사뿐만 아니라 패키지까지 검사하고 있다. 코드를 보면 ① 검사에 사용되는 패키지 목록을 확인할 수 있으며, ② “std::string” 클래스 객체를 생성해 “v8” 변수에 할당하고 있는 것을 볼 수 있다.
std::operator+ 연산자를 통해 “pm list packages” 문자열과 “v8” 변수의 문자열을 연결하고 system() 함수를 호출해 Package Manager 명령어를 수행하게 된다. 이때, 해당 패키지가 디바이스에 존재할 경우 루팅 디바이스로 탐지된다.
[그림 93]은 필자가 작성한 Frida 스크립트로, sub_5C1B0() 함수에서 파일 검사에 사용되는 fopen() 함수를 후킹하기 위한 코드이다. 코드를 보면 Java 함수를 후킹할 때 작성한 스크립트와는 구조가 다른 것을 볼 수 있는데 이는 Java와 C 등의 언어에 대한 API 사용 방식이 다르기 때문이다. 자세한 사용 방법은 링크(https://frida.re/docs/javascript-api/#interceptor)를 참고하자.
코드를 보면 ① C/C++ 함수를 후킹하기 위해 Interceptor.attach()가 사용되었으며, 첫 번째 인자에는 후킹하고자 하는 함수의 주솟값이 전달되고, 두 번째 인자로는 JavaScript 함수가 지정되어 후킹 함수로 동작한다. 이를 위해 Module.findExportByName() 함수를 사용하여 현재 프로세스의 모든 모듈에서 fopen() 함수의 주소를 찾아 반환한다. ② 코드에서는 함수 호출 시 전달되는 첫 번째 인자에서 파일 이름을 파싱하고 ③ 파일 이름에 “su”, “superuser”, ”busybox”와 같은 문자열이 포함되어 있는지를 검사한다. ④ 만약 문자열이 포함되어 있다면 파일 이름을 “fakeapp”으로 변조한다.
sub_5C1B0() 함수에서는 두 가지 방법을 사용하여 루팅 디바이스를 탐지하고 있었다. 첫 번째가 파일 기반 검사 방식이고 두 번째가 패키지 기반 검사 방식이었다. [그림 93]은 이 중 두 번째 방법에서 패키지 검사 시 사용되는 system() 함수를 후킹하기 위한 Frida 스크립트이다. 코드를 보면 ① Module.findExportByName() 함수를 사용하여 현재 프로세스의 모든 모듈에서 system() 함수의 주소를 찾아 반환한다. ② system() 함수 호출 시 전달되는 인자에서 “pm list”라는 문자열이 포함되어 있는지를 검사하고 ③ 만약 포함되어 있을 경우 해당 인자 값을 “fakeapp”으로 변조한다.
[그림 93]과 [그림 94]에서 작성한 Frida 스크립트를 합쳐 하나로 만들어 ADITER 애플리케이션에 어태치한 후, Bypass Native(Rooting-Files)의 탐지 항목을 체크하면 “Success!”가 출력되어 탐지가 우회된 것을 확인할 수 있다.
3) Bypass Native(Rooting-Execution)
[그림 96]의 Bypass Native(Rooting-Execution) 탐지 항목은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 1(루팅)”의 [Bypass Command Execution] 항목에서 소개된 내용을 기반으로 한 네이티브 코드로 구현되었으며, “su” 파일의 위치를 찾기 위해 “which” 명령어를 사용한다. 만약 명령어 실행 결과로 파일의 경로를 반환받게 될 경우 디바이스가 루팅 된 것으로 판단해 탐지한다.
※ 루팅과 관련된 내용은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 1(루팅)”에서 자세히 다루고 있으니 해당 자료를 참고하면 된다.
[그림 97]의 isCheckExecution() 함수는 Bypass Native(Rooting-Execution) 항목의 탐지 결과를 반환해 주는 역할을 한다. 코드를 보면 함수의 수식어(native)를 통해 해당 함수가 네이티브 코드로 작성되었음을 알 수 있다. 따라서, 이러한 함수를 분석하기 위해서는 네이티브 코드 정보를 담고 있는 라이브러리 파일에 대한 분석 과정이 필요하며, [그림 97]의 System.loadLibray() 함수를 통해 “libanditer.so”라는 라이브러리 파일이 로드된 것을 알 수 있다.
[그림 98]과 같이 IDA를 통해 “libanditer.so” 파일을 로드하고 Functions 뷰 패널에서 [그림 97]에서 확인한 함수명을 검색한다.
[그림 99]는 isCheckExecution() 함수의 소스코드이다. 코드를 살펴보면 사용자 전환 시 사용되는 명령어인 “su”가 포함되어 있고 해당 문자열을 인자로 findExecutable() 함수를 호출하고 있다. 이를 통해 추측해 볼수 있는 것은 findExecutable() 함수가 “su” 파일의 위치를 찾는 기능을 수행하는 함수일 것이라는 점이다.
[그림 100]은 findExecutable() 함수의 소스코드이다. 코드를 살펴보면 함수 호출 시 전달받은 인자 값을 ① std::operator+ 연결 연산자와 append() 함수를 사용하여 “which”, “2>/dev/null” 문자열과 결합한다. 그리고 ② 결합된 문자열을 인자로 popen() 함수를 호출하여 쉘 명령어를 실행하고 그 결과를 읽어오게 된다. 따라서, popen() 함수의 실행 결과에 “su” 파일의 경로가 포함되어 있으면 루팅 디바이스로 탐지된다.
[그림 101]은 필자가 작성한 Frida 스크립트로, findExecutable() 함수에서 명령어 실행을 위해 사용되는 popen() 함수를 후킹 하기 위한 코드이다. 코드를 살펴보면 ① Module.findExportByName() 함수를 사용하여 현재 프로세스의 모든 모듈에서 popen() 함수의 주소를 찾아 반환하고 ② popen() 함수 호출 시 전달되는 첫 번째 인자를 읽어와 ③ “witch”라는 문자열이 포함되어 있는지 검사한다. ④ 만약 문자열이 포함되어 있다면 해당 인자 값을 “fakeapp”으로 변조한다.
작성한 Frida 스크립트를 ADITER 애플리케이션에 어태치한 후, Bypass Native(Rooting-Executrion)의 탐지 항목을 체크하면 "Success!"가 출력되어 탐지가 우회된 것을 확인할 수 있다.
4) Bypass Native(Debug-Debuggable)
[그림 103]의 Bypass Native(Debug-Deubggable) 탐지 항목은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 2(디버그, 에뮬레이터)”의 [Bypass Debuggable] 항목에서 소개된 내용을 기반으로 한 네이티브 코드로 구현되었으며, 시스템 속성인 ro.debuggable 값을 확인하고 해당 값이 0이 아닌 경우 앱이 디버그 중인 것으로 판단해 탐지하게 된다. 디버그 모드 설정 및 관련 내용은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 2(디버그, 에뮬레이터)”에서 자세히 다루고 있으니 해당 자료를 참고하면 된다.
[그림 104]의 isCheckDebuggerable() 함수는 Bypass Native(Debug-Debuggable) 항목의 탐지 결과를 반환해 주는 역할을 한다. 코드를 보면 함수의 수식어(native)를 통해 해당 함수가 네이티브 코드로 작성되었음을 알 수 있으며, 이를 분석하기 위해서는 네이티브 코드 정보를 담고 있는 라이브러리 파일에 대한 분석 과정이 필요하다. 따라서, [그림 104]의 System.loadLibray() 함수를 통해 로드된 라이브러리 파일인 “libanditer.so”을 분석해야 한다.
[그림 105]와 같이 IDA를 통해 “libanditer.so” 파일을 로드하고 Functions 뷰 패널에서 [그림 104]에서 확인한 함수명(“isCheckDebuggable”)을 검색한다.
[그림 106]은 isCheckdebuggable() 함수의 소스코드이다. 코드를 살펴보면 ① 시스템 속성 정보를 가져올 때 사용하는 “getprop ro.debuggable”명령어가 사용되고 있으며, ② popen() 함수를 통해 해당 명령어의 결과를 받아오고 있다. 그리고 해당 결괏값을 인자로 ③ fgets() 함수를 호출하여 문자열로 읽어오고 그 값이 1인 경우 디버그 모드가 동작 중인 것으로 판단해 탐지된다.
[그림 107]은 필자가 작성한 Frida 스크립트로, fgets() 함수가 호출될 때 전달되는 인자 중에서 popen() 함수의 반환 값인 파일 포인터(File*)가 가리키는 파일을 확인하여 값을 변조하는 기능을 수행한다. 코드를 살펴보면 ① Module.findExportByName() 함수를 사용하여 현재 프로세스의 모든 모듈에서 fgets() 함수의 주소를 찾아 반환하고 ② 세 번째 인자인 파일 포인터를 가져와 별도의 변수에 저장한다. ③ 그리고 파일 포인터가 가리키는 파일을 읽기 위해 메모리에 버퍼를 할당하고 ④ 데이터를 읽어 오기 위한 fread() 함수 객체를 생성한다. ⑤ 파일에서 읽어온 데이터가 1이라면 변조를 위해 result 값을 true로 설정하고 ⑥ 콜백 함수인 onLeave에서 result 값을 확인하여 true일 경우 fgets() 함수 결과에 대한 반환 값을 0(0x0)으로 변조한다.
작성한 Frida 스크립트를 ADITER 애플리케이션에 어태치한 후, Bypass Native(Debug-Debuggable)의 탐지 항목을 체크하면 "Success!"가 출력되어 탐지가 우회된 것을 확인할 수 있다.
5) Bypass Native(Debug-TracerPID)
[그림 109]의 Bypass Native(Debug-TracerPID) 탐지 항목은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 2(디버그, 에뮬레이터)”의 [Bypass TracerPID] 항목에서 소개된 내용을 기반으로 한 네이티브 코드로 구현되었으며, 구동 중인 앱의 프로세스 정보를 확인해 TracerPid 값이 0이 아닌 경우 디버그 모드가 동작 중인 것으로 판단해 탐지하게 된다. 디버그 모드 설정 및 관련 내용은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 2(디버그, 에뮬레이터)”에서 자세히 다루고 있으니 해당 자료를 참고하면 된다.
[그림 110]의 isCheckDebuggerTracerPID() 함수는 Bypass Native(Debug-TracerPID) 항목의 탐지 결과를 반환해 주는 역할을 한다. 코드를 살펴보면 함수의 수식어(native)를 통해 해당 함수가 네이티브 코드로 작성되었음을 알 수 있으며, 이를 분석하기 위해서는 네이티브 코드 정보를 담고 있는 라이브러리 파일에 대한 분석 과정이 필요하다. 따라서, [그림 104]의 System.loadLibray() 함수를 통해 로드된 라이브러리 파일인 “libanditer.so”을 분석해야 한다.
[그림 111]와 같이 IDA를 통해 “libanditer.so” 파일을 로드하고 Functions 뷰 패널에서 [그림 110]에서 확인한 함수명(“isCheckTracerPID”)을 검색한다.
[그림 112]는 isCheckTracerPID() 함수의 소스코드이다. 코드를 살펴보면 ① 현재 실행 중인 앱의 프로세스 상태 정보를 확인하기 위해 fopen() 함수를 사용하여 “/proc/self/status” 파일을 열고 있는 것을 볼 수 있다. ② 그리고 std::string::compare() 함수를 통해 “/proc/self/status” 파일에서 가져온 데이터에서 “TracerPid:” 값을 추출하고, 해당 값이 0이 아닌 경우 디버그 모드 사용자로 탐지한다.
[그림 113]의 std::string::compare() 함수는 C++ std::string 클래스의 멤버 함수로서, 문자열 비교를 수행하는 기능을 가지고 있으며, 우리가 살펴볼 “TracerPid:” 문자열은 해당 함수 호출 시 4번째 인자로 전달되어 “/proc/self/status” 파일의 데이터에서 문자열에 해당하는 값을 가져온다.
[그림 114] std::string::compare() 함수를 후킹하기 위한 Frida 스크립트(1/2)
[그림 114]는 필자가 작성한 Frida 스크립트 일부로, std::string::compare() 함수를 후킹하기 위한 내용을 담고 있다. std::string::compare() 함수는 "libanditer.so" 라이브러리의 헤더 파일에서 호출되므로 해당 라이브러리 파일이 로딩되기 전에 std::string::compare() 함수를 후킹 하려고 시도하면 오류가 발생한다. 따라서, [그림 114]는 라이브러리 파일의 로딩을 감지하기 위한 코드이다. 코드를 살펴보면 ① 모듈 이름과 로딩 상태를 확인하기 위한 변수를 지정한다. ② “android_dlopen_ext” 함수를 후킹하여 ③ 라이브러리 파일을 볼러 올 때, 인자로 전달되는 라이브러리 경로에 “libanditer.so”가 포함되어 있는지 검사하고 ④ moduleLoad 값이 1이라면 라이브러리 파일이 로드되었음을 의미하므로 std::string::compare() 함수를 후킹하기 위한 함수를 호출한다.
이어서 [그림 115]의 코드를 살펴보겠다. ① std::string::compare() 함수를 후킹하기 위해서는 해당 함수의 망간화된 이름(Name Mangling)을 사용해야 한다. 망간화란 C++ 컴파일러가 충돌을 방지하기 위해 함수 이름과 함수의 매개변수 타입 및 반환 타입을 조합하여 고유한 심볼 이름을 생성하는 과정을 말한다. 따라서, std::string::compare() 함수 이름을 그대로 사용하는 것이 아닌 [그림 113]에서 확인한 망간화된 이름을 지정해 줘야 한다.
②에서는 함수로 전달되는 인자 중에서 4번째 인자에 비교를 위한 void * 타입의 문자열이 전달되며, 문자열에 “TracerPid”가 포함되어 있는지 확인하고 ③ 이를 변조하기 위한 “fakeapp” 문자열을 메모리에 할당하여 포인터를 반환받는다. ④ 반환받은 포인터를 4번째 인자에 재할당한다. 참고로 인자 타입이 포인터일 경우 [그림 101]과 같이 Memory.writeUtf8String() 함수를 사용한 문자열 작성은 사용하지 못한다. 따라서, [그림 115] ④의 구문과 같이 재할당 방식을 사용해야 한다.
작성한 Frida 스크립트를 ADITER 애플리케이션에 어태치한 후, Bypass Native(Debug-TracerPID)의 탐지 항목을 체크하면 "Success!"가 출력되어 탐지가 우회된 것을 확인할 수 있다.
6) Bypass Native(Frida-Files)
[그림 117]의 Bypass Native(Frida-Files) 탐지 항목은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 4-1(무결성, 동적 로딩)”의 [Bypass File & Path] 항목에서 소개된 내용을 기반으로 한 네이티브 코드로 구현되었으며, Frida 서버가 자동으로 생성하는 파일들이 위치한 디렉터리인 “/data/local/tmp”를 확인하여 Frida 관련 파일의 존재 여부를 검사한다. 따라서, 관련 파일이 해당 디렉터리에 존재할 경우 Frida 사용자로 탐지한다. Frida의 설정 및 관련 내용은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 3(프리다, 피닝)”에서 자세히 다루고 있으니 해당 자료를 참고하면 된다.
[그림 118]의 isCheckFridaFiles() 함수는 Bypass Native(Frida-Files) 항목의 탐지 결과를 반환해 주는 역할을 한다. 코드를 살펴보면 함수의 수식어(native)를 통해 해당 함수가 네이티브 코드로 작성되었음을 알 수 있으며, 이를 분석하기 위해서는 네이티브 코드 정보를 담고 있는 라이브러리 파일에 대한 분석 과정이 필요하다. 따라서, [그림 118]의 System.loadLibray() 함수를 통해 로드된 라이브러리 파일인 “libanditer.so”을 분석해야 한다.
[그림 119]와 같이 IDA를 통해 “libanditer.so” 파일을 로드하고 Functions 뷰 패널에서 [그림 118]에서 확인한 함수명(“isCheckFridaFile”)을 검색한다.
[그림 120]은 isCheckFridaFile() 함수의 소스코드이다. 코드를 살펴보면 ① opendir() 함수를 사용하여 “/data/local/tmp” 디렉터리의 스트림을 가져오고, 이를 통해 해당 디렉터리 내의 파일과 하위 디렉터리에 접근할 수 있게 된다. ②에서는 readdir() 함수를 사용하여 opendir() 함수를 통해 얻은 스트림 정보로부터 디렉터리 내의 파일과 디렉터리 정보를 순차적으로 읽어오며, 각 항목에서 “Frida”라는 문자열이 포함되어 있는지 검사하게 된다. 만약 문자열이 존재한다면 Frida 사용자로 간주되어 탐지된다.
[그림 121]은 필자가 작성한 Frida 스크립트로, opendir() 함수를 후킹하기 위한 내용을 담고 있다. 코드를 살펴보면 ① Module.findExportByName() 함수를 사용하여 현재 프로세스의 모든 모듈에서 opendir() 함수의 주소를 찾아 반환하고, ② opendir() 함수 호출 시 전달되는 디렉터리 경로를 가지고 있는 인자 값을 검사하여 ③ 해당 값이 “/data/local/tmp” 문자열을 포함하고 있다면 이를 변조하기 위한 “fakeDir” 문자열을 메모리에 할당하고 첫 번째 인자에 재할당 한다.
작성한 Frida 스크립트를 ADITER 애플리케이션에 어태치한 후, Bypass Native(Frida-Files)의 탐지 항목을 체크하면 "Success!"가 출력되어 탐지가 우회된 것을 확인할 수 있다.
7) Bypass Native(Frida-Port)
[그림 123]의 Bypass Native(Frida-Port) 탐지 항목은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 4-1(무결성, 동적 로딩)”의 [Bypass Port] 항목에서 소개된 내용을 기반으로 네이티브 코드로 구현되었으며, 디바이스의 소켓을 사용하여 Frida 서버의 기본 포트(27042)가 열려있는지 확인한다. 만약 포트가 열려 있다면 Frida를 사용 중인 것으로 판단해 탐지한다. Frida의 설정 및 관련 내용은 “ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 3(프리다 피닝)”에서 자세히 다루고 있으니 해당 자료를 참고하면 된다.
[그림 124]의 isCheckFridaPort() 함수는 Bypass Native(Frida-Port) 항목의 탐지 결과를 반환 해주는 역할을 한다. 코드를 살펴보면 함수의 수식어(native)를 통해 해당 함수가 네이티브 코드로 작성되었음을 알 수 있으며, 이를 분석하기 위해서는 네이티브 코드 정보를 담고 있는 라이브러리 파일에 대한 분석 과정이 필요하다. 따라서, [그림 124]의 System.loadLibray() 함수를 통해 로드된 라이브러리 파일인 “libanditer.so”을 분석해야 한다.
[그림 125]와 같이 IDA를 통해 “libanditer.so” 파일을 로드하고 Functions 뷰 패널에서 [그림 124]에서 확인한 함수명(“isCheckFridaPort”)을 검색한다.
[그림 126]은 isCheckFridaPort() 함수의 소스코드이다. 코드를 살펴보면 ① 디바이스에서 통신 중인 포트를 확인하기 위해 socket() 함수가 사용된 것을 볼 수 있다. 첫 번째 인자로는 IPv4(AF_INET) 도메인 정보를 지정하고 두 번째 인자로는 소켓 타입인 TCP를 지정한다. 세 번째 인자에는 TCP 기본 프로토콜 사용을 위한 값 0을 지정해 준다. 그리고 이렇게 생성된 소켓은 데이터를 송·수신할 수 있는 준비 상태가 되며, ② connect() 함수를 사용하여 바인딩하고 연결할 수 있게 된다.
보편적으로 [그림 126]과 같은 네트워크 통신 탐지를 우회하는 방안에는 두 가지가 있다. 첫 번째는 소켓 생성을 방지하기 위한 socket() 함수를 후킹하는 방법이고 두 번째는 connect() 함수를 후킹하여 특정 주소로의 연결 시도에서 통신 성공 여부를 조작하는 방법이다. 그러나 첫 번째 방법은 단순히 통신 준비를 위한 소켓 생성 단계만을 대상으로 하기 때문에 특정 주소에 대한 변조가 제한적이며, 모든 소켓 생성 과정을 변조할 경우 애플리케이션에서 오류가 발생할 수 있다. 때문에, 이와 같은 통신 탐지를 우회하기 위한 대안으로는 connect() 함수를 후킹하는 방법이 가장 안정적이고 많이 사용되는 방법이다.
탐지 우회 코드를 분석하기 전에 먼저 connect() 함수의 동작과 통신을 연결하기 위해 필요한 주소 정보에 대해 알아보겠다. 소켓을 생성하기 위해 socket() 함수를 사용하면, 통신을 위해 필요한 주소 정보를 담고 있는 “struct sockaddr” 구조체의 인스턴스가 필요하다. 이 구조체는 IP 주소, 포트 번호 등의 정보를 포함하고 있으며, connect() 함수 호출 시 이 주소 정보를 참조하여 통신을 하게 된다. 여기서는 [그림 127]의 addr 변수가 “sockaddr” 구조체의 인스턴스로 사용되며, connect() 함수 호출 시 전달된다.
특정 주소로의 연결 시도에서 통신 성공 여부를 조작하기 위해서는 인자로 전달되는 “sockaddr” 구조체의 인스턴스에서 IP 주소, 포트 번호 등의 정보를 추출해야 한다. [그림 128]은 “sockaddr” 구조체 인스턴스를 출력하는 코드이다. 코드를 살펴보면 ① connect() 함수로 전달되는 두 번째 인자인 “sockaddr” 구조체를 변수에 저장하고 구조체의 크기를 지정한다. ②에서는 지정한 크기만큼의 바이트를 구조체에서 읽어와 패킷 데이터를 저장한다.
[그림 129]는 [그림 128]의 Frida 스크립트를 실행한 결과로 “sockaddr” 구조체의 인스턴스 인스턴스를 나타낸다. 해당 데이터는 16진의 Hex 값으로 구성되어 있으며, 각 필드는 바이트의 값에 따라 IP 주소, 포트, 구조체 크기 등의 정보가 저장되어 있다.
[그림 130]은 “sockaddr” 구조체의 구조로 3 ~ 8번째 바이트에는 통신 연결을 시도하려는 대상의 주소 정보가 저장되어 있으며, 앞의 2바이트는 포트 번호를 나타내고, 뒤의 4바이트는 IP 주소를 나타낸다. 따라서, 이를 10진수로 변환하면 127.0.0.1의 루프 백 주소와 27042 포트 번호를 알 수 있다.
[그림 131]과 [그림 132]는 필자가 작성한 Frida 스크립트로, connect() 함수에서 루프 백 주소(127.0.0.1) 및 Frida 기본 포트(27042)로 통신을 시도할 때 통신 성공에 대한 결괏값을 변조하는 기능을 담고 있는 코드이다. 코드를 살펴보면 ① 전역 변수를 지정하여 루프 백 주소 및 Frida 기본 포트에 대한 통신 유무를 확인하고 ② “sockaddr” 구조체 정보를 가져온다. ③ 가져온 데이터에서 IP 주소 및 포트 번호가 저장된 값을 필드 값을 파싱한 다음에 ④ 포트 번호에 대한 16진수 값을 10진수로 변환해 준다.
⑤ IP 주소 필드 값에서 실제 IP 주소를 추출한다. ⑥ 추출한 IP 주소와 포트 번호가 루프 백 주소 및 Frida 기본 포트와 일치한다면, 전역 변수를 true로 설정해 주고 ⑦ 결괏값이 true인 경우, 통신 성공에 대한 결괏값을 변조한다. “0xffffffff”는 10진수 “-1”을 의미하며, 이는 통신이 실패했음을 나타낸다.
작성한 Frida 스크립트를 ADITER 애플리케이션에 어태치한 후, Bypass Native(Frida-Port)의 탐지 항목을 체크하면 "Success!"가 출력되어 탐지가 우회된 것을 확인할 수 있다.
04. 마무리
“ANDITER를 활용한 안드로이드 위협 탐지 및 우회 방안 PART 4”가 마무리됨으로써, 지금까지 총 8가지 주제(루팅, 디버깅, 에뮬레이터, 프리다, 피닝, 무결성, 동적 로딩, 네이티브)에 대해서 살펴보았다.
“PART 1”에서도 언급한 바와 같이 한국에서는 모바일 시큐어 코딩을 위한 공식적인 가이드라인이 존재하지 않아 대다수의 앱에서는 개발자 포럼 등에서 공유되는 탐지 코드들을 가져와서 그대로 사용한다.
그러나 이러한 코드들은 문서에서 기술한 것과 같이 간단한 분석과 작성으로 쉽게 우회될 수 있어서 이에 대한 보안 효과를 기대하기 어렵다. 따라서, 해당 문서는 안드로이드 앱을 대상으로 복사, 붙여 넣기 하듯 사용되는 코드들의 위험성과 최소한의 대응 방안을 제시하는 가이드이며, 이를 통해 앱 보안에 대한 연구와 개발자들의 의식이 높아지기를 기대한다.
05. 참고자료
[1] 순환 중복 검사
https://ko.wikipedia.org/wiki/%EC%88%9C%ED%99%98_%EC%A4%91%EB%B3%B5_%EA%B2%80%EC%82%AC
[2] 무결성
http://terms.tta.or.kr/dictionary/dictionaryView.do?subject=%EB%AC%B4%EA%B2%B0%EC%84%B1
[3] 안드로이드 개발자 가이드
https://developer.android.com/guide?hl=ko