[안드로이드 모의해킹] 코드 패치와 앱 무결성 검증 (NDK 코드 분석)

직접 연구하여 작성한 자료입니다. 공식 출처가 명시되지 않은 자료의 무단 복제, 사용을 금지합니다.
공격 기법은 학습용, 허가된 환경에서 실습 바랍니다. 실 운영망 대상 공격은 처벌받습니다. (정보통신망법 제48조 1항)

이전 글에 이어지는 글입니다.

[안드로이드 모의해킹] 코드 패치와 앱 무결성 검증 (smali 코드 분석)

 

개요

 

어셈블리 코드 수정(NDK)

 

안드로이드 NDK로 개발된 코드 또한 수정이 가능하다. 직접 예제 코드를 작성하고 변조하는 실습을 진행한다. 안드로이드 스튜디오에서 새 프로젝트 생성을 선택하고 Native C++ 프로젝트를 생성한다. 네이티브 언어로 함수를 정의한다. 함수의 기능은 이전의 실습과 유사하게 두 정수의 합을 반환하는 기능을 수행한다. 상용 앱에서는 더 복잡한 기능을 수행하지만, 실습에서는 분석하는 방법을 설명하기 위해 간단한 로직으로 구성하였다.

 

// native-lib.cpp
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_gomguk_modifyjni_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}
extern "C"
JNIEXPORT jint JNICALL
        Java_com_gomguk_modifyjni_MainActivity_addNumbers(
                JNIEnv * env,
                jobject,
                jint a,
                jint b ) {
        return a + b;
}

 

다음으로 JNI함수를 호출할 안드로이드 액티비티를 선언한다. 메인 액티비티에서 다음과 같이 개발한 JNI 함수를 호출한다.

 

// MainActivity.kt
package com.gomguk.modifyjni
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.gomguk.modifyjni.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
    companion object {
        // 앱 시작 시 'modifyjni' 라이브러리 로드
        init {
            System.loadLibrary("modifyjni")
        }
    }
    external fun stringFromJNI(): String
    external fun addNumbers(a: Int, b: Int): Int
    private lateinit var binding: ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val sum = addNumbers(7, 5).toString()
        // 네이티브 함수 호출
        binding.sampleText.text = stringFromJNI()
        binding.resultText.text = "7 + 5\nResult: $sum"
    }

 

코드 작성을 완료하였다면 빌드 파일(build.gradle)에서 앱에서 지원할 아키텍처(ABI)를 필터링한다. NDK로 개발한 코드는 다음의 필터 선언으로 다양한 아키텍처를 지원한다.

 

