React Native 안드로이드 앱(2/2)

보안

2018-11-05

지난번 포스팅에서는 React Native로 만들어진 안드로이드 앱의 decompile에 대해서 알아보았는데요. 이번 시간에는 React Native를 이용하여 만든 안드로이드 앱의 보안에 대해 알아보도록 하겠습니다.

img

React Native를 이용해 안드로이드 앱을 만들었다고 할지라도, 결국에는 안드로이드 앱입니다. 때문에 우선 기본적인 안드로이드 환경에서의 앱보안이 중요합니다. 안드로이드에서 강조하는 일반적인 보안 팁은 구글의 안드로이드 개발자 페이지에 자세히 나와 있습니다. (참고 : https://developer.android.com/training/articles/security-tips?hl=ko)

추가적인 보안 강화를 위해 Proguard를 이용한 난독화 적용이 필요합니다. Proguard는 코드 및 리소스 축소의 목적도 있지만, decompile 시 내부 로직 분석을 방해하여 어플리케이션 크랙킹을 대비하는 목적도 있습니다. React Native를 이용하더라도 커스텀 component를 만드는 경우도 있고, 안드로이드 전용 코드를 작성하는 경우도 있기 때문에 Proguard 적용은 해야 합니다. Proguard를 적용하게 되면 java로 작성된 코드는 난독화가 적용되지만, React Native의 코드가 저장된 JavaScript bundle 파일의 내용은 난독화되지 않습니다.

그렇다면 React Native 코드를 보호하려면 어떻게 해야할까요? React Native 프로젝트에 대해 조금 더 살펴보도록 하겠습니다. 우선 React Native로 생성된 프로젝트의 Android 영역을 살펴보면, 아래 이미지처럼 MainApplication.java와 MainActivity.java 두 개의 파일을 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MainApplication extends Application implements ReactApplication {

private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}

@Override
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage()
);
}

@Override
protected String getJSMainModuleName() {
return "index";
}
};

@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}

@Override
public void onCreate() {
super.onCreate();
SoLoader.init(this, /* native exopackage */ false);
}
}
MainApplication.java

그리고 MainApplication.java의 내용 중 onCreate() 메소드가 보입니다. Android에서 해당 component 객체가 생성될 때 onCreate()가 호출되기 때문에 MainApplication의 onCreate() 메소드에서 작업하는 것이 좋습니다.
코드를 조금 더 살펴보겠습니다.. ReactNativeHost 객체가 생성되어 있고, ReactNativeHost 파일 코드를 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
/**
* Returns a custom path of the bundle file. This is used in cases the bundle should be loaded
* from a custom path. By default it is loaded from Android assets, from a path specified
* by {@link getBundleAssetName}.
* e.g. "file://sdcard/myapp_cache/index.android.bundle"
*/
protected @Nullable String getJSBundleFile() {
return null;
}
ReactNativeHost.java

128 라인 정도에 getJSBundleFile()라는 메소드가 보이는데, 설명을 보면 bundle file의 경로를 커스텀하게 변경 가능하다고 되어 있습니다. 파악된 내용을 바탕으로 JavaScript Bundle 보호 시나리오를 작성합니다.

  1. 우선 앱 빌드 후 decompile 하고 assets 폴더의 index.android.bundle 추출
  2. index.android.bundle 파일 암호화
  3. 암호화된 index.android.bundle 파일로 교체
  4. compile 및 서명

위의 시나리오를 테스트하기 위해 테스트 코드를 작성합니다.

  1. MainApplication onCreate()에서 assets 폴더에 있는 index.android.bundle 파일 index.android.bundle.enc로 암호화
  2. index.android.bundle.enc 파일을 External file 폴더에 index.android.bundle.enc.dec 파일로 복호화
  3. getJSBundleFile() 메소드를 오버로딩하여 React Native에서 해당 파일을 로딩하도록 변경
