보안 우회 기법: 세 가지 접근법

탐지를 우회하는 방법은 크게 세 가지 철학으로 나뉩니다.

RootCloak (시스템 레벨 인터셉션)

  • 원리: 앱이 시스템에 "루팅 파일이 있어?"라고 물을 때, 중간에서 가로채 "없어"라고 거짓말을 하는 방식입니다. (Xposed Framework 기반)
  • 장점: GUI 기반으로 사용이 매우 쉽습니다.
  • 단점: Xposed 설치가 까다로우며, 최신 보안 솔루션은 Xposed 자체를 탐지합니다.

Repackaging (정적 수정을 통한 영구적 우회)

  • 원리: APK를 분해(Smali 코드 변환)하여 탐지 로직을 삭제하고 다시 조립하는 방식입니다. 설계도 자체를 뜯어고치는 것과 같습니다.
  • 장점: 한 번 수정하면 별도 도구 없이 영구적으로 우회됩니다.
  • 단점: 앱의 무결성이 깨져 서명 검증을 우회해야 하며, 앱 업데이트 시 과정을 반복해야 합니다.

Frida (동적 계측의 강력함) [권장]

  • 원리: 앱 실행 메모리에 JavaScript 엔진(V8)을 주입하여, 특정 함수가 호출되는 순간을 포착(Hooking)해 동작을 변경합니다.
  • 장점: APK 변조 없이 유연하게 테스트 가능하며, 빠른 반복 실험에 최적화되어 있습니다.
  • 단점: 실행 시마다 서버/스크립트 구동이 필요하며, 일부 앱은 Frida 프로세스를 탐지하기도 합니다.

루트 탐지 (Root Detection)

취약점 개요

보안이 중요한 애플리케이션은 사용자가 기기의 최고 권한(Root)을 획득했는지 확인하는 루팅 탐지(Root Detection) 로직을 포함합니다. 루팅된 환경에서는 메모리 변조나 데이터 접근이 쉬워지기 때문에, 앱은 이를 감지하여 실행을 차단하거나 기능을 제한합니다.

취약점의 위험성

루팅 탐지가 우회될 경우 다음과 같은 보안 위협이 발생할 수 있습니다.

  • 보안 메커니즘 무력화: 앱의 무결성 검증 기능을 회피
  • 데이터 조작: 메모리 상의 민감한 데이터 덤프 및 변조
  • 악성 행위 수행: 공격자가 앱의 권한을 탈취하여 악의적인 기능 수행

기능 및 원인 분석

앱으로 이동 후 CHECK ROOT버튼을 클릭하면 기기가 루팅되었다는 메시지가 나오게 됩니다. 어떻게 이 메시지가 나오는지 확인해보겠습니다.

현재 페이지의 액티비티명은 RootDetectionActivity입니다. JADX를 통해 디컴파일한 코드를 살펴보겠습니다.

1. UI 및 실행 로직:

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(C1278R.layout.activity_root_detection);
        Button rootBt = (Button) findViewById(C1278R.id.rootCheck);
        final AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("Root Detection");
        rootBt.setOnClickListener(new View.OnClickListener() { // from class: owasp.sat.agoat.RootDetectionActivity$$ExternalSyntheticLambda1
            @Override // android.view.View.OnClickListener
            public final void onClick(View view) {
                RootDetectionActivity.onCreate$lambda$1(this.f$0, builder, view);
            }
        });
    }
    public static final void onCreate$lambda$1(RootDetectionActivity this$0, AlertDialog.Builder builder, View it) {
        Intrinsics.checkNotNullParameter(this$0, "this$0");
        Intrinsics.checkNotNullParameter(builder, "$builder");
        if (this$0.isRooted()) {
            builder.setMessage("Device is rooted");
            Toast.makeText(this$0.getApplicationContext(), "Device is rooted", 1).show();
        } else {
            Toast.makeText(this$0.getApplicationContext(), "Device is not rooted", 1).show();
            builder.setMessage("Device is not rooted");
        }
        builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: owasp.sat.agoat.RootDetectionActivity$$ExternalSyntheticLambda0
            @Override // android.content.DialogInterface.OnClickListener
            public final void onClick(DialogInterface dialogInterface, int i) {
                dialogInterface.dismiss();
            }
        });
        AlertDialog dialog = builder.create();
        Intrinsics.checkNotNullExpressionValue(dialog, "builder.create()");
        dialog.show();
    }