android {
    namespace = "com.gomguk.modifyjni"
    compileSdk = 33
    defaultConfig {
        applicationId = "com.gomguk.modifyjni"
        minSdk = 24
        targetSdk = 33
        versionCode = 1
        versionName = "1.0"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        ndk{
            abiFilters.addAll(arrayOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64"))
        }
    }
... 생략
}

 

 

앱을 빌드하고 APK 파일을 설치 후 실행하면 텍스트 위젯으로 바인딩한 JNI 함수의 실행 결과가 출력되는 것을 확인할 수 있다. 자바에서 7 5의 두 개의 정수를 함수의 인자로 전달하였고, 네이티브 함수에서 덧셈을 수행하여 결과를 반환한다.

 

예제 코드 앱 실행 화면

 

코드 패치를 통해 덧셈이 아닌 뺄셈의 결과를 출력하도록 변경해본다. 개발한 앱을 디컴파일 하고 메인 액티비티를 먼저 분석한다. JNI를 통해 네이티브 코드로 작성된 함수를 호출하는 경우 자바 코드에서 System.loadLibrary() 함수를 이용한다. 이 함수를 통해 미리 컴파일된 네이티브 라이브러리를 자바 코드에 연결할 수 있다.

 

//MainActivity.class(디컴파일)
public final class MainActivity extends AppCompatActivity {
    public static final Companion Companion = new Companion(null);
    private ActivityMainBinding binding;
    public final native int addNumbers(int i, int i2);
    public final native String stringFromJNI();
    /* compiled from: MainActivity.kt */
    @Metadata(d1 = {"\u0000\f\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\b\u0086\u0003\u0018\u00002\u00020\u0001B\u0007\b\u0002¢\u0006\u0002\u0010\u0002¨\u0006\u0003"}, d2 = {"Lcom/gomguk/modifyjni/MainActivity$Companion;", "", "()V", "app_debug"}, k = 1, mv = {1, 9, 0}, xi = ConstraintLayout.LayoutParams.Table.LAYOUT_CONSTRAINT_VERTICAL_CHAINSTYLE)
    /* loaded from: classes4.dex */
    public static final class Companion {
        public /* synthetic */ Companion(DefaultConstructorMarker defaultConstructorMarker) {
            this();
        }
        private Companion() {
        }
    }
    static {
        System.loadLibrary("modifyjni");
    }
//..생략

 

앱에서 네이티브로 작성된 라이브러리를 호출하고 있음을 확인할 수 있고 이를 통해 분석할 지점이 추가로 있음을 확인한다. 액티비티 내에 네이티브 함수와 상호작용하고 있는 코드가 있는지 추가로 분석한다. 실습 코드에서는 public final native int addNumbers(int i, int i2); public final native String stringFromJNI();코드가 네이티브 코드로 작성된 함수와 상호작용하고 있다. 선언한 함수는 자바 코드에서 선언한 함수와 동일하게 함수명과 인자를 전달하여 호출할 수 있다.

네이티브 코드 분석을 위해 라이브러리 폴더(lib) 하위의 아키텍처를 선택한 후 공유 오브젝트(so) 파일을 분석한다. 실습에서는 x86을 기준으로 설명한다. IDA로 파일을 열고 문자열 검색 기능을 이용하여 함수의 이름을 검색하면 함수의 주소로 바로 접근할 수 있다.

 

함수명 검색 및 결과 확인

 

함수의 의사 코드(pseudo code)를 분석했을 때 인자 두개(a3, a4)를 덧셈한 결과를 반환하고 있으므로, 분석하려는 함수가 일치함을 확인한다.

 

 

의사코드는 디컴파일한 코드를 IDA와 같은 도구가 사람이 읽기 쉬운 형태로 해석한 것이기 때문에 이 상태에서는 코드 변조를 할 수 없다. 코드 변조를 수행하기 위해서는 도구가 해석하기 전 단계인 어셈블리를 분석해야 한다. 어셈블리 코드에서 수정할 부분을 찾고 코드 패치를 진행한다. 어셈블리를 분석하기 위해서는 키보드의 ‘esc’ 키를 눌러 화면을 이동한다. 다른 방법으로, 함수명을 검색하는 단계에서 원하는 함수명을 더블 클릭하여 어셈블리 코드로 직접 접근하는 방법도 있다.

 

addNumbers() 함수의 어셈블리 코드 확인

 

어셈블리 코드의 분석을 위해 실행에 불필요한 부분은 제외하고 코드에 주석을 추가하였다.

 

; __unwind {
push    ebp 
mov     ebp, esp ; 함수 프롤로그
mov     eax, [ebp+arg_8]
add     eax, [ebp+arg_C] ; Add
pop     ebp
retn                    ; 직전 함수 호출로 복귀
; }

 

어셈블리를 분석하기 전 필요한 명령어와 설명에 대해 간략하게 정리하면 다음과 같다.

명령어
(operand)
바이트 코드
(hex)
설명
mov a, b 8B /r(레지스터) b(source operand)의 값을 a(destination operand)로 복사
add r16, r/m16 03 /r(레지스터) 두 인자의 합을 첫 번째 피연산자에 저장
sub r16, r/m16 2B /r(레지스터) 두 번째 인자에서 첫 번째 인자를 뺀 값을 첫 번째 피연산자에 저장
retn C3 직전 함수 호출로 복귀

 

함수의 프롤로그 과정 이후 mov 명령을 통해 두 번째 인자의 값을 eax 레지스터에 저장한다. 그 다음 add 명령을 통해 두 번째 인자와 이전 과정에서 저장해둔 eax 레지스터(첫 번째 인자의 값)를 합의 결과를 eax 레지스터에 저장한다. 이후 retn을 통해 결과를 반환한다. 여기서 덧셈을 의미하는 바이트 코드인 0x03을 뺄셈의 바이트 코드인 0x2B로 변조하면 함수의 실행 결과로 덧셈이 아닌 두 인자의 뺄셈 결과가 반환될 것이다. 코드를 수정하기 위해 다시 IDA로 돌아가서 코드 영역에서 add 명령어에서 우클릭  동기화 대상(synchronized with)  hex view 를 선택한다.

 

헥스 뷰에서 바이트 코드 위치 확인

 

동기화를 선택하면 IDA의 헥스 뷰(hex view) 탭과 어셈블리 코드의 영역이 파일의 바이트 코드 오프셋과 일치하게 되며 위치를 쉽게 확인할 수 있다. 덧셈 명령어(add)를 뺄셈 명령어(sub)로 변경하기 위해서 바이트 코드를 0x03에서 0x2B로 수정한다. 바이트 코드를 수정하기 위해 IDA에서 03 앞에서 마우스를 클릭하고, 키보드의 ‘F2’ 키를 눌러 수정 모드로 변경한다. 커서의 모양이 바뀌고 키보드에 입력하는 바이트 코드가 즉시 입력된다. 2B를 입력한다. 수정이 완료된 후에는 다시 ‘F2’를 눌러 바이트 코드 수정을 종료한다. 수정 후의 헥스 뷰와 어셈블리 코드 영역에서 확인한 결과는 다음과 같다.

 

바이트 코드 수정

 

바이트 코드 수정 후 어셈블리 명령어 주석 확인

올바르게 코드 수정이 되었음을 확인한 후에는 변경된 코드를 바이너리에 반영해야 한다. IDA에서는 수정(edit)  프로그램 수정(patch program)  원본 파일에 패치내용 적용(apply patches to input file) 순서로 접근하여 대화상자를 연 후에 기본설정으로 된 주소 영역(프로그램 주소 처음부터 끝)에 대해 수정한 내용을 적용한다.

 

 

 

네이티브로 컴파일 된 코드를 디컴파일하여 코드의 동작 구조를 파악하고 바이트 코드를 직접 수정하는 방법으로 코드를 변경하였다. 변경된 라이브러리 파일은 앱의 디컴파일 경로에 위치하며, 기존의 파일을 덮어쓰기 한다. 이후에 자바 컴파일을 거쳐 서명을 한 후에 단말에 설치하여 앱의 동작이 변경된 동작을 확인한다. 실행 결과로 덧셈 연산의 결과인 12가 아닌 두 인자의 뺄셈의 결과인 2가 출력되는 것을 확인할 수 있다. 이 과정은 앱의 기능을 변경하거나 새로운 기능을 추가하기 위해 사용할 수 있다.

 

 

코드 수정 후 앱 실행 화면

 

추가 명령어에 대한 설명을 찾아야 한다면 인텔 공식 홈페이지와 소개하는 홈페이지(https://faydoc.tripod.com/cpu/index.htm)에서 확인할 수 있다. 아키텍처별로 명령어와 해석하는 방법이 다르기 때문에 분석을 목적으로 하는 아키텍처별로 명령어 해석 레퍼런스 문서를 참고하여 분석에 활용한다. ‘arm’ 아키텍처의 경우 https://armv8-ref.codingbelief.com/ 페이지를 참고할 수 있다.

 

코드 패치는 복잡한 바이트 코드를 작성하는 것이 아니라 필요 사항에 맞게 코드를 적절하게 수정하는 과정이다. 이 과정에서는 코드의 수정을 최대한 적은 바이트로 제한하는,  게으른 접근 방식이 좋다. 이 방식은 불필요한 변경을 최소화하면서도 목적을 달성하기 위한 가장 효과적인 방법이다. 디컴파일된 코드를 읽고 해석하는 것에 많은 시간을 투자하고, 코드 패치가 필요한 가장 적은 코드만 수정한다. 앱이 난독화되어 있어 읽을 수 있는 코드의 양이 많지 않더라도 자바와 네이티브 코드에서 수많은 검사를 마친 후 단 하나의 조건 검사문만 수정해도 전체 로직을 우회하는 것이 가능할 수 있다.

 

진단 방법

 

진단 보고서 작성 시 변조된 기능 실행 사실이 화면으로도 잘 드러나면 좋겠지만 그렇지 않은 경우 안드로이드 위젯 중 '토스트 메시지(toast message)'를 사용한다. 토스트 메시지는 사용자에게 간단한 정보를 잠시 동안 화면에 표시하는 기능으로, smali 코드 사용 시 함수에서 필요한 인자가 적고 다른 클래스와 의존성이 낮아 코드의 삽입만으로 기능을 동작하게 할 수 있다. 동시에 화면에서 보여줄 수 있는 시각적 피드백을 제공하기 때문에 변조한 앱의 실행 성공 여부를 증적(스크린샷)으로 제공할 수 있다. 먼저 자바 코드로 작성된 토스트 메시지 함수를 확인한다.

 

import android.widget.Toast;
Toast.makeText(getApplicationContext(), "Integrity Check", Toast.LENGTH_LONG).show();

 

위 코드를 smali 코드로 나타내면 다음과 같다.

 

const/4 v0, 0x1
 
const-string v1, "Integrity Check"
 
invoke-static {p0, v1, v0}, Landroid/widget/Toast;->
makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;
 
move-result-object v0
 
invoke-virtual {v0}, Landroid/widget/Toast;->show()V

 

코드를 첫 줄부터 살펴보면 v0 레지스터에 정수 1(0x1)을 할당한다. 레지스터는 메모리에서 변수의 값을 저장하는 공간이다. 다음으로 v1 변수에 출력할 문자열인 "Integrity Check"을 할당한다. android.widget.Toast 객체에서 makeText()의 인자로 Landroid/content/Context, Ljava/lang/CharSequence, I를 사용하는 것은 함수의 인자로 사용할 자료형을 명시한 것이다.

 

접두사로 L이 있는 경우 해당 인자가 클래스 또는 인터페이스임을 의미한다. 각 인자는 안드로이드나 자바에서 제공하는 기본 클래스로부터 상속받은 것을 이름으로 확인할 수 있으며 클래스별 선언은 공식 문서로 확인할 수 있다. 첫 번째 인자인 컨텍스트(context)의 경우 현재 실행 중인 앱의 정보를 갖는 인터페이스로 다른 클래스 접근하기 위해 사용한다. 두 번째 인자는 문자열 자료형을 받는다. 세 번째 인자는 정수형 자료형을 받는다.

 

 

makeText() 함수를 불러와서 이전에 할당한 v0, v1 변수를 인자로 하여 토스트 메시지를 띄우기 위한 객체를 준비하며 이를 객체의 인스턴스화라고 한다. 그 다음 show() 함수를 호출하여 화면에 토스트 메시지를 출력한다. 이 코드를 무결성 검증이 필요한 중요 로직을 가진 클래스 내 삽입하거나, 앱 시작시 동작하도록 MainActivity  onCreate() 콜백함수 내 삽입하고 리패키징하여 코드의 정상 실행 여부를 확인한다.

 

코드 변조한 앱의 실행 확인

 

취약여부 설명
취약 코드가 수정된 앱을 실행했을 때 무결성 검증 로직이 동작하여 경고 메시지를 띄우거나 앱을 종료하는 경우
양호 코드가 수정된 앱을 실행했을 때 변조, 삽입한 코드가 실행되는 경우

 



반응형