Frida&Emulator 탐지 우회

DOUIK·2022년 7월 29일
1

Android

목록 보기
5/7

Frida 포트 검사 우회

개발자가 의도하지 않은 방식으로 앱을 동작시킬 수 있음

Check Frida 기능(Anti-Frida)

  1. Frida 포트 검사 기능 : Frida에서 사용하는 포트 대역을 검사함
  • 기기 내에서 실행되는 Frida 서버는 클라이언트와 통신하기 위해 네트워크 포트를 연다. 일반적인 안드로이드 환경에선 거의 사용하지 않는 포트 대역을 사용하기 때문에 해당 포트가 열려있으면 Frida가 사용 중일 확률이 높다.
  1. Frida 경로 검사 기능 : Frida를 사용할 때 자동으로 생성되는 파일이나 Frida와 관련된 파일들이 저장되어 있는지 검사하여 Frida를 탐지
  2. Frida 모듈 검사 기능 : 앱 프로세스 내에 Frida 관련 모듈이 로드되어 있는지 검사.
  • Frida는 앱 프로세스 내 메모리를 간편하게 후킹하기 위해 내부적으로 자체 모듈을 프로세스 내에 로드한. 따라서 프로세스의 메모리 맵을 조회했을 때 Frida 관련 모듈이 존재한다면 Frida가 사용 중일 확률이 높다.

코드 분석

doFridaPortCheck 코드 분석

private fun doFridaPortCheck() {
    if (!checkPortLock) {
        checkPortLock = true
        val ret = fridaDetector.checkPort()
        thread(start = true) {
            handler.post {
                val t = view?.findViewById<Button>(R.id.btnFridaPortCheck)
                if (ret) {
                    t?.text = resources.getString(R.string.msg_Detected)
                    t?.setBackgroundResource(R.drawable.button_detected)
                } else {
                    t?.text = resources.getString(R.string.msg_Passed)
                    t?.setBackgroundResource(R.drawable.button_passed)
                }
            }
            checkPortLock = false
        }
    }
}
  • line 4 fridaDetector.checkPort() : Frida가 사용하는 포트 대역을 확인

FridaDetector.checkPort()

public boolean checkPort() {
    for (int i = 26000; i < 27500; i++) {
        try {
             Socket s = new Socket("127.0.0.1", i);
            if (s.isConnected()) {
                return true;
            }
        } catch (IOException ignore) {
        }
    }
    return false;
}
  • line 4 Socket s = new Socket("127.0.0.1", i) : 26000부터 27499까지 총 1500개의 포트에 대하여 Socket 객체 생성
  • line 5 s.isConnected(): isConnected 함수를 통해 열려있는 포트가 있는지 확인

checkPort 함수는 socket 객체를 통해 26000~27499까지 총 1500개 포트에 대해 열려있는지 검사하는데 열려있는 포트가 하나라도 존재하면 Frida가 탐지되었다고 판단하고 true를 반환한다. 26000~27499 포트가 일반적으로 기기 내의 Frida 서버가 Frida 클라이언트와 통신하기 위해 주로 사용하는 포트인데 Frida의 기본 설정 포트는 27042이고, 임의로 디폴트 포트를 변경했을 가능성이 있기 때문에 보통 이에 근접한 포트 대역을 전부 검사함

  1. 로컬 기기에 26000 ~ 27499까지 총 1500개 포트에 대해 Socket 객체를 생성함
  2. isConnected 함수를 통해 열려있는 포트가 있는지 확인한 후, 열린 포트가 있으면 Frida가 탐지되었다고 판단하고 true를 반환함. 만약 열린 포트가 없으면 해당 포트에 Frida 연결이 탐지되지 않을 것으로 판단하고 false를 반환함

환경 설정

설정 방법

  1. Frida 서버가 사용하는 포트 확인
generic_x86:/data/local/tmp # netstat -ntpl | grep frida
tcp        0      0 127.0.0.1:27042         0.0.0.0:*               LISTEN      12965/frida-server-15.2.2-android-x86
tcp        0      0 127.0.0.1:27042         127.0.0.1:46926         ESTABLISHED 12965/frida-server-15.2.2-android-x86
  1. Frida 서버 실행시 포트 지정
generic_x86:/data/local/tmp # ./frida-server-15.2.2-android-x86 -l 0.0.0.0:27000 &
[1] 13014
generic_x86:/data/local/tmp # ps
USER            PID   PPID     VSZ    RSS WCHAN            ADDR S NAME
root          12899   4204   12916   3220 __ia32_co+          0 S sh
root          12967      1    8764   2500 __skb_wai+          0 S logcat
root          13014  12899   71688  49696 do_sys_po+          0 S frida-server-15.2.2-android-x86
root          13016  13014    8764   2988 __skb_wai+          0 S logcat
root          13033  12899   12584   3672 0                   0 R ps
  • frida 본인이 가지고 있는 버전대로 해야함

Frida 포트 검사 우회 아이디어

Frida 포트 검사를 수행하는 checkPort 함수의 반환 값을 조작하는 등 다양한 방법으로 우회할 수 있습니다.

FridaDetector.checkPort()

