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

В рамках текущей статьи будет рассказано о способах инструментации байт-кода 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;

Комментарии (7)


  1. quaer
    24.07.2023 17:18

    Почему декомпиляторы код не всегда могут восстановить?


    1. yrime Автор
      24.07.2023 17:18

      По моему опыту, это происходит в случае когда едет стэк и в итоге не правильно пересчитывается размер фрейма.


      1. quaer
        24.07.2023 17:18
        -4

        А более понятным языком можете объяснить? И что делать в таких случаях? Какой декомпилятор умеет нормально это делать?


        1. yrime Автор
          24.07.2023 17:18

          Сложно ответить в одно сообщение… такие случаи могут возникнуть если вы декомпилируете байт-код написанный под особые интерпритаторы (или надстройки языка например), или если в код внесли изменения, но не соблюли спецификацию java

          можно взять декомпилятор jd-gui, он вполне не плохо разбирает байт-код, но например код котлин он разбирает не всегда


  1. marxxt
    24.07.2023 17:18

    Красиво, можно кстати посмотреть как в jazzer инструментируют, может интересное что-нибудь найдете


    1. aleksandy
      24.07.2023 17:18

      1. marxxt
        24.07.2023 17:18

        Хорошие инструментаторы) я про вот этот juzzer https://github.com/CodeIntelligenceTesting/jazzer