이 부분은 앱 화면을 구성하고 사용자의 입력을 처리하는 진입점입니다.

  • onCreate:
    • activity_root_detection 레이아웃을 화면에 띄웁니다.
    • rootCheck라는 ID를 가진 버튼을 찾습니다.
    • 버튼에 클릭 리스너(setOnClickListener)를 달아 버튼이 눌렸을 때의 동작을 정의합니다.
  • onCreate$lambda$1 (버튼 클릭 시 실행):
    • 핵심 로직 호출: this$0.isRooted() 함수를 호출하여 실제 루팅 여부를 검사합니다.
    • 결과 처리:
      • isRooted()true를 반환하면 "Device is rooted"라는 메시지를 띄웁니다.
      • false를 반환하면 "Device is not rooted"라는 메시지를 띄웁니다.
    • 사용자에게 결과를 보여주기 위해 Toast 메시지와 AlertDialog(팝업창) 두 가지 방식을 모두 사용하고 있습니다.

2. 파일 경로 기반 탐지:

    public final boolean isRooted() {
        String[] file = {"/system/app/Superuser/Superuser.apk", "/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su", "/su/bin/su", "re.robv.android.xposed.installer-1.apk", "/data/app/eu.chainfire.supersu-1/base.apk"};
        boolean result = false;
        for (String files : file) {
            File f = new File(files);
            result = f.exists();
            if (result) {
                break;
            }
        }
        return result;
    }

이 함수는 특정 파일의 존재 여부를 통해 루팅을 탐지합니다. 현재 버튼 클릭 시 실제로 호출되는 함수입니다.

  • 동작 원리:
    • String[] file 배열에 루팅 시 생성되는 대표적인 파일 경로들을 미리 정의해 두었습니다 (일종의 블랙리스트).
    • 탐지 대상:
      • su 바이너리: /sbin/su, /system/bin/su 등 (관리자 권한을 얻는 실행 파일)
      • 루트 관리 앱: Superuser.apk, eu.chainfire.supersu
      • 해킹 프레임워크: re.robv.android.xposed.installer (Xposed 프레임워크 설치 파일)
  • 로직:
    • for 반복문을 돌며 new File(files).exists()를 호출합니다.
    • 배열에 있는 파일 중 하나라도 존재하면 루팅된 기기로 판단(return true)합니다.

3. 명령어 실행 기반 탐지

    public final boolean isRooted1() throws IOException {
        try {
            Runtime.getRuntime().exec(new String[]{"su", "ls /data/data/"});
            return true;
        } catch (IOException e) {
            System.out.println(e);
            return false;
        }
    }

이 함수는 시스템 명령어 실행을 통해 권한을 확인하는 방식입니다. (위 코드의 버튼 클릭 이벤트에서는 호출되지 않고 있지만, 클래스 내에 정의된 또 다른 탐지 기법입니다.)

  • 동작 원리:
    • Runtime.getRuntime().exec(...)를 사용하여 리눅스 쉘 명령어를 실행합니다.
    • 실행 명령어: su (Switch User, 관리자 권한 획득 시도)
  • 로직:
    • try 블록 안에서 su 명령어를 실행했을 때, 에러 없이 실행된다면 기기가 루팅되어 su 명령어를 인식하는 상태이므로 true를 반환합니다.
    • 루팅되지 않은 기기에서는 su 명령어를 찾을 수 없거나 권한 거부로 인해 IOException이 발생하므로, catch 블록으로 넘어가 false를 반환합니다.

요악하면,

  1. 사용자가 버튼을 클릭하면 onCreate 의 리스너가 작동합니다.
  2. isRooted() 함수가 실행되어 su 파일이나 Superuser 앱이 설치되어 있는지 파일 경로를 검사합니다.
  3. 파일이 발견되면 "Device is rooted" 를 출력하고, 없으면 안전한 기기로 간주합니다.
  4. 참고로 isRooted1()su 명령어를 직접 실행해보는 더 강력한 방법이지만, 현재 버튼 로직에는 연결되어 있지 않습니다.

취약점 검증 (PoC)

Frida 이용

가장 확실하고 기초적인 방법은 탐지 함수를 후킹하여 무조건 false를 반환하도록 만드는 것입니다. Frida를 통해 해당 함수의 반환 값이 false로 되도록 설정해보겠습니다.

Java.perform(function() {
    try {
        // 1. 타겟 액티비티 클래스 로드
        var TargetClass = Java.use("owasp.sat.agoat.RootDetectionActivity");

        // 2. isRooted() 함수 재작성 (파일 검사 우회)
        TargetClass.isRooted.implementation = function() {
            console.log("[+] isRooted() 함수 호출됨 -> false 반환으로 변조");
            return false; // 항상 루팅되지 않음(false)으로 응답
        };
    } catch(e) {
        console.log("[-] 에러 발생: " + e);
    }
});

isRooted1()함수의 경우 호출되는 부분이 존재하지 않으므로 isRooted()함수의 반환 값을 false로 설정한 뒤 실행했습니다.

이후 다시 버튼을 누르면:

루팅 탐지를 우회할 수 있습니다.

애플리케이션 리패키징

리패키징 방법은 APK 파일 자체를 수정하는 방식입니다. APK 디컴파일 후 Smali 코드를 얻을 수 있습니다. 디컴파일된 Smali 코드 변조 후 컴파일 하여 앱에 접근하여 루팅 탐지를 우회할 수 있습니다.

.method public final isRooted()Z
    .locals 13

    .line 43
    nop
    # ...
    :cond_1
    :goto_1
    return v1
.end method
.method public final isRooted()Z
    .locals 1

    const/4 v0, 0x0  # v0에 0(false) 대입
    return v0        # v0 반환
.end method

코드 변경의 목적으로 해당 함수가 항상 false로 반환하도록 만들면됩니다. 이렇게 만들어진 APK를 설치하면, 루팅 탐지 함수가 호출되어도 항상 false를 반환하므로 "Device is not rooted" 메시지가 출력됩니다.

리패키징의 장점은 한 번 수정하면 영구적으로 적용된다는 점입니다. 매번 Frida를 실행할 필요가 없습니다. 하지만 앱이 업데이트되면 다시 작업해야 하고, 서명이 바뀌므로 앱의 무결성 검사를 통과하지 못할 수 있다는 단점이 있습니다.

에뮬레이터 탐지 (Emulator Detection)

에뮬레이터 탐지의 필요성

에뮬레이터 탐지는 앱이 실제 물리적 디바이스가 아닌 에뮬레이터나 시뮬레이터에서 실행되고 있는지 확인하는 메커니즘입니다.

PS C:\Users\WIN11> adb -s 2c838d0cfa0b7ece shell dumpsys window | findstr 'mCurrentFocus'
  mCurrentFocus=Window{63a8c4f u0 owasp.sat.agoat/owasp.sat.agoat.EmulatorDetectionActivity}

현재는 에뮬레이터가 아닌 실 기기로 진행하지만 코드를 통해 어떻게 탐지하는지 확인해보겠습니다.

코드 분석

EmulatorDetectionActivity:

1. 초기화 및 UI 설정

앱 화면이 생성될 때 가장 먼저 실행되는 함수입니다.

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(C1278R.layout.activity_emulator_detection);
        Button emuButton = (Button) findViewById(C1278R.id.EmulatorCheck);
        final AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("Emulator Detection");
        emuButton.setOnClickListener(new View.OnClickListener() { // from class: owasp.sat.agoat.EmulatorDetectionActivity$$ExternalSyntheticLambda0
            @Override // android.view.View.OnClickListener
            public final void onClick(View view) {
                EmulatorDetectionActivity.onCreate$lambda$1(this.f$0, builder, view);
            }
        });
    }
  • 기능: 화면을 띄우고, "EmulatorCheck"라는 버튼을 찾습니다.
  • 특이점: 버튼을 클릭했을 때의 동작을 직접 여기에 적지 않고, 코틀린 컴파일러가 생성한 onCreate$lambda$1이라는 정적(static) 메서드를 호출하도록 연결하고 있습니다.

2. 클릭 이벤트 처리 로직

사용자가 버튼을 클릭했을 때 실제로 실행되는 로직입니다.

public static final void onCreate$lambda$1(EmulatorDetectionActivity this$0, AlertDialog.Builder builder, View it) {
        Intrinsics.checkNotNullParameter(this$0, "this$0");
        Intrinsics.checkNotNullParameter(builder, "$builder");
        if (this$0.isEmulator()) {
            builder.setMessage("This device is an Emulator");
            Toast.makeText(this$0.getApplicationContext(), "This device is an Emulator", 1).show();
        } else {
            builder.setMessage("This device is not an Emulator");
            Toast.makeText(this$0.getApplicationContext(), "This device is not an Emulator", 1).show();
        }
        builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { // from class: owasp.sat.agoat.EmulatorDetectionActivity$$ExternalSyntheticLambda1
            @Override // android.content.DialogInterface.OnClickListener
            public final void onClick(DialogInterface dialogInterface, int i) {
                dialogInterface.dismiss();
            }
        });
        AlertDialog dialog = builder.create();
        Intrinsics.checkNotNullExpressionValue(dialog, "builder.create()");
        dialog.show();
    }

기능:

  • isEmulator() 함수를 호출하여 현재 기기가 에뮬레이터인지 확인합니다.
  • 결과(True/False)에 따라 사용자에게 보여줄 메시지(Toast 및 Dialog)를 다르게 설정합니다.
  • 최종적으로 결과 알림창을 화면에 띄웁니다.

3. 핵심 탐지 로직

이 코드에서 가장 중요한 보안 로직입니다. 기기의 시스템 정보를 읽어 에뮬레이터의 특징이 있는지 검사합니다.

    private final boolean isEmulator() {
        String buildDetails = (Build.FINGERPRINT + Build.DEVICE + Build.MODEL + Build.BRAND + Build.PRODUCT + Build.MANUFACTURER + Build.HARDWARE).toLowerCase();
        Intrinsics.checkNotNullExpressionValue(buildDetails, "this as java.lang.String).toLowerCase()");
        return StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "generic", false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) EnvironmentCompat.MEDIA_UNKNOWN, false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "emulator", false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "sdk", false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "vbox", false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "genymotion", false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "x86", false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "goldfish", false, 2, (Object) null) || StringsKt.contains$default((CharSequence) buildDetails, (CharSequence) "test-keys", false, 2, (Object) null);
    }
  • 동작 원리:
    • Build 클래스에서 기기의 지문, 모델명, 브랜드, 제조사, 하드웨어 정보 등을 모두 가져와 하나의 문자열로 합칩니다.
    • 합쳐진 문자열에 아래와 같은 '에뮬레이터 흔적' 단어들이 포함되어 있는지 확인합니다. 하나라도 포함되면 true(에뮬레이터임)를 반환합니다.
  • 탐지 키워드 목록:
    • generic: 일반적인 가상 장치 식별자
    • emulator: 에뮬레이터 명시
    • sdk: 안드로이드 SDK에 포함된 에뮬레이터
    • vbox: VirtualBox (지니모션 등에서 사용)
    • genymotion: 지니모션 에뮬레이터
    • x86: 인텔 CPU 기반 (보통 실제 폰은 ARM 아키텍처임)
    • goldfish: 안드로이드 에뮬레이터 커널 이름
    • test-keys: 루팅되었거나 커스텀 롬, 혹은 에뮬레이터 이미지 서명 키

Frida를 이용한 우회

  • isEmulator() 함수가 무조건 false를 반환하도록 후킹(Hooking)하면 탐지 기능을 무력화할 수 있습니다.

  • 예시 스크립트:

    Java.perform(function() {
        let Activity = Java.use("owasp.sat.agoat.EmulatorDetectionActivity");
        Activity.isEmulator.implementation = function() {
            console.log("[*] isEmulator check bypassed");
            return false; // 무조건 실제 기기라고 속임
        };
    });
    
    

시스템 속성 변조

  • 에뮬레이터의 build.prop 파일을 수정하여 모델명 등을 실제 기기(예: Samsung Galaxy)처럼 보이게 바꾸면 이 로직을 통과할 수도 있습니다.