public boolean checkPort() {
    for (int i = 26000; i < 27500; i++) {
        try {
             Socket s = new Socket("127.0.0.1", i);
            if (s.isConnected()) {
                return true;
            }
        } catch (IOException ignore) {
        }
    }
    return false;
}

checkPort 함수 반환값 조작

checkPort 함수의 반환 값은 Frida 포트 탐지 여부를 의미함. 결국 함수가 항상 false를 반환하도록 후킹해야 한다.

Socket 파라미터 조작

checkPort 함수의 반환 값은 Socket을 통해 특정 포트에 성공적으로 연결 되는지 여부를 나타냄. Socket 클래스의 생성자를 후킹해서 연결을 시도할 주소와 포트를 중간에 변경한다면, 앱 제작자가 의도하지 않은 주소와 포트로 연결하도록 할 수 있으며 이를 통해 로컬 호스트 내의 열려있는 Frida 포트에 아예 접속하지 않도록 해서 우회할 수 있음

우회 스크립트 작성

checkPort 함수 반환값 조작

Bypass_Frida_checkPort_modifyCheckPortRet.js

function modifyCheckPortRet() {
    Java.perform(function() {
        var FridaDetector = Java.use("android.com.dream_detector.FridaDetector");
        FridaDetector.checkPort.implementation = function() {
            return false;
        };
    });
}

modifyCheckPortRet();

Java.use 사용해서 Frida 내에서 FridaDetector 클래스에 접근할 수 있도록 Wrapper를 제공받고 이를 통해 클래스 내의 checkPort 함수에 접근함. 이 때 implementation을 사용하면 Frida 스크립트로 구현한 함수로 해당 함수를 덮을 수 있음. 따라서 함수의 반환 값이 항상 false인 함수를 구현해 checkPort 함수를 우회함

Socket 생성자 파라미터 변조

Bypass_Frida_checkPort_modifySocketParameter.js

function modifySocketParameter() {
    Java.perform(function() {
        var Socket = Java.use("java.net.Socket");
        var constructor = Socket.$init.overload('java.lang.String', 'int');
        constructor.implementation = function(hostname, port) {
            if (hostname == "127.0.0.1" && 26000 <= port && port < 27500) {
                port = 1234;
            }
            return constructor.call(this, hostname, port);
        };
    });
}

modifySocketParameter();

Socket 클래스는 다른 엔드포인트와 통신하기 위한 소켓 클라이언트가 구현된 클래스. 만약 앱이 Socket을 이용해서 127.0.0.1의 26000~27499 범위의 포트에 접속을 시도하는 요청이 발생한다면 이는 Frida 탐지를 위한 요청으로 판단될 수 있다. 해당 클래스의 생성자를 후킹해 다른포트로 변경한다면 Frida 포트에 접속하지 않아서 우회 가능

Frida 내에서 클래스의 생성자는 $init을 통해 접근할 수 있다. 따라서 $init의 implementation을 덮는 방식으로 생성자를 후킹할 수 있다. Socket 클래스는 다양한 형태의 파라미터를 받을 수 있도록 생성자가 오버로딩으로 선언되어 있는데 만약 Frida에서 오버로딩으로 선언된 함수를 후킹하고 싶을 때에는 overload(signature)를 통해 어떤 타입을 가지는 함수를 후킹할지 선택할 수 있다. checkPort 함수의 경우 문자열과 정수를 인자로 받는 생성자를 사용했기 때문에 overload('java.lang.String', 'int') 를 통해 후킹할 생성자를 선택할 수 있다.

후킹을 통해 파라미터를 변경했다면 원본 함수를 다시 호출해주어야 한다. 원본 함수는 call 함수를 통해 후킹되지 않은 본래의 함수를 호출할 수 있습니다. 이 때 주의해야할 점은 첫 번째 인자로 반드시 this를 넘겨야 합니다.

Frida 경로 검사 우회

코드 분석

doFridaPathCheck 코드 분석

FridaFragment.doFridaPathCheck()

private fun doFridaPathCheck() {
    if (!checkPathLock) {
        checkPathLock = true
        val ret = fridaDetector.checkPath()
        thread(start = true) {
            handler.post {
                val t = view?.findViewById<Button>(R.id.btnFridaPathCheck)
                if (ret) {
                    t?.text = resources.getString(R.string.msg_Detected)
                    t?.setBackgroundResource(R.drawable.button_detected)
                } else {
                    t?.text = resources.getString(R.string.msg_Passed)
                    t?.setBackgroundResource(R.drawable.button_passed)
                }
            }
            checkPathLock = false
        }
    }
}
  • line 4 fridaDetector.checkPath() : Frida와 관련된 파일과 경로를 확인

checkPath 함수 분석

FridaDetector.checkPath()

