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;-1indica 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.