Por que o código de máquina nativo não pode ser facilmente descompilado?


16

Com linguagens de máquina virtual baseadas em bytecode, como Java, VB.NET, C #, ActionScript 3.0, etc., você ouve algumas vezes sobre como é fácil baixar um descompilador da Internet, executar o bytecode nele uma boa hora e muitas vezes, crie algo não muito distante do código-fonte original em questão de segundos. Supostamente esse tipo de linguagem é particularmente vulnerável a isso.

Recentemente, comecei a me perguntar por que você não ouve mais sobre isso sobre o código binário nativo, quando pelo menos sabe em qual idioma ele foi escrito originalmente (e, portanto, em qual idioma tentar descompilar). Por um longo tempo, achei que era apenas porque a linguagem de máquina nativa é muito mais louca e mais complexa do que o código de código típico.

Mas como é o bytecode? Se parece com isso:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

E como é o código de máquina nativo (em hexadecimal)? Obviamente, fica assim:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

E as instruções vêm de um estado de espírito um tanto semelhante:

1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX

Portanto, dada a linguagem para tentar decompilar algum binário nativo, digamos C ++, o que há de tão difícil nisso? As únicas duas idéias que vêm à mente imediatamente são: 1) é realmente muito mais complicado que o bytecode; ou 2) algo sobre o fato de os sistemas operacionais tenderem a paginar os programas e espalhar suas peças causa muitos problemas. Se uma dessas possibilidades estiver correta, explique. Mas de qualquer maneira, por que você nunca ouve isso basicamente?

NOTA

Estou prestes a aceitar uma das respostas, mas quero mencionar algo primeiro. Quase todo mundo está se referindo ao fato de que diferentes partes do código fonte original podem ser mapeadas para o mesmo código de máquina; nomes de variáveis ​​locais são perdidos, você não sabe que tipo de loop foi usado originalmente etc.

No entanto, exemplos como os dois que acabamos de mencionar são meio triviais aos meus olhos. Algumas das respostas, no entanto, tendem a afirmar que a diferença entre o código de máquina e a fonte original é drasticamente muito mais do que algo tão trivial.

Mas, por exemplo, quando se trata de nomes de variáveis ​​locais e tipos de loop, o bytecode também perde essas informações (pelo menos no ActionScript 3.0). Eu puxei essas coisas de volta através de um descompilador antes e realmente não me importava se uma variável era chamada strMyLocalString:Stringou loc1. Eu ainda podia olhar nesse pequeno escopo local e ver como ele está sendo usado sem muitos problemas. E um forloop é praticamente a mesma coisa que umwhileloop, se você pensar sobre isso. Além disso, mesmo quando eu executava a fonte através do irrFuscator (que, diferentemente do secureSWF, não faz muito mais do que apenas randomizar variáveis ​​de membros e nomes de funções), ainda parecia que você poderia começar a isolar determinadas variáveis ​​e funções em classes menores, figura como eles são usados, atribua seus próprios nomes a eles e trabalhe a partir daí.

Para que isso seja um grande negócio, o código da máquina precisaria perder muito mais informações do que isso, e algumas das respostas vão para isso.


35
É difícil fazer uma vaca com hambúrgueres.
Kaz Dragon

4
O principal problema é que um binário nativo retém muito poucos metadados sobre o programa. Ele não retém informações sobre classes (tornando o C ++ particularmente difícil de descompilar) e nem sempre tudo sobre funções - não é necessário, pois uma CPU executa inerentemente o código de maneira bastante linear, uma instrução por vez. Além disso, é impossível diferenciar código e dados ( link ). Para mais informações, você pode querer considerar a busca ou re-perguntar no RE.SE .
Ntskrnl

Respostas:


39

Em cada etapa da compilação, você perde informações irrecuperáveis. Quanto mais informações você perde da fonte original, mais difícil é descompilar.

Você pode criar um descompilador útil para código de bytes, porque muito mais informações são preservadas da fonte original do que preservadas ao produzir o código final da máquina de destino.

A primeira etapa de um compilador é transformar a fonte em algumas representações intermediárias, muitas vezes representadas como uma árvore. Tradicionalmente, essa árvore não contém informações não-semânticas, como comentários, espaço em branco, etc. Depois que isso é descartado, você não pode recuperar a fonte original dessa árvore.

O próximo passo é transformar a árvore em algum tipo de linguagem intermediária que facilite as otimizações. Existem algumas opções aqui e cada infraestrutura de compilador possui a sua. Normalmente, no entanto, informações como nomes de variáveis ​​locais, grandes estruturas de fluxo de controle (como se você usou um loop for ou while) são perdidas. Algumas otimizações importantes geralmente acontecem aqui, propagação constante, movimento de código invariável, embutimento de funções etc. Cada uma delas transforma a representação em uma representação que possui funcionalidade equivalente, mas que parece substancialmente diferente.

Um passo a seguir é gerar as instruções reais da máquina, que podem envolver o que é chamado de otimização "peep-hole" que produz uma versão otimizada de padrões de instruções comuns.

A cada passo, você perde mais e mais informações até que, no final, perde tanto que se torna impossível recuperar algo semelhante ao código original.

O código de bytes, por outro lado, normalmente salva as otimizações interessantes e transformadoras até a fase JIT (o compilador just-in-time) quando o código da máquina de destino é produzido. O código de bytes contém muitos metadados, como tipos de variáveis ​​locais, estrutura de classes, para permitir que o mesmo código de bytes seja compilado em vários códigos de máquina de destino. Todas essas informações não são necessárias em um programa C ++ e são descartadas no processo de compilação.

Existem decompiladores para vários códigos de máquina de destino, mas eles geralmente não produzem resultados úteis (algo que você pode modificar e recompilar), pois muito da fonte original é perdida. Se você tiver informações de depuração para o executável, poderá fazer um trabalho ainda melhor; mas, se você tiver informações de depuração, provavelmente também possui a fonte original.


5
O fato de que as informações são mantidas para que o JIT possa funcionar melhor é fundamental.
btilly

As DLLs do C ++ são facilmente descompiláveis?
Panzercrisis

1
Não em nada que eu consideraria útil.
chuckj

1
Os metadados não são "para permitir que o mesmo código de bytes seja compilado em vários destinos", está lá para reflexão. A representação intermediária redirecionável não precisa ter nenhum desses metadados.
SK-logic

2
Isso não é verdade. Muitos dados existem para reflexão, mas a reflexão não é o único uso. Por exemplo, as definições de interface e classe são usadas para criar o deslocamento de campo definido, construir tabelas virtuais etc. na máquina de destino, permitindo que sejam construídas da maneira mais eficiente para a máquina de destino. Essas tabelas são construídas pelo compilador e / ou vinculador ao produzir código nativo. Feito isso, os dados usados ​​para construí-los são descartados.
28414 chuckj

11

A perda de informações, conforme apontado pelas outras respostas, é um ponto, mas não é o infrator. Afinal, você não espera o programa original de volta, apenas deseja qualquer representação em um idioma de alto nível. Se o código estiver embutido, você pode simplesmente deixar como está ou fatorar automaticamente os cálculos comuns. Em princípio, você pode desfazer muitas otimizações. Mas existem algumas operações que são, em princípio, irreversíveis (sem pelo menos uma quantidade infinita de computação).

Por exemplo, ramificações podem se tornar saltos computados. Código como este:

select (x) {
case 1:
    // foo
    break;
case 2:
    // bar
    break;
}

pode ser compilado (desculpe por não ser um montador real):

0x1000:   jump to 0x1000 + 4*x
0x1004:   // foo
0x1008:   // bar
0x1012:   // qux

Agora, se você sabe que x pode ser 1 ou 2, pode observar os saltos e reverter isso facilmente. Mas e o endereço 0x1012? Você deve criar um case 3para isso também? Você teria que rastrear o programa inteiro no pior caso para descobrir quais valores são permitidos. Pior ainda, talvez seja necessário considerar todas as entradas possíveis do usuário! O ponto principal do problema é que você não pode distinguir dados e instruções.

Dito isto, eu não seria totalmente pessimista. Como você deve ter notado no 'assembler' acima, se x vem de fora e não é garantido que seja 1 ou 2, você basicamente tem um bug ruim que permite saltar para qualquer lugar. Mas se o seu programa está livre desse tipo de bug, é muito mais fácil argumentar. (Não é por acaso que linguagens intermediárias "seguras", como CLR IL ou Java bytecode, são muito mais fáceis de descompilar, até mesmo colocando metadados de lado.) Portanto, na prática, deve ser possível descompilar certos comportamentos e comportamentos.programas. Estou pensando em rotinas individuais de estilo funcional, que não têm efeitos colaterais e dados bem definidos. Acho que existem alguns descompiladores que podem fornecer pseudocódigo para funções simples, mas não tenho muita experiência com essas ferramentas.


9

A razão pela qual o código da máquina não pode ser facilmente convertível de volta ao código-fonte original é que muitas informações são perdidas durante a compilação. Métodos e classes não exportadas podem ser incorporados, nomes de variáveis ​​locais são perdidos, nomes e estruturas de arquivos são perdidos completamente, compiladores podem fazer otimizações não óbvias. Outro motivo é que vários arquivos de origem diferentes podem produzir exatamente o mesmo assembly.

Por exemplo:

int DoSomething()
{
    return Add(5, 2);
}

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    return DoSomething();
}

Pode ser compilado para:

main:
mov eax, 7;
ret;

Meu assembly está bastante enferrujado, mas se o compilador puder verificar se uma otimização pode ser feita com precisão, ele o fará. Isso ocorre porque o binário compilado não precisa saber os nomes DoSomethinge Add, além do fato de o Addmétodo ter dois parâmetros nomeados, o compilador também sabe que o DoSomethingmétodo basicamente retorna uma constante e pode alinhar a chamada do método e o parâmetro próprio método.

O objetivo do compilador é criar um assembly, não uma maneira de agrupar arquivos de origem.


Considere alterar a última instrução para just rete apenas diga que você estava assumindo a convenção de chamada em C.
chuckj

3

Os princípios gerais aqui são mapeamentos muitos para um e falta de representantes canônicos.

Para um exemplo simples do fenômeno muitos-para-um, você pode pensar no que acontece quando você pega uma função com algumas variáveis ​​locais e compila-a no código da máquina. Todas as informações sobre as variáveis ​​são perdidas porque elas se tornam endereços de memória. Algo semelhante acontece para loops. Você pode fazer um loop forou whilee, se eles estiverem estruturados corretamente, poderá obter código de máquina idêntico com as jumpinstruções.

Isso também mostra a falta de representantes canônicos do código fonte original para as instruções do código da máquina. Ao tentar descompilar os loops, como você mapeia as jumpinstruções para as construções em loop? Você faz forloops ou whileloops.

A questão é ainda mais exasperada pelo fato de que os compiladores modernos executam várias formas de dobrar e embutir. Portanto, no momento em que você acessa o código da máquina, é praticamente impossível dizer de que alto nível constrói o código da máquina de baixo nível.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.