안드로이드 기타/ETC

React Native 기반 안드로이드 모바일 앱 분석 방안

naroSEC 2025. 2. 25. 21:25

들어가기 앞서

과거에는 모바일 애플리케이션을 개발할 때, 안드로이드는 Java(또는 Java 기반의 Kotlin), iOS는 Swift 또는 Objective-C를 사용하는 것이 일반적이었다. 그러나 최근에는 React Native와 Flutter와 같은 크로스플랫폼 프레임워크의 활용이 점점 증가하는 추세다.

 

이는 모바일 앱 개발 패러다임의 변화로 볼 수 있다. 초기에는 주로 스타트업에서 React Native와 Flutter를 적극적으로 도입했으나, 최근에는 일정 규모 이상의 기업들도 Java 또는 Kotlin을 고집하지 않고 크로스플랫폼 기술을 활용해 앱을 개발하고 출시하는 사례가 늘어나고 있다.

 

이러한 변화의 핵심 요인은 하나의 프로그래밍 언어로 iOS와 안드로이드 앱을 동시에 개발할 수 있다는 크로스플랫폼의 강점에 있다. 이를 통해 기업은 개발 비용과 시간을 절감할 수 있으며, Java/Kotlin 또는 Swift/Objective-C를 사용할 때보다 적은 리소스로도 기대 이상의 효율성을 확보할 수 있다.

 

또한, 과거와 달리 크로스플랫폼 기술의 성능이 크게 향상되었으며, 네이티브 연동 역시 원활해졌다. 따라서 대규모 서비스가 아닌 이상, 반드시 Java/Kotlin이나 Swift/Objective-C를 사용할 필요성이 점점 줄어들고 있다.

 

보안 측면에서도 Java/Kotlin이나 Swift/Objective-C에서 사용되던 라이브러리와 모듈을 그대로 활용할 수 있어, 크로스플랫폼 프레임워크가 기존 네이티브 개발 방식에 비해 보안상 취약하다고 보기 어렵다.

 

다만, React Native는 Java가 아닌 JavaScript 기반이며, Flutter는 Dart 기반이므로, 기존의 Java 기반 안드로이드 애플리케이션 보안 취약점 분석 방식과는 다른 접근이 필요하다. 이에 따라, 크로스플랫폼 환경에 맞춰 별도의 보안 점검 프로세스가 필요한데 이번 포스팅에서는 React Native 기반 앱 분석 방안에 대해서 다뤄보려고 한다.


개요

React Native는 페이스북에서 만든 오픈소스 모바일 애플리케이션 UI 프레임워크이다. 기존의 앱의 주요 기능 코드들을 코틀린 또는 자바로 제작하는 방식에 비해 JavaScript로 개발이 가능하며, 하나의 프로그래밍 언어로 안드로이드, iOS 모바일 앱을 동시에 개발할 수 있는 대표적인 크로스 플랫폼이다.

 

개발자의 입장에서는 안드로이드 앱을 개발하기 위해서 Java 또는 코틀린을 배우고 iOS를 개발하기 위해서 스위프트 언어를 배워 제작하던 방식에 비해 훨씬 간결해지고 유지 보수도 동시 적용이 가능해 많이 선호되는 개발 프레임워크 중 하나이다.


Java(Kotlin) VS React Native

개발 환경에 따른 분석 차이점

Java 기반으로 제작된 앱은 빌드(컴파일) 시 소스코드(.java, .kt 파일)들이 우선적으로 JVM 바이트코드(.class 파일)로 변환된다. 이후, .class 파일을 dx 또는 D8(안드로이드 8.0 이상) 컴파일러를 통해 DEX(Dalvik Executable) 형식으로 변환된다. 최종적으로 변환된 .dex 파일은 모바일 디바이스에서 앱을 실행할 때, 안드로이드 버전에 따라서 DVM(Dalvik VM, 안드로이드 4.4이하에서 사용되던 것으로 실상 이제는 사용을 안한다.) 또는 Android Runtime(ART)에서 실행된다.

 

[그림 1] JEB 디컴파일러

