🪄
Notice : 본 과정은 개발 및 컴퓨터 활용에 관련된 지식을 어느 정도 보유하고 있어야 원활한 이해 및 학습 진행이 가능합니다.
🪄
아래의 보안검증 우회 실습은 ANDITER 와 FRIDA 가 설치 및 구성이 완료되어야 진행이 가능합니다. 만약 실습 환경 구성이 되지 않았다면, 앞서 "Anditer : Android 보안 코드 우회 실습 환경 구축하기" 게시글을 참고하세요.

Anditer 와 Frida 가 정상적으로 설치 및 구성이 되었다면 ANDITER 앱을 실행 한 뒤 frida-ps -Ua 명령어를 통해 다음과 같이 ANDITER 프로세스 목록이 출력 됩니다.

D:\frida> uv run frida-ps -Ua
 PID  Name      Identifier
----  --------  ----------------------
1234  ANDITER   com.playground.anditer

대상 기기(Nox 등)에 frida 서버가 정상 동작하고 있고, 연결에 이상이 없다면 위와 같이 결과 값이 나온다.


실습을 시작해 봅시다.

Bypass Binaries 의 마우스 모양을 클릭하면 루팅 시 설치되는 바이너리 파일들을 검사하는 루팅 탐지 기법에 대한 설명이 나타나며, 대표적인 루팅 바이너리 파일인 su, busybox, magisk 등 키워드를 제공해주고 있습니다. 실제로 magisk 등은 현재도 루팅에 사용되는 대표적인 앱이자 바이너리 중 하나 입니다. 현재 실습 환경에서는 magisk 는 설치되어 있지 않지만, 앞서 환경 구성에서 "ROOT 켜기"를 해두었기 때문에 su 명령어가 사용 가능 하여 "Check" 스위치를 클릭하면 Fail... 이 나타나는 것을 확인할 수 있습니다.

ANDITER Binaries 검증 방식의 설명과 검증을 통과하지 못해 Fail 문구가 나타난 모습

자, 이제 ANDITER 가 우리가 루팅 검증을 우회해야 하는 대상 앱이라고 가정하고 분석을 시작해 봅시다.

APK 를 분석하는 방법은 여러가지가 있겠지만, 여기서는 간단하게 다루기 위해서 Bytecode Viewer 라는 프로그램을 이용해서 APK 를 분석하고 열어보겠습니다. Bytecode Viewer 는 아래 사이트를 방문하여 다운로드하실 수 있습니다.

GitHub - Konloch/bytecode-viewer: A Java 8+ Jar & Android APK Reverse Engineering Suite (Decompiler, Editor, Debugger & More)
A Java 8+ Jar & Android APK Reverse Engineering Suite (Decompiler, Editor, Debugger & More) - Konloch/bytecode-viewer

APK 코드를 볼 수 있도록 도와주는 Bytecode Viewer를 다운로드 받을 수 있는 Github 레파지토리

🪄
bytecode viewer는 jar 확장자를 가진 파일로 실행을 위해서는 JRE 나 JDK가 설치되어 있어야 하며, 설치되어 있지 않다면 Oracle 사이트에서 JRE 또는 JDK 를 내려받아 설치하시면 되겠습니다.
좌측 하단의 Search 에서 루팅 검증하는 코드를 검색 및 추적합니다.

위와 같이 Bytecode Viewer 를 실행하고 APK 를 드래그해서 놓거나 "File" -> "Add..." 를 눌러 APK 를 선택해 열람한 뒤 "Search" 에서 "magisk" 나 연관 된 binary 명을 검색하며 인증과 관련된 로직을 추적 및 분석합니다.

우리는 원활한 실습을 위해 난독화가 되지 않은 APK를 사용하였으므로 어렵지 않게 함수 등을 찾을 수 있을 겁니다. 난독화가 된 경우 magisk 나 바이너리 명이 아닌 의미를 알 수 없는 문자로 된 문자열 변수나 함수명 등으로 처리되어 있기 때문에 분석이나 추적이 어려울 수 있음을 감안하고 진행하면 됩니다.