1
2
3
protected String getJSBundleFile() {
return getExternalFilesDir(null).getAbsolutePath()+"/index.android.bundle.enc.dec";
}
ReactNativeHost 테스트 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Override
public void onCreate() {
super.onCreate();

AssetManager assetManager = getAssets();
byte[] key = getKey();

// make encrypt file
try {
inputStream = assetManager.open("index.android.bundle", AssetManager.ACCESS_BUFFER);
bufferedInputStream = new BufferedInputStream(inputStream);
outputStream = new FileOutputStream(new File(getExternalFilesDir(null), "index.android.bundle.enc"));
bufferedOutputStream = new BufferedOutputStream(outputStream);

byte[] readBuffer = new byte[1024];

int len = 0;
while ((len = bufferedInputStream.read(readBuffer, 0, readBuffer.length)) != -1) {
if(len < 1024) {
for(int i=len; i<1024; i++) {
readBuffer[i] = 0x00;
}
}
bufferedOutputStream.write(encodeFile(key, readBuffer));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
bufferedInputStream.close();
bufferedOutputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}

// make decrypt file
try {
//inputStream = new FileInputStream(new File(getExternalFilesDir(null), "index.android.bundle.enc"));
inputStream = assetManager.open("index.android.bundle.enc", AssetManager.ACCESS_BUFFER);
bufferedInputStream = new BufferedInputStream(inputStream);
outputStream = new FileOutputStream(new File(getExternalFilesDir(null), "index.android.bundle.enc.dec"));
bufferedOutputStream = new BufferedOutputStream(outputStream);

byte[] readBuffer = new byte[1024];

int len = 0;
while ((len = bufferedInputStream.read(readBuffer, 0, readBuffer.length)) != -1) {
bufferedOutputStream.write(decodeFile(key, readBuffer));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
bufferedInputStream.close();
bufferedOutputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
SoLoader.init(this, /* native exopackage */ false);
}
MainApplication 테스트 코드

테스트 코드 작성 후 앱을 빌드하게 되면 정상적으로 동작함을 확인할 수 있습니다. 테스트 코드를 편집하여 JavaScript Bundle 보호 시나리오를 수행하여도 정상적으로 동작합니다.

1편(https://wooyeol.github.io/2018/09/10/React-Native-Android-Decompile-1/)의 decompile 절차를 다시 수행 후, index.android.bundle 파일을 열어보면 다음과 같이 암호화되어 내용 확인 및 수정이 불가능합니다.

img

암호화된 index.android.bundle 파일

전체 테스트 코드는 GitHub에서 확인할 수 있습니다. ( 참고 :https://github.com/wooyeol/reactnative_appsec)

현재의 테스트 프로젝트를 실제 적용하기 위해서는 성능 이슈, 키 관리 이슈 등 해결해야 할 문제들이 많습니다. bundle 로딩 전 디코딩 작업을 위해 Splash 화면을 추가하는 방법, bundle 로딩 전 인증페이지를 안드로이드 only 코드로 별도로 생성하는 방법, bundle 파일 자체를 네트워크를 통하여 받아오는 방법 등 다양한 방법들을 고민해야 할 것입니다.

물론 이러한 방법으로 bundle 파일을 보호하는 것도 좋은 방법일 수 있지만, 우선적으로 Server-Client 구조의 앱을 개발한다면, 중요한 정보 처리 및 비즈니스 로직은 Server-Side에서 수행하는 것이 조금 더 보안에 적합할 수 있습니다.

지금까지 총 2회에 걸쳐서 React Native의 decompile과 보안에 대해서 알아보았는데요. 도움이 되셨나요? React Native를 이용하여 한번에 다양한 플랫폼의 앱을 개발할 수 있더라도, 각 플랫폼마다 보안 취약점을 이해하고 별도의 보안을 강화하는 작업이 무엇보다 중요합니다. 이 점을 다시 한번 강조하며 글을 마무리하겠습니다. 감사합니다.


Comments: