안드로이드 악성 앱을 분석하는 일은 악성 앱 개발자와 리버스 엔지니어 간의 끊임없는 싸움입니다. 개발자는 소스 코드와 핵심 로직을 보호하기 위해 다양한 난독화 기법을 적용하고, 리버스 엔지니어는 이를 파훼하여 내부 구조를 분석해야 하죠. 이 싸움의 최전선에 있는 기술 중 하나가 바로 가상화 기반 난독화입니다.
오늘 분석할 NMMP는 바로 이 가상화 기반 난독화를 구현한 오픈소스 프로젝트입니다. 이 글에서는 NMMP가 어떤 원리로 Dalvik/ART 바이트코드를 보호하는지 그리고 분석가의 입장에서 이 보호막을 어떻게 걷어내고 분석의 실마리를 찾을 수 있는지 깊이 있게 탐구해 보겠습니다.
이 글은 보안 분석가, 리버스 엔지니어, 안드로이드 앱의 내부 동작 원리에 깊은 관심이 있는 개발자를 대상으로 합니다.
NMMP란 무엇인가?
NMMP는 안드로이드 앱의 classes.dex 파일에 담긴 Dalvik/ART 바이트코드를 전용 가상 머신(DEX-VM) 위에서 실행되는 커스텀 바이트코드로 변환하여 리버스 엔지니어링을 극도로 어렵게 만드는 기법입니다.
구조는 크게 두 부분으로 나뉩니다.
- 코드 변환기(
nmm-protect): 빌드 단계에서 보호할 메서드의 DEX 바이트코드를 NMMP만의 커스텀 바이트코드로 변환합니다. 이 결과물은 vmCode 라는 C 구조체와 JNI 스텁(Stub) 코드로 생성됩니다. 이때 Opcode 랜덤화가 적용되어 빌드할 때마다 가상 명령어의 번호(OP)가 무작위로 매핑됩니다.
- 런타임 VM(
nmmvm): libnmmvm.so 라는 네이티브 라이브러리 형태로 앱에 포함됩니다. JNI 스텁이 넘겨준 vmCode 를 해석하고 실행하는 역할을 합니다. 자바 세계(ART)와의 상호작용은 vmResolver 라는 콜백 함수 테이블을 통해 수행합니다.
보호된 앱이 실행되고 보호된 자바 메서드를 호출하는 순간 제어권은 ART가 아닌 JNI 스텁으로 넘어갑니다. 그리고 스텁은 vmInterpret 함수를 호출하여 최종적으로 libnmmvm.so 의 VM에게 제어권을 넘깁니다. 그 결과 Jadx와 같은 디컴파일러로 메서드를 열어봐도 원래의 로직은 온데간데없이 비어있고 앱은 정상적으로 동작하는 상황이 연출됩니다. 이것이 바로 가상화 난독화의 힘입니다.

[그림 1] (좌) 보호된 자바 메서드 (우) 앱 실행 화면
NMMP 동작 분석
가상화된 메서드가 호출되는 순간, 실행 흐름은 자바(ART) → 네이티브 스텁(libnmmp.so) → VM(libnmmvm.so) 으로 경계를 넘나듭니다. 이 경계를 포착하는 것이 분석의 첫걸음입니다.

[그림 2] NMMP 동작 흐름
예를 들어 doMainMessage 메서드가 보호되었다면 이 메서드의 호출은 libnmmp.so 라이브러리의 특정 주소(0x27A24, 로드 주소 0x76ADBAEA24)로 매핑됩니다. 이는 해당 자바 메서드가 네이티브 스텁으로 대체되었음을 의미합니다.