public boolean checkPath() {
    // AccessDeniedException
    String dirName = "/data/local/tmp";
    try {
        AtomicBoolean found = new AtomicBoolean(false);
        Files.list(new File(dirName).toPath())
                .limit(200)
                .forEach(path -> {
                    if (path.toString().contains("frida"))
                        found.set(true);
                });
        if (found.get()) {
            return true;
        }
    } catch (IOException ignore) {
    }
    return false;
}
  1. Frida 관련 파일 존재 여부를 검사할 /data/local/tmp 경로를 dirName변수에 저장함 ((line 3) String dirName = "/data/local/tmp")
  2. 검사 결과를 저장할 AtomicBoolean 객체 found의 값을 False로 설정하여 생성 ((line 5) AtomicBoolean found = new AtomicBoolean(false))
  3. File 객체로 dirName 경로의 디렉터리를 열고, Files.list 함수를 이용해 디렉터리 내의 파일 이름 중 frida라는 이름이 포함된 파일이 있는지 검사함. 파일이 존재한다면 found 변수를 True로 설정한다. ((line 10) path.toString().contains("frida"))
  4. found.get 함수를 통해 found 값을 확인하고, 확인된 값에 따라 True나, False를 반환한다. ((line 14) found.get())

환경 설정

설정 방법

  1. /data/local/tmp 에 권한 부여
generic_x86_64_arm64:/ # chmod 777 /data/local/tmp
  • /data/local/tmp는 일반 앱 권한으로 열람할 수 없는 디렉토리이다. 따라서 해당 디렉토리에 권한을 부여해야 올바르게 탐지할 수 있다.
  1. Frida 서버 파일 위치 확인
generic_x86_64_arm64:/ # find / -name "*frida*"
/data/local/tmp/frida-server-15.0.18-android-x86_64

Frida 경로 검사 우회 아이디어

FridaDetector.checkPath()

public boolean checkPath() {
    // AccessDeniedException
    String dirName = "/data/local/tmp";
    try {
        AtomicBoolean found = new AtomicBoolean(false);
        Files.list(new File(dirName).toPath())
                .limit(200)
                .forEach(path -> {
                    if (path.toString().contains("frida"))
                        found.set(true);
                });
        if (found.get()) {
            return true;
        }
    } catch (IOException ignore) {
    }
    return false;
}

checkPath 함수 반환값 조작

checkPath 함수의 반환 값은 Frida 파일 탐지 여부를 의미함. 따라서 해당 함수가 항상 False를 반환하도록 후킹한다면 Frida 관련 파일이 있어도 이를 탐지되지 않은 것처럼 조작할 수 있다.

String.contains 함수 파라미터 변조

checkPath 함수의 반환 값은 path변수 중 frida 라는 문자열이 포함되어 있는지 여부를 통해 결정된다. 따라서 String.contains 함수를 후킹하여 만약 frida 라는 문자열이 파라미터로 전달되면, 이를 조작하여 다른 문자열이 전달되도록 할 수 있다.

우회 스크립트 작성

checkPath 함수 반환값 조작

Bypass_Frida_checkPath_modifyCheckPathRet.js

function modifyCheckPathRet() {
    Java.perform(function() {
        var FridaDetector = Java.use("android.com.dream_detector.FridaDetector");
        FridaDetector.checkPath.implementation = function() {
            return false;
        };
    });
}

modifyCheckPathRet();

Java.use 사용해서 Frida 내에서 FridaDetector 크래스에 접근할 수 있도로고 Wrapper를 제공받음. 제공받은 Wrapper를 통해 클래스 내의 checkPath 함수에 접근할 수 있음. 후킹 할 함수의 implementation을 자바스크립트 함수로 덮는 방식 사용함. 따라서 checkPath 함수의 implementation에 항상 False를 반환하도록 작성된 함수를 전달하여 checkPath 함수가 매번 False를 반환하도록 후킹

String.contains 함수 파라미터 변조

Bypass_Frida_checkPath_modifyStrContainsParameter.js

function modifyStrContainsParameter() {
    Java.perform(function() {
        var String_ = Java.use("java.lang.String");
        String_.contains.overload('java.lang.CharSequence').implementation = function(s) {
            if (s == "frida")
                s = "**NOT_EXIST**";
            return String_.contains.overload('java.lang.CharSequence').call(this, s);
        };
    });
}

modifyStrContainsParameter();

Strings.contains는 문자열 객체 내의 특정 문자열이 포함되어있는지 검사하기 위한 함수이다. Dream-detector 앱은 디렉토리 내의 파일 이름 중 frida라는 문자열이 존재하는지 검사하기 위해 해당 함수를 사용한다. 일반적인 앱이 frida라는 문자열이 특정 문자열 내에 존재하는지 확인할 경우가 거의 없기 때문에 frida라는 문자열이 포함되었는지 검사한다면 이는 Frida 경로 탐지를 위한 것이라고 볼 수 있다.

String 클래스의 Wrapper를 제공받고, contains 함수를 후킹하여 만약 파라미터로 전달된 값이 frida와 일치한다면 이를 NOT_EXIST 라는 문자열로 변경하도록 함수를 후킹한다. String.contains 함수는 다양한 형태의 파라미터를 받을 수 있도록 오버로딩으로 선언되어 있다. 만약 Frida에서 오버로딩으로 선언된 함수를 후킹하고 싶을 때에는 overload(signature)를 통해 어떤 타입을 가지는 함수를 후킹할지 선택할 수 있다. checkPath 함수의 경우 CharSequence 타입을 인자로 받는 함수를 사용했기 때문에 overload('java.lang.CharSequence') 를 통해 후킹할 함수를 선택할 수 있다.

