Análise reversa de aplicações Qt em C++ com Ghidra

Introdução

Analisar aplicações compiladas a partir de C++ é consideravelmente mais desafiador do que trabalhar com código C, principalmente devido a construções como classes, polimorfismo, herança e templates. No entanto, ao final, todo objeto ocupa um bloco contínuo de memória e toda função é chamada por mecanismos previsíveis. Compreender o Application Binary Interface (ABI) do C++ é fundamental para entender o layout de objetos, tabelas virtuais, convenções de chamada e tratamento de exceções.

Quando compilamos binários para comparar com nossas expectativas, convém utilizar strip e otimização -O2, para que o compilador simplifique chamadas aninhadas de construtores. Ferramentas como o Compiler Explorer ajudam bastante nessa validação.

Layout de classes

A principal diferença entre C e C++ é a existência de classes. Uma classe encapsula dados e funções que operam sobre eles. A palavra-chave virtual introduz polimorfismo em tempo de execução. Para isso, o compilador gera uma tabela virtual (vtable), um vetor de ponteiros de função, e coloca seu endereço no início da região de memória da instância.

Quando uma classe possui herança, o compilador concatena os layouts dos pais. Considere o exemplo abaixo:

class Alpha {
    Tipo1 campoA1;
    ...
    TipoN campoAN;
    virtual metodoA1();
    ...
    virtual metodoAM();
};

class Beta {
    Tipo1 campoB1;
    ...
    TipoN campoBP;
    virtual metodoB1();
    ...
    virtual metodoBQ();
};

class Gamma : public Alpha, public Beta {
    Tipo1 campoC1;
    ...
    TipoR campoCR;
    virtual metodoC1();
    ...
    virtual metodoCS();
};

O layout em memória será aproximadamente:

Deslocamento Descrição
0 vtable de Gamma derivado de Alpha
1 campos de Alpha
... ...
N vtable de Beta
N+1 campos de Beta
... ...
M campos de Gamma

Podem haver padding entre os campos, dependendo do tamanho e alinhamento de cada tipo.

Em Ghidra, é possível definir classes no painel Symbol tree. Ao definir uma classe, cria-se uma instância de GhidraClass (um tipo de Namespace) e uma struct associada. Não existe suporte automático para tabelas virtuais; é necessário adicionar manualmente o primeiro campo como ponteiro para uma tabela de ponteiros de função.

Métodos

Métodos não virtuais são funções comuns, porém recebem implicitamente o parâmetro this. Na prática da engenharia reversa, this geralmente é o primeiro argumento, sendo um ponteiro para a struct da classe. Em Ghidra, podemos indicar que uma função pertence a uma classe movendo-a para o namespace correspondente, seja renomeando com o padrão NomeClasse::nomeFuncao, seja arrastando-a manualmente na árvore de símbolos.

Após atribuir o namespace, editamos a assinatura da função e definimos a convenção de chamada como __thiscall. O Ghidra então ajusta o primeiro parâmetro automaticamente, embora às vezes apresente bugs na contagem de argumentos.

Quando o tipo de retorno é uma classe não trivial, o chamador passa implicitamente um endereço adicional antes de this. Nesse caso, o retorno deve ser alterado para void e o argumento implícito deve ser adicionado manualmente.

Para funções externas (importadas de bibliotecas), o nome mangled contém informações sobre os argumentos. O Ghidra pode decodificar esses nomes, desde que o compiler spec correto tenha sido selecionado durante a importação do binário.

É essencial conhecer a convenção de chamada e a ABI da arquitetura alvo. Em ARM, por exemplo, um long long pode ocupar dois registradores, começando em um índice par.

Templates

Templates em C++ geram código em tempo de compilação. Dessa forma, eles não aparecem como chamadas a bibliotecas externas, mas sim como funções internas de nomes peculiares, como _List_node_base::_M_hook(). Estruturas como std::list, std::shared_ptr e suas contrapartes em Qt costumam deixar rastros dessa natureza.

No Qt, templates variádicos são amplamente utilizados, especialmente em mecanismos de sinais e slots. É importante estar familiarizado com a implementação desses padrões para reconhecê-los no binário.