[그림 3] doMainMessage가 네이티브 라이브러리(libnmmp.so)에 매핑되는 과정
FRIDA 툴을 이용해 이 지점(0x76ADBAEA24)을 후킹하여 어셈블리 코드를 살펴보면 전형적인 AArch64 함수 프롤로그(Function prologue)와 함께 VM 진입을 위한 컨텍스트 준비 코드가 나타납니다.
; --- 함수 프롤로그 시작 ---
sub sp, sp, #0x70 ; 스택을 0x70 바이트만큼 내려 프레임 공간을 예약
stp x29, x30, [sp, #0x30] ; FP/LR(x29/x30) 레지스터를 스택에 저장
; callee-saved 레지스터들을 순서대로 스택에 보관
stp x24, x23, [sp, #0x40]
stp x26, x25, [sp, #0x50]
stp x20, x19, [sp, #0x60]
add x29, sp, #0x30
; --- VM 진입 준비 ---
mrs x24, tpidr_el0 ; 스레드 로컬 스토리지(TLS)의 베이스 주소를 읽음
mov x21, x1 ; 호출 시 전달된 인자(JNIEnv*)를 로컬 레지스터로 복사
mov x20, x2 ; 두 번째 인자(jobject)도 복사
ldr x8, [x24, #0x28] ; TLS의 특정 오프셋에서 포인터를 로드
mov w0, #0xa
즉, 스텁 함수가 호출되면 먼저 스택 프레임을 구성하고 주요 레지스터 값을 보존하는 프롤로그를 수행한 뒤 VM 실행에 필요한 값들(JNIEnv*, 인자 등)을 준비하는 컨텍스트 준비 과정으로 이어집니다.
스텁의 코드를 IDA Pro와 같은 디스어셈블러로 보면 함수 마지막에 다음과 같은 결정적인 한 줄을 발견할 수 있습니다.

[그림 4] 스텁 말미에서 vmInterpret(env, &code, resolver) 호출
// vmInterpret(env, &code, resolver)
vmInterpret(a1, &v9, off_76ADB82C60);
vmInterpret 함수 호출이 바로 자바 세계에서 VM의 세계로 넘어가는 관문입니다. 각 인자의 역할은 다음과 같습니다.
a1: 현재 스레드의 JNIEnv* 입니다. VM이 ART의 함수를 호출할 때 사용됩니다.
&v9: 스텁이 구성한 vmCode 구조체의 주소입니다. 커스텀 바이트코드, 가상 레지스터 수 등 핵심 정보가 담겨있습니다.
off_76ADB82C60: vmResolver 함수 테이블 주소입니다. VM이 실행 중 필요한 클래스, 메서드, 문자열 등을 ART에서 동적으로 찾아오기 위한 콜백 집합입니다.
결국 스텁의 역할은 자바의 호출 정보를 VM이 이해할 수 있는 형태로 가공하여 vmInterpret 함수에 전달하는 ‘문지기’인 셈입니다.
| 스텁의 역할 |
설명 |
| 호출 인자 적재 |
자바 인자를 VM의 가상 레지스터에 맞게 포맷 정리 |
vmCode 구성 |
커스텀 Opcode 배열, 메타데이터 등을 구조체에 채움 |
vmResolver 바인딩 |
ART 심볼 해석을 위한 콜백 테이블 연결 |
| VM 진입 |
vmInterpret(env, &code, resolver) 호출 |
랜덤화된 Opcode와 인터프리터 루프
vmInterpret가 호출된 이후 VM은 vmCode에 담긴 커스텀 바이트코드를 한 줄씩 해석하며 실행하는 루프에 진입합니다.
- 가상 레지스터(regs)를
code->regsCount 만큼 초기화합니다.
- PC(Program Counter)를 커스텀 바이트코드의 시작점(
code->insns)으로 설정합니다.
- 반복문:
op = *pc++ : 현재 PC에서 Opcode를 하나 읽고 PC를 증가시킵니다.
handlerTable[op]로 분기 : 읽어온 Opcode에 해당하는 핸들러(실제 동작)로 점프합니다.
- 필요시
resolver를 통해 ART의 클래스/메서드/필드를 동적으로 해석하여 사용합니다.
- 루프가 끝나면 결과를
jvalue로 포장하여 자바 호출자에게 반환합니다.
여기서 핵심은 Opcode 랜덤화입니다. 빌드 시
nmm-protect가 Opcode와 핸들러의 매핑 관계를 무작위로 섞어버립니다. 따라서 동일한 소스 코드라도 빌드 결과물마다 가상 명령어 체계가 완전히 달라져, 시그니처 기반의 정적 분석을 원천적으로 차단합니다.

[그림 5] (좌) 정상적인 Opcode (우) 랜덤하게 적용된 Opcode
NMMP 분석 전략
그렇다면 이 견고한 보호막을 어떻게 뚫고 분석을 시작할 수 있을까요? 정답은 정적 분석의 한계를 명확히 인지하고 동적 분석을 통해 핵심 관문을 공략하는 데 있습니다.
- NMMP 적용 식별: 가장 먼저 할 일은 NMMP의 사용 여부를 확인하는 것입니다. 앱의
lib 폴더에 libnmmvm.so(VM)와 libnmmp.so(스텁) 파일이 함께 존재한다면 NMMP가 적용되었을 가능성이 매우 높습니다.
- 정적 분석의 한계 인지: IDA Pro와 같은 툴로
libnmmp.so를 열어봐도 의미 있는 정보를 얻기 어렵습니다. 이 라이브러리는 보호된 자바 메서드와 VM을 연결하는 ‘연결 고리’ 역할만 할 뿐, 실제 로직이 담긴 커스텀 바이트코드는 포함하지 않기 때문입니다. 실제 해석은 libnmmvm.so의 인터프리터가 담당하고 가장 중요한 Opcode 매핑 정보는 빌드마다 무작위로 변경됩니다. 따라서, 런타임에 앱이 실제로 어떻게 동작하는지 관찰하는 동적 분석으로 눈을 돌려야 합니다.
- 핵심 분석 전략: 가상화된 코드는 모두
vmInterpret(env, &code, resolver)를 통과하지만 이 함수를 직접 후킹해 바이트코드를 해독하는 접근법은 옳지 않습니다. 이는 빌드마다 Opcode 매핑과 해석 규칙, 안티 후킹/안티 디버깅 등이 변경되므로 커스텀 인터프리터를 구현하기 위해 큰 시간이 소요됩니다. 그래서 권장되는 전략은 의미가 드러나는 “측면 채널(side channel)”을 광범위하게 관찰해서 실행 시점에 실제로 무엇이 로드·해석·호출·전달됐는지를 사람이 읽을 수 있는 문장(Ref map + Event trace)으로 복원하는 것입니다.
결론
NMMP는 vmInterpret(env, &code, resolver)라는 단 한 줄의 코드로 가상화 난독화의 정수를 보여줍니다. JNI 스텁이 문지기 역할을 하고 vmCode(보호된 로직)와 vmResolver(런타임 해석기)를 매개로 VM이 실제 연산을 수행하는 구조는 분석가들을 지치게 합니다. 특히 빌드마다 달라지는 Opcode 랜덤화는 정적 분석을 거의 불가능하게 만듭니다. 하지만 모든 보호 기법에는 약점이 존재합니다. NMMP의 약점은 결국 실행 시점에 드러나는 해석(Resolve)과 룩업(lookup) 입니다. 결국 리버스 엔지니어링은 창과 방패의 싸움이며, 상대방의 무기를 이해하는 것이야말로 가장 강력한 창을 만드는 첫걸음일 것입니다.
이번 글에서는 NMMP의 동작 원리와 분석의 핵심 관문을 파악하는데 집중했습니다. 다음 편에서는 중요한 정보들(ART Resolve, JNI Lookup, 문자열 등)을 수집하고 복원하는 방법을 정리하여 재구성하는 분석 과정을 단계별로 다루겠습니다. 많이 기대해주세요!
용어 미니 사전
- DEX: 안드로이드가 실행하는 바이트코드 포맷으로
classes.dex 파일에 들어 있습니다.
- Dalvik/ART: 안드로이드의 자바 실행 환경입니다. ART는 Dalvik의 후속 버전입니다.
- JNI: 자바(Java)에서 네이티브(C/C++) 코드를 호출하기 위한 표준 인터페이스입니다.
- 스텁(Stub): 자바 메서드 바디를 대신하는 네이티브 ‘껍데기’ 코드입니다. VM으로 진입하는 문지기 역할을 합니다.
- 인터프리터: 바이트코드를 한 줄씩 읽어서 해석하고 실행하는 프로그램입니다.
- Opcode: 바이트코드의 각 명령에 부여된 고유 번호입니다. (예:
MOVE, INVOKE)
- 점프 테이블(Jump Table): Opcode 값을 인덱스로 삼아 해당 명령을 처리하는 핸들러로 즉시 분기(점프)하는 데 사용되는 테이블입니다.
- Resolver: VM이 실행 중에 필요한 클래스, 메서드, 문자열 등을 ART 환경에서 동적으로 찾아주는 콜백 함수들의 집합입니다.
- jvalue: JNI에서 다양한 타입의 반환 값을 하나의 형태로 전달하기 위해 사용하는 공용체(union)입니다.
- 프롤로그(prologue): 함수가 시작되자마자 수행하는 준비 동작입니다. 스택 공간을 확보하고, FP/LR 같은 주요 레지스터를 저장합니다.
- 에필로그(epilogue): 함수가 종료되기 직전에 수행하는 마무리 동작입니다. 프롤로그에서 저장했던 값들을 원래대로 복구하고 호출 지점으로 돌아갑니다.
- SP (Stack Pointer): 스택의 가장 꼭대기 위치를 가리키는 포인터입니다. ARM64 아키텍처에서는 주소가 낮은 쪽으로 자랍니다.
- FP (Frame Pointer): 현재 실행 중인 함수의 스택 프레임 기준점입니다. 지역 변수나 인자에 고정된 오프셋으로 접근할 수 있게 해줍니다.
- LR (Link Register): 함수 실행이 끝난 뒤 돌아갈 주소를 저장하는 레지스터입니다.
- callee-saved 레지스터: 피호출 함수(callee)가 사용하기 전에 반드시 원래 값을 저장했다가, 함수가 끝나기 전에 복원해야 하는 레지스터 그룹입니다.
- TLS (Thread-Local Storage): 스레드마다 독립적으로 할당되는 저장 공간입니다.
tpidr_el0 레지스터가 이 공간의 시작 주소를 담고 있습니다.
- VM 진입 컨텍스트(Context): VM 인터프리터가 실행을 시작하는 데 필요한 모든 정보의 묶음입니다. (예:
JNIEnv*, vmCode 포인터, vmResolver 테이블 등)