Inside VMProtect

안상준·2025년 7월 29일

VMProtect

목록 보기
4/6

2015년 Samuel Chevet의 Inside VMProtect 발표를 분석한 내용이다.
https://webtv.univ-lille.fr/video/7566/inside-vmprotect

목차
1. Introduction
2. Internal
3. Analysis
4. VM Logic
5. Conclusion

Introduction

Packer, Protector : 실행 파일의 섹션 내용을 암호화 하거나 압축하고, 이를 복호화 하거나 복원할 수 있는 코드를 추가하는 것
이 과정에서 다양한 안티 디버깅, 인티 VM 기법이 추가되지만 결국 오회할 수 있는 방법이 존재

VMProtect

  • VMProtect는 Packing 뿐만 아니라 메모리 보호 기능도 존재
  • VMProtect로 보호된 바이너리를 실행하면, 실행 파일의 무결성을 검사
  • 코드 섹션을 수정하거나, 새로운 import를 추가(IAT를 수정하건 영향을 주는 행위)하면 아래와 같이 메시지 박스가 뜬다.

Import Protection

원래 실행 파일에 있던 import table 항복들은 제거되고, API 호출을 위한 리디렉션 코드가 삽입된다.

남은 1 byte?
Fake push : 가짜 push 명령을 넣어 인자가 있는 것처럼 위장
Dead code : 의미 없는 명령어를 추가해 분석을 어렵게 함

Resource Protection

  • 바이너리에 포함도니 리소스들(텍스트 파일 등)은 모두 암호화 되어 저장됨
  • VMProtect는 몇몇 API에 후킹을 걸어, 리소스가 접근될 때 마다 해당 리소스를 복호화해서 반환

Code Virtualization

  • Packer를 사용하면 실행 파일이 실행되는 순간 메모리에서 복호화 또는 압축 해제가 이루어 진다. -> 프로세서 메모리를 덤프하면 원래의 코드가 그대로 나오게 됨
  • 이를 보완하기 위해 가상화 기법을 도입
  • 원래의 native code를 disassemble한 다음, 이를 새롭고 독자적인 byte code로 다시 compile
  • bytecode를 실행할 수 있는 interpreter or VM을 만들어 동작시킴

비유하면 실행을 위한 새로운 CPU를 만드는 것과 같음

위 사진은 메시지박스를 띄우는 코드에 가상화를 적용했을 때의 예시이다. 당연히 가상화를 적용해도 원래의 동작을 수행한다. code를 덤프 하였을때 어떠한 코드인지 해석이 불가능 함을 볼 수 있다.

  • CPU 명령어를 완전히 동일하게 재현
  • 가상 머신으로 제어를 넘기기 전에 원래 실행 중이던 Context(Register, Flag)를 정확하게 저장하고 복원 해야함
  • 모든 산술 또는 논리 연산에 대하여 플래그의 결과를 정확하게 처리
  • VMProtect는 native code를 disassemble 하여, 다형성 bytecode로 다시 compile -> 실행할 때마다 생성되는 bytecode가 서로 다름
  • 하나의 실행 파일 안에 여러 개의 VM이 존재할 수 있음

Interpreter

  • Fetch : bytecode에서 opcode를 가져옴
  • Decode : opcode와 operand를 해석
  • Execute : 해석된 명령어를 실행

원래의 native code가 완전히 사라짐. 실행 중인 프로세스의 메모리를 덤프 하더라도 어떠한 동작을 하는지 이해할 수 없음

Internal

VM Archintecture

VMProtect가 사용하는 VM은 전부 RISC구조를 기반으로 한다.

Stack-based language

스택 머신은 레지스터를 사용하지 않고 모든 산술 연산이 스택 위에서 이루어진다.

native code를 복원하고 싶다면, 이러한 스택 머신의 구조 자체를 제거하는 과정이 필요하다.

Context