Java 기반으로 개발된 애플리케이션을 분석할 때는 [그림 1]과 같이 JEB, GDA, Jadx 등의 Java 디컴파일 도구를 활용한다. 이를 통해 컴파일 과정의 역순으로 .dex 파일을 디컴파일하여 소스코드를 분석할 수 있다.

 

[그림 2] React Native로 개발된 모바일 앱

반면, React Native로 개발된 모바일 애플리케이션은 기본적인 프레임워크 구조를 제외하면 대부분의 핵심 기능이 JavaScript로 작성된다. 따라서, React Native 기반 앱의 .dex 파일을 디컴파일해 보면 [그림 2]와 같이 주요 기능을 담당하는 코드가 Java 파일에 포함되지 않은 것을 확인할 수 있다. 이는 React Native 앱이 기존 Java 기반 애플리케이션과는 다른 방식으로 분석해야 함을 시사한다.

 

그렇다면, 실제 앱을 구성하고 사용자와 상호작용하는 핵심 소스 코드는 어디에 있을까? 이 코드는 index.android.bundle 파일에 저장되어 있다.

React Native, Flutter로 개발된 앱은 Java, Kotlin에서 제공하는 모듈을 사용하지 못한다?
실제로 예전에는 네이티브 연동이 원할하지 않아 Java와 Kotlin에서 제공하는 모듈과 같은 기능들을 사용하지 못 했다. 다만, 이제는 버전이 업그레이드 되면서 많은 부분들이 개선되며 대부분의 모듈 사용이 가능해졌다.
추가로, React Native에서는 @ReactMethod 어노테이션을 통해 Java, Kotlin에서 제공하는 네이티브 모듈을 사용할 수 있다.

 

index.android.bundle 빌드 과정

[그림 3] React Native 빌드 과정

React Native 앱의 빌드 과정은 크게 두 단계로 나눌 수 있다. 첫 번째 단계에서는 JavaScript 코드가 Metro Bundler를 통해 단일 번들 파일(index.android.bundle)로 변환된다. 여기서 사용된 Metro Bundler는 모든 JavaScript 파일을 하나의 번들로 묶는 역할을 하며, 이 과정에서 ES6+ 문법 변환(Babel)과 트리 쉐이킹이 수행되며, 트리 쉐이킹을 통해 불필요한 코드가 제거된다.

 

두 번째 단계에서는 React Native 앱이 네이티브 코드를 실행해야 하므로, Gradle 빌드 시스템을 사용하여 JavaScript 코드를 네이티브 코드로 변환한다. 이 변환이 완료되면, JavaScript와 네이티브 코드 간의 연결이 이루어지며, 성능 최적화 작업이 JavaScript 엔진인 Hermes에 의해 수행된다. 나중에 설명하겠지만, Hermes가 난독화 작업도 수행하게 된다.

 

[그림 4] index.android.bundle 파일 위치

React Native에서 핵심 소스 코드는 index.android.bundle 파일에 저장되며, 앱 빌드 시 [그림 4]와 같이 "apk → assets → index.android.bundle" 경로에 위치한다.

 

다만, 별도의 설정을 하지 않는 경우 해당 파일은 "assets" 디렉터리에 저장되지만, 이는 절대적인 규칙이 아니다. 개발자의 설정에 따라 다른 디렉터리에 배치될 수도 있으며, 이러한 경우 소스코드 분석을 통해 정확한 위치를 파악할 수 있다.

 

index.android.bundle 로딩 및 실행 과정

React Native 앱은 앞서 설명한 바와 같이 네이티브(Android) 코드와 JavaScript 코드가 결합된 형태이다. 따라서 앱이 실행될 때 UI를 렌더링하고 네이티브 기능을 호출하기 위해 필요한 JavaScript 코드(index.android.bundle)가 로드된다.

 

index.android.bundle 파일은 MainApplication 클래스에서 설정된 ReactNativeHost를 통해 React Native 인스턴스를 초기화하는 과정에서 로드된다. ReactNativeHost는 JavaScript 엔트리 포인트와 관련된 설정이 정의된 객체이다.


MainApplication 클래스는 앱이 실행될 때, 최초로 호출되는 네이티브 클래스로 앱의 초기 설정 및 인프라를 초기화하는 역할을 수행한다.

 

[그림 5] MainApplication 클래스