Navegando no código-fonte do Qt

Para estudar o Qt, ferramentas como sourcetrail (descontinuado) ou visualizadores online como o Woboq ajudam. Para compilar o Qt e gerar um banco de dados navegável, um processo possível é:

./init-repository -f --module-subset=default,-qtwebengine
git submodule foreach --recursive "git clean -dfx" && git clean -dfx
mkdir qt5-build && cd qt5-build
../configure -developer-build -opensource -nomake examples -nomake tests \
    -recheck-all -confirm-license -fontconfig -sql-sqlite -no-sql-odbc \
    -system-freetype -qt-zlib -qt-libpng -qt-libjpeg -no-compile-examples \
    -no-opengl -no-feature-concurrent -no-feature-xml -no-feature-testlib \
    -skip qt3d -skip qtcharts -skip qtmultimedia ...

bear -- make -j2

O compile_commands.json gerado pode ser aberto no Sourcetrail. Caso necessário, use --append no bear para evitar sobrescrever o arquivo.

Tipos de dados comuns do Qt

QString

QString é um dos tipos mais recorrentes. Internamente, ela encapsula um ponteiro para uma estrutura QArrayData, que gerencia contagem de referências, tamanho, capacidade e deslocamento para os dados reais.

struct QArrayData {
    QtPrivate::RefCount ref;
    int size;
    uint alloc : 31;
    uint capacityReserved : 1;
    qptrdiff offset;
    static const QArrayData shared_null[2];
    static QArrayData *sharedNull();
};

typedef QTypedArrayData<ushort> QStringData;

class QString {
public:
    typedef QStringData Data;
    Data *d;
};

Campos relevantes:

  • ref: contador de referências; -1 indica objeto estático.
  • size: tamanho da string.
  • alloc: bytes alocados.
  • offset: deslocamento até os dados reais.

Quando um QString vazio é inicializado, o ponteiro interno aponta para shared_null. No código descompilado, atribuições como QArrayData::shared_null geralmente indicam a criação de uma string vazia.

Uma operação comum é a cópia com incremento de referência, semelhante a:

void copiarString(QString *dest, const QString &origem) {
    QArrayData *dados = origem.d;
    dest->d = dados;
    if (dados->ref > 0) {
        dados->ref++;
    }
}

Em binários ARM, instruções como DataMemoryBarrier(0xb) correspondem ao dmb, garantindo consistência entre CPUs.

QVariant, QSettings e QList

QVariant funciona como um tipo "universal", armazenando diferentes tipos em uma union de 8 bytes. QSettings utiliza QVariant inetrnamente para guardar configurações.

QList<T> é um template que encapsula um ponteiro para QListData::Data. A implementação interna pode incluir métodos como begin(), end() e detach(), que geralmente aparecem como código inline ou funções auxiliares como _detach_helper.

Trabalhando com QObject

QObject é a base de quase toda a arquitetura do Qt. Cada classe derivada de QObject possui um QMetaObject associado, gerado pelo moc (Meta-Object Compiler).

O macro Q_OBJECT insere três funções virtuais na classe:

Função Descrição
metaObject() Retorna o QMetaObject associado.
qt_metacast(const char *) Realiza conversões dinâmicas de tipo.
qt_metacall(...) Resolve métodos, sinais e propriedades em tempo de execução.

Com acesso ao QMetaObject, é possível recuperar nomes de métodos, sinais, slots e propriedades. Isso é extremamente útil na análise reversa de aplicações Qt.

Sinais e slots

O mecanismo de sinais e slots do Qt conecta objetos por meio de QObject::connect. Internamente, a função connectImpl é chamada com informações sobre remetente, sinal, receptor e slot. No descompilador, algo como:

QMetaObject::Connection conexao;
QSlotObjectBase *slotObj = novo_slot_objeto(...);
QObject::connectImpl(
    &conexao,
    remetente, reinterpret_cast<void*>(&sinal),
    receptor, reinterpret_cast<void*>(&slot), slotObj,
    Qt::AutoConnection, nullptr, &Classe::staticMetaObject
);