🪄
좀 더 심화된 실습을 해보고 싶다면, 난독화가 된 APK를 대상으로 진행하시되 이후 스크립트를 난독화된 코드 블럭에 맞추어 수정하여 정상적으로 우회에 성공하는지 확인 해보시면 됩니다.
com/playground/anditer/RootingDetector 에서 확인되는 Rooting 처리에 관련된 코드

난독화가 되지 않은 APK로 분석이 재대로 되었다면 com/playground/anditer/RootingDetector.class 경로에 루팅 탐지를 위한 로직이 있음을 확인하였을 겁니다. 해당 로직에서 Binaries 탐지와 관련된 핵심 코드를 살펴보면 다음과 같은 부분으로 추정해볼 수 있습니다.

// 생성자에서 정의된 검사할 Binaries 대상 배열 변수
public RootingDetector(final Context mContext) {
  ...
  this.rootingBinaries = new String[] { "su", "busybox", "magisk", "supersu", "Superuser.apk", "KingoUser.apk", "SuperSu.apk", "daemonsu" };
  ...
}

rootingBinaries 변수에서 체크하는 바이너리 파일명을 확인할 수 있습니다.

// Binary 를 체크하는 함수 정의 부분
public final boolean isCheckRootingBinary() {
  for (final String parent : this.rootingPath) {
    final String[] rootingBinaries = this.rootingBinaries;
      for (int length2 = rootingBinaries.length, j = 0; j < length2; ++j) {
        this.setFile(new File(parent, rootingBinaries[j]));
        if (this.isFile().exists()) {
          return true;
        }
      }
    }
  return false;
}

Binary 체크 함수에서 변수를 활용해 파일 존재 여부를 체크하는 부분을 화인할 수 있습니다.

위와 같은 코드 부분에 있어 루팅(Rooting) 바이너리(Binary) 체크를 우회하기 위한 포인트로는 다음과 같이 3가지 정도를 추려볼 수 있을 겁니다.

  1. isCheckRootingBinary 의 결과 값을 항상 false 로 반환하여 우회시키기.
  2. File class 의 exists 결과 값을 항상 false 로 반환하여 우회시키기.
  3. rootingBinaries 를 빈 배열로 만들어 우회시키기.

위와 같은 방법들로 루팅 바이너리 탐지를 우회해볼 수 있을 것이고, 이제 이러한 우회 로직을 만들기 위해 Frida 스크립트를 작성해야 할 필요가 발생합니다.

frida 스크립트를 만드는 방법이야 다양하고 고급 기법들도 있겠지만, 이 실습과정에서는 Java.choose(...) 를 이용해 메모리 상에 있는 객체를 찾아 값을 변경하는 방법implementation 를 활용해 함수를 재정의(Override) 하는 기법을 활용하도록 하겠습니다.

아래는 제가 작성한 Frida 스크립트로 스크립트를 직접 작성하실 수 없으신 분들은 아래 스크립트를 복사하여 anditer_bypass_binaries.js 파일로 저장하시거나 첨부된 파일을 다운로드 받아 진행을 따라오시면 되겠습니다.

/*
  frida -U -N <com.playground.anditer> -l anditer_bypass_binaries.js [--no-pause]
*/


setTimeout(function(){
  Java.perform(function (){
    console.log("[*] Running - Bypass Binaries");
    
    var RootingDetector = "com.playground.anditer.RootingDetector"
    var targetClass = Java.use(RootingDetector); 
    var fileClass   = Java.use("java.io.File");

    var valuableHooking  = false
    var functionHooking  = false
    var fileExistHooking = false
 
    // 현재 실행 중인 앱 메모리에서 RootingDetector의 모든 인스턴스를 찾음.
    Java.choose(RootingDetector, {
      onMatch: function (instance) { // 객체를 찾으면 변경을 수행.
        if (valuableHooking) {
          if ('rootingBinaries' in instance) {
            // 이미 실행된 객체의 rootingBinaries 값을 빈 배열로 변경.
            instance.rootingBinaries.value = Java.array('java.lang.String', []);
            console.log("[*] rootingBinaries value modified to empty array at runtime.");
          }
        }
      },
      onComplete: function () { // 탐색이 끝나면 로그 출력.
        console.log("");
      }
    })

    // Rooting Binary 체크 함수 호출시 false 값을 반환하도록 처리.
    if (functionHooking) {
      targetClass.isCheckRootingBinary.implementation = function () {
        try {
          console.log("[*] Called - RootingDetector.isCheckRootingBinary() : Returned false");
          return false
        } catch (e) { }
      }
    }
    
    // File Exists 함수 호출시 false 값을 반환하도록 처리.
    if (fileExistHooking) {
      fileClass.exists.implementation = function () {
        try { 
          console.log("[*] Called - java.io.File.exists() : Returned false");
          return false 
        } catch (e) { return false }
      }
    }
    
  })
}, 0);

실습을 위해 마련한 루팅 Binaries 체크 검증 우회하는 Frida 스크립트

앞서 3가지 방법에 대해 각각 다 확인을 해보기 위해서 위의 실습 스크립트에는 3가지 스위치 변수를 만들어 두었습니다. 각각의 과정은 우회를 위한 변경 상태가 유지되어 정상적인 결과 값을 확인하기 어려울 수 있으니, 아래부터는 각 방법별 실습이 마무리 될 때마다 변수 변경과 함께 ANDITER 라는 앱을 종료했다가 다시 실행하여 결과 값을 확인하는 것을 권장합니다.

이제 ANDITER 앱을 실행시키고 다음의 명령어를 이용해 스크립트를 동작 시킵니다. 정상적으로 실행이 되었다면 [*] Running - Bypass Binaries 라는 문구를 확인할 수 있을 겁니다.

uv run frida -U -N com.playground.anditer -l .\anditer_bypass_binaries.js

우회 스크립트 실행 명령어

ANDITER 및 Script 동작이 정상적으로 처리 되었을 때의 모습

isCheckRootingBinary 의 결과 값을 항상 false 로 반환하여 우회시키기.

앞서 3가지 우회 방법 중에서 isCheckRootingBinary 함수 호출 시 반환 되는 값을 false로 하여 검증을 우회하는 모습을 확인해보기 위해 스크립트 중 functionHooking 변수를 true로 변경해 봅니다.

    var valuableHooking  = false
    var functionHooking  = true
    var fileExistHooking = false

function 우회 실습을 위해 functionHooking 값을 true로 변경합니다.

위와 같이 변수가 변경되었고, ANDITER 및 스크립트를 다시 가동시켜 Check 를 확인해보면, 다음과 같이 Success 문구와 [*] Called - isCheckRootingBinary() : Returned false 메시지를 확인할 수 있을 겁니다.

isCheckRootBinary 함수 후킹으로 재정의(Override)하여 Binaries 기반 검증 우회에 성공한 모습

위의 결과는 아래의 스크립트가 동작한 결과로 볼 수 있는데, targetClass(com.playground.anditer.RootingDetector)의 isCheckRootingBinary 함수가 false 값으로만 반환하도록 재정의(Override) 되면서 위와 같이 우회에 성공한 것입니다.

    // Rooting Binary 체크 함수 호출시 false 값을 반환하도록 처리.
    if (functionHooking) {
      targetClass.isCheckRootingBinary.implementation = function () {
        try {
          console.log("[*] Called - RootingDetector.isCheckRootingBinary() : Returned false");
          return false
        } catch (e) { }
      }
    }

isCheckRootingBinary 함수를 재정의(Override) 한 코드 블럭


File class 의 exists 결과 값을 항상 false 로 반환하여 우회시키기.

이번에는 앞서 3가지 우회 방법 중에서 file의 존재 여부를 검증하는 exist 함수 호출 시 반환 되는 값을 false로 하여 검증을 우회하는 모습을 확인해보기 위해 스크립트 중 fileExistHooking 변수를 true로 하고 나머지 변수를 false로 변경합니다.

    var valuableHooking  = false
    var functionHooking  = false
    var fileExistHooking = true

file Exist 우회 실습을 위해 fileExistHooking 값을 true로 변경합니다.

위와 같이 변수가 변경되었고, ANDITER 및 스크립트를 다시 가동시켜 Check 를 확인해보면, 다음과 같이 Success 문구와 반복해서 나타나는 [*] Called - java.io.File.exists() : Returned false 메시지를 확인할 수 있을 겁니다.

File 의 exists 함수 후킹으로 재정의(Override)하여 Binaries 기반 검증 우회에 성공한 모습

스크립트 함수는 아래와 같고 앞서 isCheckRootingBinary 와 큰 차이는 없으나 위와 같이 여러 차례 메시지가 반복되는 이유는 for 이 동작하며 exists 를 반복적으로 호출하기 때문이고, 매 처리마다 false가 반환 되어 검증 우회에 성공한 것으로 이해할 수 있습니다.

    // File Exists 함수 호출시 false 값을 반환하도록 처리.
    if (fileExistHooking) {
      fileClass.exists.implementation = function () {
        try { 
          console.log("[*] Called - java.io.File.exists() : Returned false");
          return false 
        } catch (e) { return false }
      }
    }

File Class 의 exists 함수를 재정의(Override) 한 코드 블럭

// Binary 를 체크하는 함수 정의 부분
public final boolean isCheckRootingBinary() {
  for (final String parent : this.rootingPath) {
    final String[] rootingBinaries = this.rootingBinaries;
      // 아래의 for문으로 인해 반복적인 Message가 발생한 것으로 유추해볼 수 있음
      for (int length2 = rootingBinaries.length, j = 0; j < length2; ++j) {
        this.setFile(new File(parent, rootingBinaries[j]));
        if (this.isFile().exists()) {
          return true;
        }
      }
    }
  return false;
}

APK 내 binary 검증을 위해 정의된 코드 블럭 부분. for문에 의해 반복 출력 됨을 추정할 수 있음.


rootingBinaries 를 빈 배열로 만들어 우회시키기.

이번에는 앞서 3가지 우회 방법 중에서 변수 배열을 공백으로 치환하여 검증을 우회하는 모습을 확인해보기 위해 스크립트 중 valuableHooking 변수를 true로 하고 나머지 변수를 false로 변경합니다.

    var valuableHooking  = true
    var functionHooking  = false
    var fileExistHooking = false

변수변경을 통한 우회 실습을 위해 valuableHooking 값을 true로 변경합니다.

위와 같이 변수가 변경되었고, ANDITER 및 스크립트를 다시 가동시켜 보면 이번에는 Check 를 누르기도 전에 [*] rootingBinaries value modified to empty array at runtime. 메시지가 나타나고 Check 버튼을 눌러보면 다음과 같이 Success 문구를 확인할 수 있을 겁니다.

검사하는 binaries 배열을 빈 배열로 치환하여 Binaries 기반 검증 우회에 성공한 모습

위와 같은 결과가 나타난 이유는 Java.choose 의 onMatch 이벤트로 인해 함수 호출 이벤트와는 상관 없이 스크립트 실행 과정에서 메모리 상의 인스턴스를 조회해 rootingBinaries 가 있을 경우 값을 빈 배열로 치환하여 검증을 우회 할 수 있게 된 것입니다.

    // 현재 실행 중인 앱 메모리에서 RootingDetector의 모든 인스턴스를 찾음.
    Java.choose(RootingDetector, {
      onMatch: function (instance) { // 객체를 찾으면 변경을 수행.
        if (valuableHooking) {
          if ('rootingBinaries' in instance) {
            // 이미 실행된 객체의 rootingBinaries 값을 빈 배열로 변경.
            instance.rootingBinaries.value = Java.array('java.lang.String', []);
            console.log("[*] rootingBinaries value modified to empty array at runtime.");
          }
        }
      },
      onComplete: function () { // 탐색이 끝나면 로그 출력.
        console.log("");
      }
    })

실행 시 메모리상의 rootingBinaries를 빈 배열로 치환하는 코드 블럭


정리

본 내용은 실습을 통해 어떠한 방식으로 동작하고 우회하는지에 대한 개념이나 감을 잡는데 목표가 있는 것으로, 기술적 보안과 관련된 업무나 실무를 준비하는 분이시라면 난독화 된 APK 를 대상으로 심화 학습을 시도해 보는 것을 권장하고, 그렇지 않은 경우에는 본 내용 정도 만을 살펴보고 실습하여 이해와 경험을 쌓는데 의미를 두시면 되겠습니다.