해당 과정을 직접 React Native로 제작된 앱의 소스코드를 통해서 살펴보겠다. 앱이 실행되면, 가장 먼저 MainApplication 클래스가 호출되며, 앱의 초기 설정이 이뤄진다. [그림 5]를 보면 해당 클래스가 React Native를 구현하고 있는 것을 볼 수 있다.

 

[그림 6] React Native 인스턴스 초기화 과정 (1/4)

MainApplication 클래스에서는 라이프 사이클에 의해 onCreate 함수가 실행된다. onCreate 함수를 살펴보면, [그림 6] 과 같이 React Natvie 인스턴스 초기화 작업을 위한 ReactNativeHost가 호출되는 것을 볼 수 있다.

 

[그림 7] React Native 인스턴스 초기화 과정 (2/4)

[그림 7]의  ③을 보면, 마지막 단계에서 ReactInstanceManger를 통해 React Natvie 인스턴스가 초기화 된다. 이 과정에서 setBundleAssetName 함수를 사용하여 JavaScript 번들 파일의 이름을 설정하고 있으며, 이 설정을 통해 앱은 JavaScript 파일(index.android.bundle)의 위치를 확인하고 이를 로드할 수 있게 된다. 

 

[그림 8] React Native 인스턴스 초기화 과정 (3/4)

JavaScript 번들 파일 이름이 설정 되었다면, 실제로 파일을 로드하기 위한 과정이 필요하다. 이를 ReactInstanceManagerBuilder가 해당 역할을 맡아 수행하게 된다. [그림 8]의 ②를 보면, createAssetLoader 함수의 매개변수 값으로 번들 이름(index.android.bundle)을 전달한다.

 

[그림 9] React Native 인스턴스 초기화 과정 (4/4)

전달된 매개변수(번들 파일 이름)는 다시 loadScriptFromAssets 함수에 인자로 전달되고 jniLoadScriptFromAssets 함수에 의해 해당 번들 파일을 앱으로 로드하게 된다. 또한, [그림 9]의 ③을 보면 알 수 있듯이, jniLoadScriptFromAssets 함수는 안드로이드 네이티브 코드 작성되어 있어 로직을 확인하기 위해서는 라이브러리 분석이 필요하다.

 

[그림 10] libreactnativejni.so 라이브러리 로드

React Native에서 사용되는 네이티브 코드는 libreactnativejni.so 라이브러리에 저장되어 있으며, ReactBridge 인스턴스가 초기화 될 때 호출된다.

[그림 11] libreactnativejni.so 라이브러리 파일이 위치한 경로

libreactnativejni.so 라이브러리는 "lib→[디바이스 아키텍처]→libreactnativejni.so" 경로에 위치해 있다.

 

[그림 12] IDA를 사용해 디컴파일 한 결과

라이브러리 파일은 C 디컴파일 도구를 사용하여 디컴파일이 가능하다. [그림 12]는 libreactnativejni.so 라이브러리를 디컴파일 된 결과이며, [그림 9]에서 호출된 jniLoadScriptFromAssets 함수 로직을 확인할 수 있다.

 

Hermes(헤르메스)

Hermes는 React Native 앱의 빠른 시작을 위해 최적화된 JavaScript 엔진으로, 번들 파일을 최적화 및 컴팩트 바이트 코드로 변환하는 역할을 수행한다.

 

a.default.createElement(o.Text,{style:c.welcome},"Hello React Native!"))

과거에는 낮은 버전의 React Native 환경에서 앱을 개발한 후, index.android.bundle 파일을 열면 위 코드블럭과 같이 실제 소스 코드를 직접 확인할 수 있었다.

 

[그림 13] Hermes 바이트코드로 변환된 index.android.bundle 파일

그러나 최근에는 기본적으로 Hermes JS 엔진이 적용됨에 따라, 앱을 빌드하는 과정에서 JavaScript 번들 파일이 자동으로 Hermes 바이트코드로 변환된다. [그림 13]은 Hermes 바이트 코드로 변환된 index.android.bundle 파일 내용 중 일부이다.

 

따라서, React Native로 개발된 앱은 예전처럼 번들 파일을 단순히 추출하는 것만으로는 소스코드 분석이 불가능하다. 이를 분석하려면 Hermes 전용 디컴파일 과정을 거쳐야 한다.


