/보안 기법/Frida를 이용한 모바일 앱 동적 분석 실전 가이드
모바일 보안2025-04-20

Frida를 이용한 모바일 앱 동적 분석 실전 가이드

Frida v17 API 변경사항부터 android_dlopen_ext 후킹, Anti-Frida 우회, adb reverse 포트포워딩, mitmproxy Python 연동까지. Android/iOS 모바일 앱 보안 분석에 필요한 실전 기법 정리.

#Frida#Android#iOS#동적분석#후킹#리버싱#mitmproxy

기본 원리: 동적 계측이란

앱 샌드박스와 분석의 어려움

Android/iOS 앱은 샌드박스(Sandbox) 환경에서 실행된다. 앱은 자신의 디렉터리와 메모리에만 접근할 수 있고, 다른 앱이나 시스템 영역은 격리된다.

Android 앱 실행 환경:
  ├── ART (Android Runtime) — Dalvik 바이트코드 실행
  ├── Java 레이어 — 앱 비즈니스 로직
  ├── JNI 레이어 — Java ↔ Native 연결
  └── Native 레이어 — .so 파일 (C/C++ 컴파일된 코드)
        ├── 암호화/복호화 로직 (흔히 보호 목적으로 native에 배치)
        ├── 루트 탐지, Frida 탐지
        └── 라이센스 검증

분석의 어려움:
  - 코드가 컴파일된 바이너리 → 직접 읽기 어려움
  - ProGuard/R8으로 난독화된 Java 코드
  - Native 레이어는 역어셈블러(IDA, Ghidra) 필요
  - SSL 피닝으로 HTTPS 트래픽 차단
  - 루트/에뮬레이터 탐지로 분석 환경 차단

동적 계측(Dynamic Instrumentation)

정적 분석: 실행하지 않고 바이너리 파일을 분석. 역어셈블러, 디컴파일러 사용.

동적 계측: 앱을 실행하면서 실시간으로 코드에 개입. 함수 호출을 가로채고, 인자/반환값을 확인하고, 메모리를 수정한다.

동적 계측의 핵심: 함수 프롤로그 수정 (Hooking)

정상 함수 실행:
  caller → func() → 원본 코드 실행 → 반환

후킹된 함수 실행:
  caller → func() → [Frida가 삽입한 JMP 명령] → Frida 핸들러 실행 (onEnter)
                                                  → 원본 코드 실행
                                                  → Frida 핸들러 실행 (onLeave)
                                                  → 반환

Frida는 런타임에 대상 함수의 첫 몇 바이트를 패치해 자신의 코드로 점프하도록 만든다.
이를 "인라인 후킹(Inline Hooking)"이라 한다.

Frida 아키텍처

[PC — 분석자]                    [Android 기기]
  frida (CLI)                      frida-server (루트 권한 실행)
  frida-tools (Python)      ←→     (USB 또는 TCP로 통신)
  JavaScript 스크립트               대상 앱 프로세스에 frida-agent 주입
                                    → 앱 메모리 내에서 후킹 실행

1. Frida v17 API 변경사항

Frida 17부터 모듈 관련 API가 큰 변화가 있다. 구버전 스크립트는 수정이 필요하다.

항목 v16 이하 v17 이상
모듈 base 주소 Module.getBaseAddress('libc.so') Process.getModuleByName('libc.so').base
특정 모듈 export 조회 Module.findExportByName('libc.so', 'open') Process.getModuleByName('libc.so').getExportByName('open')
전체 모듈 export 조회 Module.findExportByName(null, 'malloc') Module.getGlobalExportByName('malloc')
모듈 열거 Process.enumerateModules({ onMatch: fn }) (콜백 방식) for (const mod of Process.enumerateModules()) (이터레이터)

변경의 핵심: v17부터 모듈 인스턴스를 먼저 가져온 뒤 그 인스턴스의 메서드를 호출한다. 인스턴스를 변수에 캐싱하면 성능도 향상된다.

// ✅ v17 이후 권장 방식

// 모듈 인스턴스 획득 (한 번만 하고 재사용)
const libc = Process.getModuleByName('libc.so');
console.log('libc base:', libc.base);        // NativePointer
console.log('libc size:', libc.size);
console.log('libc path:', libc.path);        // 실제 파일 경로

// export 조회 — 특정 모듈에서 (정확하고 빠름)
const openPtr = libc.getExportByName('open');

