Если вас не пугает картинка выше, если вы знаете чем отличается big-endian от little-endian, если вам всегда было интересно как "устроены" бинарные файлы, значит эта статья для ВАС!
Введение
На Хабре уже было несколько статей про реверс инжинеринг бинарных форматов и про исследование структуры байткода .class файла:
Пул констант,
Java Bytecode Fundamentals,
Java байткод «Hello world»,
Hello World из байт-кода для JVM и т.д.
У исследователя возникает задача либо разобраться с неизвестным бинарным протоколом либо поковырять бинарную структуру на которую есть спецификация.
Мой интерес к бинарным форматам возник еще когда я был студентом и писал курсовую работу по разработке драйвера файловой системы Linux. Несколько лет спустя я читал лекции по основам Linux для экспертов-криминалистов — в давние времена Linux был в новинку и молодой специалист после ВУЗа мог поведать взрослым экспертам много нового. Рассказывая, как снять дамп с диска с помощью dd, а после подключить образ на другом компьютере для изучения, я понимал, что в образе диска лежит много интересной информации. Эту информацию можно было бы извлечь и без монтирования образа (ага, mount -o loop ...), если знать спецификацию на формат файловой системы и иметь соответствующие инструменты. К сожалению, у меня не было таких инструментов.
Еще через несколько лет мне потребовалось декомпилировать Java библиотеку. JD GUI в те времена еще не было, как и идеевского декомпайлера, но был JAD. Для моей библиотеки JAD выдавал смесь Java опкодов с сообщениями об ошибах. К тому же JAD не поддерживал аннотации, а в появившейся тогда Java 6 они использовались по полной. Вооружившись спецификацией на виртуальную машину Java, я начал работу...
Идея
Мне был нужен универсальный механизм для описания бинарных структур и универсальный загрузчик. Загрузчик, используя описание, будет читать бинарные данные в память. Обычно приходиться иметь дело с числами, строками, массивами данных и составными структурами. С числами все просто — они имеют фиксированную длину — 1, 2, 4 или 8 байт и могут быть сразу отображены в типы данных, имеющиеся в языке. Например: byte, short, int, long для Java. Для числовых типов длиной более одного байта нужно предусмотреть маркер порядка байт (так называемое BigEndian/LittleEndiang представление).
Со строками сложнее — они могут быть в различных кодировках (ASCII, UNICODE), иметь фиксированную или переменную длину. Строку фиксированной длинны, можно считать как массив байт. Для строк с переменной длиной можно использовать два варианта записи — указывать в начале строки ее длину (Pascal или Length-prefixed strings) либо в конце строки ставить специальный знак, обозначающий конец строки. В качестве такого знака используют байт со значением ноль (так называемые null-terminated srings). Оба варианта имеют преимущества и недостатки, обсуждение которых выходит за рамки этой статьи. Если размер задается в начале, то при разработке формата нужно определиться с максимальной длиной строки: от этого зависит сколько байт мы должны выделить на маркер длины: 28 — 1 для одного байта, 216 — 1 для двух байт и т.д.
Составные структуры данных будем выделять в отдельные классы, продолжая декомпозицию до чисел и строк.
Структура .class файла
Нам необходимо каким-то образом описать структуру Java .class файла. В качестве результата хотелось бы иметь набор Java классов, где каждый класс содержит только поля, соответсвующие исселдуемой структуре данных и, возможно, вспомогательные методы для отображения объекта в человеко-читаемом виде при вызове toString() метода. Категорически не хотелось бы иметь внутри логику, отвечающую за чтение или запись файла.
Берем спецификациею виртуальной машины Java,
JVM Specification, Java SE 12 Edition.
Нас будет интересовать секция 4 "The class File Format".
Для того, чтобы определить какие поля в каком порядке загружать, введем аннотацию @FieldOrder(index=...). Нам необходимо явно указывать порядок полей для загрузчика, поскольку спецификация не даем нам гарантии на то, в каком порядке они будут сохранены в бинарном файле.
Java .class файл начинается с 4 байт magic number, двух байт минорной версии Java и двух байт мажорной версии. Упакуем magic number в переменную int, а номер минорной и мажорной версии — в short:
@FieldOrder(index = 1)
private int magic;
@FieldOrder(index = 2)
private short minorVersion;
@FieldOrder(index = 3)
private short majorVersion;
Дальше в .class файле идет размер пула констант (двухбайтовая переменная) и сам пул констант. Введем аннотацию @ContainerSize для объявления размера массивов и списочных структур. Размер может быть фиксированный (будем задавать его через аттрибут value) либо иметь переменную длинну, определяемую прочитанной ранее переменной. В этом случае будем использовать "fieldName" аттрибут, который указывает из какой переменной будем считывать размер контейнера. В соответствии со спецификацией (секция 4.1,
"The ClassFile Structure"), реальный размер пула констант отличается на 1 от того значения,
которое записано в constant_pool_count:
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
Чтобы учесть такие коррекции, введем дополнительный аттрибут corrector в @ContainerSize аннотации.
Теперь мы можем добавить описание пула констант:
@FieldOrder(index = 4)
private short constantPoolCount;
@FieldOrder(index = 5)
@ContainerSize(fieldName = "constantPoolCount", corrector = -1)
private List<ConstantPoolItem> constantPoolList = new ArrayList<>();
@FieldOrder(index= 1)
private int containerSize;
@FieldOrder(index = 2)
@ContainerSize(filed="actualContainerSize")
private List<ContainerItem> containerItems;
public int getActualContainerSize(){
return containerSize * 2 + 3;
}
Constant Pool
Каждый элемент в пуле констант представляет из себя либо описание соответствующей константы типа int, long, float, double, String, либо описание одной из составных частей Java класса — поля класса (fields), методы, сигнатуры методов и т.д. Под термином "контстанта" здесь подразумевается неименованое значение, используемое в коде:
if (intValue > 100500)
Значение 100500 будет представленно в пуле констант как экземпляр CONSTANT_Integer. JVM спецификация для Java 12 определяет 17 типов, которые могут быть в пуле констант.
Constant type | Tag |
---|---|
CONSTANT_Class | 7 |
CONSTANT_Fieldref | 9 |
CONSTANT_Methodref | 10 |
CONSTANT_InterfaceMethodref | 11 |
CONSTANT_String | 8 |
CONSTANT_Integer | 3 |
CONSTANT_Float | 4 |
CONSTANT_Long | 5 |
CONSTANT_Double | 6 |
CONSTANT_NameAndType | 12 |
CONSTANT_Utf8 | 1 |
CONSTANT_MethodHandle | 15 |
CONSTANT_MethodType | 16 |
CONSTANT_Dynamic | 17 |
CONSTANT_InvokeDynamic | 18 |
CONSTANT_Module | 19 |
CONSTANT_Package | 20 |
В нашей реализации создадим класс ConstantPoolItem в котором будет однобайтовое поле tag, определяющее какую именно структуру мы читаем в данный момент. На каждый элемент в таблице выше создадим Java класс, наследник ConstantPoolItem. Универсальный загрузчик бинарных файлов должен уметь определять какой именно класс-наследник должен быть использован на основании уже прочитанного тега
(в общем случае тег может быть переменной любого типа). Для этой цели определим интерфейс HasInheritor и реализуем этот интерфейс в классе 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();
}
}
Универсальный загрузчик сам инстанцирует необходимый класс и продложит считывание. Единственное условие: индексы в классах-наследниках должны иметь сквозную нумерацию с родительским классом. Это означает что во всех классах-наследниках ConstantPoolItem, FieldOrder аннатация должна иметь индекс больше единицы, поскольку в родительском классе мы уже прочитали поле tag с номером "1".
Структура .class файла (продолжение)
После списка элементов пула констант в .class файле идет двухбайтовый идентификатор, определяющий детали данного класса — является ли класс аннотацией, интерфейсом, абстрактным классом, имеет ли флаг final и т.п. Далее следует двухбайтовый идентификатор (ссылка на элемент в пуле констант), определяющий данный класс. Этот идентификатор должен указывать на элемент с типом ClassInfo. Аналогичным образом определяется суперкласс для данного класса (то что указано после слова "extends" в определении класса). Для классов, не имеющих явно определенных суперклассов, в данном поле присутствует ссылка на класс Object.
В языке Java у любого класса может быть только один суперкласс, но количество
интерфейсов, которые реализует данный класс может быть несколько:
@FieldOrder(index = 9)
private short interfacesCount;
@FieldOrder(index = 10)
@ContainerSize(fieldName = "interfacesCount")
private List<Short> interfaceIndexList;
Каждый элемент в interfaceIndexList представляет ссылку на элемент в пуле констант (по указанному
инедксу должен находится элемент с типом ClassInfo).
Переменные класса (properties, fields) и методы представленны соответсвующими списками:
@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;
Последним элементом в описании Java .class файла является список аттрибутов класса. Здесь могут быть перечислены аттрибуты описывающие исходный файл, относящийся к классу, вложенные классы и т.д.
Java bytecode оперирует числовыми данными в big-endian представлении, будем это представление использовать по умолчанию. Для двоичных форматов с little-endian числами будем использовать LittleEndian аннотацию. Для строк, которые не имеют предопределенной длины, а
считываются до терминального символа (как C-like null-terminated строки) будем использовать
аннотацию @StringTerminator:
@FieldOrder(index = 2)
@StringTerminator(0)
private String nullTerminatedString;
Иногда в нижележащие классы нужно пробросить информацию с более высокого уровня. Объект Method в methodList не имеет информации об имени класса, в котором он находится, более того объект-метод не содержит своего названия и списка параметров. Вся эта информация представленна в виде индексов на элементы в пуле констант. Для виртуальной машины этого достаточно, но нам хотелось бы реализовать методы toString(), чтобы они отображали информацию о методе в удобном для человека виде, а не в виде индексов на элементы в пуле констант. Для этого класс Method должен получить ссылку на ConstantPoolList и на переменную со значением thisClassIndex. Чтобы иметь возможность передавать ссылки на нижележащие уровни вложенности, будем использовать аннотацию Inject:
@FieldOrder(index = 14)
@ContainerSize(fieldName = "methodsCount")
@Inject(fieldName = "constantPoolList")
@Inject(fieldName = "thisClassIndex")
private List<Method> methodList;
В текущем классе (ClassFile) будут вызываться getter методы для constantPoolList и thisClassIndex переменных, а в принимающем классе (в данном случае Method), будут вызваны setter методы (если они присутствуют).
Универсальный загрузчик
Итак, у нас есть один интерфейс HasInheritor и пять аннотаций @FieldOrder, @ContainerSize, LittleEndian, Inject и @StringTerminator, которые позволяют описывать бинарные структуры на высоком уровне абстракции. Имея формальное описание, мы можем передать его универсальному загрузчику, который сможет инстанцировать описанную структуру, осуществить разбор бинарного файла и зачитать его в память.
В результате мы должны иметь возможность использовать такой код:
ClassFile classFile;
try (InputStream is = new FileInputStream(inputFileName)) {
Loader loader = new InputStreamLoader(is);
classFile = (ClassFile) loader.load();
}
К сожалению, разработчики Java платформы немного перемудрили и для восьмибайтных значений в пуле
констант предусмотрели две ячейки, причем первая ячейка должна содержать значение, а вторая остается
пустой. Это касается long и double констант.
All 8-byte constants take up two entries in the constant_pool table of the class
file. If a CONSTANT_Long_info or CONSTANT_Double_info structure is the entry
at index n in the constant_pool table, then the next usable entry in the table is
located at index n+2. The constant_pool index n+1 must be valid but is considered
unusable.
По всей видимости, разработчики Java хотели применить какую-то низкоуровневую оптимизацию, но позже
было признано, что это дизайнерское решение оказалось
In retrospect, making 8-byte constants take two constant pool entries was a poor choice.
Чтобы обработать эти специфичные случаи, добавим аннотацию @EntrySize, которую будем использовать,
чтобы пометить восьмибайтные константы:
@EntrySize(value = 2, index = 1)
public class EightByteNumberInfo extends ConstantPoolItem {
@FieldOrder(index = 2)
private int highBytes;
@FieldOrder(index = 3)
private int lowBytes;
}
Аттрибут value указывает на количество ячеек, которые будет занимать элемент, index — индекс элемета,
который содержит значение. классы LongInfo и DoubleInfo будут расширять класс EightByteNumberInfo.
Универсальный загрузчик нужно будет расширить фукционалом, поддерживающим аннотацию @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);
}
}
После загрузки класса ClassFileLoader'ом можно остановить отладчик и исследовать загруженный класс в инспекторе переменных в IDE.
Class file будет выглядеть вот так:
А Constant Pool так:
Заключение
Тот кто смог дочитать до конца, возможно захочет поковырять Java байткод своими руками. Смело идите на гитхаб и качайте описание Java class файла в виде набора Java классов: https://github.com/esavin/annotate4j-classfile. Универсальный загрузчик и аннотации лежат здесь: https://github.com/esavin/annotate4j-core.
Для загрузки скомпилированного class файла воспользуйтесь загрузчиком annotate4j.classfile.loader.ClassFileLoader.
Большая часть кода была написана для Java 6, к современным версиям я адоптировал только constant pool. Сил и желания полностью реализовать загрузчик Java opcode'ов у меня не хватило, поэтому там только небольшие наработки в этой части.
Используя эту библиотеку (core часть) мне удалось зареверсить бинарный файл с данными Холтер мониторинга (ЭКГ исследование суточной активности сердца). С другой стороны, я не смог расшифровать бинарный протокол одной учетной системы, написанной на Delphi. Я не разобрался как передаются даты и иногда возникала ситуация, когда фактичиские данные не соответствовали структуре, построенной по предыдущим значениям.
Я пытался построить аналогично Java class файлу модель для ELF формата (запускаемый формат в Unix/Linux), но я не смог полностью понять спецификацию — она оказалась для меня слишком расплывчатой. Та же участь постигла JPEG и BMP форматы — все время натыкался на какие-то сложности с пониманием спецификации.
Комментарии (4)
pewpew
20.12.2019 02:13Та же участь постигла JPEG и BMP форматы — все время натыкался на какие-то сложности с пониманием спецификации.
Хм… Помнится ещё во времена Turbo Pascal ковырял эти форматы. Начал с .TGA, как самого простого, дальше был .BMP. Ещё даже заставка в Windows 95 с переливающимся градиентом оказалась простой .BMP-шкой с немного хитрым заголовком.
Что касается .JPEG, то разобрался с baseline-кодированными файлами. До progressive руки тогда не дошли. Но точно помню, что особой сложности не было.esavin Автор
20.12.2019 11:05Когда я работал над классами для Java bytecode, я прогонял через загрузку/выгрузку все классы от сервера приложений Weblogic, далее сравнивал выход моей программы с оригиналом. Там где были изменения я анализировал в чем были мои недочеты. Когда начал работать над BMP форматом — то сразу застрял на загололвке: там описаны возможные варианты для OS2, а у меня таких файлов не было для исследования. Скорее всего правилней бьыло бы взять любой имеющийся BMP файл и создавать структуру, как частный случай. Но мне хотелось покрыть полностью специйикацию, а без конкретных примеров это было бы проблематично.
APXEOLOG
Для любителей поковырять бинарные форматы рекомендую Kaitai Struct и их довольно удобную Web IDE. Возможность на лету строить маппинг данных и видеть результат сильно ускоряет процесс
esavin Автор
Действительно Kaitai классная штука. Когда я начинал свою разработку ее еще не было.