Flutter 앱의 코드를 패치해보자
모바일 앱 진단을 진행하면서 Flutter로 작성된 앱에 대해 진단을 할 때는 보통 SSL Pinning과 관련해서 이슈가 있어서 인증서를 강제로 넣어주는 것 외에 추가적인 조치가 필요하다. 관련해서 우회와 관련된 글이나 도구들이 많이 공개가 되어 있다.

그런데 최근 진단했던 앱 중에 버전업을 하면서 자바/코틀린 기반이었던게 Flutter로 마이그레이션되면서 루팅/탈옥 탐지나, 백신설치여부 확인 코드도 죄다 Flutter쪽으로 구현이 되어있던 상태였다.
검색해보니 Flutter의 root_checker_plus 플러그인이 적용된 앱에서 frida를 통해 코드패치를 하면서 우회를 시도하는 글이 있었다. 그래서 아래 글을 번역하면서 root_checker_plus 대신 내가 진단했던 앱의 사례로 보충해보도록 하겠다.

Flutter 앱을 리버싱하는 것은 전통적인 안드로이드 앱을 분석하는 것과는 다르다.
자바나 코틀린 애플리케이션에서는 대부분의 로직이 classes.dex 내부에 위치해 있으며, JADX와 같은 도구를 통해 분석할 수 있다. Flutter 릴리스 빌드에서는 Dart 코드가 네이티브 머신 명령어로 컴파일되어 libapp.so 안에 저장된다.
이 글에서는 Flutter내 보안 탐지 Dart AOT 코드까지 추적한 과정과 하나의 조건부 ARM64 명령어가 최종 결과를 어떻게 제어했는지 설명한다.
libapp.so가 왜 중요한가?
Flutter는 릴리스 빌드에서 Dart 코드를 "ahead of time"(AOT) 방식으로 컴파일 한다.
즉 보안 탐지와 같은 메서드는 libapp.so 내에 ARM64 기계어로 변환되어 있다.
일반 디스어셈블러는 명령어를 표시할 수 있지만, Dart의 많은 맥락이 빠져 있다:
- 클래스 명칭
- Dart 메소드 이름
- 문자열
- 객체 풀 참조
- 런타임 메타데이터
이 컨텍스트를 복원하기 위해 우리는 Blutter를 사용했다.
Blutter로 함수 찾기