파라미터를 임의의 문자열로 변경하였으니, 원본 함수를 다시 호출해주어야 한다. 원본 함수는 call 함수를 통해 후킹되지 않은 본래의 함수를 호출할 수 있다. 이 때 주의해야할 점은 첫 번째 인자로 반드시 this를 넘겨야 한다.

Frida 모듈 검사 우회

코드 분석

doFridaModuleCheck 코드 분석

FridaFragment.doFridaModuleCheck()

private fun doFridaModuleCheck() {
    handler.post {
        val t = view?.findViewById<Button>(R.id.btnFridaModuleCheck)
        if (fridaDetector.checkModule()) {
            t?.text = resources.getString(R.string.msg_Detected)
            t?.setBackgroundResource(R.drawable.button_detected)
        } else {
            t?.text = resources.getString(R.string.msg_Passed)
            t?.setBackgroundResource(R.drawable.button_passed)
        }
    }
}
  • line 4 fridaDetector.checkModule() : Frida 모듈이 로드되어있는지 확인

checkModule 함수 분석

FridaDetector.checkModule()

public boolean checkModule() {
    File f = new File("/proc/self/maps");
    try {
        FileInputStream fio = new FileInputStream(f);
        StringBuilder resultStringBuilder = new StringBuilder();
        try (BufferedReader br = new BufferedReader(new InputStreamReader(fio))) {
            String line;
            while ((line = br.readLine()) != null) {
                resultStringBuilder.append(line).append("\n");
            }
            fio.close();
        } catch (IOException ignore) {
        }
        return resultStringBuilder.toString().contains("frida");
    } catch (FileNotFoundException ignore) {
    }
    return false;
}

프로세스의 메모리 맵 정보를 가지고 있는 /proc/self/maps 파일에서 frida 문자열의 여부를 검사합니다. /proc/self/maps 파일은 보편적으로 어느 공유 라이브러리들이 함께 로드되어있고, 베이스 주소를 찾기 위해 이용합니다.
일반적으로 Frida를 통해 앱이 실행되면 Frida 서버는 앱 프로세스 내의 frida-agent-{arch}.so 라이브러리를 로드합니다. 따라서 Frida를 통해 실행된 앱은 반드시 메모리 맵 내에 frida 라는 이름이 포함됩니다. Frida 모듈 탐지는 이와 같이 앱 프로세스의 메모리 맵 파일을 참조하여 Frida가 함께 실행 중인지 탐지합니다.

  1. File 객체로 /proc/self/maps 파일에 접근한다.
  2. FileInputStream 클래스와 BufferedReader 클래스를 이용해 해당 파일의 내용을 모두 읽어온다.
  3. 파일 내용 내에 frida라는 문자열이 존재하는지 검사한다. 만약 문자열이 있다면 Frida 모듈이 실행중인 것으로 판단하여 True를 반환하고, 문자열이 없다면 False를 반환한다.

환경 설정

설정 방법

  1. Frida 서버 실행
generic_x86_64_arm64:/data/local/tmp # ./frida-server-15.0.8-android-x86_64
  1. Frida를 이용해 앱 실행
$ frida -D emulator-5554 -f android.com.dream_detector --no-pause

Frida 모듈 검사 우회 아이디어

FridaDetector.checkModule()

public boolean checkModule() {
    File f = new File("/proc/self/maps");
    try {
        FileInputStream fio = new FileInputStream(f);
        StringBuilder resultStringBuilder = new StringBuilder();
        try (BufferedReader br
                     = new BufferedReader(new InputStreamReader(fio))) {
            String line;
            while ((line = br.readLine()) != null) {
                resultStringBuilder.append(line).append("\n");
            }
            fio.close();
        } catch (IOException ignore) {
        }
        return resultStringBuilder.toString().contains("frida");
    } catch (FileNotFoundException ignore) {
    }
    return false;
}

checkModule 함수 반환값 조작

checkModule 함수의 반환 값은 Frida 모듈 탐지 여부를 의미함. 해당 함수가 항상 false를 반환하도록 후킹해준다면 Frida 관련 포트가 열려있더라도 이를 탐지되지 않은 것처럼 조작할 수 있음

BufferedReader.ReadLine 함수 반환값 조작

fridaDetector.checkModule 함수가 내부적으로 사용하는 BufferedReader.readLine 함수를 후킹하여 반환 값 내에 frida라는 문자열이 포함되어있는지 여부를 조작할 수 있음. 이를 통해 메모리 맵 내에 Frida 모듈이 로드되지 않은 것처럼 보이게 할 수 있음

우회 스크립트 작성

fridaDetector.checkModule 반환 값 변조

Bypass_Frida_checkModule_modifyCheckModuleRet.js

function modifyCheckModuleRet(){
    Java.perform(function() {
        var FridaDetector = Java.use("android.com.dream_detector.FridaDetector");
    
        FridaDetector.checkModule.implementation = function() {
            return false;
        };
    });
}
modifyCheckModuleRet();

