기본 원리: 동적 계측이란
앱 샌드박스와 분석의 어려움
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 # 자식 프로세스도 후킹
⚠️ 이 모든 기법은 본인 소유의 기기 또는 서면 동의된 환경에서만 사용해야 한다. 타인의 앱을 무단으로 분석하는 것은 저작권법 및 정보통신망법 위반이다.