Blutter는 Flutter의 Dart AOT 스냅샷을 분석하고 네이티브 명령어에 주석을 단다.
다음은 Arch Linux 기준으로 Blutter를 설치하고 libapp.so에서 코드를 추출하는 방법을 설명한다.
1.필수 패키지 설치
sudo pacman -S --needed base-devel git cmake ninja icu capstone python-pyelftools python-requests2.git에서 blutter 저장소 클론
git clone https://github.com/worawit/blutter.git3.blutter 실행
// path/to/app/lib/arm64-v8a는 apktool 등으로 추출하거나 zip 압축 풀어서 추출 후 so 라이브러리 경로 지정
python3 blutter.py path/to/app/lib/arm64-v8a out_dir
output 폴더 내부 :
output/
├── asm/ # 주석이 달린 libapp.so 어셈블리
├── blutter_frida.js # Frida 스크립트 템플릿
├── objs.txt # 중첩 객체 풀 덤프
└── pp.txt # 객체 풀의 Dart 객체들asm 폴더 내부에서 보안 탐지 관련된 메서드를 찾을 수 있었다.
// class id: 2470, size: 0x14, field offset: 0x10
class SplashViewModel extends Notifier<dynamic> {
...
_ checkSecurity(/* No info */) async {
// ** addr: 0x49c350, size: 0x14c
// 0x49c350: EnterFrame
// 0x49c350: stp fp, lr, [SP, #-0x10]!
// 0x49c354: mov fp, SP
// 0x49c358: AllocStack(0x18)
// 0x49c358: sub SP, SP, #0x18
// 0x49c35c: SetupParameters(SplashViewModel this /* r1 => r1, fp-0x10 */)
// 0x49c35c: stur NULL, [fp, #-8]
// 0x49c360: stur x1, [fp, #-0x10]
// 0x49c364: CheckStackOverflow
// 0x49c364: ldr x16, [THR, #0x48] ; THR::stack_limit
// 0x49c368: cmp SP, x16
// 0x49c36c: b.ls #0x49c494
// 0x49c370: InitAsync() -> Future<void?>
// 0x49c370: ldr x0, [PP, #0x2d0] ; [pp+0x2d0] TypeArguments: <void?>
// 0x49c374: bl #0x3989c4 ; InitAsyncStub
// 0x49c378: ldur x1, [fp, #-0x10]
// 0x49c37c: r0 = _setLoading()
// 0x49c37c: bl #0x49dd70 ; [package:***/feature/splash/presentation/splash_view_model.dart] SplashViewModel::_setLoading
// 0x49c380: ldur x1, [fp, #-0x10]
// 0x49c384: r0 = _isPhysicalDevice()
// 0x49c384: bl #0x49dd18 ; [package:***/feature/splash/presentation/splash_view_model.dart] SplashViewModel::_isPhysicalDevice
// 0x49c388: mov x1, x0
// 0x49c38c: stur x1, [fp, #-0x18]
// 0x49c390: r0 = Await()
// 0x49c390: bl #0x39876c ; AwaitStub
// 0x49c394: r16 = true
// 0x49c394: add x16, NULL, #0x20 ; true
// 0x49c398: cmp w0, w16
// 0x49c39c: b.ne #0x49c410
// 0x49c3a0: ldur x1, [fp, #-0x10]
// 0x49c3a4: r0 = _isRootedOrJailbroken()
// 0x49c3a4: bl #0x49dc5c ; [package:***/feature/splash/presentation/splash_view_model.dart] SplashViewModel::_isRootedOrJailbroken
// 0x49c3a8: mov x1, x0
// 0x49c3ac: stur x1, [fp, #-0x18]
// 0x49c3b0: r0 = Await()
// 0x49c3b0: bl #0x39876c ; AwaitStub
// 0x49c3b4: r16 = true
// 0x49c3b4: add x16, NULL, #0x20 ; true
// 0x49c3b8: cmp w0, w16
// 0x49c3bc: b.ne #0x49c3d8
// 0x49c3c0: r0 = SecurityViolationDetected()
// 0x49c3c0: bl #0x49dc50 ; AllocateSecurityViolationDetectedStub -> SecurityViolationDetected (size=0x8)
// 0x49c3c4: ldur x1, [fp, #-0x10]
// 0x49c3c8: mov x2, x0
// 0x49c3cc: r0 = emitEvent()
// 0x49c3cc: bl #0x49e978 ; [package:***/feature/splash/presentation/splash_view_model.dart] SplashViewModel::emitEvent
// 0x49c3d0: r0 = Null
// 0x49c3d0: mov x0, NULL
// 0x49c3d4: r0 = ReturnAsyncNotFuture()
// 0x49c3d4: b #0x3984fc ; ReturnAsyncNotFutureStub
...
}
}주석 상 주소는 고정된 런타임 주소가 아니라 libapp.so내 오프셋이다.
안드로이드는 ASLR(Address Space Layout Randomization)를 사용하기 때문에, 애플리케이션이 시작할 때마다 라이브러리가 서로 다른 기본 주소로 로드될 수 있다.

