[Android 취약점 진단] 상용앱 루팅 탐지 우회 로직 비교 분석

반응형

본 글은 교육 및 연구 목적으로 작성된 분석 기록입니다. 실 서비스에 대한 무단 우회나 악용을 권장하지 않습니다.
공격 기법은 학습용, 허가된 환경에서 실습 바랍니다. 실 운영망 대상 공격은 처벌받습니다. (정보통신망법 제48조 1항)

본문에 포함된 디컴파일 코드는 식별 가능 정보 보호를 위해 함수명·클래스명·상수값을 비식별화했으며, 일부는 가독성을 위해 정리·축약하였습니다. 대상 앱과 보안 솔루션의 명칭은 직접적으로 노출하지 않습니다.

 

상용앱을 분석하면서 마주친 루팅탐지 솔루션에 대해 서로 다른 기법이 적용된 경우가 있어 정리하였습니다. LLM을 통한 분석이었지만 시도한 과정을 모두 기록하게 하여 안드로이드 솔루션 기법 및 우회 관점에서 새로운 인사이트를 얻을 수 있었습니다.

 

들어가며

상용 안드로이드 앱의 루팅 탐지(Root Detection) 우회는 더 이상 su 바이너리 경로 몇 개를 숨기는 수준에서 끝나지 않습니다. 실제 진단 현장에서 마주치는 앱 대부분은 자체 휴리스틱, 상용 RASP(Runtime Application Self-Protection) SDK, 네이티브 보호 라이브러리, 원격 차단 서비스가 겹겹이 쌓인 구조입니다. 단일 후킹 한 줄로 무력화되던 시절은 지났습니다.

이번 글에서는 필자가 분석한 두 종류의 상용앱(이하 솔루션 A, 솔루션 B)에 대해, 디컴파일 소스에서 식별한 탐지 진입점과 그에 대응하는 Frida 우회 스크립트를 함께 대조합니다. 단순한 후킹 포인트의 나열이 아니라, "왜 그 위치를 후킹했어야 했는가" 를 원인 분석 관점에서 풀어내는 것이 이 글의 핵심입니다.

다루는 내용은 다음과 같습니다.

  • 두 솔루션이 채택한 서로 다른 방어 모델(엔진 위임형 vs. 앱 흐름 주도형)
  • 디컴파일 코드에서 드러나는 탐지 신호와 그 신호의 소비 지점(consumer)
  • Frida 우회 스크립트가 탐지 체인의 어디를 끊었는가
  • 두 솔루션이 공통으로 보는 신호와 차별적으로 보는 신호
  • 진단자가 새로 주목해야 할 관점 — 탐지 함수 한 곳을 찾는 능력에서, 탐지 체인 전체의 의존 관계를 읽는 능력으로

타겟 독자는 안드로이드 앱 분석을 어느 정도 경험한 중급 이상의 분석가입니다. Frida 후킹과 디컴파일 도구(JADB, jadx, IDA Pro 등)에 익숙하다는 전제로 본문을 진행합니다.

 

사전지식: 루팅 탐지의 7개 계층

본문에 들어가기 전, 상용앱이 루팅 여부를 판단하기 위해 사용하는 신호를 계층별로 정리하면 다음과 같습니다. 이 계층 구분은 본문에서 두 솔루션의 탐지 깊이를 비교하는 기준선으로 사용합니다.

계층 탐지 신호 예시 일반적인 후킹 지점
1. 파일 기반 /system/xbin/su, busybox, Magisk 디렉터리 java.io.File.exists, libc.fopen, libc.access
2. 패키지 기반 SuperSU, Magisk Manager, Xposed Installer PackageManager.getPackageInfo, getInstalledPackages
3. 명령 실행 기반 which su, getprop, mount, id Runtime.exec 오버로드, libc.system
4. 시스템 속성 기반 Build.TAGS == "test-keys", ro.debuggable Build 정적 필드, SystemProperties.get
5. SDK/API 기반 상용 RASP SDK 또는 자체 래퍼의 boolean 판정 SDK 진입 메서드, JNI 브리지
6. 네이티브 방어 ptrace 안티 디버깅, fork 모니터링, 시그널 후킹 libc.ptrace, libc.fork, dlopen
7. 탐지 후 반응 다이얼로그, 액티비티 종료, 프로세스 킬, 원격 차단 Activity.finish, System.exit, Process.killProcess

