Короткое введение
В рамках текущей статьи будет рассказано о способах инструментации байт-кода java или, другим языком, внесения изменений в компилированные файлы java .class. Здесь будут приведены примеры работы с фреймворками Javaassist и ASM и базовое описание байт-кода.
База
Инструментация байт-кода проводится путем внедрения необходимых инструкций перед определенными операциями. Каждая операция описывается собственным набором инструкций. Список инструкций можно посмотреть здесь, на страничке официальной документации.
Немного о байт-коде
В результате компиляции кода создаются файлы формата .class, которые можно упаковать в jar-файл. Формат .class файла имеет следующий вид:
docs
подробное описание формата можно найти в официальной документации или в демонстрационном коде разбора формата файла
Заголовок class всегда начинается с сигнатуры CAFEBABE
, после чего следует версия формата класса и размер его заголовка. Этими полями, соответственно, являются magic, version, constant_pool_size
.
Описание некоторых интересных полей файла:
magic — 0xCAFEBABE;
constant_pool — набор констант, содержащих в себе описание классов, методов, полей класса, статических строк и другие значения, которые были заданы в коде;
attributes — содержат в себе информацию, которая расширяет заданные в constant_pool значения. В файле находятся после массива constant_pool. Формат поля attributes:
Теперь создадим тестовый класс и декодируем его:
Из декодированного класса интересуют следующие атрибуты:
Code — содержит в себе непосредственно байт-код, который будет выполняться;
StackMapTable — содержит в себе размер стека, необходимый для вычисления размера frame.
что такое frame, зачем он, как вычисляется
минутка компиляции документации [1, 2, 3]
Java требует проверки всех загружаемых классов на уровне байт-кода на то, что байт-код имеет смысл в соответствии с его правилами. Помимо прочего, проверка байт-кода гарантирует, что инструкции правильно сформированы, что все переходы осуществляются к действительным инструкциям внутри метода и что все инструкции работают со значениями правильного типа.
Проверка байт-кода замедляет загрузку классов в Java. Oracle решила эту проблему, добавив новый, более быстрый верификатор, который может проверять байт-код за один проход. Для этого они потребовали, чтобы все новые классы, начиная с Java 7 (с Java 6 в переходном состоянии), содержали метаданные о своих типах. Поскольку сам формат байт-кода изменить нельзя, информация об этом типе хранится отдельно в атрибуте с именем StackMapTable.
Каждый поток виртуальной машины Java имеет частный стек виртуальной машины Java, созданный одновременно с потоком. Стек виртуальной машины Java хранит фреймы(frame). Frame используется для хранения локальных данных и частичных результатов выполнения байт-кода, динамического связывания, возврата значений для методов и отправки исключений. На каждый вызов метода jrm создает новый фрейм, который содержит в себе локальный стек, выделенный из стека виртуальной машины java.
Каждый фрейм имеет свой собственный массив локальных переменных, свой собственный стек операндов и ссылку на пул констант времени выполнения класса текущего метода. Размеры массива локальных переменных и стека операндов определяются во время компиляции и предоставляются вместе с кодом метода, связанного с фреймом.
Заданы различные типы фреймов:
same_frame;
same_locals_1_stack_item_frame;
same_locals_1_stack_item_frame_extended;
chop_frame;
same_frame_extended;
append_frame;
full_frame.
Данные типы фрейма определяют его размер и смещение в зависимости от определенных в них размеров массива локальных переменных, стека операндов и пула констант.
Структура StackMapTable имеет следующий формат:
StackMapTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 number_of_entries;
stack_map_frame entries[number_of_entries];
}
Каждая stack_map_frame структура из структуры StackMapTable определяет состояние типа фрейма по определенному смещению в байт-коде. Каждый тип фрейма задает значение offset_delta, которое используется для вычисления фактического смещения байт-кода, при котором применяется фрейм. Смещение байт-кода, при котором применяется фрейм, вычисляется путем прибавления offset_delta + 1 к смещению байт-кода предыдущего фрейма, если только предыдущий фрейм не является начальным, в этом случае смещение байт-кода равно offset_delta.
При использовании дельты смещения, а не фактического смещения байт-кода гарантируется, что кадры карты стека находятся в правильном порядке и дубликаты фрейма отсутствуют.
Следовательно, при ручном вмешательстве в байт-код необходимо произвести пересчет фреймов.
Приведем пример декодирования одного поля Code:
На картинке выше видно поле Code = 0x1b36420375b1, его декодирование равно следующей строке опкодов: iload_1, istore, 4, lload_2, lstore 5, return, что, соответственно, равно следующему коду:
Имя, тип возвращаемого значения, аргументы находятся в других атрибутах.
Рассматриваемые фреймворки инструментации
Наиболее популярными фреймворками для инструментации байт-кода являются:
javaassist;
ASM.
По сравнению данных фреймворков есть хорошие статьи:
Здесь приведем краткую выдержку.
API Javaassist значительно проще в использовании и лучше документировано, чем фреймворк ASM. Javaassist фактически скрывает в себе операции манипулирования байт-кодом, программист пишет код java, который уже средствами библиотеки транслируется в байт-код и внедряется в уже существующие классы. Но за фактической легкостью использования javaassit скрывается его ограниченность в задачах по манипуляции байт-кода, например, данный фреймворк не позволяет производить внедрение исполняемых инструкций в произвольное место класса.
Учитывая, что javaassist использует механизм отражения кода java в байт-код, это делает работу фреймворка значительно более медленной, чем фреймворка ASM, разница в скорости работы может составлять более 5-ти раз.
Таким образом, область приложения Javassist — это динамическое управление и создание классов Java, которое не требует высокой производительности. Для других же задач, связанных с манипуляцией байт-кода, где также требуется высокая скорость работы выбирается фреймворк ASM.
Документация
JavaAssist
Пример простого приложения, которое внедряет код в jar-файл.
Разберем его.
На вход приложению поступает jar-файл, в который необходимо внедрить код. Данный файл необходимо загрузить в classloader:
jarUrl = new URL("file:///" + jarname);
loader = new URLClassLoader(new URL[]{jarUrl});
jar = new JarFile(jarname);
Далее производится инициализация javassist. ClassPool создает пул классов CtClass из classloader, передача загрузчика в пул осуществляется классом LoaderClassPath:
ClassPool cp = new ClassPool ();
ClassPath ccp = new LoaderClassPath(loader);
cp.insertClassPath ( ccp );
Необходимо дать уточнение, что по документации предлагается использовать javaagent, но так как в данном случае создается статический инструментатор, приложение пишется в обычной форме.
Получив пул классов, которые мы будем инструментировать, необходимо их перебрать и передать на инструментацию. В случае с javaagent данное действие не требуется, так как агент берет классы под инструментацию из classloader.
Перебор классов будем делать по jar-файлу. Каждый класс, прочитанный из jar, необходимо привести к виду package.class. По имени класса извлекается объект из пула классов:
for(Enumeration entries = jar.entries(); entries.hasMoreElements(); ){
entry = entries.nextElement();
fileName = entry.getName();
if(fileName.endsWith(".class")){
className = fileName.replace('/', '.').substring(0, fileName.length() - 6);// 6 = (".class").length()
try{
....
try {
cc = cp.get(className);//get class from pool class
injectCode(cc);
}catch (NotFoundException e){
e.printStackTrace();
}
}catch (Throwable e){
e.printStackTrace();
}
}
}
Внедрим код в начало каждого метода заданного класса, для этого получаем массив методов CtMethod ctm[] = cc.getDeclaredMethods();
. Для вставки кода в начало метода используется метод insertBefore
. Обратим внимание, что внедряемый код описывается строкой: String.format( "System.out.println(" injected code to method %s ");", mName )
private void injectCode(CtClass cc) throws CannotCompileException, IOException
{
CtMethod ctm[] = cc.getDeclaredMethods();
....
for(int i = 0; i < ctm.length; ++i){
mName = ctm[i].getName();
CtMethod ctmBuffer = ctm[i];
ctmBuffer.insertBefore(String.format(
"System.out.println(" injected code to method %s ");", mName
));
}
....
После того как было проведено внедрение, необходимо заново сформировать байт-код класса и записать его изменения. Производится это следующим образом:
byte[] b = cc.toBytecode();
cc.writeFile("instrumentedClasses");
В аргументах writeFile
указывается имя директории, в которую будет сохранен модифицированный класс. Соответственно, при запуске кода из репозитория с примером в стартовом каталоге появится папка instrumentedClasses
, в которой будут модифицированные классы; чтобы закончить инструментацию, необходимо внедрить их в jar-файл:
Внедрение модифицированных классов в тестовое приложение test-1.0.jar:
jar uf test-1.0.jar *
Продемонстрируем работу инструментатора. Запуск тестового приложения:
Запуск того же приложения после инструментации:
ASM
По аналогии с примером javaassist на вход инструментатора подается jar-файл, далее производятся манипуляции с классами, и их измененные копии сохраняются в указанную директорию. Пример приложения, реализующего фреймворк ASM.
Чтобы проводить манипуляции с классами, необходимо преобразовать их в байтовый поток InputStream
:
InputStream is; // = clazz.getClassLoader().getResourceAsStream(fileName);
Class<?> clazz;
byte[] out;
...
try{
clazz = loader.loadClass(className);
is = clazz.getClassLoader().getResoutceAsStream(fileName);
out = Transformer.transform(is.readAllBytes());
}
Байтовый массив byte[] out
содержит в себе результат преобразования класса, который необходимо сохранить в директорию:
File outFile;
outFile = new File(String.format("ModifyClasses/%s", fileName));
Files.createDirectories(outFile.getParentFile().toPath());
try(FileOutputStream fos = new FileOutputStream(outFile)){
fos.write(out);
}
Теперь опишем работу функции преобразования класса Transformer.transform
:
public class Transformer {
public static byte[] transform(byte[] bytes) throws Exception{
ClassReader classReader = new ClassReader(bytes);
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES);
ClassVisitor classVisitor = new ClassInstrumentation(classWriter);
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
return classWriter.toByteArray();
}
}
Класс ClassReader
выполняет чтение инструментируемого класса, а затем — его преобразование с использованием класса ClassVisitor
.
Класс ClassWriter (public class ClassWriter extends ClassVisitor)
производит трансляцию модифицированного байт-кода в непосредственно двоичный файл.
Абстрактный класс ClassVisitor
предоставляет реализацию API ASM
и позволяет производить модификацию байт-кода. Реализация класса построена на механизме методов «визиторов»: посещение каждого опкода байт-кода регистрируется фреймворком и формируется событие, которое может перехватить пользовательский обработчик и произвести манипуляцию с кодом.
Класс ClassInstrumentation (public class ClassInstrumentation extends ClassVisitor)
— пользовательский класс, реализующий абстрактный класс ClassVisitor
:
public ClassInstrumentation(ClassVisitor classVisitor) {
super(ASM9, classVisitor);
}
void visit(int i, int i1, String s, String s1, String s2, String[] strings) {
System.out.println("instrumented class: " + s);
super.visit(i, i1, s, s1, s2, strings);
}
MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
methodVisitor = new MethodTransformer(methodVisitor, access, name, descriptor, signature, exceptions);
return methodVisitor;
}
Реализация класса строится путем перекрытия методов суперкласса ClassVisitor
. Например, метод public MethodVisitor visitMethod
реализует событие посещения первой инструкции метода. При посещении метода его преобразование производится классом MethodTransformer
:
private class MethodTransformer extends MethodVisitor{
String name;
public MethodTransformer(MethodVisitor methodVisitor, int access, String name,
String descriptor, String signature, String[] exceptions){
super(ASM9, methodVisitor);
this.name = name;
}
private void instr(){
super.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out",
"Ljava/io/PrintStream;");
super.visitLdcInsn("this instrumented method " + name);
super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream",
"println", "(Ljava/lang/String;)V", false);
}
public void visitCode(){
super.visitCode();
instr();
}
}
Класс MethodTransformer
реализует абстрактный класс MethodVisitor
, в котором мы добавляем вызов System.out.println("this instrumented method " + name);
, для этого перегружается метод public void visitCode()
. В нем добавляется вызов пользовательского метода модификации байт-кодаinstr();
.
Итак, запустим инструментатор, на вход подается тестовый файл test-1.0.jar
:
Результат инструментации сохраняется в формате .class
в каталог ModifyClasses
, где необходимо записать изменения в jar-файл (jar uf test-1.0.jar *
):
Запуск неинструментированного jar-файла test-1.0.jar:
И результат запуска инструментированного jar:
Инструментатор в виде JavaAgent
Инструментация байт-кода во время выполнения или динамическая инструментация выполняется, используя опцию:
java: -javaagent
дока
Javaagent представляет из себя программу, которая запускается перед запуском основной программы. Пример:
java: -javaagent:myagent.jar -jar myjar.jar
Javaagent реализует пакет java.lang.instrument
, который позволяет производить инструментацию загружаемого класса в jrm.
Каждый javaagent должен реализовывать метод premain, имеющий следующие определения:
public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs).
Класс Instrumentation
[Instrumentation (Java SE 17 & JDK 17) (oracle.com)] предоставляет интерфейс для инструментации. Получая экземпляр класса, javaagent регистрирует инструментатор (JavaTransformer jTransformer) в jrm, который будет запускаться каждый раз, когда classloader будет загружать исполняемый класс в java машину:
public static void premain(String arg, Instrumentation inst) {
System.out.println("Hello! I`m java agent");
JavaTransformer jTransformer = new JavaTransformer();
inst.addTransformer(jTransformer);
}
Пользовательский инструментатор байт-кода должен реализовывать класс ClassFileTransformer
[ClassFileTransformer (Java SE 17 & JDK 17) (oracle.com)], а конкретно его метод public byte[] transform(...)
public class JavaTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
{
...
}
}
Метод transform
выполняет непосредственную инструментацию байт-кода, получая байт-массив класса:
byte[] classfileBuffer
Далее, получив байт-код класса, выполняется инструментация по одному из описанных выше методов инструментации:
// Пример на ASM:
ClassReader classReader = new ClassReader(classfileBuffer);
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES);
ClassVisitor classVisitor = new ClassIntrumentation(classWriter);
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
classfileBuffer = classWriter.toByteArray();
И зачем все это было?
Стоит вопрос о применении инструментации: зачем она нужна? В данном случае работа велась для создания фаззера Java, скрещения ежа и ужа, WinAFL и Spring. О чем и будет рассказано позже.
Спасибо за внимание ;)
Ссылки
Мера покрытия кода: https://docs.microsoft.com/ru-ru/previous-versions/visualstudio/visual-studio-2015/test/using-code-coverage-to-determine-how-much-code-is-being-tested?view=vs-2015&redirectedfrom=MSDN;
Инструкции байт-кода: https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html;
Описание формата class файла: https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.6-200-A.1;
Демонстрационный код разбора class файла: https://github.com/yrime/BytecodeDecoder.git;
Описание фрейма байт кода:
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-2.html#jvms-2.6;
https://stackoverflow.com/questions/25109942/what-is-a-stack-map-frame;
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7.15;Сравнение фреймворков ASM и JavaAssist:
https://blog.spacepatroldelta.com/a?ID=01550-464f0477-df0a-44a4-8e9d-fabe8aafdbe7;
https://newrelic.com/blog/best-practices/diving-bytecode-manipulation-creating-audit-log-asm-javassist;Документация фреймворка ASM: https://asm.ow2.io/documentation.html;
Документация фреймворка javaassit: https://www.javassist.org/tutorial/tutorial.html;
Демонстрационный код использования фреймворка javaassit: https://github.com/yrime/JAssist_test.git;
Демонстрационный код использования фреймворка ASM: https://github.com/yrime/JASM_test.git;
Документация на опцию javaagent:
https://docs.oracle.com/en/java/javase/17/docs/api/java.instrument/java/lang/instrument/package-summary.html;
https://docs.oracle.com/en/java/javase/17/docs/api/java.instrument/java/lang/instrument/Instrumentation.html;
https://docs.oracle.com/en/java/javase/17/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html;
Комментарии (7)
marxxt
24.07.2023 17:18Красиво, можно кстати посмотреть как в jazzer инструментируют, может интересное что-нибудь найдете
aleksandy
24.07.2023 17:18Однако...
marxxt
24.07.2023 17:18Хорошие инструментаторы) я про вот этот juzzer https://github.com/CodeIntelligenceTesting/jazzer
quaer
Почему декомпиляторы код не всегда могут восстановить?
yrime Автор
По моему опыту, это происходит в случае когда едет стэк и в итоге не правильно пересчитывается размер фрейма.
quaer
А более понятным языком можете объяснить? И что делать в таких случаях? Какой декомпилятор умеет нормально это делать?
yrime Автор
Сложно ответить в одно сообщение… такие случаи могут возникнуть если вы декомпилируете байт-код написанный под особые интерпритаторы (или надстройки языка например), или если в код внесли изменения, но не соблюли спецификацию java
можно взять декомпилятор jd-gui, он вполне не плохо разбирает байт-код, но например код котлин он разбирает не всегда