Hermes JS 엔진은 여러 버전이 존재하며, 어떠한 버전을 사용하는지에 따라서, 바이트 코드를 변환하는 방식이 달라진다. 이러한 이유로 디컴파일 시에는 버전에 맞는 방법으로 변환해 줘야 한다.

 

Hermes 디컴파일 도구 소개

Hermes JS 엔진에 의해 컴파일 된 JavaScript 번들 파일을 디컴파일 하기 위한 방법은 2가지가 있다.

 

 

GitHub - facebook/hermes: A JavaScript engine optimized for running React Native.

A JavaScript engine optimized for running React Native. - facebook/hermes

github.com

첫 번째는 벤더에서 제공하는 공식 도구를 이용하는 방법이다. 이 방법의 경우 벤더사에서 제공하는 공식 도구이기 때문에 오류가 적다는 장점이 있으나, 최신 버전으로 빌드 된 번들 파일은 디컴파일 할 수 없다. 또한, 디컴파일을 수행하더라도 JavaScript 문법이 아닌 어셈블리어 형식으로 출력된다.

 

 

두 번째 방법은 오픈 소스 도구를 이용하는 방법이다. 가장 추천되는 방법으로 Hermes 버전에 맞춰 사용 가능하며, 컴파일 기능까지 제공하기 때문에 앱 무결성 변조 테스트 시에도 유용하게 사용할 수 있다.

 

GitHub - bongtrop/hbctool: Hermes Bytecode Reverse Engineering Tool (Assemble/Disassemble Hermes Bytecode)

Hermes Bytecode Reverse Engineering Tool (Assemble/Disassemble Hermes Bytecode) - bongtrop/hbctool

github.com

 

먼저 소개할 오픈 소스 도구는 HbcTool로, 컴파일과 디컴파일 기능을 모두 제공한다. 다만, 모든 버전을 지원하는 것은 아니며, 59, 62, 74, 76, 84, 85 버전에 대해서만 지원된다. 추가적으로, 포크(Fork)된 다른 프로젝트를 찾아보면 90과 94 버전도 지원한다.

 

 

GitHub - P1sec/hermes-dec: A reverse engineering tool for decompiling and disassembling the React Native Hermes bytecode

A reverse engineering tool for decompiling and disassembling the React Native Hermes bytecode - P1sec/hermes-dec

github.com

두 번째 오픈 소스 도구는 Hermes-dec 도구로 컴파일 기능은 제공하지 않고 디컴파일 기능만 제공한다. 해당 도구의 장점으로는 모든 버전 디컴파일이 가능하다는 것이다.

 

HbcTool을 이용한 디컴파일 및 컴파일 과정

index.android.bundle 파일을 디컴파일 하기 앞서, 해당 번들 파일이 몇 버전의 Hermes JS 엔진으로 컴파일 되었는지 확인이 필요하다.


HbcTool이 지원하는 Hermes JS 엔진 버전과 컴파일 버전이 일치하지 않는 경우 디컴파일은 가능하나 컴파일이 되지 않는다. 따라서, 무결성 변조 테스트를 위한 코드 변조 과정이 필요하다면 버전을 맞출 필요가 있다.

 

[그림 14] index.android.bundle 컴파일 버전 확인

file 명령어를 통해 사용된 Hermes JS 엔진의 버전을 빠르고 간편하게 확인할 수 있다. [그림 14]를 보면 사용된 Hermes JS 엔진 버전이 94임을 알 수 있다.


file 명령어는 기본적으로 Linux/Unix 환경에서 제공되는 명령어이나, Cmder 콘솔 에뮬레이터를 사용하면 Windows 환경에서도 사용이 가능하다.

 

[그림 15] 포크된 HbcTool 프로젝트

 

GitHub - gilcu3/hbctool: Hermes Bytecode Reverse Engineering Tool (Assemble/Disassemble Hermes Bytecode)

Hermes Bytecode Reverse Engineering Tool (Assemble/Disassemble Hermes Bytecode) - gilcu3/hbctool

github.com

