
Se você não tem medo da imagem acima, se você sabe como o big-endian difere do little-endian, se você sempre esteve interessado em saber como os arquivos binários são "organizados", este artigo é para VOCÊ!
1. Introdução
Em Habré já havia vários artigos sobre engenharia reversa de formatos binários e sobre o estudo da estrutura de bytecode de um arquivo .class:
Pool de constantes
Fundamentos de Bytecode Java ,
Bytecode Java "Olá, mundo" ,
Olá Mundo da bytecode para JVM etc.
O pesquisador tem a tarefa de lidar com um protocolo binário desconhecido ou cavar uma estrutura binária para a qual existe uma especificação.
Meu interesse em formatos binários surgiu mesmo quando eu era estudante e escrevi um artigo sobre o desenvolvimento do driver do sistema de arquivos Linux. Alguns anos depois, dei palestras sobre os fundamentos do Linux para especialistas forenses - antigamente, o Linux era novo e um jovem especialista depois da universidade podia contar muitas coisas novas para especialistas adultos. Falando sobre como remover um despejo de um disco usando o dd, e depois de conectar a imagem a outro computador para estudo, percebi que a imagem do disco contém muitas informações interessantes. Essas informações podem ser extraídas mesmo sem a montagem da imagem (huh, mount -o loop ...) se você soubesse a especificação para o formato do sistema de arquivos e tivesse as ferramentas apropriadas. Infelizmente, eu não tinha essas ferramentas.
Depois de alguns anos, eu precisava descompilar a biblioteca Java. Não havia JD GUI naqueles dias, assim como um descompilador ideológico, mas havia JAD. Para minha biblioteca, o JAD produziu uma mistura de códigos de código Java com mensagens de erro. Além disso, o JAD não suportava anotações e, no Java 6, que apareceu então, elas eram usadas ao máximo. Armado com a especificação da máquina virtual Java, comecei ...
Idéia
Eu precisava de um mecanismo universal para descrever estruturas binárias e um carregador universal. O carregador, usando a descrição, lerá os dados binários na memória. Você geralmente precisa lidar com números, seqüências de caracteres, matrizes de dados e estruturas compostas. Tudo é simples com números - eles têm um comprimento fixo - 1, 2, 4 ou 8 bytes e podem ser mapeados imediatamente para os tipos de dados disponíveis no idioma. Por exemplo: byte, curto, int, longo para Java. Para tipos numéricos com mais de um byte, um marcador de ordem de bytes (a chamada representação BigEndian / LittleEndiang) deve ser fornecido.
As strings são mais complicadas - elas podem estar em codificações diferentes (ASCII, UNICODE), ter um comprimento fixo ou variável. Uma cadeia de comprimento fixo pode ser considerada como uma matriz de bytes. Para strings de comprimento variável, você pode usar duas opções de gravação - indique seu comprimento no início da linha (strings com prefixo Pascal ou Length) ou coloque um caractere especial no final da linha para indicar o final da linha. Como tal sinal, é utilizado um byte com valor zero (os chamados srings terminados em nulo). Ambas as opções têm vantagens e desvantagens, cuja discussão está além do escopo deste artigo. Se o tamanho for especificado no início, ao desenvolver o formato, você precisará decidir sobre o comprimento máximo da string: quantos bytes precisamos alocar para o marcador de comprimento depende disso: 2 8 - 1 para um byte, 2 16 - 1 para dois bytes, etc.
Vamos distinguir estruturas de dados compostos em classes separadas, continuando a decomposição em números e seqüências de caracteres.
Estrutura do arquivo .class
Precisamos descrever de alguma forma a estrutura do arquivo .class Java. Como resultado, eu gostaria de ter um conjunto de classes Java, em que cada classe contém apenas campos que correspondem à estrutura de dados em estudo e, possivelmente, métodos auxiliares para exibir o objeto em um formato legível por humanos quando o método toString () é chamado. Categoricamente, eu não gostaria de ter a lógica interna responsável pela leitura ou gravação de um arquivo.
Tomamos a especificação da máquina virtual Java,
Especificação da JVM, Java SE 12 Edition .
Estaremos interessados na seção 4 "A classe File Format".
Para determinar quais campos em que ordem carregar, introduzimos a anotação @FieldOrder (index = ...). Precisamos indicar explicitamente a ordem dos campos para o carregador, pois a especificação não nos dá uma garantia da ordem em que eles serão salvos em um arquivo binário.
O arquivo .class Java inicia com 4 bytes de número mágico, dois bytes da versão secundária do Java e dois bytes da versão principal. Empacotamos o número mágico na variável int e o número da versão secundária e secundária em resumo:
@FieldOrder(index = 1) private int magic; @FieldOrder(index = 2) private short minorVersion; @FieldOrder(index = 3) private short majorVersion;
Além disso, no arquivo .class, está o tamanho do pool constante (variável de dois bytes) e o próprio pool constante. Introduzimos a anotação @ContainerSize para declarar o tamanho de matrizes e estruturas de lista. O tamanho pode ser fixo (vamos defini-lo através do atributo value) ou ter um comprimento variável, determinado pela variável lida anteriormente. Nesse caso, usaremos o atributo "fieldName", que indica de qual variável iremos ler o tamanho do contêiner. De acordo com a especificação (seção 4.1,
"The ClassFile Structure"), o tamanho real do pool constante difere em 1 do valor
que é gravado em constant_pool_count:
u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1];
Para explicar essas correções, introduzimos um atributo de corretor adicional nas anotações @ContainerSize.
Agora podemos adicionar uma descrição do pool constante:
@FieldOrder(index = 4) private short constantPoolCount; @FieldOrder(index = 5) @ContainerSize(fieldName = "constantPoolCount", corrector = -1) private List<ConstantPoolItem> constantPoolList = new ArrayList<>();
No caso de cálculos mais complexos, você pode simplesmente adicionar um método get que retornará o valor desejado: @FieldOrder(index= 1) private int containerSize; @FieldOrder(index = 2) @ContainerSize(filed="actualContainerSize") private List<ContainerItem> containerItems; public int getActualContainerSize(){ return containerSize * 2 + 3; }
Piscina constante
Cada elemento no conjunto de constantes é uma descrição da constante correspondente do tipo int, long, float, double, String ou uma descrição de um dos componentes da classe Java - campos (campos) da classe Java, métodos, assinaturas de métodos etc. O termo "constante" aqui significa um valor sem nome usado no código:
if (intValue > 100500)
Um valor de 100500 será representado no pool constante como uma instância de CONSTANT_Integer. A especificação da JVM para Java 12 define 17 tipos que podem estar em um conjunto constante.
Possíveis instâncias de elementos do conjunto const Em nossa implementação, criaremos uma classe ConstantPoolItem na qual haverá uma tag de campo de byte único, que determina qual estrutura estamos lendo no momento. Para cada elemento da tabela acima, crie uma classe Java, um descendente de ConstantPoolItem. Um carregador de arquivos binários universal deve poder determinar qual classe deve ser usada com base em uma marca já lida.
(em geral, uma tag pode ser uma variável de qualquer tipo). Para esse fim, defina a interface HasInheritor e implemente essa interface na classe ConstantPoolItem:
public interface HasInheritor<T> { public Class<? extends T> getInheritor() throws InheritorNotFoundException; public Collection<Class<? extends T>> getInheritors(); }
public class ConstantPoolItem implements HasInheritor<ConstantPoolItem> { private final static Map<Byte, Class<? extends ConstantPoolItem>> m = new HashMap<>(); static { m.put((byte) 7, ClassInfo.class); m.put((byte) 9, FieldRefInfo.class); m.put((byte) 10, MethodRefInfo.class); m.put((byte) 11, InterfaceMethodRefInfo.class); m.put((byte) 8, StringInfo.class); m.put((byte) 3, IntegerInfo.class); m.put((byte) 4, FloatInfo.class); m.put((byte) 5, LongInfo.class); m.put((byte) 6, DoubleInfo.class); m.put((byte) 12, NameAndTypeInfo.class); m.put((byte) 1, Utf8Info.class); m.put((byte) 15, MethodHandleInfo.class); m.put((byte) 16, MethodTypeInfo.class); m.put((byte) 17, DynamicInfo.class); m.put((byte) 18, InvokeDynamicInfo.class); m.put((byte) 19, ModuleInfo.class); m.put((byte) 20, PackageInfo.class); } @FieldOrder(index = 1) private byte tag; @Override public Class<? extends ConstantPoolItem> getInheritor() throws InheritorNotFoundException { Class<? extends ConstantPoolItem> clazz = m.get(tag); if (clazz == null) { throw new InheritorNotFoundException(this.getClass().getName(), String.valueOf(tag)); } return clazz; } @Override public Collection<Class<? extends ConstantPoolItem>> getInheritors() { return m.values(); } }
O carregador universal instancia a classe necessária e continua lendo. A única condição: os índices nas classes sucessoras devem ter numeração de ponta a ponta com a classe pai. Isso significa que em todas as classes derivadas de ConstantPoolItem, FieldOrder, a anotação deve ter um índice maior que um, pois na classe pai já lemos o campo de tag com o número "1".
Estrutura do arquivo .class (continuação)
Após a lista de elementos do pool constante no arquivo .class, há um identificador de dois bytes que define os detalhes dessa classe - a classe é uma anotação, uma interface, uma classe abstrata, possui um sinalizador final etc. Isso é seguido por um identificador de dois bytes (uma referência a um elemento no pool constante) que define essa classe. Este identificador deve apontar para um elemento do tipo ClassInfo. A superclasse para uma determinada classe é definida de maneira semelhante (o que é indicado após a palavra "estender" na definição da classe). Para classes que não possuem superclasses explicitamente definidas, este campo contém uma referência à classe Object.
Em Java, qualquer classe pode ter apenas uma superclasse, mas o número
Pode haver várias interfaces que essa classe implementa:
@FieldOrder(index = 9) private short interfacesCount; @FieldOrder(index = 10) @ContainerSize(fieldName = "interfacesCount") private List<Short> interfaceIndexList;
Cada elemento em interfaceIndexList representa um link para um elemento no pool constante (conforme especificado
o índice deve ser um elemento do tipo ClassInfo).
Variáveis de classe (propriedades, campos) e métodos são representados pelas listas correspondentes:
@FieldOrder(index = 11) private short fieldsCount; @FieldOrder(index = 12) @ContainerSize(fieldName = "fieldsCount") private List<Field> fieldList; @FieldOrder(index = 13) private short methodsCount; @FieldOrder(index = 14) @ContainerSize(fieldName = "methodsCount") private List<Method> methodList;
O elemento final na descrição do arquivo .class Java é a lista de atributos de classe. Os atributos que descrevem o arquivo de origem relacionado à classe, classes aninhadas etc. podem ser listados aqui.
O bytecode Java opera com dados numéricos em uma representação big endian; usaremos essa representação por padrão. Para formatos binários com números little-endian, usaremos a anotação LittleEndian . Para cadeias que não têm um comprimento predefinido, mas
são lidos antes do caractere terminal (como cadeias terminadas em nulo do tipo C), usaremos
Anotação @StringTerminator:
@FieldOrder(index = 2) @StringTerminator(0) private String nullTerminatedString;
Às vezes, nas classes subjacentes, você precisa encaminhar informações de um nível superior. O objeto Method em methodList não possui informações sobre o nome da classe em que está localizado; além disso, o objeto de método não contém seu nome e lista de parâmetros. Toda esta informação é apresentada como índices sobre os elementos no pool constante. Isso é suficiente para uma máquina virtual, mas gostaríamos de implementar os métodos toString () para que eles exibam informações sobre o método de forma amigável ao ser humano, e não na forma de índices de elementos no pool constante. Para fazer isso, a classe Method deve obter uma referência ao ConstantPoolList e a uma variável com o valor thisClassIndex. Para poder transmitir links para os níveis subjacentes de aninhamento, usaremos a anotação Injetar :
@FieldOrder(index = 14) @ContainerSize(fieldName = "methodsCount") @Inject(fieldName = "constantPoolList") @Inject(fieldName = "thisClassIndex") private List<Method> methodList;
Na classe atual (ClassFile), os métodos getter serão chamados para as variáveis constantPoolList e thisClassIndex, e na classe receptora (neste caso, Method), os métodos setter serão chamados (se estiverem presentes).
Carregador de inicialização universal
Portanto, temos uma interface HasInheritor e cinco anotações @FieldOrder, @ContainerSize, LittleEndian , Inject e @StringTerminator, que nos permitem descrever estruturas binárias em um alto nível de abstração. Tendo uma descrição formal, podemos transmiti-la ao carregador universal, que pode instanciar a estrutura descrita, analisar o arquivo binário e lê-lo na memória.
Como resultado, devemos poder usar este código:
ClassFile classFile; try (InputStream is = new FileInputStream(inputFileName)) { Loader loader = new InputStreamLoader(is); classFile = (ClassFile) loader.load(); }
Infelizmente, os desenvolvedores da plataforma Java são um pouco sofisticados demais para valores de oito bytes no pool.
constantes são fornecidas para duas células, a primeira célula deve conter um valor e a segunda permanece
vazio. Isso se aplica a constantes longas e duplas.
Descrição da especificação da JVMTodas as constantes de 8 bytes ocupam duas entradas na tabela constant_pool da classe
arquivo Se uma estrutura CONSTANT_Long_info ou CONSTANT_Double_info for a entrada
no índice n na tabela constant_pool, a próxima entrada utilizável na tabela é
localizado no índice n + 2. O índice constant_pool n + 1 deve ser válido, mas é considerado
inutilizável.
Aparentemente, os desenvolvedores de Java queriam aplicar algum tipo de otimização de baixo nível, mas mais tarde
Reconheceu-se que esta decisão de projeto virou
sem sucesso.Em retrospecto, fazer constantes de 8 bytes tomar duas entradas constantes no pool foi uma má escolha.
Para lidar com esses casos específicos, adicionaremos a anotação @EntrySize, que usaremos,
para marcar constantes de oito bytes:
@EntrySize(value = 2, index = 1) public class EightByteNumberInfo extends ConstantPoolItem { @FieldOrder(index = 2) private int highBytes; @FieldOrder(index = 3) private int lowBytes; }
O atributo value indica o número de células que o elemento ocupará, index - o índice do elemento,
que contém o valor as classes LongInfo e DoubleInfo estenderão a classe EightByteNumberInfo.
O carregador de inicialização universal precisará ser expandido com um suporte funcional à anotação @EntrySize.
public ClassFileLoader(String fileName) { try { File f = new File(fileName); FileInputStream fis = new FileInputStream(f); loader = new EntrySizeSupportLoader(fis); } catch (FileNotFoundException e) { throw new RuntimeException(e); } }
Após carregar a classe com ClassFileLoader, você pode parar o depurador e examinar a classe carregada no inspetor de variáveis no IDE.
O arquivo de classe ficará assim:

E o Constant Pool é assim:

Conclusão
Qualquer pessoa que possa ler até o final pode querer escolher o bytecode Java com suas próprias mãos. Sinta-se à vontade para acessar o github e fazer o download da descrição do arquivo de classe Java como um conjunto de classes Java: https://github.com/esavin/annotate4j-classfile . O carregador universal e as anotações estão aqui: https://github.com/esavin/annotate4j-core .
Para baixar um arquivo de classe compilado, use o carregador annotate4j.classfile.loader.ClassFileLoader.
A maior parte do código foi escrita para Java 6; adaptei apenas o pool constante às versões modernas. Eu não tinha força e desejo de implementar completamente o Java loader para Java opcodes, portanto, existem apenas pequenos desenvolvimentos nesta parte.
Utilizando esta biblioteca (parte principal), consegui reverter o arquivo binário com os dados de monitoramento de Holter (estudo de ECG da atividade cardíaca diária). Por outro lado, não consegui decifrar o protocolo binário de um sistema de contabilidade escrito em Delphi. Não entendi como as datas são transmitidas e, às vezes, surgia uma situação em que os dados reais não correspondiam à estrutura construída com os valores anteriores.
Tentei criar um modelo semelhante ao arquivo de classe Java para o formato ELF (formato executável no Unix / Linux), mas não conseguia entender completamente a especificação - ela acabou sendo muito vaga para mim. O mesmo destino aconteceu nos formatos JPEG e BMP - o tempo todo me deparei com algumas dificuldades em entender a especificação.