Java.use 함수를 사용해 Frida 내에서 FridaDetector 클래스에 접근할 수 있도록 Wrapper를 제공받는다. 제공받은 Wrapper를 통해 클래스 내의 checkModule 함수에 접근할 수 있다. checkPath 함수의 implementation에 항상 False를 반환하도록 작성된 함수를 전달하여 checkModule 함수가 매번 False를 반환하도록 후킹할 수 있다.

BufferedReader.readLine 반환 값 변조

Bypass_Frida_checkModule_modifyReadLineRet.js

function modifyReadLineRet(){
    Java.perform(function() {
        var File = Java.use("java.io.File");
        var constructor = File.$init.overload('java.lang.String');
    
        constructor.implementation = function(pathname) {
            if (pathname == "/proc/self/maps") {
                var BufferedReader = Java.use("java.io.BufferedReader");
                var readLine = BufferedReader.readLine.overload();
    
                readLine.implementation = function() {
                    var ret = readLine.call(this);
                    if (ret.includes("frida"))
                        ret = "";
                    return ret;
                };
            }
            return constructor.call(this, pathname);
        };
    });
}
modifyReadLineRet();

BufferedReader.readLine 함수는 스트림으로부터 개행으로 끝나는 한 줄의 문자열을 읽을 때 사용하는 함수이다. Dream-detector 앱은 메모리 맵 파일의 내용을 전부 읽기 위해 해당 함수를 사용한다. BufferedReader.readLine 함수는 Frida를 탐지할 때 외에도 앱에서 일반적으로 많이 사용하는 함수입니다. 따라서 frida라는 다섯 문자열이 포함되어 있다고 해서 무조건 frida 문자열을 지우도록 후킹하면 앱이 원래 수행해야 하는 동작도 문제가 생길 가능성이 존재합니다.

이를 위해 File 클래스의 생성자를 먼저 후킹한 후, 생성자로 전달된 파일 이름이 /proc/self/maps인 경우에만 BufferedReader.readLine 함수를 후킹하도록 작성하였습니다.

한가지 더 주의해야할 점은 기존 후킹을 통한 파라미터 변조와 반대로 이번에는 반환 값을 변조해야 하기 때문에 원본 함수를 먼저 호출하고, 반환 값 내에 frida라는 문자열이 존재하는지 검사하는 방식으로 후킹해야 합니다.

Emulator 속성 검사 우회

Check Emulator 기능이 에뮬레이터를 탐지하기 위해 일반적으로 사용되는 방법
1. Emulator 속성 검사 기능 : 에뮬레이터 탐지를 위해 시스템 속성을 검사함

  • 디버깅 수행하기 위해서 필수적으로 적용되는 시스템 속성들이 있다. 기기의 시스템 속성에서 에뮬레이터 관련 속성 값이 변경되었다면 해당 기기는 에뮬레이터라고 판단할 수 있다.
  1. Emulator 파일 검사 기능 : 에뮬레이터에서만 존재하는 파일의 유무를 검사해서 앱이 에뮬레이터에서 동작하고 있는지 판별함. 이와 같은 파일들이 기기에서 발견되면 해당 기기는 높은 확률로 에뮬레이터라고 판단 가능함.

함수 분석

doEmulatorPropertiesCheck 함수 분석

EmulatorFragment.doEmulatorPropertiesCheck()

private fun doEmulatorPropertiesCheck() {
    val emulatorDetector = EmulatorDetector(context)
    handler.post {
        val t = view?.findViewById<Button>(R.id.btnEmulatorPropertiesCheck)
        if (emulatorDetector.checkProperties()) {
            t?.text = resources.getString(R.string.msg_Detected)
            t?.setBackgroundResource(R.drawable.button_detected)
        } else {
            t?.text = resources.getString(R.string.msg_Passed)
            t?.setBackgroundResource(R.drawable.button_passed)
        }
    }
}
  • EmulatorDetector.checkProperties 함수를 호출하여 시스템 속성에 에뮬레이터 관련 속성이 존재하는지 확인
  • Handler.post(new Runnable()) : Runnable 객체를 message queue에 전달하는 함수

checkProperties 함수 분석

EmulatorDetector.checkProperties()

public boolean checkProperties() {
    for (String propertyName : Constants.knownEmulatorProperties.keySet()) {
        String propertyValue = Utils.getProp(mContext, propertyName);
        String property_seek = Constants.knownEmulatorProperties.get(propertyName);
        if ("".equals(property_seek) && !"".equals(propertyValue)) {
            return true;
        }
        if (!"".equals(property_seek)) {
            assert propertyValue != null;
            if (property_seek != null && propertyValue.contains(property_seek)) {
                return true;
            }
        }
    }
    return false;
}
  • checkProperties() : 시스템 속성에서 에뮬레이터와 관련된 속성이 있는지 검사하여 에뮬레이터 환경여부를 판단
  • assert : 첫 번째는 인자로 boolean으로 평가되는 표현식 또는 값을 받아서 참이면 그냥 지나가고, 거짓이면 AssertionError 예외가 발생함

Utils.getProp

public final class Utils {
    public static String getProp(Context context, String property) {
        try {
            ClassLoader classLoader = context.getClassLoader();
            @SuppressLint("PrivateApi") Class<?> systemProperties = classLoader.loadClass("android.os.SystemProperties");

            Method get = systemProperties.getMethod("get", String.class);

            Object[] params = new Object[1];
            params[0] = property;

            return (String) get.invoke(systemProperties, params);
        } catch (Exception ignore) {
        }
        return null;
    }
}
  • @SuppressLint : 해당 프로젝트의 설정 된 minSdkVersion 이후에 나온 API를 사용할때  warning을 없애고 개발자가 해당 APi를 사용할 수 있게 한다.
  • ClassLoader : 자바는 클래스 파일을 동적으로 읽어온다. JVM이 동작하다가 클래스 파일을 참조하는 순간 동적으로 읽어서 메모리에 로드되면서 JVM에 링크 된다. 자바 클래스로더(Java ClassLoader)는 클래스들을 동적으로 메모리에 로딩하는 역할을 담당한다.
  • android.content.Context
  • android.os.SystemProperties 클래스는 자바 시스템 속성 메서드를 제공합니다.
  1. Constants.knownEmulatorProperties에 정의된 에뮬레이터 관련 시스템 속성의 키를 하나씩 propertyName변수로 설정합니다.
  2. 기기의 시스템 속성을 확인하는 Utils.getProp함수에 인자로 propertyName을 전달하여 해당 필드값을 propertyValue 변수에 저장합니다.
  3. Constants.knownEmulatorProperties.get함수(키 값으로 value 가져오는 함수)에 인자로 propertyName을 전달하여 미리 정의해 둔 시스템 속성 값을 property_seek에 저장합니다.
  4. property_seek가 빈 문자열이고, propertyValue가 빈 문자열이 아니면 시스템 속성 값이 존재한다는 뜻이므로 에뮬레이터라고 판단하여 True를 반환합니다.
  5. 4번과 5번 조건에 해당하지 않으면 에뮬레이터 관련 시스템 속성이 존재하지 않거나, 시스템 속성의

Constants.knownEmulatorProperties

public static final Map<String, String> knownEmulatorProperties = new HashMap<String, String>() {{
    put("init.svc.qemu-props", "");
    put("qemu.hw.mainkeys", "");
    put("qemu.sf.fake_camera", "");
    put("qemu.sf.lcd_density", "");
    put("ro.bootloader", "unknown");
    put("ro.kernel.android.qemud", "");
    put("ro.kernel.qemu.gles", "");
    put("ro.kernel.qemu", "");
    put("ro.product.device", "generic");
    put("ro.product.model", "sdk");
    put("ro.product.name", "sdk");
    put("ro.serialno", "EMULATOR");
}};

시스템 속성 리스트는 Map 형태로 정의되어 있고 키-값 형태로 선언되어 있다. 값이 빈 문자열("")인 경우 에뮬레이터 환경이 아닌 경우에는 존재해서는 안되는 시스템 속성을 의미하고, 값이 존재할 때는 시스템 속성이 해당 값을 포함하고 있으면 에뮬레이터임을 의미함

환경 설정

AVD는 Qemu를 기반으로 동작하기때문에 에뮬레이터 관련 시스템 속성인 init.svc.qemu-props, qemu.hw.mainkeys, qemu.sf.fake_camera 등의 속성이 존재한다. 따라서 탐지 함수는 Constants.knownEmulatorProperties에 정의된 시스템 속성들을 탐지하여 앱이 에뮬레이터에서 동작하고 있다고 판단한다. 만약 에뮬레이터가 아니거나 해당 속성이 존재하지 않으면 동일한 속성을 생성한다.

설정 방법

  1. Constants에 정의된 시스템 속성과 동일한 키와 값을 가진 속성 생성
generic_x86_64_arm64:/ # setprop ro.serialno EMULATOR

Emulator 속성 검사 우회 아이디어

함수 반환값 조작

checkProperties함수에서 시스템 속성 값이 정의된 값과 다르다면 false를 반환하고 디버깅 관련 파일이 탐지 되지 않은 것으로 판단함. 따라서 우회 가능

속성 값 조작

해당 함수는 기기의 시스템 속성을 읽어올 때 Utils.getProp함수를 사용합니다. getProp 함수는 시스템 속성에서 전달받은 인자와 동일한 키를 찾아 해당 키에 대한 값을 반환합니다. 만약 시스템 속성에 전달받은 인자와 동일한 키가 없다면 null을 반환합니다. 따라서 getProp함수의 반환 값을 정의된 시스템 속성 리스트에 맞춰, 리스트에 값이 없는 속성 키라면 null을 반환하고, 특정 값이 있는 속성 키라면 특정 값을 포함하지 않는 값을 반환하여 checkProperties함수를 우회할 수 있습니다.

우회 스크립트 작성

checkProperties 반환 값 변조

Bypass_Emulator_checkProperties_modifyCheckPropertiesRet.js

function modifyCheckPropertiesRet(){
    Java.perform(function() {
        var EmulatorDetector = Java.use("android.com.dream_detector.EmulatorDetector");
    
        EmulatorDetector.checkProperties.implementation = function() {
            return false;
        };
    });
}