코드 가상화를 하기 전에 실행 중인 프로그램의 context를 먼저 저장해야 한다.
context는 가상 머신의 내부 구조체 안에 저장돼 있으며, 32/64bit에 따라 다르지만 보통 8/16개의 가상 레지스터에 이전 레지스터 값들을 저장한다.
VMProtect의 VM Context 안에 두 개의 저장 공간이 존재한다. Relocation Difference, ICE Layer가 저장이 된다.

  • Relocation DIfference : 물리 주소와 프로세스 메로리의 주소 차이
  • ICE Layer : 실제 바이너리 주소 재배치시의 추가 계층 구조 정보를 의미

산술 연산의 결과나 플래그 같은 것을 임시로 저장할 수 있는 temporal register가 존재한다.

VM Stack

VMProtect에서는 모든 VM context pointer를 EDI or RDI가 가리키도록 되어 있다.
RBP는 VM stack pinter로 사용이 되고,
RDI는 VM context structure로 사용 된다.
RBP가 RDI에 도달하게 되면, 전체 구조를 재조정하고 더 많은 공간을 확보한 뒤, 기존의 VM context와 VM stack을 복사해서 다시 저장한다.

VM 구현

  1. instruction pointer를 통해 bytecode 읽기
  2. 복호화
  3. 해당 opcode에 맞는 handler 계산
  4. handler로 점프후 실행

이때 instruction pointer가 메모리를 읽는 방식은

  • 왼쪽 -> 오른쪽
  • 오른쪽 -> 왼쪽

이렇게 두 가지 방식으로 읽는다. 가상머신을 생성할 때, boolean 값을 랜덤으로 하여 읽는 방향을 정하는 것으로 추측

VMProtect Bytecode

VMProtect의 bytecode는 암호화 되어 있을 수 있다. 코드 가상화의 시작 지점은 하나의 암호화 키에 의해 결정된다. 그렇기 때문에 10번째 명령어를 분석하고 싶어도 해당 키를 모른다면 복호화가 불가능하다.
이 암호화 키는 VM loop, opcode handler, operand에 의존한다.
해당 logic에서 키가 동적으로 갱신이 된다. 그렇기 때문에 임의의 위치에서 분석을 시작하는 것이 불가능 하다.
암호화 키는 EBX or RBX를 사용

Logical & Arithmetic operations

논리 연산이나 산술 연산이 끝날 때마다 해당 핸들러는 연산 후에 CPU 플래그(EFLAGS/RFLAGS)를 push해서 VM stack에 저장한다.
이후 플래그들을 VM context에 저장하는 bytecode가 따라온다.
VM opcode는 쌍을 움직인다.
산술 연산 + 결과 플래그를 context에 저장하는 opcode
호스트에서 VM으로 진입할 때는 모든 호스트 레지스터들과 플래그들을 push한다.

VM Block Start

VM block에 진입하게 되면 실행되는 로직이다.

여기서 SECURITY_CONSTANT는 가상화된 코드 블럭의 적합성 검증, 흐름 난독화, 암호화 키 파새 ㅇ등에 사용하는 임의값이다.
stack에 레지스터를 push하는 순서가 랜덤이기 때문에 VM context에 저장되는 레지스터의 순서는 랜덤이 되게 된다.

VM Block End


VM block이 끝나는 경우 2가지의 상황을 만나게 된다. 먼저 VM_EXIT을 만나는 경우는 VM이 종료된 경우로 모든 레지스터와 플래그를 pop하여 리턴한다.
만약 VM이 끝나지 않은 경우에는 SECURITY_CONSTANT를 암호화 하고 push한다. 그 다음 재배치 차이값을 push하고 다음 VM block으로 이동한다.

Internal registers

내부에서 사용하는 레지스터와 역할이다.

  • RBX : encryption key
  • RDI : VM context
  • RSI : VM instruction pointer
  • RBP : VM stack pointer
  • RDX : arithmetic/result operation of handler
  • RAX : opcode value
  • R13 : relocation-difference
  • R12 : opcode handler table

여기서는 이렇게 알려주었지만 VMProtect 버전 3을 보면 레지스터가 하는 역할을 랜덤으로 바꾸는 것을 확인할 수 있다. 참고용으로 보면 좋을 것 같다.

Analysis

Dynmic analysis

정적 분석도구들은 종종 버그가 많아 시간이 낭비되는 경우가 있다. 좋은 방법은 동적 분석을 먼저 하는 것이다.
이를 위해 intelligent code tracer를 만들어 분석을 하였다고 한다.

Intelligent code tracer

  1. VM loop 찾기
  2. 프로세스에 DLL Injecton
  3. 해당 루프 지점에 하드웨어 브레이크포인트 걸기
  4. VM loop가 실행될 때마다 VM stack, VM context를 저장
  5. 명령어 실행 전 후 저장한 데이터를 비교

    위 사진은 intelligent code tracer를 이용하여 VM stack, VM context, register를 비교한 사진이다.

Metasm

https://github.com/jjyg/metasm
발표자 Samuel Chevet이 만든 도구로 Ruby로 작성된 Open Source Framework다. 아래와 같은 기능을 한다.

  • Assembler
  • Disassembler
  • Compiler
  • Linker

각 명령어의 의미에 대한 설명이 포함되며, 중간언어를 사용하지 않고 직접 명령어의 의미를 표현한다.
하나의 opcode 핸들러 전체가 수행하는 의미를 분석 가능하다.

VM's sympolic internal

handler opcode 의미 분석을 하는 방법이다.
code_binding함수는 시작 주소와 종료 주소를 인자로 받는다. 이를 통해 코드 블록에 대하 자동으로 의미 분석을 한다.
먼저 레지스터나 VM stack에 간접적으로 접근하는 표현식들을 매핑한다.

이런식으로 opcode, vmkey, op_01처럼 레지스터와 스택의 특정 위치를 알기 쉽도록 매핑해 준다.

Static analysis

code binding을 수행하기 위해 VM의 start context를 설정하고 RSI/RBX를 초기화 한다.
현재 opcode에 해당하는 handler를 disassemble 하고, 그 명령어들의 의미를 계산한다.
상태 해석과 해석 결과를 바탕으로 계산해서, VM exit을 만날 때까지 루프를 계속 돌린다.
code binding의 종료 지점을 아래 방법을 사용하여 종료한다.

  1. Basic Block의 리스트를 추적 : 전체 가상 opcode가 실행할 basic block들을 모아서 추적하여 각 block의 의미를 분석한다.
  2. VM stack 최대값 기준으로 중지 : 가상 명령어들은 연산 결과를 VM stack에 저장한다. 특정 시점 이후에 VM stack이 더 이상 증가하지 않게 디고 이를 이용하여 종료지점을 파악한다.
  3. VM loop 복귀를 찾기 : 가상 명령어 실행이 끝나면 반드시 VM loop로 돌아가기 때문에 jmp, ret 명령어를 탐지하여 종료한다.

가상화된 코드는 코드를 직접 실행하지 않고 VM_ENTRY를 호출한다. 이때 첫 번째 인자로 bytecode pointer가 사용된다. 이 값에는 constant unfolding 이나 간단한 난독화가 적영돼 있을 수 있다.

VM Logic

앞서 얘기 했듯이 VM은 stack based language 이다.

코드를 보면 어떠한 읽는 것이 매우 어렵다. 해석이 용이를 위해 스택 머신의 특성을 제거하는 것이 좋다.

  • push와 pop 명령을 단순 할당문으로 바꾸기
    push 3 -> stack[sp++] = 3
  • AND, SUB, NOT 같은 opcode는 존재X
    magic handler 내부에 NOR 게이트를 이용하여 NOT, AND, XOR 구현

VM CRC

VM 내부에는 포인터 하나와 크기 하나를 operand로 받아 체크섬 연산을 수행하는 로직이 존재한다. 이를 통해 파일의 무결성이나, VM 무결성을 확인한다.

하지만 쉽고 단순하여 우회가 가능하다고 한다.

Conclusion

가상 명령어 집합 목록을 완성하고 각 opcode handler의 의미를 모두 알아내면 VMProtect로 보호된 어떤 바이너리든지 disassemble이 가능하다.
항상 동일한 아키텍처(RISC + stack based)를 사용한다.
항상 암호화가 적용되지만 initial key는 유추 가능핟.
static disassembler를 만드는 것은 매우 어렵다. symbolic execution을 사용하거나 dynamic + static analysis을 해야 한다.

0개의 댓글