앞서 설명했듯이, HbcTool은 59, 62, 74, 76, 84, 85 버전에 대해서만 기능을 지원한다. 그러나, [그림 15]와 같이 포크된 프로젝트에서 94 버전을 지원하므로, 해당 레포지토리에서 다운로드 받으면 된다.

 

[그림 16] HbcTool을 이용한 디컴파일

사용 방법은 간단하다. 첫 번째 인자로 디컴파일(disasm) 및 컴파일(asm) 옵션을 설정하고 두 번째 인자로 번들 파일을 지정해준다. 마지막으로 세 번째 인자에는 산출물이 저장 될 디렉터리 이름을 지정해준다.

 

[그림 17] 디컴파일 후 산출된 결과물

디컴파일이 완료되면 [그림 17]과 같이 총 3개의 결과 파일이 산출된다.

 

[그림 18] instruction.hasm 파일 내용 중 일부

instruction.hasm 파일은 주요 JavaScript 소스코드가 저장된 파일이다. 다만, 우리가 알고 있는 JavaScript 문법과는 다르며, smail 코드와 비슷한 형태를 띄운다.

 

[그림 19] metadata.json

metadata.json 파일은 디컴파일 시 사용된 Hermes JS 엔진에 대한 메타 정보가 저장되어 있다.

 

[그림 20] string.json

string.json 파일은 앱의 UI 인터페이스에서 사용되는 문자열이 저장되어 있다.

 

앱 진단 시 앱에서 무결성 변조 여부를 탐지하고 있는지 확인하려면 코드 변조가 필요하다. 이 경우  instruction.hasm, string.json 파일을 수정하여 테스트할 수 있다. 기능 변조(예: Alert, Toast Message, 정보 탈취 등)는 instruction.hasm 파일을 수정하여 수행하고, 단순한 문자열 변경은 string.json 파일을 수정하면 된다. 참고로, metadata.json 파일에는 디컴파일 시 사용된 Hermes JS 엔진 관련 정보가 저장되어 있기 때문에 함부로 건드는 경우 컴파일이 되지 않는 상황이 발생할 수 있다.

 

[그림 21] HbcTool 컴파일

컴파일은 디컴파일 후 산출된 3개의 파일이 위치한 디렉터리 경로를 인자로 전달해주면 된다. 여기서 중요한 건 반드시 3개의 파일이 하나의 디렉터리에 위치해 있어야 한다. 이후, index.android.bundle 파일을 기존 파일과 교체 후 APK 리패키징을 하면 된다.


컴파일이 되지 않는다?
컴파일이 되지 않는 경우는 코드를 잘 못 수정한 경우가 대부분이다. 코드 수정 시에는 반드시 문법을 맞춰줘야 한다.

 

 

Frida를 이용한 index.android.bundle 파일 변조 과정

앞서 설명한 대로, 코드 변조를 위해서는 HbcTool을 사용하여 index.android.bundle 파일을 디컴파일한 후, 코드를 수정하고 다시 컴파일하는 과정을 거쳐야 한다. 이후 수정된 index.android.bundle 파일을 기존 파일과 교체하고, 마지막으로 리패킹징을 진행하면 된다.

 

다만, 이 방법은 디컴파일, 컴파일, 리패키징 과정을 거치기 때문에 적지 않은 시간이 소요된다. 또한, 컴파일 과정에서 잘못된 코드 수정으로 인해 앱 실행 시 오류가 발생하는 경우 다시 이 과정을 처음부터 진행해야 한다.

 

그러나, Frida를 이용한다면 위 단점들을 최소화하고 시간을 획기적으로 단축시킬 수 있다.

 

먼저, index.android.bundle 파일을 변조하기 앞서 소스코드에서 번들 파일을 어떠한 함수가 로드하고 그 함수는 어디서 호출되고 있는지 확인이 필요한데 이 과정은 우리가 [그림 12]에서 확인한 바 있다.(라이브러리 이름, 함수 이름)

 

따라서, 바로 Frida를 사용해 libreactnativejni.so 라이브러리에서 호출되는 jniLoadScriptFromAssets 함수의 offset 값을 확인하면 된다.


실습에서 사용된 앱은 현재 마켓에 올라와 있는 상용앱으로 따로 테스트 앱을 제공하지 않습니다.

 