// export 조회 — 어떤 라이브러리인지 모를 때 전체 탐색
const mallocPtr = Module.getGlobalExportByName('malloc');

// 모듈 열거 (이터레이터 방식)
for (const mod of Process.enumerateModules()) {
    if (mod.name.includes('target')) {
        console.log(`${mod.name}: base=${mod.base}, size=${mod.size}`);
    }
}

// 후킹 예시
Interceptor.attach(openPtr, {
    onEnter(args) {
        const path = args[0].readUtf8String();
        console.log('[open]', path);
    },
    onLeave(retval) {
        console.log('[open] fd =', retval.toInt32());
    }
});
// ❌ v16 이하 구버전 방식 (v17에서 제거됨)
Module.getBaseAddress('libc.so');               // ❌ 제거됨
Module.findExportByName('libc.so', 'open');     // ❌ 제거됨
Process.enumerateModules({ onMatch: fn });      // ❌ 제거됨

2. Native Library 후킹 — android_dlopen_ext

Android에서 앱이 .so 파일을 동적으로 로드할 때 android_dlopen_ext 함수가 사용된다. 보안 라이브러리(루트 탐지, 안티-프리다 등)는 앱 시작 후 동적으로 로드되는 경우가 많다. 이를 후킹하면 어떤 라이브러리가 언제 로드되는지 모니터링하고, 로드 직후에 추가 후킹을 적용할 수 있다.

// android_dlopen_ext 후킹
// ⚠️ 주의: 오프셋은 기기/OS 버전마다 다름. 반드시 직접 확인해야 함

// 방법 1: 심볼 이름으로 직접 찾기 (심볼이 export된 경우)
const dlopen = Module.getGlobalExportByName('android_dlopen_ext');

// 방법 2: 오프셋으로 찾기 (심볼이 strip된 경우)
// 오프셋 확인: adb shell "readelf -s /system/lib64/libnativeloader.so | grep android_dlopen_ext"
// 또는 Ghidra/IDA로 바이너리 분석
const libnativeloader = Process.getModuleByName('libnativeloader.so');
const dlopen2 = libnativeloader.base.add(0x2180);  // 버전마다 다름!

Interceptor.attach(dlopen, {
    onEnter(args) {
        const libPath = args[0].readUtf8String();
        console.log('[dlopen] 로딩:', libPath);

        if (libPath && libPath.includes('libpairipcore.so')) {
            console.log('[!] 보안 라이브러리 감지!');
            this.isPairip = true;
        }
    },
    onLeave(retval) {
        if (this.isPairip) {
            // 라이브러리 로드 완료 — 이제 내부 함수 후킹 가능
            const pairipBase = Process.getModuleByName('libpairipcore.so').base;
            console.log('[!] libpairipcore base:', pairipBase);
            // 여기서 루트 탐지 함수 후킹 적용
        }
    }
});

호출 스택 추적 (Backtrace)

어떤 코드가 특정 함수를 호출하는지 역추적할 때 유용하다.

Interceptor.attach(targetFuncPtr, {
    onEnter(args) {
        // 현재 호출 스택 출력
        console.log('\n=== Backtrace ===\n' +
            Thread.backtrace(this.context, Backtracer.ACCURATE)
                .map(DebugSymbol.fromAddress)  // 주소 → 함수명+오프셋 변환
                .join('\n')
        );
    }
});

// 출력 예시:
// 0x7b3c1234 libapp.so!Java_com_example_NativeLib_encrypt+0x24
// 0x7b3c5678 libapp.so!check_license+0x80
// 0x7b1a0000 libdvm.so!dvmCallJNIMethod+0x120

3. Anti-Frida 탐지와 우회

많은 앱이 Frida를 탐지하고 종료하는 로직을 포함한다.

주요 탐지 방식

1. /proc/self/maps 파싱
   → "frida-agent" 또는 "frida" 포함된 메모리 맵 탐지

2. 포트 스캔
   → Frida 기본 포트 27042에 connect() 시도
   → 성공하면 Frida 서버가 실행 중이라 판단

3. 프로세스 목록 확인
   → frida-server, re.frida 같은 프로세스명 탐지

4. 메모리 스캔
   → Frida 에이전트의 특정 시그니처 바이트 패턴 검색

5. 함수 무결성 검사
   → 자신의 함수 프롤로그 바이트 비교 (인라인 후킹 탐지)
   → 원본: 55 48 89 E5 (push rbp; mov rbp, rsp)
   → 후킹됨: E9 XX XX XX XX (JMP to frida handler)

