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 가 우리가 루팅 검증을 우회해야 하는 대상 앱이라고 가정하고 분석을 시작해 봅시다.
APK 를 분석하는 방법은 여러가지가 있겠지만, 여기서는 간단하게 다루기 위해서 Bytecode Viewer
라는 프로그램을 이용해서 APK 를 분석하고 열어보겠습니다. Bytecode Viewer 는 아래 사이트를 방문하여 다운로드하실 수 있습니다.
APK 코드를 볼 수 있도록 도와주는 Bytecode Viewer를 다운로드 받을 수 있는 Github 레파지토리

위와 같이 Bytecode Viewer 를 실행하고 APK 를 드래그
해서 놓거나 "File" -> "Add..."
를 눌러 APK 를 선택해 열람한 뒤 "Search"
에서 "magisk"
나 연관 된 binary 명을 검색하며 인증과 관련된 로직을 추적 및 분석합니다.
우리는 원활한 실습을 위해 난독화
가 되지 않은 APK를 사용하였으므로 어렵지 않게 함수 등을 찾을 수 있을 겁니다. 난독화가 된 경우 magisk 나 바이너리 명이 아닌 의미를 알 수 없는 문자로 된 문자열 변수나 함수명 등으로 처리되어 있기 때문에 분석이나 추적이 어려울 수 있음을 감안하고 진행하면 됩니다.

난독화가 되지 않은 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가지 정도를 추려볼 수 있을 겁니다.
- isCheckRootingBinary 의 결과 값을 항상 false 로 반환하여 우회시키기.
- File class 의 exists 결과 값을 항상 false 로 반환하여 우회시키기.
- 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
우회 스크립트 실행 명령어

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
메시지를 확인할 수 있을 겁니다.

위의 결과는 아래의 스크립트가 동작한 결과로 볼 수 있는데, 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
메시지를 확인할 수 있을 겁니다.

스크립트 함수는 아래와 같고 앞서 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
문구를 확인할 수 있을 겁니다.

위와 같은 결과가 나타난 이유는 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 를 대상으로 심화 학습을 시도해 보는 것을 권장하고, 그렇지 않은 경우에는 본 내용 정도 만을 살펴보고 실습하여 이해와 경험을 쌓는데 의미를 두시면 되겠습니다.
토론하기