DroidGuard: A Deep Dive into SafetyNet
2025년 10월 24일
ref #
SafetyNet #
Google이 기기의 무결성을 검증할 수 있는 라이브러리를 만든 것이 SafetyNet이다. 처음엔 Java 레이어에서 구현되어 있었지만, 점점 DroidGuard 라는 네이티브 모듈로 이식했다.
- CTS 프로필 일치: 잠금해제된 부트로더, 커스텀 ROM, 인증되지 않은 기기 등 감지
- 기본 무결성: 에뮬레이터, 루팅, 후킹 프레임워크 등 감지
감지 작업 결과에 따라 개발자는 특정 기능을 비활성화하거나 애플리케이션을 중지하는 등의 작업을 할 수 있다.
안드로이드 클라이언트 측 코드 #
1// 개발자는 재전송 공격을 방지하기 위한 nonce 값과 API_KEY를 사용해서 API 요청
2SafetyNet.getClient(this).attest(nonce, API_KEY)
3 .addOnSuccessListener(this) { response ->
4 // 서비스와 통신이 성공했을때 진입
5 // response.getJwsResult() 로 결과 데이터 획득 가능
6 }
7 .addOnFailureListener(this) { e ->
8 // 서비스와 통신하는 중 에러 발생
9 if (e is ApiException) {
10 // 에러에는 추가 정보가 포함되어 있다.
11 val apiException = e as ApiException
12
13 // 상태코드 확인: apiException.statusCode property.
14 } else {
15 // A different, unknown type of error occurred.
16 Log.d(FragmentActivity.TAG, "Error: " + e.message)
17 }
18 }
19}
동작 프로세스 #
개발자는 API키와 nonce(재전송 방지용)를 가지고 Google SafetyNet SDK 에서 제공하는 API를 사용해서 패키지명, 서명정보 등을 포함한 인텐트를 생성한다.
이 인텐트를 GMSCore(=Google Play Service)가 수신하면 Google 백엔드 서버에서 기기 조작 여부를 확인할 수 있는 protobuf 메시지를 아래의 구조로 작성한다.
이 정보들은 GMSCore의 Java 레벨에서 점검하고 세팅된다.
1SafetyNetData = {
2 nonce = [ca ee ...]
3 packageName = "com.demo.snet"
4 signatureDigest = [66 49 ...]
5 fileDigest = [fa 0a ...]
6 gmsVersionCode = 213918046
7 suCandidates = {
8 fileName = "/system/bin/su"
9 digest = [25 53 ...]
10 }
11 seLinuxState = {
12 supported = true
13 enabled = true
14 }
15 currentTimeMs = 1638672572674
16 googleCn = false
17}
DexGuard는 GMSCore와 별도의 프로세스인 com.google.android.gms.unstable로 동작한다.
필요한 경우 동적으로 구글 백엔드 서버에서 최신 DroidGuardVM apk를 내려받아서 업데이트되고, GMSCore로부터 작업 요청이 들어올때마다 서버에서 요청마다 유니크한(요청마다 같은 코드를 다른방식으로 다형화되기 때문에 유니크함) VM 바이트코드 스크립트를 받아와 DroidGuardVM 에서 실행된다.
바이트코드를 실행하고나면 고급 검사(Frida, 추가 루팅검사, 부트로더 검사 등)들이 전부 끝나고, 결과는 DroidGuardResult에 담아서 GMSCore에 돌려주면 이전 검사 결과까지 포함해서 protobuf로 합치게된다.
1{
2 SafetyNetData = { nonce = [ca ee ...], packageName = "com.demo.snet"}
3 DroidGuardResult = "CgZpApMYiWYSi9cB [...]"
4}
이후 이 protobuf를 Google 백엔드로 전송하고 Google 백엔드에서는 이 데이터를 확인하여 기기의 상태가 어떤 상태인지 확인 후 JWS로 서명된 Json 데이터를 돌려준다.
이후 앱(또는 앱 백엔드)에서 이 서명을 검증하고 결과값에 따른 앱 로직을 실행시킨다.
ex) 결제 등 핵심보안 기능 ctsProfileMatch == true 요구, 일반구간은 basicIntegrity == true 요구 등
DroidGuard #
GMS apk를 확인해보면 서비스의 정의가 있지만, 다른 프로세스로 실행되는 것을 알 수 있다.
1<service android:name="com.google.android.gms.droidguard.DroidGuardService"
2 android:enabled="true"
3 android:exported="true"
4 android:process="@string/common_unstable_process">
5 <intent-filter>
6 <action android:name="com.google.android.gms.droidguard.service.INIT"/>
7 <action android:name="com.google.android.gms.droidguard.service.PING"/>
8 <action android:name="com.google.android.gms.droidguard.service.START"/>
9 <action android:name="com.google.android.gms.droidguard.service.VP"/>
10 <category android:name="android.intent.category.DEFAULT"/>
11 </intent-filter>
12</service>
위에서 말했듯 앱이 GMSCore에 SafetyNet 인증을 요청하면 DroidGuardService가 트리거되며 unstable 프로세스로 실행된다.
실제 구현체는 /data/data/com.google.android.gms/app_dg_cache/<hash>/the.apk 이 앱이며, DroidGuardService에 의해 동적으로 로드된다.
로드된 이후 DroidGuardVM의 바이트코드 스크립트를 백엔드에서 전달받고 실행한다.
initNative는 DroidGuard를 초기화하고 bArr 인자로 전달되는 바이트코드 스크립트를 실행하는 역할을 한다.
이후 ssNative에서 DroidGuardResult를 만들어내고, closeNative로 VM을 정리하고 동적할당 메모리나 Java 참조들을 해제한다.
1private native long initNative(Context context, String str, byte[] bArr, Object obj, Bundle bundle, int i, int i2);
2private static native byte[] ssNative(long j, String[] strArr, Bundle bundle);
3private static native void closeNative(long j, String[] strArr);
4
5public synchronized boolean init() {
6 ...
7 this.j = initNative(context, str, bArr, obj, bundle, fd, runtimeEnvironment.e);
8}
9
10public synchronized byte[] ss(Map map) {
11 ...
12 // 첫번째 인자(long 타입 포인터)가 C++에서 초기화된 DroidGuardVM 포인터이다.
13 return ssNative(this.j, e(map), bundle);
14}
이 메서드들은 앱의 루트경로에 있는 네이티브 라이브러리파일에 구현되어 있다.
1PS C:\workspace\ISSUE\the.apk> ls
2d----- 2025-10-24 오후 4:57 META-INF
3d----- 2025-10-24 오후 4:57 res
4-a---- 2009-01-01 오전 12:00 2052 AndroidManifest.xml
5-a---- 2009-01-01 오전 12:00 32996 classes.dex
6-a---- 2009-01-01 오전 12:00 380800 libd06286D4E7AE3.so
7-a---- 2009-01-01 오전 12:00 13 library.txt
8-a---- 2009-01-01 오전 12:00 560 resources.arsc
9-a---- 2009-01-01 오전 12:00 32 stamp-cert-sha256
10
11PS C:\workspace\ISSUE\the.apk> readelf -d .\libd06286D4E7AE3.so | grep SONAME
12 0x000000000000000e (SONAME) Library soname: [libdroidguard.so]
libdroidguard.so 에서 JNI_OnLoad를 따라가면 initNative, ssNative, closeNative 를 RegisterNatives 로 등록하는 것을 볼 수 있다.
local_2e4[0x35:0x3f] = initNative, local_2e4[0x6f:0x77] = ssNative …
DroidGuardVM 메모리 레이아웃 #
아래는 Romain Thomas가 리버싱으로 분석한 DroidGuardVM 의 구조이다.
1static constexpr size_t NB_REGISTERS = 0x100;
2
3enum REG_TYPES : uint8_t {
4 STRING,
5 INT, // NONE을 제외하고 버전마다 인덱스가 섞임
6 LONG,
7 DOUBLE,
8 JOBJ,
9 POINTER,
10 NONE = 6, // 이 위치는 변하지 않음
11};
12
13struct reg_t {
14 REG_TYPES type;
15 uintptr_t value;
16};
17
18struct registers_t {
19 DroidGuardVM* vm;
20 uintptr_t _;
21 stdF:array<reg_t, NB_REGISTERS> r;
22};
23
24class DroidGuardVM {
25private:
26 registers_t* registers;
27 std::vector<registers_t*> frames;
28 std::array<uintptr_t, 0x200> handlers;
29 uint32_t counter;
30 uint32_t pc;
31 std::array<HMAC_CTXF*, 0x100> hmac; // VM 레지스터용 BoringSSL HMAC_CTX
32 __uint128_t enc_key; // 포인터 및 버퍼 인코딩에 관련된 키
33 int32_t enc_register;
34 stdF:string bytecode; // 실행할 바이트코드 저장
35
36 uintptr_t crypto_key_1;
37 uintptr_t crypto_key_2;
38 int32_t count;
39 stdF:array<uint64_t, 0x42> constants;
40
41 pthread_t thread;
42 uintptr_t tagged_buffer;
43
44 stdF:array<uint8_t, 0x400> scratch_buffer_1;
45 stdF:array<uint8_t, 0x410> scratch_buffer_2;
46
47 JavaVM jvm;
48 JNIEnv* env;
49 jobject mDroidGuard;
50 jobject mDroidGuardChimeraService;
51 jobject jobj1;
52 jobject jobj2;
53 jobject mRuntimeAPI;
54 jobject mJavaLangString;
55 stdF:string Flow;
56 jobject mExtra;
57 bool has_error;
58};
- registers: 현재 실행 컨텍스트(frame)의 레지스터(256개) 세트를 가리키는 포인터. 각 명령 핸들러는 이 포인터를 통해 레지스터에 접근해 파라미터/리턴값 처럼 사용한다.
핸들러는 파라미터/리턴 값을 사용하지 않고 이 레지스터만으로 상태만 변경시키기 때문에 후킹 및 분석이 어렵다.
- 레지스터를 읽거나 쓸때 MBA(Mixed Boolean-Arithmetic obfuscation)로 인해 논리연산과 산술연산을 섞어서 단순한식을 복잡한 동치식으로 변경하는 난독화 기법도 적용되어 있다. 역시 VM의 릴리즈마다 MBA 방식 자체도 변경된다.
- 접근한다 하더라도 값 자체가 인코딩되어
- get/set 레지스터 경로에 bp를 걸고 값이 평문화되는 지점을 찾으면서 분석하면 된다.
- frames: VM 내부 함수 호출이나 JNI 경계 등 컨텍스트 스위칭 시 현재 레지스터의 스냅샷을 push/pop 한다.
- handlers: 핸들러의 점프 테이블.
syscall,dlsym,sha256,JNI call,산술 연산,인코딩된 버퍼 읽기등의 작업을 하는 핸들러들이 있다.
register의 타입(REG_TYPES) 이나 handler의 인덱스는 매번 VM 릴리즈마다 인덱스가 셔플되어 같은 기능, 타입이라도 다른 인덱스에 매핑되어 분석이 어렵게된다.
vm_init_handlers 코드 #
DroidGuardVM 동작 방식 #
clock_gettime 핸들러 동작 예시 #
cmp_equal 핸들러 동작 예시 #
-
명령어의 오퍼랜드 디코딩
cmp_equal은 세개의 피연산자를 사용한다. 값이 MBA로 저장되어 인덱스자체도 해석해서 꺼내와야한다.
결과 저장OP_DST_IDX, 왼쪽 값OP_LHS_IDX오른쪽 값OP_RHS_IDX1static constexpr uint8_t PC_REG_IDX = 0x12; 2 3uint32_t pc; 4uint8_t tmp; 5 6// == Read the first operand == 7pc = read_register(PC_REG_IDX); // PC 레지스터 읽기 8pc = decode(&tmp, pc, sizeof(tmp)); // 명령어 해석 후 tmp에 값 저장 9set_register(PC_REG_IDX, pc); // PC 이동 10uint8_t OP_DST_IDX = MBA1_DECODE(tmp); // MBA 디코드. (OP_DST 레지스터의 인덱스 값 획득) 11 12// == Read the second operand == 13pc = read_register(PC_REG_IDX); 14pc = decode(&tmp, pc, sizeof(tmp)); 15set_register(PC_REG_IDX, pc); 16uint8_t OP_LHS_IDX = MBA2_DECODE(tmp); 17 18// == Read the third operand == 19pc = read_register(PC_REG_IDX); 20pc = decode(&tmp, pc, sizeof(tmp)); 21set_register(PC_REG_IDX, pc); 22 23uint8_t OP_RHS_IDX = MBA3_DECODE(tmp); -
실제 핸들러의 역할을 수행
1bool are_equal = false; 2reg_t& RHS = get_reg(OP_RHS_IDX); reg_t& LHS = get_reg(OP_LHS_IDX); 3if (RHS.type == LHS.type) { 4 switch (RHS.type) { 5 case JNI: 6 are_equal = this->env_->IsSameObject(decode(RHS.value), decode(LHS.value)); break; 7 case LONG: 8 are_equal = decode(RHS.value) == decode(LHS.value); break; 9 case INT: 10 are_equal = (int)decode(RHS.value) == (int)decode(LHS.value); break; 11 case DOUBLE: 12 are_equal = (double)decode(RHS.value) == (double)decode(LHS.value); break; 13 case STR: 14 // byte-per-byte comparison 15 case NONE: 16 default: 17 are_equal = false; 18 } 19} -
결과를 레지스터에 저장
1this->set_register(OP_DST_IDX, are_equal);
DroidGuard의 역할 #
이 글 자체가 2022년 분석 글이기 때문에 글에 나온 것보다 더 많은 작업을 할 것이다.
루팅 확인 #
- /data/local/tmp/su, /data/local/xbin/su, /data/local/bin/su
- /sbin/su, /bin/su
- /system/bin/.ext/su, /system_ext/bin/su, /system/bin/su, /system/xbin/su
- /odm/bin/su, /product/bin/su
- /vendor/bin/su, /vendor/xbin/su
Magisk #
- init.svc.magisk_service, init.svc.magisk_pfs, init.svc.magisk_pfsd
- persist.magisk.hide, magisk.version, ro.magisk.disable
- /proc/self/mounts, /proc/self/mountinfo
KingRoot Check #
- TANGBOX
- REDIRECT_SRC1, REDIRECT_DST1
- FORBID_SRC1, WHITELIST_SRC1
DBI 검사 #
- libarthook_native.so
- libsandhook.edxp.so, libsandhook-native.so, libsandhook.so
- libxposed_art.so
- libfrida-gadget.so, frida-agent-64.so, frida-agent-32.so
- libmemtrack_real.so
- libvaF+.so, * librfbinder-cpp.so, libva-native.so
- libwhale.edxp.so
- libriru_edxp.so, libriru_snet-tweak-riru.so, libriru_edxposed.so
Xposed #
- /system/bin/app_process
에뮬레이터 #
- 프로퍼티
- init.svc.droid4x, init.svc.noxd, init.svc.qemud
- init.svc.goldfish-logcat, init.svc.goldfish-setup
- vmos.browser.home
- init.svc.ttVM_x86-setup
- ro.trd_yehuo_searchbox
- qemu.sf.fake_camera
- vmos.camera.enable
- init.svc.microvirtd, init.svc.vbox86-setup
- ro.rf.vmname
- 메모리 기능
- getSystemService(Context.ACTIVITY_SERVICE).getMemoryInfo().totalMem
- ApplicationPackageManager.hasSystemFeature(PackageManager.FEATURE_RAM_NORMAL)
- 화면정보
- getSystemService(Context.WINDOW_SERVICE).getDefaultDisplay()
- getMetrics()
- DisplayMetrics.widthPixels, DisplayMetrics.heightPixels
- DisplayMetrics.density, DisplayMetrics.xdpi, DisplayMetrics.ydpi
- getRealMetrics()
- android.util.DisplayMetrics.widthPixels
- android.util.DisplayMetrics.heightPixels
- 배터리정보
- getSystemService(Context.POWER_SERVICE).isInteractive()
- BatteryManager.EXTRA_LEVEL, EXTRA_SCALE, EXTRA_STATUS, EXTRA_PLUGGED
- getSystemService(Context.POWER_SERVICE).isInteractive()
부트로더 상태확인 #
-
프로퍼티
- ro.boot.Flash.locked
- ro.boot.vbmeta.device_state
- ro.boot.verifiedbootstate
- ro.boot.vbmeta.digest
-
Java API
- getSystemService(“persistent_data_block”).getFlashLockState()
- ApplicationPackageManager.hasSystemFeature(PackageManager.FEATURE_VERIFIED_BOOT)
-
하드웨어 인증
1KeyStore ks = KeyStore.getInstance("AndroidKeyStore") 2ks.load(null); 3ks.aliases(); // Iterate and check the aliases 4 5long rndLong = (new Random()).nextLong() 6String alias = "unstable.<hash>." + rndLong.toString() 7 8KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) 9 .setAlgorithmParameterSpec(new ECGenParameterSpec("secp256r1")) 10 .setDigests(KeyProperties.DIGEST_SHA512) 11 .setAttestationChallenge(<unique number>) 12 .build(); 13 14KeyGenerator keyGenerator = KeyPairGenerator.getInstance("EC", "AndroidKeyStore") 15keyGenerator.initialize(spec); 16keyGenerator.generateKeyPair(); 17 18// The first certificate extends an ASN.1 structure described here 19// https://developer.android.com/training/articles/security-key-attestation#certificate_schema_keydescription 20// Among the information, it contains the bootloader status 21Certificate certificates[] = keyStore.getCertificateChain(alias);
기타 키워드 검사 #
- daemonsu, supersu
- androVM-prop
- busybox
- pegasus.apk
- .coldboot_init (spyware Pegasus 관련)
- mu, su, su2, temp_su
- init.magisk.rc, magisk
- baservice, badamon, smsdamon, smsservice
- droid4x-prop, ttVM-prop
- igpi
- qemu_propsm, nox-prop
- giefroot
- microvirt-prop, microvirtd
- waw, wland
- libimcrc_64.so, libinjector.so
- sbin_orig
- .author
기타 #
- API 후킹 감지
- SafetyNet 검사
- sma-f, checkin, ad_attest 등 구글계정 등록을 위한 용도나 광고사기 방지를 위한용도 등 다양한 용도로도 DroudGuard의 바이트코드가 사용됨
DroidGuard의 한계 #
리버싱을 하기위해서는 유니콘이나 QBDL, msynth, 가상머신 덤프 등 많은 작업이 필요해서 우회가 어렵다.
하지만 설계적으로 VM을 실행하는 com.google.android.gms.unstable 프로세스의 메모리에서만 검사하기 때문에 VM 프로세스만 우회하면 된다.
→ SafetyNet 요청을 수행한 애플리케이션의 로컬 변조를 감지할 수 없다.