Инструментация байт-кода Java

от автора

Короткое введение

В рамках текущей статьи будет рассказано о способах инструментации байт-кода java или, другим языком, внесения изменений в компилированные файлы java .class. Здесь будут приведены примеры работы с фреймворками Javaassist и ASM и базовое описание байт-кода.

База

база

база

Инструментация байт-кода проводится путем внедрения необходимых инструкций перед определенными операциями. Каждая операция описывается собственным набором инструкций. Список инструкций можно посмотреть здесь, на страничке официальной документации.

Немного о байт-коде

В результате компиляции кода создаются файлы формата .class, которые можно упаковать в jar-файл. Формат .class файла имеет следующий вид:

формат .class

формат .class
docs

подробное описание формата можно найти в официальной документации или в демонстрационном коде разбора формата файла

Заголовок .class

Заголовок .class

Заголовок 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, что, соответственно, равно следующему коду:

декодированное поле code

декодированное поле code

Имя, тип возвращаемого значения, аргументы находятся в других атрибутах.

Рассматриваемые фреймворки инструментации

Наиболее популярными фреймворками для инструментации байт-кода являются:

  • 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. О чем и будет рассказано позже.

Спасибо за внимание 😉

Ссылки

  1. Мера покрытия кода: 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;

  2. Инструкции байт-кода: https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html;

  3. Описание формата class файла: https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.6-200-A.1;

  4. Демонстрационный код разбора class файла: https://github.com/yrime/BytecodeDecoder.git;

  5. Описание фрейма байт кода:
    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;

  6. Сравнение фреймворков 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;

  7. Документация фреймворка ASM: https://asm.ow2.io/documentation.html;

  8. Документация фреймворка javaassit: https://www.javassist.org/tutorial/tutorial.html;

  9. Демонстрационный код использования фреймворка javaassit: https://github.com/yrime/JAssist_test.git;

  10. Демонстрационный код использования фреймворка ASM: https://github.com/yrime/JASM_test.git;

  11. Документация на опцию 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;


ссылка на оригинал статьи https://habr.com/ru/articles/750028/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *