1. 무결성(Integrity)

무결성(Integrity)은 애플리케이션 코드나 데이터가 변조되지 않았음을 보장하는 보안 기법입니다. 주로 악성 코드 삽입, 리패키징(앱 변조), 후킹 방어 등의 목적으로 사용됩니다.

무결성 검사 방법

  • (1) 서명(Signature) 검사
  • (2) 체크섬(Checksum) 검사
  • (3) 메모리 무결성 검사(앱 실행 중 코드나 데이터가 조작되었는지 검사)

1-1. BYPASS APP NAME : 앱 이름 변조 여부 탐지 방식

IntegrityDetector 생성자에서 선언되는 ANDITER 값과 R.string.app_name의 값(문자열)을 비교하여 애플리케이션 이름이 변경되었는지 검사하고 있으므로 isCheckAppName() 함수의 리턴 값 변조 Intrinsics.areEqual(...) 함수의 인자 혹은 리턴 값을 변조하는 방법으로 우회가 가능합니다.


애플리케이션명 변경

현재 애플리케이션명이 ANDITER이며 특별한 코드 패치를 진행하지 않았으므로 앱 무결성이 변조되지 않았습니다. 실습을 위해 앱의 무결성 변조를 진행해보겠습니다.

<!-- \res\values => app_name -->
[ . . . ]
<string name="app_name">NEW-ANDITER</string>
[ . . . ]


앱의 무결성 변조를 검사하는 함수를 우회하기 위하여 Intrinsics.areEqual(...) 함수를 후킹하여 리턴 값을 변조하면 무결성 변조를 검사(애플리케이션 이름 변경) 로직을 우회할 수 있게 됩니다.

Java.perform(function () {
  var Intrinsics = Java.use("kotlin.jvm.internal.Intrinsics");
  Intrinsics.areEqual.overload("java.lang.Object", "java.lang.Object").implementation = function (args0, args1) {
    
    var stackTrace = Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new());
 
    if (stackTrace.includes("com.playground.anditer.IntegrityDetector.isCheckAppName")) {
      console.warn("[*] Intrinsics.areEqual() called inside isCheckAppName()");
      console.log(`\tOriginal - Intrinsics.areEqual(${args0}, ${args1}) => ${this.areEqual(args0, args1)}`);
      console.log(`\tModified - Intrinsics.areEqual(${"ANDITER"}, ${args1}) => ${this.areEqual("ANDITER", args1)}`);
      return this.areEqual("ANDITER", args1);
    }
 
    return this.areEqual(args0, args1);
  };
});


1-2. BYPASS HASH KEY : 사이니킹 변조 여부 탐지 방식

디컴파일 오류로 정확한 코드 분석은 어려웠는데요. 아래 코드를 살펴보면 패키지의 서명(Signing) 값을 가져온 뒤 MessageDigest 클래스로 SHA 해시 암호화를 진행하고 있습니다. 이후 이 값을 개발자가 기존에 선언한 해시 값(r3: "7nd5QagBehUoHzVC+c43zic+/ro=")[더 정확하게는 해시 값을 base64 인코딩한 값]과 비교하고, 동일하지 않으면 애플리케이션에 위/변조가 발생했다고 판단하고 있습니다.


여기서 서명 값(HASH) 변조를 우회할 수 있는 방법은 3가지로 나눌 수 있습니다. isCheckHashKey 함수의 리턴 값을 변조하거나, Intrinsics.areEqual 함수의 리턴/인자 값을 변조하거나, android.util.Base64.encodeToString(r1, r0)의 리턴 값을 기존에 개발자가 설정해놓은 값(7nd5QagBehUoHzVC+c43zic+/ro=)으로 변조하면 됩니다. 필자는 간단하게 1번째 방법을 사용했습니다.

Java.perform(function () {
  var IntegrityDetector = Java.use("com.playground.anditer.IntegrityDetector");
  IntegrityDetector.isCheckHashKey.implementation = function () {
    console.log(this.isCheckHashKey());
    return false;
  };
});


1-3. BYPASS INSTALLER : 마켓 출처 탐지 방식

isCheckInstaller() 코드를 살펴보면 애플리케이션(APK)을 설치한 소스 정보를 가져온 뒤 installStore 배열에 선언된 소스(ex; 구글 플레이스토어(com.andorid.vending))에서 다운로드 받은 경우에만 검증을 통과시키고 있습니다.


따라서, isCheckInstaller() 함수의 리턴 값을 변조하거나, Intrinsics.areEqual(...) 함수의 리턴/인자 값을 변조하거나, this.mContext.getPackageManager().getInstallSourceInfo(this.mContext.getPackageName()).getInstallingPackageName() 함수의 리턴 값을 변조하면 우회가 가능합니다. 이번에는 2번째 방법으로 진행을 해보았습니다.

Java.perform(function () {
  var IntegrityDetector = Java.use("com.playground.anditer.IntegrityDetector");
  var Intrinsics = Java.use("kotlin.jvm.internal.Intrinsics");
 
  var areEqual = Intrinsics.areEqual.overload(
    "java.lang.Object",
    "java.lang.Object"
  );
  areEqual.implementation = function (str, installerPackageName) {
    var stackTrace = Java.use("android.util.Log").getStackTraceString(
      Java.use("java.lang.Exception").$new());
 
    if (stackTrace.includes("com.playground.anditer.IntegrityDetector.isCheckInstaller")) {
      var installStore = [
        "com.android.vending",
        "com.google.android.feedback",
        "com.skt.skaf.A000Z00040",
        "com.sec.android.app.samsungapps",
        "com.sec.android.easyMover.Agent",
        "com.google.android.packageinstaller",
        "com.samsung.android.mateagent"
      ];
 
      console.log(str, installerPackageName);
      if (!installStore.includes(installerPackageName)) return true;
    }
    return this.areEqual(str, installerPackageName);
  };
});


1-4. BYPASS CRC : Dex 파일 체크섬 변조 여부 탐지 방식

애플리케이션 내에 기존에 선언된 R.string.CRC_Check_Code 값과 classes.dex 파일의 CRC(Cyclic Redundancy Check) 값을 비교하여 동일하지 않으면 변조된 APK 파일로 탐지하는 방법입니다.


탐지 우회 방법은 아래와 같습니다.

  1. String.valueOf(...).getCrc()) 리턴 값 변조
  2. new ZipFile(...) 인자(파일 경로) 값 변조
  3. this.mContext.getString(R.string.CRC_Check_Code) 리턴 값 변조
  4. Intrinsics.areEqual(...) 리턴/인자 값 변조
  5. isCheckCRC 리턴 값 변조

CRC(dex checksum) 검증 우회 코드 1

(1) String.valueOf(...) 리턴 값 변조를 통한 우회

위 이미지의 isCheckCRC() 함수 코드를 확인하면 valueOf, string 변수의 값을 비교하여 무결성 검증을 하고있는 것을 알 수 있는데요. 이 때 string(R.string.CRC_Check_Code) 값은 아래 이미지에서 볼 수 있듯이, 이미 리소스 파일 내에 선언된 값입니다. 만약 APK가 변조되었다면 valueOf 값이 이전과 달라지게 됩니다. 따라서 해당 값을 후킹하여 this.mContext.getString(R.string.CRC_Check_Code) 값으로 변환해주면 우회가 가능합니다.


(1) this.mContext.getString(R.string.CRC_Check_Code) 값 확인

Java.perform(function () {
  // Context 클래스와 getString 메서드를 타겟팅
  var context = Java.use("android.content.Context");
  context.getString.overload("int").implementation = function (resId) {
    // R.string.CRC_Check_Code 값을 확인
    if (resId == 0x7f110000) {
      // CRC_Check_Code의 리소스 ID
      console.log("CRC_Check_Code is being accessed!");
    }
 
    // 원래 메서드를 호출하여 값을 반환
    var result = this.getString(resId);
    console.log("Returned value from getString: " + result);
    return result;
  };
});
 
/*
CRC_Check_Code is being accessed!
resId value is:  2131820544
Returned value from getString: 2303541361
*/

(2) valueOf 인자 값을 2303541361로 변조

Java.perform(function () {
  var String = Java.use("java.lang.String");
 
  var valueOf = String.valueOf.overload("long");
  valueOf.implementation = function (arg) {
    var stackTrace = Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new());
    if (stackTrace.includes("com.playground.anditer.IntegrityDetector.isCheckCRC")) {
      return "2303541361";
    }
 
    return this.valueOf(arg);
  };
});


CRC(dex checksum) 검증 우회 코드 2

Intrinsics.areEqual(...) 인자 값 변조를 통한 우회

areEqual 함수를 후킹하여 인자(valueOf, string)에 동일한 값을 사용하면 우회가 가능합니다.

Java.perform(function () {
  var Intrinsics = Java.use("kotlin.jvm.internal.Intrinsics");
 
  var areEqual = Intrinsics.areEqual.overload(
    "java.lang.Object",
    "java.lang.Object"
  );
  areEqual.implementation = function (valueOf, string) {
    var stackTrace = Java.use("android.util.Log").getStackTraceString(
      Java.use("java.lang.Exception").$new()
    );
 
    if (
      stackTrace.includes("com.playground.anditer.IntegrityDetector.isCheckCRC")
    ) {
      console.log(valueOf, string);
      return this.areEqual(valueOf, valueOf);
    }
    return this.areEqual(valueOf, string);
  };
});


REFERENCE

  1. https://github.com/naroSEC/Anditer?tab=readme-ov-file
  2. https://www.igloo.co.kr/security-information/anditer%eb%a5%bc-%ed%99%9c%ec%9a%a9%ed%95%9c-%ec%95%88%eb%93%9c%eb%a1%9c%ec%9d%b4%eb%93%9c-%ec%9c%84%ed%98%91-%ed%83%90%ec%a7%80-%eb%b0%8f-%ec%9a%b0%ed%9a%8c-%eb%b0%a9%ec%95%88-part-4-1-%eb%ac%b4/
  3. How to Hook Dynamic Loaded DEX Files