대부분의 상용앱은 이 중 3~4개 계층을 동시에 사용합니다. 이번에 본 두 솔루션은 이 계층들을 서로 다른 순서와 깊이로 조합한 사례입니다.

 

분석 대상 개요

편의상 두 대상을 다음과 같이 부르겠습니다.

  • 솔루션 A : 외부 보안 엔진 + 네이티브 루팅 체크 래퍼를 사용하는 앱
  • 솔루션 B : 앱 자체 루팅 체크 + 상용 RASP SDK + 보조 네이티브 탐지 모듈 + 원격 차단 서비스를 함께 사용하는 앱

분석에 사용한 근거는 두 가지입니다.

  1. 우회에 성공한 Frida 스크립트 2종 (frida_root_bypass_*.js)
  2. 별도 분석 서버에 보관된 두 앱의 디컴파일 결과물

두 스크립트가 공략하는 후킹 포인트를 디컴파일 소스의 탐지 진입점과 1:1로 매핑하면, 각 솔루션의 방어 설계 의도가 비교적 선명하게 드러납니다.

 

 

솔루션 A — 엔진 위임형 방어 모델

1. 앱은 판정을 직접 하지 않고 엔진에 위임한다

솔루션 A의 가장 큰 특징은 앱 코드 자체에는 휴리스틱이 거의 없다는 점입니다. 앱은 별도 보안 엔진 객체를 구성한 뒤 검사 시작 메서드를 호출하고, 그 결과만 소비합니다.

// 비식별화·축약된 디컴파일 예시
RootCheckElement elem = new RootCheckElement.Builder(app)
    .setCheckScope(24)
    .setInterval(60)
    .build();

int rc = rootChecker.checkStart(elem, new RootCheckCallback() {
    @Override
    public void onCheck(int result, RootCheckElement e, RootCheckInfo info) {
        if (result < 0) {
            log("engine error=" + result);
        } else if (info.getRuleID() == 0) {
            log("clean");
        } else {
            log("detected rule=" + info.getRuleID());
            if (info.getDescription() != null) {
                log(info.getDescription());
            }
            showToast(...);
            mainActivity.terminateFlow();
        }
    }
});

여기서 진단자가 주목해야 할 핵심은 두 가지입니다.

  • 앱의 판정 기준은 단 하나, RootCheckInfo.ruleID 입니다.
  • ruleID != 0이면 즉시 사용자 통지와 종료 흐름으로 분기합니다.

즉 이 앱의 루팅 탐지 구조는 "자체 휴리스틱 보유자"가 아니라 "엔진 결과 소비자(consumer)" 에 가깝습니다. 후킹 포인트를 찾을 때 자바 코드만 들여다보면 정작 룰이 보이지 않는 이유입니다.

2. 원샷 검사와 상시 검사가 분리되어 있다

같은 소스에는 일회성 검사 경로도 별도로 존재합니다.

RootCheckElement elem = new RootCheckElement.Builder(app)
    .setCheckScope(24)
    .setOptions(9)
    .build();

int rc = rootChecker.checkStart(elem, callback);

이 앱이 사용하는 탐지 모드는 최소 두 가지입니다.

  • 상시형(periodic) : setInterval(60)처럼 주기 인자를 가진 검사
  • 단발형(one-shot) : 특정 액티비티 진입 시 1회만 호출되는 검사

따라서 우회 스크립트는 checkStart()의 여러 오버로드와 동기/비동기 경로를 모두 후킹해야 합니다. 하나만 막으면 다른 경로의 콜백이 살아남아 종료 흐름이 트리거됩니다. "같은 이름의 메서드가 여러 시그니처로 존재한다"는 사실 자체가 분석 단서라는 점을 기억해 두면 좋습니다.

3. 자바 SDK 아래 별도의 네이티브 래퍼가 한 겹 더 있다

디컴파일 소스에서 가장 중요한 발견은 자바 SDK가 끝이 아니라는 점이었습니다.

private static int checkStart(Context ctx, String key, int option, int interval, RootCheckCallback cb) {
    if (ctx != null && key != null && cb != null) {
        if (!isIntegrated(ctx)) {
            return -2;
        }
        cancelTimer();
        if (setDelayTimer(ctx, interval, key, option) != 0) {
            return -1;
        }
        sCallback = cb;
        return 0;
    }
    return -3;
}

private static int check(Context ctx, String key, int option) {
    if (ctx != null && key != null && option >= 0) {
        if (isIntegrated(ctx)) {
            return nativeCheck(ctx, key, option);
        }
        return -2;
    }
    return -3;
}

nativeCheck(...)로 내려가는 별도 네이티브 판정 경로가 존재한다는 것은, 자바 후킹만으로는 탐지를 완전히 회피할 수 없다는 뜻입니다. 솔루션 A의 탐지 흐름을 정리하면 다음과 같습니다.

  1. 앱이 앱 레벨 보안 오케스트레이터를 호출
  2. 오케스트레이터가 보안 엔진 checkStart()를 시작
  3. 일부 경로는 별도 네이티브 루팅 래퍼까지 내려감
  4. 결과 객체의 ruleID != 0이면 종료 흐름으로 이어짐

4. 종료 흐름은 별도 콜백 인터페이스로 추상화되어 있다

메인 액티비티는 보호 콜백 인터페이스를 구현하고 있으며, 종료 메서드가 추상화되어 있습니다.

public abstract class MainSecurityActivity implements SecurityCheckListener {
    @Override
    public void onSecurityDetectA(int type) throws Exception {
        dispatch(type);
    }

    @Override
    public void onSecurityDetectB(int type) throws Exception {
        dispatch(type);
    }

    public final void terminateFlow() throws Exception {
        dispatchFinish();
    }
}

난독화 때문에 내부 구현은 깔끔히 보이지 않지만, 구조는 명확합니다. "보호 콜백 A · 보호 콜백 B · 종료 메서드" 이 세 개의 지점이 탐지 후 반응 지점입니다. 하나의 콜백만 막으면 나머지 콜백이 살아남는 이중화 구조이므로, 우회 스크립트는 세 지점 모두를 차단해야 합니다.

5. 전역 플래그로 탐지 활성 여부가 제어된다

앱 전역 클래스에는 다음과 같은 정적 플래그가 존재합니다.

public static boolean ROOT_CHECK_ENABLED = false;

이는 앱이 "항상 탐지" 모드로 동작하지 않으며, 특정 트리거(로그인, 결제 등) 에서만 플래그를 올렸다가 엔진을 호출하는 설계임을 시사합니다. 우회 시 이 플래그를 false로 강제 고정하면 엔진 호출 자체가 일어나지 않게 만들 수 있습니다.

 

 

6. 우회 스크립트가 후킹한 지점

위 원인 분석을 기준으로 Frida 스크립트의 후킹 포인트를 다시 보면, 각 후킹이 어떤 흐름을 끊고 있는지 명확하게 매핑됩니다.

 

 

6-1. 엔진 진입점 차단

  • 앱 오케스트레이터의 엔진 호출 메서드를 nop 처리
  • 전역 루팅 체크 플래그를 false로 고정

엔진이 아예 호출되지 못하도록 흐름의 가장 앞단을 막습니다.

 

 

6-2. 엔진 결과 위조

  • 핵심 엔진 시작 메서드 → 성공 코드 반환
  • 동기 검사 메서드 → 결과 리스트 비우고 0 반환
  • 콜백 호출 시 ruleID=0인 정상 결과 객체를 강제로 전달

엔진을 우회하지 못한 호출 경로가 있더라도, 콜백에 도달하는 결과를 정상값으로 위조하여 종료 흐름이 트리거되지 않게 합니다.

 

 

6-3. 네이티브 래퍼 차단

  • 네이티브 래퍼의 checkStart, check 오버로드 모두 0 반환
  • 라이브러리 내 nativeCheck() 함수의 반환값도 0으로 치환
  • dlopen 감시를 통해 늦게 로드되는 네이티브 라이브러리까지 후킹

자바 경로와 네이티브 경로를 동시에 닫아 4단계 흐름의 어느 지점에서도 탐지 결과가 나오지 못하게 만듭니다.

 

 

6-4. 탐지 후 반응 차단 (안전망)

  • 메인 액티비티의 보호 콜백 A·B 차단
  • terminateFlow() 차단
  • System.exit, Process.killProcess, 네이티브 exit 차단

탐지 함수 일부를 놓치더라도 최종 종료를 막으면 분석 세션이 유지됩니다. 이 안전망은 동적 분석 중 예기치 못한 신규 룰이 트리거되더라도 앱이 강제 종료되지 않도록 하는 보험 역할을 합니다.

 

 

7. 솔루션 A 요약

솔루션 A의 방어 모델은 "엔진 위임형(engine-delegated)" 으로 정리할 수 있습니다.

  • 탐지 룰의 세부 사항은 앱 코드보다 상용 엔진/네이티브 라이브러리 안에 은닉
  • 앱 코드에서 명확히 드러나는 것은 "엔진 호출 → 룰 ID 확인 → 종료" 구조뿐
  • 우회 전략은 진입점 차단 + 결과 위조 + 종료 차단의 3단 구성

진단자 입장에서 솔루션 A는 "무엇을 보느냐"보다 "어떤 결과가 종료 조건으로 소비되느냐" 를 역으로 추적해야 하는 구조입니다.

 

 

솔루션 B — 앱 흐름 주도형 + 다층 방어 모델

1. 메인 액티비티가 직접 루팅 체크를 호출한다

솔루션 B는 정반대 설계입니다. 메인 액티비티가 직접 루팅 판정을 호출하고, 그 결과를 앱 흐름 제어에 즉시 사용합니다.

public void rootCheckEntry() {
    if ((Guard.getInstance() != null || createGuardInstance(ctx)) && Guard.getInstance() != null) {
        if (Guard.getInstance().isRooted(mode)) {
            new Handler(Looper.getMainLooper()).postDelayed(new DetectHandler(this), 0L);
            return;
        }
        new BackgroundScanTask(this, UpdateType.BackgroundNonUI, 33).execute(...);
    }
}

흐름은 직관적입니다.

  1. RASP SDK 인스턴스 생성
  2. 루팅 판정 메서드 호출
  3. true이면 후속 핸들러(DetectHandler) 실행
  4. false이면 백그라운드 스캔 태스크로 진행

즉 이 앱은 루팅 여부를 앱 흐름 제어의 초입에서 직접 사용합니다. 진입점이 코드에 노출되어 있다는 점에서 분석은 더 쉬워 보이지만, 실제 우회 난도는 더 높습니다. 그 이유는 다음 절부터 드러납니다.

2. RASP SDK는 Build.TAGS와 네이티브 판정을 결합한다

public boolean isRooted(int mode) throws Exception {
    String tags = Build.TAGS;
    String sys = new NativeBridge().systemIntegrity(mode, tags);

    boolean extra = false;
    if (mode == 0) {
        extra = new ExtraNativeChecks().detect(pkgList, malwareList);
    }

    if (Integer.parseInt(sys.split(",")[1]) <= 0 && !extra) {
        return false;
    }
    return true;
}

이 SDK는 두 종류의 신호를 결합합니다.

  • Build.TAGS 기반 시스템 무결성 신호 (네이티브 브리지를 통해 가공)
  • 추가 네이티브 보조 모듈이 내는 패키지/바이너리/악성 도구 신호

특히 흥미로운 점은 sys.split(",")[1]을 정수 변환하여 비교한다는 점입니다. 이는 네이티브 브리지가 단순 boolean이 아니라 "점수 또는 비트마스크" 형태의 결과를 반환한다는 단서입니다. 결과를 위조할 때는 단순히 false를 돌려주는 것이 아니라 포맷에 맞는 정상 문자열을 돌려줘야 합니다.

 

3. 같은 SDK 안에 증거 수집 API가 별도로 존재한다

public String collectRootingEvidence(int mode) throws Exception {
    String tags = Build.TAGS;
    String evidence = new NativeBridge().evidence(mode, tags);
    return evidence.equals(",0") ? "" : evidence;
}

이 메서드의 존재는 진단자에게 매우 중요한 시사점을 줍니다. SDK가 단순한 yes/no 판정만 하는 것이 아니라, "어떤 증거가 검출됐는가" 를 별도 문자열 포맷으로 외부에 노출한다는 뜻입니다. 즉 이 SDK를 호출하는 또 다른 사용처가 있을 수 있고, 그 사용처는 isRooted() 외에 collectRootingEvidence()까지 본다는 것입니다.

우회 스크립트가 시스템 점검 브리지뿐 아니라 증거 수집 브리지까지 함께 무력화한 것은 이런 이유에서입니다. 진단자가 한 메서드만 막으면, 다른 메서드의 결과가 별도 경로로 흘러가서 차단·로깅·서버 보고가 일어날 수 있습니다.

 

4. 보조 네이티브 모듈이 휴리스틱을 직접 노출한다

public class NartBridge {
    private native boolean isRootingBinaryExec();
    private native boolean isRootingBinaryExists();
    private native boolean isRootingPackageInstalled(String[] pkgs);
    private native boolean isMalwareExists(String[] names);

    public boolean a(String[] pkgs, String[] badTools) {
        return isRootingPackageInstalled(pkgs)
            || isRootingBinaryExec()
            || isMalwareExists(badTools);
    }
}

이 모듈은 솔루션 A와 가장 다른 지점입니다. 솔루션 A는 룰을 엔진 안에 숨겼지만, 솔루션 B는 휴리스틱이 메서드 시그니처에 그대로 드러납니다. 패키지·바이너리·악성 도구의 세 축을 OR 조건으로 결합하므로, 하나라도 살아 있으면 탐지가 성립합니다. 우회 시에는 세 메서드를 모두 무력화해야 합니다.

 

5. 원격 차단 서비스가 별도 스레드로 동작한다

루팅 판정과 별개로, 원격 차단 서비스가 백그라운드에서 계속 동작합니다.

private ArrayList<String> detectNetworkProcess(Context context) {
    for (PackageInfo pkg : context.getPackageManager().getInstalledPackages(0)) {
        if (isNeedCheckPackageListFile(pkg.packageName)
            && checkIsRunningUid(appInfo.uid) > 0) {
            result.add(pkg.packageName);
        }
    }
}

private boolean runDetection(Context context, OnDetectHandler handler) {
    if (hasDetectType(1)) {
        // 네트워크 프로세스 감지
    }
    if (hasDetectType(2)) {
        // 원격 앱 감지 (TeamViewer, AnyDesk 등 원격 제어 앱)
    }
    if (hasDetectType(4)) {
        // 위협 앱 감지 (분석/후킹/디버깅 도구)
    }
}

이 서비스는 루팅 자체를 보는 것이 아니라 분석 환경 전반을 감시하는 운영 방어 레이어입니다. 비트마스크(hasDetectType(1|2|4))로 탐지 종류를 토글한다는 점에서, 운영 정책에 따라 서버에서 동적으로 활성화될 수 있는 구조로 보입니다.

진단자에게 의미하는 바는 분명합니다. "루팅을 우회했는데도 앱이 죽는다" 면, 원인은 루팅 탐지가 아니라 이런 운영 방어 레이어일 수 있습니다.

 

 

6. 종료 흐름은 단순하지만 진입점이 다양하다

public void terminateApp() {
    AppUtil.setAppStatusClosed();
    finish();
}

종료 함수 자체는 평범합니다. 그러나 이 함수에 도달하는 경로가 여러 개라는 점이 핵심입니다. 메인 액티비티의 DetectHandler, RASP SDK의 콜백, 원격 차단 서비스의 핸들러, 보조 네이티브 모듈의 시그널 — 모두 결국 종료로 수렴합니다.

 

 

7. 우회 스크립트가 후킹한 지점

7-1. 네이티브 보호 체인을 가장 먼저 가로챈다

이 스크립트의 핵심은 자바 후킹이 아닌, 그보다 훨씬 앞단의 네이티브 후킹입니다.

  • ptrace 차단 (안티 디버깅 무력화)
  • fork 차단 (자식 프로세스 모니터링 회피)
  • 보호 라이브러리 로딩 시점 감시 (dlopen 인터셉트)
  • 라이브러리 로드 중 생성되는 워치독 스레드 차단
  • sigaction 등 시그널 핸들러 등록 흐름 교란

이 후킹들은 RASP SDK의 루팅 판정이 시작되기 전에 보호 모듈이 살아나는 과정 자체를 겨냥합니다. 솔루션 B의 보호 모듈은 자기 자신이 후킹되었는지를 확인하기 위해 별도 스레드에서 메모리 무결성을 검사하므로, 이 단계가 성공하지 못하면 이후 자바 후킹이 곧바로 검출됩니다.

