> ## Content Index
> Fetch the complete content index at: https://blog.m1ngiunus.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# Flutter 앱의 코드를 패치해보자
- URL: https://blog.m1ngiunus.me/lets-patch-the-code-of-a-flutter-app/
- Published: 2026-09-29T08:00:00.000Z
- Updated: 2026-10-01T13:04:39.000Z
- Author: m1ngiunus
- Tags: 보안, 리버스 엔지니어링

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

[GitHub - f3rb123/kill\_flutter: Flutter SSL Pinning Bypass Tool for Android & iOS — works on any Flutter version without pattern databasesFlutter SSL Pinning Bypass Tool for Android & iOS — works on any Flutter version without pattern databases - f3rb123/kill\_flutter![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/icon/favicon-a3106d84-02dd-4617-adb3-ab681b8ad8d4.png)GitHubf3rb123![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/thumbnail/kill_flutter-7f525590-fdaa-461d-97fa-2995fc8cddc7.png)](https://github.com/f3rb123/kill%5Fflutter?ref=blog.m1ngiunus.me)

그런데 최근 진단했던 앱 중에 버전업을 하면서 자바/코틀린 기반이었던게 Flutter로 마이그레이션되면서 루팅/탈옥 탐지나, 백신설치여부 확인 코드도 죄다 Flutter쪽으로 구현이 되어있던 상태였다.

검색해보니 Flutter의 root\_checker\_plus 플러그인이 적용된 앱에서 frida를 통해 코드패치를 하면서 우회를 시도하는 글이 있었다. 그래서 아래 글을 번역하면서 root\_checker\_plus 대신 내가 진단했던 앱의 사례로 보충해보도록 하겠다.

[Bypassing Flutter Root Detection with One ARM64 Patch “Android”.Reverse-engineering a Flutter application is different from analyzing a traditional Android app.![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/icon/5d8de952517e8160e40ef9841c781cdc14a5db313057fa3c3de41c6f5b494b19-0c5d2852-2a0a-4466-987a-67f20fd90b69.png)MediumeverythingBlackkk![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/thumbnail/1-G82WdKdrcC5ZMPrllRQ_VA-d7d4b61e-82e0-4856-a598-1a4d730f1984.png)](https://medium.com/@everythingBlackkk/bypassing-flutter-root-detection-with-one-arm64-patch-android-43a88f9ce12e?ref=blog.m1ngiunus.me)

---

## 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로 함수 찾기

[GitHub - worawit/blutter: Flutter Mobile Application Reverse Engineering ToolFlutter Mobile Application Reverse Engineering Tool - worawit/blutter![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/icon/favicon-7869ad84-f797-4ed2-855d-cbb6fed5399c.png)GitHubworawit![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/thumbnail/blutter-a5f5a93b-9fe1-4a36-958f-6d5e63f0ab45.png)](https://github.com/worawit/blutter?ref=blog.m1ngiunus.me)

Blutter는 Flutter의 Dart AOT 스냅샷을 분석하고 네이티브 명령어에 주석을 단다. 

다음은 Arch Linux 기준으로 Blutter를 설치하고 libapp.so에서 코드를 추출하는 방법을 설명한다.

1.필수 패키지 설​치

```
sudo pacman -S --needed base-devel git cmake ninja icu capstone python-pyelftools python-requests
```

2.git에서 blutter 저장소 클론

```bash
git clone https://github.com/worawit/blutter.git
```

3.blutter 실행

```
// path/to/app/lib/arm64-v8a는 apktool 등으로 추출하거나 zip 압축 풀어서 추출 후 so 라이브러리 경로 지정
python3 blutter.py path/to/app/lib/arm64-v8a out_dir
```

![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/2026/09/image.png)

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)를 사용하기 때문에, 애플리케이션이 시작할 때마다 라이브러리가 서로 다른 기본 주소로 로드될 수 있다.

![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/2026/09/image-1.png)

출처: [https://medium.com/@everythingBlackkk/bypassing-flutter-root-detection-with-one-arm64-patch-android-43a88f9ce12e](https://medium.com/@everythingBlackkk/bypassing-flutter-root-detection-with-one-arm64-patch-android-43a88f9ce12e?ref=blog.m1ngiunus.me)

따라서 공격자는 코드와 데이터가 어디에 로드되는지 예측할 수 없다.

실제 런타임 주소는 다음과 같이 계산된다:

```
런타임 주소 = libapp.so 베이스 주소 + Blutter에서 확인된 함수 오프셋
```

## ARM64 코드 읽기

여기에는 몇 가지 ARM64 개념만 필요하다:

- `x0` 리턴 값에 일반적으로 사용되는 64비트 CPU 레지스터이다.
- `w0` 는 x0 레지스터의 하위 32비트 부분이다.
- `cmp` 두 값을 비교하고 CPU 상태 플래그를 업데이트한다. w0 값은 변하지 않고 내부 CPU 플래그만 업데이트한다.
- `b.ne` **cmp 결과가 같이 않을 시 브랜치**하라는 뜻이다(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();
```

위 스크립트 실행 시 루팅 탐지가 우회된 것을 확인할 수 있다.

![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/2026/09/image-2.png)

![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/2026/09/Screenshot_20260930_110158-1--1.jpg)

스크립트 없이 실행 시

![](https://storage.ghost.io/c/3a/1e/3a1ef6a3-6635-4c78-9684-04492bbca618/content/images/2026/09/Screenshot_20260930_110908-1--1.jpg)

스크립트 실행 시(현재는 진단 대상 앱의 연동서버에 방화벽 허용 정책이 삭제되어 정상 접근은 안된다.)