[그림 22] jniLoadScriptFromAssets offset 값 확인

[Frida 코드]

더보기
function log(str, sel) {
    if(sel === 'r') {
        console.log(Color.Light.Red+str+Color.Reset)
        return
    }
    if(sel === 'b') {
        console.log(Color.Blue+str+Color.Reset)
        return
    }
    if(sel === 'p') {
        console.log(Color.Light.Purple+str+Color.Reset)
        return
    }
    if(sel === 'c') {
        console.log(Color.Cyan+str+Color.Reset)
        return
    }
    if(sel === 'y') {
        console.log(Color.Yellow+str+Color.Reset)
		return
    }
    console.log(str)
}

let Color = {
    Reset: '\x1b[39;49;00m',
    Black: '\x1b[30;01m', Blue: '\x1b[34;01m', Cyan: '\x1b[36;01m', Gray: '\x1b[37;11m',
    Green: '\x1b[32;01m', Purple: '\x1b[35;01m', Red: '\x1b[31;01m', Yellow: '\x1b[33;01m',
    Light: {
                    Black: '\x1b[30;11m', Blue: '\x1b[34;11m', Cyan: '\x1b[36;11m', Gray: '\x1b[37;01m',
                    Green: '\x1b[32;11m', Purple: '\x1b[35;11m', Red: '\x1b[31;11m', Yellow: '\x1b[33;11m'
                }
};

function find_RegisterNatives(params) {
    let symbols = Module.enumerateSymbolsSync("libart.so");
    let addrRegisterNatives = null;
    for (let i = 0; i < symbols.length; i++) {
        let symbol = symbols[i];
        
        if (symbol.name.indexOf("art") >= 0 &&
                symbol.name.indexOf("JNI") >= 0 && 
                symbol.name.indexOf("RegisterNatives") >= 0 && 
                symbol.name.indexOf("CheckJNI") < 0) {
            addrRegisterNatives = symbol.address;
            console.log("RegisterNatives is at ", symbol.address, symbol.name, "\n");
            hook_RegisterNatives(addrRegisterNatives)
        }
    }

}

function hook_RegisterNatives(addrRegisterNatives) {

    if (addrRegisterNatives != null) {
        Interceptor.attach(addrRegisterNatives, {
            onEnter: function (args) {
                let java_class = args[1];
                let class_name = Java.vm.tryGetEnv().getClassName(java_class);

                let methods_ptr = ptr(args[2]);

                let method_count = parseInt(args[3]);
                for (let i = 0; i < method_count; i++) {
                    let name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
                    let sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
                    let fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));

                    let name = Memory.readCString(name_ptr);
                    let sig = Memory.readCString(sig_ptr);
                    let symbol = DebugSymbol.fromAddress(fnPtr_ptr)

                    var find_module = Process.findModuleByAddress(fnPtr_ptr);

                    if (find_module.name.includes("libreactnativejni")) {
                        log(`[RegisterNatives] java_class: ${class_name}`, "y");
                        log(`name: ${name}`, "r");
                        log(`module name: ${find_module.name}`);
                        log(`module base: ${find_module.base}`);
                        log(`sig: ${sig}`);
                        log(`fnPtr ${fnPtr_ptr}`);
                        log(`Symbol(or fnOffset): ${symbol}`, "b")
                        log(`fnOffset: ${ptr(fnPtr_ptr).sub(find_module.base)}`, "p")
                        log(`callee: ${DebugSymbol.fromAddress(this.returnAddress)}\n`, "c")
                    }
                }
            }
        });
    }
}

setImmediate(find_RegisterNatives)

코드에 대해서 간략히 설명하자면, 기본 라이브러인 libart.so에서 JNI RegisterNatives를 추적하여 리액트 네이티브에서 사용되는 함수들을 찾는다. 그리고 find_module.name.includes 구문을 통해 libreactnativejni 모듈 안에서 호출되는 함수 정보를 확인하게 된다. [그림 22]를 보면 jniLoadScriptFromAssets 함수가 호출된 것을 확인할 수 있다.

 

[그림 23] jniLoadScriptFromAssets 인자로 전달되고 있는 index.android.bundle 파일

[Frida 코드]

