본문 바로가기
프로그래밍/Android, iOS

[Android] 취약점 분석 정리

by YuminK 2022. 3. 29.

요즘 또 작업하는 것들 중에 취약점 분석 보고서가 있어서 여기에 나온 결과에 따라 보안을 강화해야 하는데 몇 가지 작업해 놓은 것을 정리해 보려고 한다.

난독화 적용(Proguard)

난독화 조치가 되어 있지 않은 경우에 앱 디컴파일시 소스코드 분석이 용이하여 보안상 문제가 발생할 여지가 있다.

안드로이드 자체적으로 제공하는 Proguard를 적용한 내용을 정리합니다.

Build.gradle에 작성합니다.

buildTypes {
    release {
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
    debug {
        minifyEnabled false
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
}

 

다음으로 proguard-rules.pro를 작성해야 하는데 나는 이 과정에서 많이 헤맸다.

기본적으로 warning을 좀 없애줘야 하는데 상단부터 로그처리 / 사전 검증 / 그 외 추가 옵션들 / keep 처리이다.

 

# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
#   http://developer.android.com/guide/developing/tools/proguard.html
#   https://www.guardsquare.com/manual/configuration/usage

# Specifies to write out some more information during processing
-verbose

# Specifies not to preverify the processed class files
-dontpreverify

# Specifies any optional attributes to be preserved
# https://www.guardsquare.com/manual/configuration/attributes
-keepattributes SourceFile, LineNumberTable, Exceptions, InnerClasses, Signature, MethodParameters, LocalVariableTable, LocalVariableTypeTable

-dontwarn module-info

# optimization
-dontoptimize

# buildconfig
-keep class com.test.BuildConfig { *; }

# android
-keep class android.** { *; }
-keep class androidx.** { *; }
-keep class com.google.** { *; }
-dontnote com.google.**

# kotiln
-keep class kotlin.** { *; }
-dontnote kotlin.**
-dontwarn kotlin.**

# coroutine
-keep class kotlinx.** {*; }
-dontwarn kotlinx.**
-dontnote kotlinx.**

# webrtc
 -keep class org.webrtc.** { *; }
 -keep interface org.webrtc.** { *; }
 -dontwarn org.webrtc.**

# apache
-keep class org.apache.commons.** {*; }
-dontnote org.apache.commons.**
-dontwarn org.apache.commons.**

# okhttp
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
-dontwarn okhttp3.**
-dontnote okhttp3.**

# glide
-keep class com.bumptech.glide.** { *; }

# spinkit and photoview
-keep class com.github.** { *; }

# opencv
-keep class org.opencv.** { *; }

# json
-keep class org.json.** { *; }

# naverlogin
-keep class com.nhn.android.** { *; }

# jsoup
-keep class org.jsoup.** { *; }

# enum class
-keep class com.test.ENUM

# 다른 모듈이 있는 경우에 다른 모듈에서 keep 해준 것들을 다시 추가했습니다.

구글 코드이거나 오픈 소스의 경우에는 이미 찾으면 다 나오는 소스라서 그냥 keep 처리를 해줬다.

(일부 코드의 경우에는 옵션과 상관없이 keep 처리가 되는 것을 보면 라이브러리 내부에서 하고 있는 것 같습니다.)

그 외 enum class에 대해 keep 처리를 해줘야 문제가 발생하지 않았다.

직접적으로는 빌드 하면서 나오는 note, warning을 처리해 주면 된다.

또한 실행을 하면서 프로그램이 죽는 경우도 생기는 데 그럴 때 keep 처리를 해줘야 합니다.

apk 파일 디컴파일 방법

Android Studio 상단에 Build라고 있는데 클릭해 보면 중간에 Build Bundles / apk 옵션이 있다.

눌러서 빌드 되면 locate로 이동하면 얻을 수 있다.

https://github.com/skylot/jadx/releases 여기서

jadx-gui-x-x-x-no-jre - win 파일 받아서 디컴파일 하시면 돼요.

흰색 부분에 apk 드래그하면 갱신됩니다.

이런 식으로 인자/함수의 이름이 이해하기 힘들게 변하게 됩니다.

프로그래밍의 비정상 종료 없이 잘 처리가 되고 난독화가 제대로 처리가 되었다면 완료입니다.

(번외)Proguard 삽질

프로그램을 돌리면서 BuildConfig쪽에서 계속 문제가 발생했다. 프로가드를 사용하지 않는 경우에는 문제가 발생하지 않았지만, 실행만 하면 BuildConfig를 찾을 수 없다는 오류로 죽었다. 심지어는 keep 처리를 해줘도 문제가 발생했다.

한참 찾아다니며 어플을 직접 디컴파일하면서 상태를 보니 뭔가 특이한 점을 찾아냈다. 라이브러리 소스에서는 BuildConfig값이 패키지 내부에 있는데 우리 어플에서는 이것이 패키지 외부에 존재하는 것인데 일단 이 위치를 바꿔야겠다고 판단했다.

예전 어플에서 사용하던 패키지명을 그대로 manifests에서 정의하여 사용하고 있어 BuildConfig값이 패키지 외부에 생성되었다. applicationId(Playstore에서 사용하며 패키지 이름과는 다른 개념)를 따로 gradle에 정의를 하고 있으므로 패키지명을 바꿔도 문제가 없는 상황이었고 실제 패키지명에 맞게 변경을 하고 돌리니...

BuildConfig에 관련된 오류가 사라졌다.

BuildConfig에서 DEBUG 값을 못 가져오는 경우가 있어서 keep 추가했다.

 

+ 해당 부분을 고치니 예전부터 build 2번씩 돌려야 실행되는 이슈가 있었는데 고쳐졌다;; 

참고: applicationId 와 패키지명 차이

https://developer.android.com/studio/build/application-id.html

https://stackoverflow.com/questions/42366756/what-is-the-difference-between-changing-package-name-vs-applicationid

루팅된 기기를 탐지하여 어플 차단

stackoverflow, medium 같은 곳에서 정리된 코드를 가져왔다.

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;

// https://medium.com/@deekshithmoolyakavoor/root-detection-in-android-device-9144b7c2ae07
public class RootDetector {

    private static final String[] binaryPaths = {
            "/data/local/",
            "/data/local/bin/",
            "/data/local/xbin/",
            "/sbin/",
            "/su/bin/",
            "/system/bin/",
            "/system/bin/.ext/",
            "/system/bin/failsafe/",
            "/system/sd/xbin/",
            "/system/usr/we-need-root/",
            "/system/xbin/",
            "/system/app/Superuser.apk",
            "/cache",
            "/data",
            "/dev"
    };
    
    public static boolean checkRootedDevice() {
        return detectTestKeys() || checkForSuBinary() || checkForBusyBoxBinary() || checkSuExists();
    }

    private static boolean detectTestKeys() {
        String buildTags = android.os.Build.TAGS;
        return buildTags != null && buildTags.contains("test-keys");
    }

    private static boolean checkForSuBinary() {
        return checkForBinary("su");
    }

    private static boolean checkForBusyBoxBinary() {
        return checkForBinary("busybox");
    }

    /**
     * @param filename - check for this existence of this
     * file("su","busybox")
     * @return true if exists
     */
    private static boolean checkForBinary(String filename) {
        for (String path : binaryPaths) {
            File f = new File(path, filename);
            boolean fileExists = f.exists();
            if (fileExists) {
                return true;
            }
        }
        return false;
    }

    /**
     * A variation on the checking for SU, this attempts a 'which su'
     * different file system check for the su binary
     * @return true if su exists
     */
    private static boolean checkSuExists() {
        Process process = null;
        try {
            process = Runtime.getRuntime().exec(new String[]
                    {"/system /xbin/which", "su"});
            BufferedReader in = new BufferedReader(
                    new InputStreamReader(process.getInputStream()));
            String line = in.readLine();
            process.destroy();
            return line != null;
        } catch (Exception e) {
            if (process != null) {
                process.destroy();
            }
            return false;
        }
    }
}

사용 예시

// Root check in BaseActivity
isRootedDevice = RootDetector.checkRootedDevice()
if(isRootedDevice)
{
    AppFunc.okDialog(this, null, R.string.str_detected_root,
        R.drawable.err_icon, false, listener = {
            finishAffinity()
        })
    return
}

BaseActivity의 특정 시점에 루팅 처리를 해서 강제 종료가 되도록 처리하면 됩니다.

평문으로 저장한 주요 사용자 정보 암호화

SharedPreferences 같은 곳에 저장하는 주요 사용자 정보를 암호화한다.

// AES 암호화 
public static String encrypt(byte[] key, byte[] clear)
    {
        try {
            MessageDigest md = MessageDigest.getInstance("md5");
            byte[] digestOfPassword = md.digest(key);

            SecretKeySpec skeySpec = new SecretKeySpec(digestOfPassword, "AES");
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS7Padding");
            cipher.init(Cipher.ENCRYPT_MODE, skeySpec);
            byte[] encrypted = cipher.doFinal(clear);
            return Base64.encodeToString(encrypted, Base64.DEFAULT);
        } catch(Exception e) {
            e.printStackTrace();
            return "";
        }
    }

    public static String decrypt(String key, byte[] encrypted)
    {
        try {
            MessageDigest md = MessageDigest.getInstance("md5");
            byte[] digestOfPassword = md.digest(key.getBytes("UTF-16LE"));

            SecretKeySpec skeySpec = new SecretKeySpec(digestOfPassword, "AES");
            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS7Padding");
            cipher.init(Cipher.DECRYPT_MODE, skeySpec);
            byte[] decrypted = cipher.doFinal(encrypted);
            return new String(decrypted, "UTF-16LE");
        }catch (Exception e) {
            e.printStackTrace();
            return "";
        }
    }

 

String으로 값을 받을 수 있도록 래핑(Kotlin)

fun encode(strKey: String, strValue: String): String
{
    return HashCode.encrypt(strKey.toByteArray(charset("UTF-16LE")),
        strValue.toByteArray(charset("UTF-16LE")))
}

fun decode(strKey: String, strEncodedKey: String): String
{
    return HashCode.decrypt(strKey,
        Base64.decode(strEncodedKey.toByteArray(charset("UTF-16LE")), Base64.DEFAULT))
}

encode 함수를 사용하여 특정키를 가지고 암호화

decode 함수를 사용하여 특정키를 가지고 복호화 하시면 됩니다.

비밀번호 변경 기능이 있는 경우 현재 비밀번호 확인 항목 추가

비밀번호 변경 시 현재 로그인된 사용자에 대한 인증을 추가할 필요가 있다.

무결성 검증

찾아보니 Playstore나 OneStore에서 배포되는 패키지 명을 가지고 검사를 하는 방법, 서버에 apk의 해시값(SHA256, MD5)을 저장하여 위변조된 어플을 판단하는 방법 등이 있다.

사설 배포를 하는 경우에는 패키지로 판단하는 방법은 이용할 수 없으므로 해시값으로 검증하는 것이 무난할 것으로 예상하고 있다. (아직 작업 안 해서 패스)

댓글