modifyCheckPropertiesRet();

먼저 Java.use 함수를 사용해 프리다 내에서 EmulatorDetector 클래스에 접근할 수 있도록 Wrapper를 제공받음. 이를 통해 클래스 내의 checkProperties 함수에 접근할 수 있음. checkProperties 함수의 implementation에 항상 false를 반환하도록 작성된 함수를 전달하여 checkProperties함수가 매번 False를 반환하도록 후킹

getProp 반환 값 변조

Bypass_Emulator_checkProperties_modifyGetPropRet.js

function modifyGetPropRet(){
    var knownEmulatorProperties = {
        "init.svc.qemu-props": "",
        "qemu.hw.mainkeys": "",
        "qemu.sf.fake_camera": "",
        "qemu.sf.lcd_density": "",
        "ro.bootloader": "unknown",
        "ro.kernel.android.qemud": "",
        "ro.kernel.qemu.gles": "",
        "ro.kernel.qemu": "",
        "ro.product.device": "generic",
        "ro.product.model": "sdk",
        "ro.product.name": "sdk",
        "ro.serialno": "EMULATOR"
    };
    Java.perform(function() {
        var Utils = Java.use("android.com.dream_detector.Utils");
        Utils.getProp.implementation = function(context, property) {
            for (var prop in knownEmulatorProperties){
                if (property == prop){
                    if(knownEmulatorProperties.prop == ""){
                        return null;
                    }
                }
            }
            return Utils.getProp.call(this, context, "nothing");
        }
    });
}

modifyGetPropRet();

Java.use 함수를 사용해 Frida 내에서 android.com.dream_detector.Utils클래스에 접근할 수 있도록 Wrapper를 제공받음. 속성 값을 반환해주는 getProp함수에 전달된 인자값이 에뮬레이터 관련 속성인 knownEmulatorProperties의 키와 동일하다면 존재하지 않아야하는 속성의 경우 null을 반환하고, 속성 값이 지정된 경우 다른 속성 값이 도출될 수 있도록 getProp에 nothing이라는 이름의 속성 값을 가져오도록 함. 해당 함수에서 null이 반환되면 checkProperties함수는 false를 반환하기때문에, 이를 통해 checkProperties함수를 우회할 수 있다.

함수 분석

doEmulatorFilesCheck 함수 분석

EmulatorFragment.doEmulatorFilesCheck()

private fun doEmulatorFilesCheck() {
    val emulatorDetector = EmulatorDetector(context)
    handler.post {
        val t = view?.findViewById<Button>(R.id.btnEmulatorFilesCheck)
        if (emulatorDetector.checkFiles()) {
            t?.text = resources.getString(R.string.msg_Detected)
            t?.setBackgroundResource(R.drawable.button_detected)
        } else {
            t?.text = resources.getString(R.string.msg_Passed)
            t?.setBackgroundResource(R.drawable.button_passed)
        }
    }
}

EmulatorDetector.checkFiles()

public boolean checkFiles() {
    return checkFiles(Constants.knownGenyFiles)
            || checkFiles(Constants.knownAndyFiles)
            || checkFiles(Constants.knownNoxFiles)
            || checkFiles(Constants.knownQemuPipes)
            || checkFiles(Constants.knownx86Files);
}
private boolean checkFiles(String[] files) {
    for (String file : files) {
        File f = new File(file);
        if (f.exists()) {
            return true;
        }
    }
    return false;
}

checkFiles 함수 분석

checkFiles 함수는 오버로딩되어 파라미터를 받지 않는 함수와 문자열 배열 파라미터를 받는 두 개의 함수가 존재한다. 이 중 Fragment에서 호출한 함수는 파라미터를 받지 않는 함수이다. 파라미터를 받지 않는 checkFiles함수는 android/com/dream_detector/Constants.java에 별도로 정의된 에뮬레이터용 파일 리스트를 가져와 파라미터를 받는 checkFiles함수로 전달한다. 파라미터를 받는 checkFiles함수는 반복문을 돌며 파라미터로 전달된 문자열 리스트 중 존재하는 파일이 있는지 검사한다. 해당 파일 리스트는 실 기기에서 동작할 때는 존재하지 않지만, 에뮬레이터를 통해 안드로이드를 구동시킬 때에만 존재하는 파일들을 모아둔 것으로 만약 해당 파일이 시스템 내에 존재한다면 에뮬레이터라고 판단할 수 있다.

검사할 파일명

Constants.java에 존재하는 에뮬레이터 파일 리스트

public static final String[] knownGenyFiles = {
    "/dev/socket/genyd",
    "/dev/socket/baseband_genyd"
};
public static final String[] knownQemupipes = {

"/dev/socket/qemud",
    "/dev/qemu_pipe"
};
public static final String[] knownx86Files = {
    "ueventd.android_x86.rc",
    "x86.prop",
    "ueventd.ttVM_x86.rc",
    ...
};
public static final String[] knownAndyFiles = {
    "fstab.andy",
    "ueventd.andy.rc"
};
public static final String[] knownNoxFiles = {
    "fstab.nox",
    "init.nox.rc",
    "ueventd.nox.rc"
};

일반적으로 많이 사용하는 에뮬레이터로는 지니모션, 앤디, 녹스, Qemu가 있는데 AVD의 경우 Qemu를 사용한다. Constants에는 에뮬레이터별로 탐지할 파일들이 정의되어 있다.

  • knownGenyFiles : 지니모션 (GenyMotion) 에뮬레이터 탐지용 파일 목록입니다.
  • knownAndyFiles : 앤디 (Andy) 에뮬레이터 탐지용 파일 목록입니다.
  • knownNoxFiles: 녹스 (Nox) 에뮬레이터 탐지용 파일 목록입니다.
  • knownQemuPipes : Qemu 에뮬레이터 탐지용 드라이버 파일 목록입니다.
  • knownx86Files: 기타 x86 에뮬레이터 (Virtual Box, TianTian Emulator 등) 탐지용 파일 목록입니다.

환경 설정

AVD는 Qemu를 기반으로 동작하기때문에 Qemu 에뮬레이터 관련 파일인 /dev/qemu_pipe 파일이 존재합니다. 따라서 탐지 함수는 Constants.knownQemupipes에 정의된 /dev/qemu_pipe파일을 탐지하여 에뮬레이터에서 동작하고 있다고 판단한다. 만약 에뮬레이터가 아니거나 해당 파일이 존재하지 않는 경우, 동일한 파일명을 생성한다.

설정 방법

  1. Constants에 정의된 파일명과 동일한 파일명을 가진 파일 생성
generic_x86_64_arm64:/ # touch /dev/qemu_pipe 

Emulator 파일 검사 우회 아이디어

함수 반환 값 변조

checkFiles 함수의 반환 값은 에뮬레이터 파일 탐지 여부를 의미한다. 따라서 해당 함수가 항상 False를 반환하도록 후킹해준다면 에뮬레이터 관련 파일이 존재하더라도 이를 탐지되지 않은 것처럼 조작할 수 있다.

탐지 파일 리스트 변조

emulatorDetector.checkFiles 함수가 파라미터를 받는 checkFiles 함수로 파일 리스트를 전달할 때 중간에 후킹하여 존재하지 않는 파일만 모아둔 리스트로 변경할 수 있다. 이를 통해 checkFiles 함수 내에서는 무조건 존재하지 않는 파일만 검사하여 해당 함수를 우회할 수 있다.

우회 스크립트 작성

checkFiles 반환 값 변조

Bypass_Emulator_checkFiles_modifyCheckFilesRet.js

function modifyCheckFilesRet() {
    Java.perform(function() {
        var EmulatorDetector = Java.use("android.com.dream_detector.EmulatorDetector");
    
        EmulatorDetector.checkFiles.overload().implementation = function() {
            return false;
        };
    });
}
modifyCheckFilesRet();

Java.use 함수를 사용해 프리다 내에서 EmulatorDetector 클래스에 접근할 수 있도록 Wrapper를 제공받아 Wrapper를 통해 클래스 내의 checkFiles 함수에 접근할 수 있음. checkPath 함수의 implementation에 항상 False를 반환하도록 작성된 함수를 전달하여 checkFiles 함수가 매번 False를 반환하도록 후킹

유의할 것은 checkFiles 함수는 오버로딩 된 함수이기 때문에 프리다에서도 이를 명시해주어야 함. 프리다에서 오버로딩으로 선언된 함수를 후킹하고 싶을 때에는 overload(signature)를 통해 어떤 타입을 가지는 함수를 후킹할지 선택할 수 있음. 우리가 후킹할 checkFiles 함수는 파라미터를 받지 않기 때문에 "overload()"를 붙여서 명시할 수 있다.

파라미터를 받는 checkFiles 함수 파라미터 변조

Bypass_Emulator_checkFiles_modifyCheckFilesParam.js

function modifyCheckFilesParam() {
    Java.perform(function() {
        var EmulatorDetector = Java.use("android.com.dream_detector.EmulatorDetector");
        var checkFiles = EmulatorDetector.checkFiles.overload('[Ljava.lang.String;');
    
        checkFiles.implementation = function (files) {
            var emulator_files_not_exists = [ "/not_exist", "/bypass_emulator_check" ];
            return checkFiles.call(this, emulator_files_not_exists);
        };
    });
}
modifyCheckFilesParam();

파라미터를 받는 checkFiles 함수는 파일 이름 리스트를 전달받는다. 따라서 이를 후킹하여 다른 리스트로 변경해 항상 False를 반환하도록 후킹할 수 있다. 이번엔 파라미터를 받는 함수를 후킹할 것이기 때문에 overload('[Ljava.lang.String;')를 이용해 후킹할 함수를 선택할 수 있다. 이후 존재하지 않는 파일만 모아둔 emulator_files_not_exists 리스트를 생성하고 원본 함수를 호출하여 우회할 수 있다. 원본 함수는 call 함수를 통해 후킹되지 않은 본래의 함수를 호출할 수 있고, 이 때 주의해야할 점은 첫 번째 인자로 반드시 this를 넘겨야 한다.

0개의 댓글