더보기
function log(str, sel) {
    if(sel === 'r') {
        console.log(Color.Light.Red+str+Color.Reset)
        return
    }
    if(sel === 'b') {
        console.log(Color.Blue+str+Color.Reset)
        return
    }
    if(sel === 'p') {
        console.log(Color.Light.Purple+str+Color.Reset)
        return
    }
    if(sel === 'c') {
        console.log(Color.Cyan+str+Color.Reset)
        return
    }
    if(sel === 'y') {
        console.log(Color.Yellow+str+Color.Reset)
		return
    }
    console.log(str)
}

let Color = {
    Reset: '\x1b[39;49;00m',
    Black: '\x1b[30;01m', Blue: '\x1b[34;01m', Cyan: '\x1b[36;01m', Gray: '\x1b[37;11m',
    Green: '\x1b[32;01m', Purple: '\x1b[35;01m', Red: '\x1b[31;01m', Yellow: '\x1b[33;01m',
    Light: {
                    Black: '\x1b[30;11m', Blue: '\x1b[34;11m', Cyan: '\x1b[36;11m', Gray: '\x1b[37;01m',
                    Green: '\x1b[32;11m', Purple: '\x1b[35;11m', Red: '\x1b[31;11m', Yellow: '\x1b[33;11m'
                }
};

let ModuleLoadName = 'libreactnativejni.so' // 라이브러리 이름 지정
const regex = new RegExp(ModuleLoadName, "gi");

Interceptor.attach(Module.findExportByName(null, 'android_dlopen_ext'), {
    onEnter: function (args) {
        this.path = Memory.readUtf8String(args[0]);
        this.fileName = this.path.split("/").pop();
        log(`[!!] dlopen_ext 모듈(.so) 호출 감지 : ${this.fileName}`, 'y')
    },
    onLeave: function (retval) {
        if(regex.test(this.path)) {
            log(`${this.fileName} is load`, "r");
            const library = this.path.split("/").pop();
            c_HookStart(library)
        }
    }
});

Interceptor.attach(Module.findExportByName(null, 'dlopen'), {
    onEnter: function (args) {
        this.path = Memory.readUtf8String(args[0]);
        log(`[!!] dlopen 모듈(.so) 호출 감지 : ${this.path}`, 'y')
    },
    onLeave: function (retval) {
        if(regex.test(this.path)) {
            const library = this.path.split("/").pop();
            c_HookStart(library)
        }
    }
});

function c_HookStart(library) 
    moduleAddrBaseHook(library)
}

function moduleAddrBaseHook(library) {
    const base_addr = Module.findBaseAddress(library);
    log(`[@] 후킹 Start!!!\n [=>] ${library} 베이스 주소 : ${base_addr}\n`, 'y')

    let jniLoadScriptFromAssets = base_addr.add(0x71484); // 이전 과정에서 획득한 offset 주소를 입력해준다.

    //private native void jniLoadScriptFromAssets(AssetManager assetManager, String assetURL, boolean loadSynchronously);
    Interceptor.attach(jniLoadScriptFromAssets, {
        onEnter(args) {
			// JNI에 의해 호출되어 전달된 인자 값은 Java.vm.tryGetEnv()을 사용해서 출력할 수 있다.
            console.log("jniLoadScriptFromAssets:", Java.vm.tryGetEnv().getStringUtfChars(args[3]).readCString())
        },  
        onLeave(retval) {

        },
    });
}

코드를 간략히 설명하자면, jniLoadScriptFromAssets 함수는 libreactnativejni.so 라이브러리에서 제공하는 모듈이므로, 후킹을 수행하기 전에 해당 라이브러리가 먼저 메모리에 로드되어 있어야 한다. 이를 위해 libreactnativejni.so의 이름을 전역 변수로 설정하고, dlopen 모듈이 호출될 때 정규 표현식을 활용해 전달된 값을 검사하여 라이브러리 로드 여부를 확인한다. 이후, 이전 과정에서 얻은 offset 주소 정보로 후킹을 시도하게 된다.

[그림 23]을 보면, jniLoadScriptFromAssets로 인자로 전달된 index.android.bundle 파일을 확인할 수 있다.

 

[그림 24] HbcTool을 사용해 index.android.bundle 디컴파일

이제 번들 파일이 어떤 함수를 통해 로드되며, 해당 함수가 어디에서 호출되는지 확인했으므로, HbcTool을 활용해 번들 파일을 디컴파일한 후 변조 과정을 수행한다.

 

[그림 25] string.json 파일 변조

[그림 20] 단락에서 설명한 것처럼, HbcTool을 사용해 디컴파일을 수행하면 총 세 개의 결과 파일(instruction.hasm, metadata.json, string.json)이 생성된다. 여기서는 string.json 파일에서 [그림 25]와 같이 도메인 정보를 변경하여, 앱이 해당 주소로 접근을 시도할 때 변경된 주소로 접속되도록 만든다. 특히, WebView 방식으로 동작하는 앱의 경우 이와 같은 방법을 이용하면 사용자를 특정 사이트로 리다이렉트를 시킬 수 있다.

 

[그림 26] string.json 파일의 id 식별 값을 통해 해당 문자열이 어디에 사용되었는지 알 수 있다.

참고로, string.json 파일에 명시된 ID 식별 값을 통해 해당 문자열이 instruction.hasm 파일 내에서 어디에 사용되었는지 확인할 수 있다.

 

[그림 27]

코드 변조 후 다시 HbcTool을 사용해 번들 파일로 컴파일 해준다.

 

[그림 28] 번들 파일 복사

변환된 번들 파일을 ADB를 사용하여 디바이스의 /data/local/tmp 경로로 복사한다.

 

[그림 29] 번들 파일을 실행 파일 내부의 assets/ 경로로 이동

디바이스에서 APK 관리 도구(NP Manager, Lucky Patcher, APK Editor Pro 등)를 활용하여 /data/local/tmp 경로에 복사한 번들 파일을 앱 실행 파일(APK)의 assets/ 경로로 이동시킨다.

 

[그림 30] jniLoadScriptFromAssets 호출 시 index.android.bundle 파일 변경

[Frida 코드]

더보기
function moduleAddrBaseHook(library) {
    const base_addr = Module.findBaseAddress(library);
    log(`[@] 후킹 Start!!!\n [=>] ${library} 베이스 주소 : ${base_addr}\n`, 'y')

    let jniLoadScriptFromAssets = base_addr.add(0x71484);

    //private native void jniLoadScriptFromAssets(AssetManager assetManager, String assetURL, boolean loadSynchronously);
    Interceptor.attach(jniLoadScriptFromAssets, {
        onEnter(args) {
    // JNI 호출 인자이기 때문에 Java.vm.tryGetEnv() 함수를 사용해서 인자로 전달된 문자열을 변경 해줘야 한다.
            args[3] = Java.vm.tryGetEnv().newStringUtf("assets://index.android.bundle2")
            console.log("jniLoadScriptFromAssets:", Java.vm.tryGetEnv().getStringUtfChars(args[3]).readCString())
        },  
        onLeave(retval) {

        },
    });
}

 

코드를 간략히 설명하자면, jniLoadScriptFromAssets 함수가 호출되어 index.android.bundle 파일을 로드할 때, 전달되는 번들 파일 이름을 변조된 파일 이름으로 변경해준다. 이렇게 하면 별도의 리패키징 없이 번들 파일을 교체할 수 있게 된다.

그리고 [그림 30]을 보면 앱을 실행할 때, 변조된 번들 파일이 로드되는 것을 확인할 수 있다.

 

[그림 31] 번들 파일 변조 성공

[그림 31]과 같이 앱에서  WebView를 사용해 페이지를 이동할 때, [그림 25]에서 변조한 도메인 주소로 이동되는 것을 볼 수 있다.


마무리

지금까지 React Native 모바일 앱 분석 방법에 대해 살펴보았다. 모바일 애플리케이션 시장은 빠르게 성장하고 있으며, 새로운 개발 프레임워크와 기술들이 지속적으로 등장하고 있다. 이에 따라, 진단자 입장에서는 지속적인 관심과 학습이 요구된다. 다음 포스팅에서는 Flutter 프레임워크를 기반으로 제작된 모바일 앱 분석 방법에 대해 다뤄보겠다.