7-2. 자바 레이어 판정도 정상값으로 위조한다

  • Guard.getInstance().isRooted()false
  • NativeBridge.systemIntegrity() → 정상 상태 문자열(",0" 형태) 반환
  • NativeBridge.evidence() → 빈 문자열 반환
  • NartBridge의 패키지·바이너리·악성 도구 검사 → 모두 false

네이티브 방어가 일부 살아 있더라도, 자바 레벨 소비 지점은 다시 한 번 무력화합니다.

 

7-3. 흔적 은닉 범위가 더 넓다

  • 패키지 단건 조회뿐 아니라 getInstalledPackages 결과 리스트 자체를 필터링
  • File.exists, canRead, canExecute, isFile까지 차단
  • getprop, mount, id, which su, 후킹 도구 문자열을 포함한 Runtime.exec 명령 우회

솔루션 A가 "특정 값" 위주로 막는다면, 솔루션 B는 "의심 문자열 집합 전체를 기준으로 포괄적으로 차단" 합니다. 이는 SDK가 정확히 어떤 문자열을 보는지 명확히 드러나지 않기 때문에, 화이트리스트가 아닌 블랙리스트 매칭으로 광범위하게 막아 둔 결과입니다.

 

7-4. 종료와 운영 방어도 함께 우회한다.

  • 메인 액티비티의 rootCheckEntry() 차단
  • RASP SDK 인스턴스 생성 강제 성공
  • terminateApp() 차단
  • 원격 차단 서비스의 execute()checkIsRunningUid() 무력화
  • Activity.finish, killProcess, System.exit, Runtime.exit 차단

이 시점에서 솔루션 B의 우회 스크립트는 "단순 루팅 우회" 가 아니라 "분석 방해 체인 전체의 안정화 스크립트" 에 가깝습니다.

 

공통점과 차이점

두 솔루션이 공통으로 보는 신호

신호 솔루션 A 솔루션 B
루팅·분석 관련 패키지 우회 스크립트가 패키지명 숨김 isRootingPackageInstalled() 직접 호출
루팅 바이너리 / 실행 가능 여부 su 경로, Runtime.exec("which su") 차단 isRootingBinaryExec/Exists() 직접 호출
시스템 태그 (Build.TAGS) release-keys로 위조 시스템 점검 브리지에 직접 전달
탐지 후 종료 흐름 콜백 → terminateFlow 핸들러 → terminateApp

이 네 가지는 상용 안드로이드 앱이 보편적으로 보는 신호입니다. 진단자가 신규 앱을 분석할 때 우선 점검해야 할 기본 체크리스트로 사용할 수 있습니다.

 

차이점 — 실제 우회 난이도가 갈리는 지점

1. 엔진 위임형 vs. 앱 흐름 주도형

관점 솔루션 A 솔루션 B
앱 코드의 역할 결과 소비자(consumer) 탐지 오케스트레이터(orchestrator)
룰 위치 상용 엔진/네이티브 내부 (불투명) 앱 코드와 SDK 양쪽 (투명)
진단자 접근법 "결과가 어디서 종료를 트리거하는가?" 역추적 "탐지가 어디서 시작되는가?" 정추적

2. 다층 방어의 폭

솔루션 A는 핵심적으로 루팅 탐지와 종료에 집중합니다. 반면 솔루션 B는 다음까지 결합되어 있습니다.

  • 원격 차단 서비스 (운영 정책 기반)
  • 위협 앱·원격 제어 앱 감시
  • 네이티브 보호 라이브러리 (안티 디버깅, 안티 후킹, 메모리 무결성)
  • SSL Pinning과의 결합

즉 솔루션 B는 "루팅 탐지 앱"이 아니라 "분석 저항성이 높은 상용 앱" 에 가깝습니다.

3. 우회 타이밍의 차이

  • 솔루션 A : 엔진 호출 직전·직후 후킹으로 충분
  • 솔루션 B : 보호 라이브러리 로딩과 워치독 스레드 생성 타이밍을 먼저 잡아야 안정적

이 차이 때문에 솔루션 B 스크립트에는 dlopen, pthread_create, ptrace, fork, sigaction 같은 네이티브 초반 후킹이 들어갑니다. 단순히 자바 메서드를 후킹하면 워치독 스레드가 메모리 무결성을 깨뜨려 앱이 죽기 때문입니다.

 

