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:String
ou loc1
. Eu ainda podia olhar nesse pequeno escopo local e ver como ele está sendo usado sem muitos problemas. E um for
loop é praticamente a mesma coisa que umwhile
loop, 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.
fonte
Respostas:
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.
fonte
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:
pode ser compilado (desculpe por não ser um montador real):
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 3
para 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.
fonte
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:
Pode ser compilado para:
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
DoSomething
eAdd
, além do fato de oAdd
método ter dois parâmetros nomeados, o compilador também sabe que oDoSomething
mé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.
fonte
ret
e apenas diga que você estava assumindo a convenção de chamada em C.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
for
ouwhile
e, se eles estiverem estruturados corretamente, poderá obter código de máquina idêntico com asjump
instruçõ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
jump
instruções para as construções em loop? Você fazfor
loops ouwhile
loops.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.
fonte