Para rastrear ativações de sinais, buscamos por chamadas a QMetaObject::activate() filtradas pelo metavtable e pelo índice correto.

QML e recursos

O módulo QML permite definir interfaces em uma sintaxe semelhante a JSON. Novos tipos podem ser registrados em C++ por meio de qmlregister().

O sistema de recursos do Qt armazena arquivos dentro do executável. Eles são definidos em arquivos .qrc, compilados por rcc e registrados em tempo de execução por qRegisterResourceData():

bool qRegisterResourceData(int versao,
                         const unsigned char *arvore,
                         const unsigned char *nomes,
                         const unsigned char *dados);

Onde:

  • arvore: estrutura de árvore do sistema de arquivos.
  • nomes: vetor de strings Unicode.
  • dados: conteúdo binário dos arquivos.

Os dados podem estar comprimidos com qCompress(), que adiciona 4 bytes com o tamanho descomprimido em big-endian seguidos de dados zlib.

Conceitos básicos do Ghidra

O Ghidra oferece APIs em Java e Python. Os scripts Python podem importar:

from ghidra.program.flatapi.FlatProgramAPI import *
from ghidra.app.script.GhidraScript import *

Para acessar variáveis globais disponíveis no ambiente do script, pode ser necessário:

from __main__ import *

Espaços de endereçamento

Um Address em Ghidra está associado a um AddressSpace. Os principais são:

  • ram: memória principal.
  • register: registradores do processador.
  • unique: registradores temporários do decompilador.
  • stack: espaço de pilha relativo a um registrador base.
  • constant: valores constantes em expressões Pcode.
  • other: espaços definidos pelo usuário.

Símbolos e namespaces

Symbol é a associação entre um endereço e uma string. GhidraClass é uma especialização de Namespace.

ss = getSymbol("ScreenShare", None)
classe = getNamespace(None, "ScreenShare")
print(ss == classe.getSymbol())  # True

Seleções

Para obter o intervalo de uma seleção atual:

intervalo = currentSelection.getFirstRange()
endereco_inicio = intervalo.getMinAddress()
tamanho = intervalo.getLength()

Blocos de memória

Blocos de memória representam regiões do binário. Para ler dados:

def ler_bytes(endereco, tamanho):
    v = jarray.zeros(tamanho, 'b')
    currentProgram.getMemory().getBytes(endereco, v)
    return v.tostring()

Tipos de dados

É possível criar estruturas programaticamente:

from ghidra.program.model.data import StructureDataType, IntegerDataType, DataTypeConflictHandler

estrutura = StructureDataType("Exemplo", 0)
estrutura.insertAtOffset(0, IntegerDataType.dataType, 4, "campoA", "")
estrutura.insertAtOffset(4, IntegerDataType.dataType, 4, "campoB", "")

dtm = currentProgram.getDataTypeManager()
dtm.addDataType(estrutura, DataTypeConflictHandler.REPLACE_HANDLER)

Funções

Exemplo de busca de função por nome:

def obter_funcao_por_nome(nome, namespace=None):
    candidatos = [f for f in currentProgram.getFunctionManager().getFunctionsNoStubs(True) if f.name == nome]
    if namespace:
        candidatos = [f for f in candidatos if f.getParentNamespace().getName() == namespace]
    if len(candidatos) != 1:
        raise ValueError("Esperava uma única função chamada %s" % nome)
    return candidatos[0]

Para aplicar uma assinatura:

from ghidra.app.cmd.function import ApplyFunctionSignatureCmd
from ghidra.program.model.symbol import SourceType

func = getFunctionAt(toAddr(0x0036af14))
runCommand(ApplyFunctionSignatureCmd(func.entryPoint, assinatura, SourceType.USER_DEFINED))

Análise com Pcode

O Ghidra representa operações de máquina em uma linguagem intermediária chamada Pcode. Cada variável é representada por um Varnode (espaço, offset, tamanho).

Exemplo de decompilação e análise de Pcode:

from ghidra.app.decompiler import DecompileOptions, DecompInterface
from ghidra.util.task import ConsoleTaskMonitor