따라서 공격자는 코드와 데이터가 어디에 로드되는지 예측할 수 없다.
실제 런타임 주소는 다음과 같이 계산된다:
런타임 주소 = libapp.so 베이스 주소 + Blutter에서 확인된 함수 오프셋ARM64 코드 읽기
여기에는 몇 가지 ARM64 개념만 필요하다:
x0리턴 값에 일반적으로 사용되는 64비트 CPU 레지스터이다.w0는 x0 레지스터의 하위 32비트 부분이다.cmp두 값을 비교하고 CPU 상태 플래그를 업데이트한다. w0 값은 변하지 않고 내부 CPU 플래그만 업데이트한다.b.necmp 결과가 같이 않을 시 브랜치하라는 뜻이다(x86에서의 jne와 동일).bl리턴 주소를 저장하면서 다른 함수를 호출한다.
흐름 분석
확인된 checkSecurity 함수의 전체적인 흐름은 다음과 같았다.
[시작: _setLoading()]
│
▼
[1. _isPhysicalDevice() 검사]
├── false (에뮬레이터) ──────┐ (루팅/백신 검사 건너뜀)
└── true (실기기) │
│ │
▼ │
[2. _isRootedOrJailbroken()] │
├── true (루팅 감지) ───────┼──> emitEvent(SecurityViolationDetected) ──> [종료]
└── false (정상) │
│ │
▼ │
[3. _isVaccineInstalled()] │
├── false (백신 없음) ──────┼──> emitEvent(VaccineNotFound) ────────────> [종료]
└── true (백신 있음) │
│ │
▼ <─────────────────┘
...
[4. _isNetworkConnected()]
├── false (오프라인) ───────┼──> emitEvent(NetworkUnavailable) ─────────> [종료]
└── true (정상 연결)
│
▼
[5. checkAppUpdate() 호출] ──────────────────────────────────────────────> [완료]위의 흐름이라면 _isPhysicalDevice()의 결과를 cmp로 비교한 후 b.ne 분기로 루팅탐지 및 백신설치여부 부분을 건너뛰게 되는 것이다.
// 0x49c380: ldur x1, [fp, #-0x10]
// 0x49c384: r0 = _isPhysicalDevice()
// 0x49c384: bl #0x49dd18 ; [package:***/feature/splash/presentation/splash_view_model.dart] SplashViewModel::_isPhysicalDevice
// 0x49c388: mov x1, x0
// 0x49c38c: stur x1, [fp, #-0x18]
// 0x49c390: r0 = Await()
// 0x49c390: bl #0x39876c ; AwaitStub
// 0x49c394: r16 = true
// 0x49c394: add x16, NULL, #0x20 ; true
// 0x49c398: cmp w0, w16
// 0x49c39c: b.ne #0x49c410여기서 목표는 0x49c39c 오프셋의 b.ne #0x49c410를 b.eq #0x49c410로 패치해서 실기기일 때 루팅탐지 및 백신설치여부 부분을 건너 뛰어 볼 것이다.
Frida를 통한 코드 패치
우선 0x49c39c 어셈블리 부터 확인해보겠다. b.ne #0x49c410(상대 주소 표기로 b.ne #0x74)는 헥스 값으로 0x540003A1이고 아래와 같은 의미이다
31 24 23 5 4 3 0
┌───────────┬───────────────────────────────────┬──┬──────────┐
│ 01010100 │ imm19 │0 │ cond │
└───────────┴───────────────────────────────────┴──┴──────────┘
[0x54] 0000 0000 0000 0011 101 0 0001여기서 b.ne를 b.eq로 바꾸려면 cond 값을 아래를 참고하여 바꾸면 된다.
NE(Not Equal):cond값이0001(0x1)EQ(Equal):cond값이0000(0x0)
그럼 결론적으로 헥스값으로는 0x540003A0이 되는 것이다.
다음으론 Frida 스크립트를 작성한다. 원글의 스크립트에서 우리에게 맞게 수정한다.
function findAndPatchLibApp() {
var libapp = Process.findModuleByName("libapp.so");
if (libapp === null) {
setTimeout(findAndPatchLibApp, 50);
return; // stop here and wait
}
console.log("[+] Found libapp.so at address: " + libapp.base);
var instructionAddress = libapp.base.add(0x49c39c); //패치할 오프셋을 넣어준다
console.log("[+] Target instruction is at: " + instructionAddress);
Memory.patchCode(instructionAddress, 4, function (code) {
code.writeU32(0x540003A0); //패치할 코드를 넣어준다.
});
console.log("[+] Done! Instruction replaced with B.EQ.");
}
findAndPatchLibApp();위 스크립트 실행 시 루팅 탐지가 우회된 것을 확인할 수 있다.