/proc/self/maps 탐지 우회

// open/openat 시스템콜 후킹으로 /proc/self/maps 접근 차단
const openPtr = Module.getGlobalExportByName('open');
const openatPtr = Module.getGlobalExportByName('openat');

function hookOpen(ptr) {
    Interceptor.attach(ptr, {
        onEnter(args) {
            const path = args[0].readUtf8String();
            if (path && path.includes('/proc/self/maps')) {
                console.log('[Anti-Frida] /proc/self/maps 접근 차단');
                // /dev/null로 교체 → 앱이 빈 파일을 읽어 Frida 탐지 실패
                args[0].writeUtf8String('/dev/null');
            }
        }
    });
}

hookOpen(openPtr);
hookOpen(openatPtr);

포트 탐지 우회

// connect() 후킹으로 27042 포트 접근 차단
const connectPtr = Module.getGlobalExportByName('connect');

Interceptor.attach(connectPtr, {
    onEnter(args) {
        // sockaddr 구조체에서 포트 추출
        const sockaddr = args[1];
        const family = sockaddr.readU16();
        
        if (family === 2) {  // AF_INET (IPv4)
            // sockaddr_in: family(2) + port(2, big-endian) + addr(4)
            const port = sockaddr.add(2).readU16();
            // big-endian to host byte order
            const hostPort = ((port & 0xFF) << 8) | ((port >> 8) & 0xFF);
            
            if (hostPort === 27042) {
                console.log('[Anti-Frida] Frida 포트 27042 접근 차단');
                // ECONNREFUSED처럼 보이도록 포트 변경
                sockaddr.add(2).writeU16(0);  // 포트 0으로 교체
            }
        }
    }
});

함수 무결성 검사 우회 (고급)

// 앱이 자신의 함수 프롤로그를 읽어 후킹 여부 확인하는 경우
// memcmp, memcpy 후킹으로 원본 바이트 반환

const memcmpPtr = Module.getGlobalExportByName('memcmp');

Interceptor.attach(memcmpPtr, {
    onEnter(args) {
        this.ptr1 = args[0];
        this.ptr2 = args[1];
        this.size = args[2].toInt32();
    },
    onLeave(retval) {
        // 두 포인터 중 하나가 후킹된 함수 영역을 가리키면 0(일치) 반환
        // (원본과 다름 탐지 무력화)
        // 실제 구현은 어떤 메모리를 비교하는지 확인 후 적용
    }
});

4. adb reverse — 네트워크 격리 환경에서 트래픽 캡처

일반 프록시 vs adb reverse

일반 프록시 설정 (같은 WiFi 필요):
  Android 기기 → WiFi → PC 192.168.1.100:8080 (Burp/mitmproxy)
  
  제약: 기기와 PC가 같은 네트워크에 있어야 함
        내부망 진단 환경에서는 불가능한 경우 많음

adb reverse (USB, 네트워크 불필요):
  Android 기기 → USB → PC 8080 (Burp/mitmproxy)
  
  기기에서 프록시: 127.0.0.1:8080
  adb가 기기의 127.0.0.1:8080을 PC의 127.0.0.1:8080으로 터널링
# adb reverse 설정
adb reverse tcp:8080 tcp:8080
# 기기의 8080 포트 → PC의 8080 포트로 포워딩

# mitmproxy 또는 Burp Suite를 PC의 8080에서 실행
mitmweb --listen-port 8080
# 또는 Burp Suite → Proxy → Listen on 8080

# Android 기기에서:
# WiFi 설정 → 네트워크 수정 → 프록시 수동 설정
# 호스트: 127.0.0.1
# 포트: 8080
# → 모든 HTTP/HTTPS 트래픽이 PC의 mitmproxy로 전달됨

# CA 인증서 설치 (HTTPS 복호화)
# 기기 브라우저에서 http://mitm.it 접속 → 플랫폼 선택 → 인증서 설치

5. mitmproxy Python 연동 — 실시간 트래픽 변조

mitmproxy의 mitmdump는 Python 애드온 스크립트를 지원한다. 프록시를 통과하는 요청/응답을 실시간으로 조작한다.

# intercept.py — mitmproxy 애드온 스크립트

from mitmproxy import http
import json