monitor = ConsoleTaskMonitor()
ifc = DecompInterface()
ifc.setOptions(DecompileOptions())
ifc.openProgram(currentProgram)

func = getFunctionContaining(currentAddress)
resultado = ifc.decompileFunction(func, 60, monitor)
high_func = resultado.getHighFunction()

for op in high_func.getPcodeOps(currentAddress):
    print(op)

Para obter argumentos de uma chamada de função, inspecionamos os Varnode de entrada da operação CALL:

op = currentLocation.getToken().getPcodeOp()
for i in range(op.getNumInputs()):
    print(op.getInput(i))

Scripts úteis

Criar tabela virtual

Este script cria uma estrutura de vtable a partir de uma seleção de ponteiros de função. Convenção: o rótulo no início deve ter o formato NomeClasse::vtable.

def criar_vtable(nome_classe, endereco_inicial, quantidade):
    from ghidra.program.model.data import StructureDataType, PointerDataType, FunctionDefinitionDataType

    vtable = StructureDataType("%s_vtable_t" % nome_classe, 0)

    for idx in range(quantidade):
        offset = idx * currentProgram.getDefaultPointerSize()
        ptr_addr = endereco_inicial.add(offset)
        func_addr = toAddr(getDataAt(ptr_addr).getInt(0))
        funcao = getFunctionAt(func_addr)

        if funcao is None:
            funcao = createFunction(func_addr, None)

        if funcao.getParentNamespace().isGlobal():
            funcao.setParentNamespace(getNamespace(None, nome_classe))

        if funcao.getName().startswith("FUN_"):
            funcao.setCallingConvention("__thiscall")

        definicao = FunctionDefinitionDataType(funcao, False)
        vtable.insertAtOffset(
            offset,
            PointerDataType(definicao),
            currentProgram.getDefaultPointerSize(),
            funcao.getName(),
            ""
        )

    dtm = currentProgram.getDataTypeManager()
    dtm.addDataType(vtable, DataTypeConflictHandler.REPLACE_HANDLER)

    classe = dtm.getDataType("/%s" % nome_classe)
    if classe is None:
        raise ValueError("Classe %s não definida" % nome_classe)

    campo = classe.getComponent(0)
    if campo.getFieldName() != "_vtable":
        raise ValueError("Primeiro campo deve se chamar _vtable")

    campo.setDataType(PointerDataType(vtable))

Reconstruir QString

class QStringReconstruida:
    INDEX_REF = 0
    INDEX_TAMANHO = 1
    INDEX_OFFSET = 3

    def __init__(self, endereco):
        self.endereco = endereco
        tipos = getDataTypes("QArrayData")
        if not tipos:
            raise ValueError("QArrayData não definido")
        self.tipo = tipos[0]
        self._d = createData(endereco, self.tipo)

    def deslocamento(self):
        return self._d.getComponent(self.INDEX_OFFSET).value.getValue()

    def tamanho(self):
        return self._d.getComponent(self.INDEX_TAMANHO).value.getValue()

    def endereco_dados(self):
        return self.endereco.add(self.deslocamento())

Extrair recursos do Qt

Um script que localiza chamadas a qRegisterResourceData() e extrai os argumentos pode ser usado para despejar todos os recursos embutidos no binário. A implementação completa envolve navegar pela árvore de arquivos, interpretar os campos de 22 bytes de cada nó e descomprimir os dados quando necessário.

Extrair metadados de QObject

Com base nas funções virtuais de QObject, podemos escrever um script que, dado o endereço de uma instância, localiza o QMetaObject e extrai nomes de métodos, sinais e propriedades. A implementação pode ser aprimorada para inferir também os tipos dos argumentos.

Considerações finais

A análise reversa de aplicações Qt em C++ exige familiaridade com o ABI do C++, a estrutura interna das classes do Qt e o uso avançado do Ghidra. Combinando a decompilação com scripts automatizados, é possível reconstruir classes, rastrear sinais e slots, e até recuperar recursos embutidos no binário.

Tags: Qt Ghidra C++ Reverse Engineering Pcode

Publicado em 7-13 09:32