Engenharia Reversa do DroidGuard VM: Arquitetura de Validação de Integridade no Android

Arquitetura do Ecossistema DroidGuard

O componente DroidGuard atua como o núcleo de validação de integridade de dispositivos no ecossistema da Play Store. Ao contrário de verificações convencionais que dependem de leitura direta de propriedades do sistema ou chamadas de API, o DroidGuard encapsula sua lógica de verificação em uma máquina virtual (VM) proprietária e ofuscada. Este mecanismo evita técnicas tradicionais baseadas em API Hooking, pois a lógica não reside em chamadas de funções nativas, mas sim em fluxos de bytecode processados por um interpretador mínimo.

Camadas do DroidGuard VM

A arquitetura da VM pode ser dividida em três camadas distintas:

  • Camada Host: Implementada nativamente na biblioteca libdroidguard.so. Suas responsabilidades incluem a descriptografia do payload de bytecode a partir dos recursos do APK, a alocação de memória executável protegdia e a transferência do fluxo de execução para o interpretador.
  • Camada de Interpretação: Um núcleo de cerca de 4KB escrito em assemb (ARM64/ARM/x86_64) que executa um conjunto de instruções customizado. Cada instrução possui 4 bytes fixos. Comandos típicos incluem LOAD_IMM (empilhar imediato), CALL_NATIVE (invocar primitivas do host), XOR_STACK (operação lógica na pilha) e JMP_IF_ZERO (controle de fluxo condicional).
  • Camada de Bytecode: O payload real contendo a lógica de verificação. É distribuído como um blob binário criptografado e comprimido. Constantes de string são substituídas por hashes criptográficos para impedir aálise estática trivial.

Estratégia de Criptografia e Vinculação

O sistema de criptografia utiliza uma abordagem de vinculação de dispositivo. A chave de descriptografia K não é estática; ela é derivada combinando uma string fixa da seção .rodata da biblioteca nativa com o identificador único android_id do dispositivo. O algoritmo PBKDF2-HMAC-SHA256 é aplicado com 10.000 iterações para gerar a chave AES-128-CBC. Consequentemente, um bytecode extraído de um dispositivo não funcionará em outro devido à invalidade da chave, garantindo proteção contra adulteração.

Mecanismos Anti-Depuração

A proteção contra análise dinâmica é implementada através de isolamento de contexto:

  1. Isolamento de Pilha: A VM aloca uma nova região de memória (geralmente 64KB) para atuar como pilha dedicada, descartando a pilha nativa e ocultando o rastreamento de chamadas (call stack).
  2. Limpeza de Registradores: Após a execução de cada instrução, os registradores de propósito geral são zerados, impedindo a inspeção de estados intermediários via depuradores.
  3. Perturbação Temporal: Desvios condicionais incorporam micro-segundos do relógio do sistema (gettimeofday()) em seus cálculos de deslocamento. A introdução de atrasos por um depurador altera o destino do salto, causando falhas na execução.

Pipeline de Extração e Análise Estática

Desempacotamento e Descriptografia do Payload

O bytecode da VM não está embutido na biblioteca compartilhada, mas localizado no diretório assets/ do pacote com.google.android.gms (ex: dg_vm.bin). O cabeçalho do arquivo contém um identificador mágico (DGVM), o tamanho do blob e o vetor de inicialização (IV).

O script abaixo automatiza a extração do android_id e a derivação da chave AES para revelar o payload comprimido:


import sys
import struct
import hashlib
from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2

class PayloadDecoder:
    MAGIC_SIGNATURE = b'DGVM'
    
    def __init__(self, device_identifier: str, salt_phrase: str = "droidguard_key_v3"):
        self.device_id = device_identifier
        self.salt = salt_phrase
        self.encryption_key = self._generate_key()

    def _generate_key(self) -> bytes:
        combined_seed = (self.device_id + self.salt).encode('utf-8')
        salt_bytes = self.salt.encode('utf-8')
        return PBKDF2(combined_seed, salt_bytes, 16, count=10000, hmac_hash_module=hashlib.sha256)

    def decode(self, input_path: str) -> bytes:
        with open(input_path, "rb") as file_stream:
            raw_data = file_stream.read()
        
        if raw_data[:4] != self.MAGIC_SIGNATURE:
            raise ValueError("Assinatura DGVM inválida")
        
        payload_size = struct.unpack('<I', raw_data[4:8])[0]
        initialization_vector = raw_data[8:24]
        encrypted_content = raw_data[24:24 + payload_size]
        
        cipher_engine = AES.new(self.encryption_key, AES.MODE_CBC, initialization_vector)
        decrypted_data = cipher_engine.decrypt(encrypted_content)
        
        # Remoção do padding PKCS#7
        padding_length = decrypted_data[-1]
        if not (1 <= padding_length <= 16):
            raise ValueError("Padding inválido detectado")
            
        return decrypted_data[:-padding_length]

if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("Sintaxe: python decoder.py <arquivo_vm.bin> <android_id> <saida.bin>")
        sys.exit(1)
    
    decoder = PayloadDecoder(sys.argv[2])
    raw_bytecode = decoder.decode(sys.argv[1])
    
    with open(sys.argv[3], "wb") as out_file:
        out_file.write(raw_bytecode)
    print(f"Payload descriptografado salvo em {sys.argv[3]}")

O arquivo resultante deve ser descomprimido utilizando o algoritmo LZ4 para expor o fluxo de instruções puras.

Desmontagem e Mapeamento de Instruções

Cada instrução descomprimida ocupa 4 bytes, estruturados em: Opcode (8 bits), Operando 1 (8 bits), Operando 2 (8 bits) e Flags (8 bits).

Bits Campo Propósito
31-24 Opcode Define a operação (ex: 0x01 = LOAD, 0x02 = NATIVE_CALL)
23-16 Op1 Valor imediato ou índice de registrador
15-8 Op2 Operando secundário
7-0 Flags Modificadores de execução (ex: ativação de perturbação temporal)

O processo de desmontagem converte o binário em uma representação legível:


import sys

class BytecodeDisassembler:
    INSTRUCTION_SET = {
        0x01: "PUSH_IMM",
        0x02: "INVOKE_NATIVE",
        0x03: "XOR_TOP",
        0x04: "BRANCH_ZERO",
        0x05: "COMPUTE_HASH",
        0x06: "POP_STACK",
        0x08: "ADD_VALUES"
    }

    @staticmethod
    def process(bytecode_stream: bytes) -> str:
        assembly_lines = []
        offset = 0
        
        while offset + 4 <= len(bytecode_stream):
            chunk = bytecode_stream[offset:offset+4]
            op = chunk[0]
            arg1, arg2, modifiers = chunk[1], chunk[2], chunk[3]
            
            mnemonic = BytecodeDisassembler.INSTRUCTION_SET.get(op, f"UNK_{op:02X}")
            line = f"[{offset//4:04d}] {mnemonic:<14} arg1={arg1:02X} arg2={arg2:02X} mod={modifiers:02X}"
            assembly_lines.append(line)
            offset += 4
            
        return "\n".join(assembly_lines)

if __name__ == "__main__":
    with open(sys.argv[1], "rb") as f:
        print(BytecodeDisassembler.process(f.read()))

Reconstrução de Tabelas de Hash

As chamadas ao sistema operacional não utilizam strings literais, mas referências a hashes de 16 bits. Para entender os caminhos de arquivo ou propriedades verificadas, é necessário extrair strings do segmento .rodata da biblioteca nativa e reconstruir o dicionário de mapeamento CRC32.


import re
import zlib
import json