class AppInterceptor:
    def request(self, flow: http.HTTPFlow) -> None:
        """요청 가로채기 — 서버로 전달 전"""
        
        # 인증 토큰 로깅
        if "api.example.com" in flow.request.pretty_host:
            auth = flow.request.headers.get("Authorization", "없음")
            print(f"[REQ] {flow.request.method} {flow.request.path}")
            print(f"      Auth: {auth[:50]}...")
            
            # 요청 파라미터 변조 (예: 관리자 파라미터 추가)
            if flow.request.path == "/api/users":
                flow.request.query["admin"] = "true"
    
    def response(self, flow: http.HTTPFlow) -> None:
        """응답 가로채기 — 클라이언트로 전달 전"""
        
        if "api.example.com" in flow.request.pretty_host:
            content_type = flow.response.headers.get("content-type", "")
            
            if "application/json" in content_type:
                try:
                    data = json.loads(flow.response.content)
                    
                    # 점수 변조 예시
                    if flow.request.path == "/api/user/score":
                        original = data.get("score", 0)
                        data["score"] = 999999
                        flow.response.content = json.dumps(data).encode()
                        print(f"[PATCH] score: {original} → 999999")
                    
                    # 구독 상태 변조 (프리미엄 기능 우회)
                    if "isPremium" in data:
                        data["isPremium"] = True
                        flow.response.content = json.dumps(data).encode()
                        print(f"[PATCH] isPremium → True")
                        
                except json.JSONDecodeError:
                    pass

addons = [AppInterceptor()]
# 스크립트와 함께 실행
mitmdump -p 8080 -s intercept.py

# 웹 UI와 함께 실행 (실시간 확인)
mitmweb -p 8080 -s intercept.py

Flutter 앱 SSL 핀닝 우회

Flutter는 자체 네트워크 스택(dart:io)을 사용해 시스템 프록시와 인증서를 무시한다.

# 방법 1: Frida 스크립트로 SSL 핀닝 우회
# dart io의 TLS 핸드셰이크 함수를 후킹해 인증서 검증 건너뜀
frida -U -f com.example.flutter_app -l flutter_ssl_bypass.js
# flutter_ssl_bypass.js 스크립트: github.com/frida/frida-scripts

# 방법 2: ProxyDroid (루팅 필요)
# iptables 레벨에서 모든 트래픽을 강제로 프록시로 리다이렉트
# → 앱이 프록시 설정을 무시해도 패킷 레벨에서 캡처

# 방법 3: APK 리패키징
# network_security_config.xml 수정 → 사용자 CA 인증서 신뢰 추가
# APK 서명 후 재설치

# AndroidManifest.xml에 추가:
# android:networkSecurityConfig="@xml/network_security_config"

# res/xml/network_security_config.xml:
# <network-security-config>
#   <base-config>
#     <trust-anchors>
#       <certificates src="system"/>
#       <certificates src="user"/>   ← 사용자 CA 추가
#     </trust-anchors>
#   </base-config>
# </network-security-config>

도구 설치 및 설정

# PC에서 frida-tools 설치
pip install frida-tools

# Android 기기에 frida-server 설치
# 1. 기기 아키텍처 확인
adb shell getprop ro.product.cpu.abi
# → arm64-v8a, armeabi-v7a, x86_64 등

# 2. 일치하는 frida-server 다운로드
# https://github.com/frida/frida/releases
# 예: frida-server-17.x.x-android-arm64

# 3. 기기에 푸시 및 실행
adb push frida-server-17.x.x-android-arm64 /data/local/tmp/frida-server
adb shell chmod +x /data/local/tmp/frida-server
adb shell /data/local/tmp/frida-server &

# 4. 연결 확인
frida-ps -U         # USB 연결된 기기의 프로세스 목록
frida-ps -U -a      # 앱(설치된 패키지) 목록

# 스크립트 실행 방법
frida -U -f com.example.app -l script.js    # 앱 시작 시 스크립트 주입
frida -U -n "AppName" -l script.js          # 실행 중인 앱에 주입
frida -U --enable-child-gating -f com.example.app -l script.js  # 자식 프로세스도 후킹

⚠️ 이 모든 기법은 본인 소유의 기기 또는 서면 동의된 환경에서만 사용해야 한다. 타인의 앱을 무단으로 분석하는 것은 저작권법 및 정보통신망법 위반이다.

⚠️ 이 글의 내용은 교육 및 허가된 침투 테스트 목적으로만 사용해야 합니다. 무단으로 타인의 시스템에 적용하는 것은 법적 처벌을 받을 수 있습니다.