비교 표 — 한눈에 보기

항목 솔루션 A 솔루션 B
앱 코드 주 진입점 보안 엔진 오케스트레이터의 검사 시작 메서드 메인 액티비티의 루팅 판정 메서드
핵심 판정 방식 엔진 콜백의 ruleID 소비 시스템 점검 브리지 + 보조 네이티브 결과 결합
소스에 드러나는 탐지 신호 적음 (엔진 위임 구조 중심) 많음 (Build.TAGS, 패키지, 바이너리, 악성 도구, 원격 앱)
네이티브 의존성 엔진 래퍼 + 네이티브 체크 함수 RASP 네이티브 브리지 + 보호 라이브러리 + 보조 모듈
탐지 후 반응 토스트/terminateFlow 호출 핸들러/다이얼로그/terminateApp/원격 차단 서비스
우회 핵심 결과 위조 + 종료 차단 네이티브 선제 차단 + 결과 위조 + 운영 방어 차단
우회 난이도 중상 높음

진단자를 위한 새로운 관점 — 4가지 인사이트

여기까지가 두 솔루션의 비교 분석입니다. 이제 이 분석에서 진단자가 취해야 할 새로운 관점을 정리하겠습니다. 단순한 후킹 레시피보다, 앞으로의 진단 시야를 넓히는 데 도움이 될 만한 시사점에 초점을 맞춥니다.

 

인사이트 1. "탐지 함수"가 아니라 "결과 소비 지점"을 진단 대상으로 본다

솔루션 A처럼 엔진 위임형 앱은 디컴파일 소스에서 룰이 잘 보이지 않습니다. 이때는 다음을 봐야 합니다.

  • 어떤 엔진 API를 호출하는가
  • 어떤 결과 객체(info.getRuleID(), getDescription() 등)를 소비하는가
  • 어떤 값일 때 종료 흐름으로 분기하는가

진단자는 "무엇을 탐지하는가"가 아니라 "탐지 결과를 어떤 조건으로 소비하는가" 를 추적해야 합니다. 이는 진단 대상을 "탐지 로직"에서 "탐지 결과의 의존 그래프(dependency graph)"로 바꾸는 시야 전환입니다.

인사이트 2. 보이는 휴리스틱은 우회 포인트와 1:1로 매핑된다 — 하지만 보이지 않는 휴리스틱이 더 위험하다

솔루션 B는 좋은 사례입니다.

  • Build.TAGS를 본다 → Build.TAGS 위조
  • 패키지 존재 여부를 본다 → 패키지 조회 숨김
  • 바이너리 실행 여부를 본다 → 파일/명령 실행 차단
  • 증거 문자열을 만든다 → 증거 수집 브리지 결과 위조

소스에 보이는 휴리스틱과 Frida 우회 코드는 거의 1:1로 대응합니다. 하지만 진단자가 더 신경 써야 할 것은 "소스에 안 보이는 부분" 입니다. 솔루션 A의 엔진 내부, 솔루션 B의 네이티브 보호 라이브러리 — 이런 영역은 동적 트레이스(예: frida-trace -i, Interceptor.attach + 백트레이스)로만 식별됩니다. 정적 분석만으로 진단을 종결지으면 누락이 발생합니다.

 

인사이트 3. 종료 경로까지 추적하지 않으면 진단이 끝나지 않는다

탐지 함수만 막아도 앱이 죽는다면, 종료 경로 중 하나가 살아 있다는 뜻입니다. 진단 시 다음 항목을 종료 트리거의 체크리스트로 사용하길 권장합니다.

  • 어떤 콜백 인터페이스가 종료를 트리거하는가
  • 종료 메서드는 어떤 이름·시그니처로 구현되어 있는가 (terminateApp, terminateFlow, dispatchFinish 등)
  • Activity.finish, Process.killProcess, System.exit, Runtime.exit, 네이티브 exit()까지 모두 추적했는가
  • 경고 UI가 먼저 뜨는가, 조용히 종료되는가 (조용한 종료는 백그라운드 서비스 신호일 가능성)

종료 경로는 방어자의 마지막 안전장치이며, 진단자에게는 "후킹 누락의 단서"입니다.

 

인사이트 4. 자바가 아니라 로딩 타이밍부터 진단해야 하는 앱이 늘고 있다

솔루션 B처럼 네이티브 보호 라이브러리를 채택한 앱은 자바 후킹 시점에 이미 워치독이 가동 중입니다. 이 경우 다음 순서로 진단을 진행하는 것이 안정적입니다.

  1. Frida.attach가 아닌 spawn 모드로 시작
  2. dlopen 후킹을 가장 먼저 걸어 보호 라이브러리 로딩 시점 포착
  3. 보호 라이브러리 로딩 직후 생성되는 스레드 식별 (pthread_create 트레이스)
  4. ptrace, fork, sigaction 등 안티 디버깅 진입점 무력화
  5. 그 다음에 자바 후킹 적용

또한 앱이 죽는 원인이 루팅 탐지인지, 안티 디버깅인지, 안티 후킹인지 를 분리해서 봐야 합니다. 세 원인은 비슷한 증상(앱 종료, SIGABRT 등)을 일으키지만 대응 방법이 완전히 다릅니다. 증상이 같다고 같은 원인이라고 단정하는 것이 진단자가 가장 자주 빠지는 함정입니다.

 

일반화된 의사 코드

비식별화 원칙에 맞춰, 두 스크립트의 구조를 의사 코드로 정리하면 다음과 같습니다.

솔루션 A 유형

// 교육 및 연구 목적의 일반화된 의사 코드 (식별자 비식별화)
hook(appSecurityRunner.runEngine, function () {
  return; // 엔진 진입점 차단
});

hook(engine.checkStart, function (elem, cb) {
  sendCleanCallback(cb, { ruleId: 0, desc: null });
  return 0;
});

hook(nativeRootWrapper.check, function () {
  return 0;
});

hook(nativeRootFunction, function (retval) {
  retval.replace(0);
});

hook(appTerminateFlow, block);
hook(systemExitFlow, block);

솔루션 B 유형

// 교육 및 연구 목적의 일반화된 의사 코드 (식별자 비식별화)
// 1. 네이티브 선제 차단
hook(ptrace, returnZero);
hook(fork, returnMinusOne);
hook(dlopen, function (path) {
  if (isProtectionLibrary(path)) {
    blockThreadsDuringLoad();
    patchProtectionRoutines();
  }
});

// 2. 자바 레이어 결과 위조
hook(guardSdk.isRooted, returnFalse);
hook(nativeBridge.systemIntegrity, returnCleanStatus);
hook(nativeBridge.evidence, returnNoEvidence);
hook(extraNativeChecks, returnFalse);

// 3. 흔적 포괄 은닉
hook(packageAndFileHeuristics, hide);
hook(runtimeExecHeuristics, redirect);

// 4. 운영 방어 + 종료 차단
hook(remoteBlockService, block);
hook(terminateFlow, block);

마치며

이번 분석에서 가장 흥미로웠던 점은, 같은 "루팅 탐지 우회"라도 대상 앱의 탐지 소비 구조에 따라 우회 전략이 완전히 달라진다는 것이었습니다.

  • 엔진 위임형 앱(솔루션 A) 은 결과 객체와 종료 지점을 잡는 것이 핵심
  • 앱 흐름 주도형 + 네이티브 보호 결합 앱(솔루션 B) 은 로딩 타이밍과 보호 체인을 먼저 무너뜨리는 것이 핵심

진단 현장에서 상용앱의 루팅 탐지에 막혔다면, su 흔적 숨기기에서 멈추지 말고 다음 5단계 질문을 순서대로 던져 보길 권장합니다.

  1. 앱이 직접 판정하는가, 엔진에 위임하는가?
  2. 판정 결과는 어디에서 어떻게 소비되는가?
  3. 탐지 후 반응은 종료뿐인가, 별도 운영 방어 레이어가 있는가?
  4. 네이티브 보호 모듈이 자바 후킹보다 먼저 가동되는가?
  5. 종료 경로가 여러 개인가? 모두 추적했는가?

상용앱 분석의 실제 난이도는 "탐지 함수 하나를 찾는 능력" 보다 "탐지 체인 전체의 의존 관계를 읽고 후킹해야 할 지점을 식별하는 능력" 에서 갈립니다. 본 글이 그 시야를 넓히는 데 작은 단서가 되기를 바랍니다.

 

References