def rebuild_string_dictionary(native_lib_path: str, output_json: str):
    with open(native_lib_path, "rb") as lib_file:
        binary_content = lib_file.read()
    
    # Extração de caminhos absolutos
    path_pattern = re.compile(b'/[a-zA-Z0-9_./-]{4,60}')
    extracted_strings = {match.group(0).decode('ascii') for match in path_pattern.finditer(binary_content)}
    
    hash_mapping = {}
    for path in extracted_strings:
        # DroidGuard utiliza os 16 bits inferiores do CRC32
        path_hash = zlib.crc32(path.encode('utf-8')) & 0xFFFF
        hash_mapping[f"0x{path_hash:04X}"] = path
        
    with open(output_json, "w") as json_file:
        json.dump(hash_mapping, json_file, indent=4)

if __name__ == "__main__":
    rebuild_string_dictionary("libdroidguard.so", "paths_mapping.json")

Emulação da Lógica de Validação

Modelagem de Máquina Virtual em Python

Para analisar o comportamento do bytecode de forma controlada, um emulador educacional pode ser construído. Este ambiente simula o estado da pilha e intercepta chamadas nativas, permitindo a injeção de propriedades de sistema arbitrárias através de um arquivo de configuração (snapshot).


import json

class EmulatorContext:
    def __init__(self, snapshot_data: dict):
        self.stack = []
        self.program_counter = 0
        self.sys_properties = snapshot_data.get("props", {})
        self.file_system = snapshot_data.get("files", {})

class NativeInterface:
    def __init__(self, context: EmulatorContext):
        self.ctx = context

    def handle_invoke(self, primitive_id: int, hash_ref: int):
        if primitive_id == 0x01: # getprop
            prop_name = self._resolve_hash(hash_ref)
            value = self.ctx.sys_properties.get(prop_name, "")
            self.ctx.stack.append(hash(value))
        elif primitive_id == 0x02: # readlink
            path = self._resolve_hash(hash_ref)
            self.ctx.stack.append(hash(path))

    def _resolve_hash(self, h: int) -> str:
        # Simulação de resolução de hash baseada no dicionário reconstruído
        return f"resolved_path_{h:04X}"

class ExecutionEngine:
    def __init__(self, bytecode: bytes, context: EmulatorContext, native_if: NativeInterface):
        self.code = bytecode
        self.ctx = context
        self.native = native_if

    def run(self):
        while self.ctx.program_counter < len(self.code):
            instruction = self.code[self.ctx.program_counter:self.ctx.program_counter+4]
            if len(instruction) < 4: 
                break
            
            opcode, p1, p2, flags = instruction
            
            if opcode == 0x01:  # PUSH_IMM
                self.ctx.stack.append((p1 << 8) | p2)
                
            elif opcode == 0x02:  # INVOKE_NATIVE
                self.native.handle_invoke(p1, (p2 << 8) | flags)
                
            elif opcode == 0x03:  # XOR_TOP
                if len(self.ctx.stack) >= 2:
                    val_a = self.ctx.stack.pop()
                    val_b = self.ctx.stack.pop()
                    # Aplicação de perturbação baseada no PC se flag estiver ativa
                    modifier = self.ctx.program_counter % 256 if (flags & 0x01) else 0
                    self.ctx.stack.append((val_a ^ val_b) ^ modifier)
                    
            elif opcode == 0x04:  # BRANCH_ZERO
                if self.ctx.stack and self.ctx.stack[-1] == 0:
                    self.ctx.program_counter = p1 * 4
                    continue
                    
            self.ctx.program_counter += 4

Análise de Resultados e Restrições Arquiteturais

A execução do emulador contra um payload descomprimido gera um rastreamento preciso das operações de validação. A arquitetura impõe quatro categorias de restrições que definem o perímetro de segurança: limitações temporais baseadas em micro-segundos para execução de instruções, confinamento espacial de dados em memórias voláteis de curta duração, interfaces de comunicação estritamente tipadas via primitivas indexadas, e fluxo de controle orientado exclusivamente por dados internos. A compreensão dessas regras permite mapear com precisão como o mecanismo reage a anomalias como a presença do binário su ou alterações nas propriedades ro.debuggable, sem depender de instrumentação invasiva do sistema operacional.

Tags: DroidGuard android Reverse Engineering Virtual Machine Bytecode

Publicado em 6-12 22:00 por Thomas