Оглавление

  1. Немного теории

  2. Разбор кода вредоносного агента

  3. Методы защиты

  4. Заключение

Обычно Java‑агенты используются для сбора телеметрии, логирования, профилирования, каких‑то ультрабыстрых хотфиксов и прочих скучных вещей.

Но сегодня мы исследуем приложение Java‑агент, которое модифицирует код другого Java‑приложения «на лету» и создано для обхода лицензионных ограничений. Называть его мы будем «агент», «вредоносный агент» и т. п.

Меня зовут Сергей Капустин, тимлид бэкэнд-команды продукта Data Ocean Cluster Manager вендора Data Sapience.

Код в нашем примере будет максимально обезличен.

Эта тема рассматривается исключительно в целях исследования вопросов безопасности приложений и этичного использования инструментов изменения кода во время компиляции.

В конце я предложу примеры методов отслеживания несанкционированного вмешательства в Java-приложение, а также методов противодействия, чтобы избавиться от рисков, которые мы обнаружим в процессе исследования.

Дисклеймер

В статье приводятся фрагменты кода, относящиеся к вредоносному агенту, эти фрагменты изменены, из них вычищены все sensitive data, названия некоторых функций и переменных изменены. У вас не получится собрать из этого кода исходный вредоносный Java Agent.

Также в статье не рассматриваются вопросы обхода криптографических проверок и деталей реализации фейковых ответов от лицензионных серверов. Я рассказываю о зловреде исключительно со стороны разработчика Java.

Автор не использовал и не собирается использовать исследуемый программный код в противоправных целях.

Немного теории

Вся магия происходит с помощью двух библиотек: Java Instrumentation API и ASM Framework.

В данном разделе будут рассматриваться примеры кода для иллюстрации теории, примеры реального кода вредоносного агента можно увидеть в следующем разделе.

1 Java Instrumentation API

Java Instrumentation API дает возможность изменять файлы классов Java во время выполнения. Эта модификация, часто называемая «инструментацией байт-кода», позволяет разработчикам менять поведение существующих классов без изменения исходного кода. Пакет java.lang.instrument.

Основные возможности:

  1. Трансформация классов — изменение байт-кода классов при их загрузке;

  2. Перезагрузка классов — модификация уже загруженных классов;

  3. Мониторинг — отслеживание размера объектов, количества экземпляров;

  4. Профилирование — анализ производительности приложения.

Ключевые интерфейсы

Instrumentation

interface Instrumentation
public interface Instrumentation {
 void addTransformer(ClassFileTransformer transformer);
 void retransformClasses(Class... classes);
 long getObjectSize(Object objectToSize);
 Class[] getAllLoadedClasses();
 }

ClassFileTransformer

interface ClassFileTransformer
public interface ClassFileTransformer {
 byte[] transform(ClassLoader loader, String className,
 Class classBeingRedefined,
 ProtectionDomain protectionDomain,
 byte[] classfileBuffer);
 }

Описание статического способа подключения Java-агента (premain) при старте JVM:

 Создаем класс с методом «premain»

class MyAgent
public class MyAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new MyTransformer());
    }
}

Прописываем созданный класс в файле Manifest.mf:

Premain-Class: com.example.MyAgent
Can-Retransform-Classes: true

При запуске указываем jar-файл с программой-агентом:

java -javaagent:myagent.jar TargetApp

Области применения:

  • APM-системы (Application Performance Monitoring);

  • Логирование и трассировка;

  • Инъекции кода для мониторинга;

  • Хот-деплой в процессе разработки;

  • Анализ памяти и производительности.

Ограничения:

  • Нельзя добавлять/удалять методы и поля;

  • Нельзя изменять иерархию классов;

  • Изменения схемы класса требуют перезапуска JVM;

  • Некоторые системные классы защищены от модификации.

Instrumentation API особенно полезен для создания инструментов мониторинга, профилирования и отладки enterprise-приложений.

2 ASM Framework

ASM — низкоуровневая Java-библиотека для манипуляций с байт-кодом, предоставляющая возможность генерации, трансформации и анализа Java-классов на уровне байт-кода. Библиотека позволяет разработчику динамически создавать новые классы, модифицировать существующие, а также выполнять статический анализ кода без необходимости работать с исходными файлами Java. Пакет org.objectweb.asm.

Основные возможности: чтение структуры классов через visitor pattern, прямая модификация инструкций байт-кода, создание классов "на лету" с минимальными накладными расходами по CPU и RAM.

Основные особенности:

  • Высокая производительность — минимальные накладные расходы;

  • Компактность — размер JAR ~60KB;

  • Visitor Pattern — элегантная архитектура обхода структуры класса;

  • Поддержка современных Java — включая Records, Pattern Matching.

Рассмотрим примеры кода с применением этого фреймворка:

Core API (Visitor-based)

Принципы работы:

  • Потоковая обработка — события генерируются последовательно при чтении/записи;

  • Visitor Pattern — каждый элемент класса посещается через callback-методы;

  • Однопроходная обработка — нельзя вернуться к предыдущим элементам;

  • Минимальное потребление памяти — данные не хранятся в памяти. ASM обрабатывает байт-код «на лету», поэтому не хранит целиком класс в памяти — оттуда и минимальные накладные расходы.

ClassVisitor

abstract class ClassVisitor
public abstract class ClassVisitor {
    public void visit(int version, int access, String name, 
                     String signature, String superName, String[] interfaces);
    public MethodVisitor visitMethod(int access, String name, String desc, 
                                   String signature, String[] exceptions);
    public FieldVisitor visitField(int access, String name, String desc, 
                                 String signature, Object value);
}
Пример чтения класса
ClassReader reader = new ClassReader("com.example.MyClass");
ClassVisitor visitor = new ClassVisitor(ASM9) {
    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, 
                                   String signature, String[] exceptions) {
        System.out.println("Method: " + name + desc);
        return super.visitMethod(access, name, desc, signature, exceptions);
    }
};
reader.accept(visitor, 0);

Tree API (Object-based)

Принципы работы:

  • Объектная модель — весь класс представлен как граф объектов;

  • Случайный доступ — можно обращаться к любому элементу в любом порядке;

  • Многопроходная обработка — можно несколько раз проходить по структуре;

  • Высокое потребление памяти — вся структура класса в памяти.

ClassNode

ClassNode
public class ClassNode extends ClassVisitor {
    public int version;
    public int access;
    public String name;
    public String signature;
    public String superName;
    public List<String> interfaces;
    public List<AnnotationNode> visibleAnnotations;
    public List<FieldNode> fields;
    public List<MethodNode> methods;
}
Работа с ClassNode
ClassReader reader = new ClassReader("com.example.MyClass");
ClassNode classNode = new ClassNode();
reader.accept(classNode, 0);

// Модификация
for (MethodNode method : classNode.methods) {
    if (method.name.equals("targetMethod")) {
        // Модифицируем инструкции
        method.instructions.insert(new LdcInsnNode("Log message"));
        method.instructions.insert(new MethodInsnNode(INVOKESTATIC, 
            "java/lang/System", "println", "(Ljava/lang/String;)V"));
    }
}

// Генерация байт-кода
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
classNode.accept(writer);
byte[] bytecode = writer.toByteArray();
Создание класса с нуля
ClassWriter cw = new ClassWriter(0);
cw.visit(V11, ACC_PUBLIC, "com/example/Generated", null, 
         "java/lang/Object", null);

// Конструктор
MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();

// Метод
mv = cw.visitMethod(ACC_PUBLIC, "hello", "()Ljava/lang/String;", null, null);
mv.visitCode();
mv.visitLdcInsn("Hello World");
mv.visitInsn(ARETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();

byte[] bytecode = cw.toByteArray();
Добавление логирования в методы
public class LoggingAdapter extends MethodVisitor {
    private String methodName;
    
    public LoggingAdapter(MethodVisitor mv, String methodName) {
        super(ASM9, mv);
        this.methodName = methodName;
    }
    
    @Override
    public void visitCode() {
        super.visitCode();
        // Добавляем логирование в начало метода
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", 
                         "Ljava/io/PrintStream;");
        mv.visitLdcInsn("Entering method: " + methodName);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", 
                          "println", "(Ljava/lang/String;)V", false);
    }
}

Области применения:

  • Фреймворки — Spring, Hibernate и др. используют ASM для proxy-генерации;

  • Инструментирование — добавление метрик, логирования, трассировки;

  • Компиляторы — Kotlin, Scala компилируют через ASM;

  • Мокинг — Mockito, PowerMock для создания mock-объектов;

  • AOP — AspectJ для внедрения cross-cutting concerns.

Преимущества по сравнению с библиотекой Reflection:

  • Производительность — в 10-100 раз быстрее Reflection;

  • Compile-time — генерация кода во время сборки;

  • Типобезопасность — генерируемый код проверяется компилятором.

На данный момент ASM — де-факто стандарт для работы с байт-кодом в Java-экосистеме.

3 Интеграция ASM с Instrumentation

Интеграция ASM с Instrumentation в Java используется для изменения байт-кода классов во время их загрузки JVM. 

Вот основные причины и сценарии:

  • Профилирование. Позволяет внедрять дополнительную логику (логирование, трэкинг, метрики, профилирование) без изменений исходного кода.

  • AOP. Реализация аспектно-ориентированного программирования (например, аннотирование методов для безопасности или мониторинга).

  • Тестирование и анализ. Динамическая вставка проверок, мок-объектов, возможность ловить исключения.

  • Оптимизация. Замена и оптимизация определённых фрагментов кода на лету.

Как это работает:

  • Java Agent подключается через Instrumentation API (java.lang.instrument.Instrumentation).

  • ASM позволяет читать и трансформировать байт-код классов.

  • Agent использует Instrumentation для перехвата загрузки класса; ASM — для трансформаций.

Пример:

Профилирующий агент real-time инструментирования: на каждый метод вставляет счетчики времени выполнения.

Сервер мониторинга автоматически инжектирует логи на вход и выход методов.

Пример ProfilingAgent
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;

public class ProfilingAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new MethodTimeTransformer());
    }
}

class MethodTimeTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(Module module, ClassLoader loader, String className,
                            Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) {
        if (className == null || className.startsWith("java/")) return null;
        ClassReader cr = new ClassReader(classfileBuffer);
        ClassWriter cw = new ClassWriter(cr, 0);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM9, cw) {
            @Override
            public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
                MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
                if (mv != null && !name.equals("<init>") && !name.equals("<clinit>")) {
                    mv = new ProfilingMethodVisitor(mv, name);
                }
                return mv;
            }
        };
        cr.accept(cv, 0);
        return cw.toByteArray();
    }
}

class ProfilingMethodVisitor extends MethodVisitor {
    private final String methodName;
    public ProfilingMethodVisitor(MethodVisitor mv, String methodName) {
        super(Opcodes.ASM9, mv);
        this.methodName = methodName;
    }
    @Override
    public void visitCode() {
        mv.visitCode();
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
        mv.visitVarInsn(Opcodes.LSTORE, 1);
        mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("Enter: " + methodName);
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }
    @Override
    public void visitInsn(int opcode) {
        if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) || opcode == Opcodes.ATHROW) {
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
            mv.visitVarInsn(Opcodes.LLOAD, 1);
            mv.visitInsn(Opcodes.LSUB);
            mv.visitVarInsn(Opcodes.LSTORE, 3);
            mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
            mv.visitInsn(Opcodes.DUP);
            mv.visitLdcInsn("Exit: " + methodName + " elapsed ");
            mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "(Ljava/lang/String;)V", false);
            mv.visitVarInsn(Opcodes.LLOAD, 3);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn(" ns");
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
        mv.visitInsn(opcode);
    }
}

Интеграция ASM и Instrumentation даёт гибкий способ вмешиваться в работу Java-приложений без пересборки исходников, это ценно для мониторинга, безопасности и оптимизации.

Реализация интерфейса ClassFileTransformer с использованием библиотеки ASM

Пример ASMTransformer
public class ASMTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, 
                          Class<?> classBeingRedefined, 
                          ProtectionDomain protectionDomain, 
                          byte[] classfileBuffer) {
        
        ClassReader reader = new ClassReader(classfileBuffer);
        ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES);
        
        ClassVisitor visitor = new ClassVisitor(ASM9, writer) {
            @Override
            public MethodVisitor visitMethod(int access, String name, String desc, 
                                           String signature, String[] exceptions) {
                MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
                return new LoggingAdapter(mv, name);
            }
        };
        
        reader.accept(visitor, 0);
        return writer.toByteArray();
    }
}

Разбор кода вредоносного агента

Агент состоит из bash-скрипта, который:

  • создает структуру папок для размещения Target-приложения и агента;

  • находит свежий релиз Target-приложения и скачивает его;

  • запускает Target-приложение с вредоносным агентом.

Сам скрипт
#!/bin/bash

# Download Targetapp Professional Latest.
    echo 'Downloading Targetapp Professional Latest...'
    mkdir -p /usr/share/targetapp
    cp loader.jar /usr/share/targetapp/
    cp target_app.ico /usr/share/targetapp/
    rm -rf .git
    cd /usr/share/targetapp/
    html=$(curl -s https://targetapp.com/target/releases)
    version=$(echo $html | grep -Po '(?<=/target/releases/prof-community-)[0-9]+\-[0-9]+\-[0-9]+' | head -n 1)
    Link="https://targetapp.com/target/releases/download?product=pro&version=&type=jar"
    echo $version
    wget "$Link" -O Targetapp_pro_v$version.jar --quiet --show-progress

# Execute Key Generator.
    echo 'Starting Key Generator'
    (java -jar loader.jar) &
    sleep 2s

# Execute target app Professional
    echo 'Executing Targetapp Professional with Key Generator'
    echo "java --add-opens=java.desktop/javax.swing=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED --add-opens=java.base/jdk.internal.org.objectweb.asm.tree=ALL-UNNAMED --add-opens=java.base/jdk.internal.org.objectweb.asm.Opcodes=ALL-UNNAMED -javaagent:$(pwd)/loader.jar -noverify -jar $(pwd)/Targetapp_pro_v$version.jar &" > targetapp
    chmod 777 targetapp
    cp targetapp /bin/targetapp
    (./targetapp)

Нас из кода скрипта больше всего интересуют параметры запуска Java:

Параметры запуска
java \
--add-opens=java.desktop/javax.swing=ALL-UNNAMED \
--add-opens=java.base/java.lang=ALL-UNNAMED \
--add-opens=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED \
--add-opens=java.base/jdk.internal.org.objectweb.asm.tree=ALL-UNNAMED \
--add-opens=java.base/jdk.internal.org.objectweb.asm.Opcodes=ALL-UNNAMED \
-javaagent:$(pwd)/loader.jar \
-noverify \
-jar $(pwd)/Targetapp_pro_v$version.jar &

Давайте разберем их.

  1. Аргумент --add-opens  "открывает" внутренние пакеты для сторонних библиотек или инструментов. Это нужно, чтобы loader.jar мог модифицировать поведение target app. Значение ALL-UNNAMED означает "для всех неназванных модулей". В Java с модульной системой каждый JAR может быть модулем (с описанием module-info.java) либо "неназванным модулем" (обычный JAR без module-info.java).

    --add-opens=module/package=ALL-UNNAMED заставляет JVM открыть указанный пакет для всех библиотек, которые не являются модульными (т. е. старые JAR-файлы, сторонние зависимости и т. п.). В Java 21 ключ -add-opens объявлен deprecated, и Oracle/JDK-команды рекомендуют разработчикам не полагаться на такие способы доступа, а использовать официальные/поддерживаемые API.

    В нашем случае открываются модули:

    • javax.swing (графический интерфейс),

    • java.lang (базовые классы Java),

    • внутренние классы ASM (библиотека для манипуляции байт-кодом).

  2. Аргумент -javaagent:$(pwd)/loader.jar загружает loader.jar как Java Agent. Java-агенты могут изменять код других Java-программ во время выполнения. В нашем контексте может использоваться для патчинга target app и, например, активации функционала, доступного по платной подписке.

  3. Аргумент -noverify отключает верификацию байт-кода JVM. Это может быть нужно, если loader.jar модифицирует классы target app нестандартным образом.

  4. Аргумент -jar $(pwd)/Targetapp_pro_v$version.jar запускает основной JAR-файл target app Professional.

Для декомпиляции исходного кода вредоносного агента нам понадобится утилита jadx. Устанавливаем и запускаем jadx:

Скриншот запуска декомпилятора jadx
Скриншот запуска декомпилятора jadx

Открываем jar-файл агента и видим такую картину:

Структура классов кряка после декомпиляции
Структура классов кряка после декомпиляции

Классы, указанные в «Source code», декомпилированы успешно. Теперь мы можем приступить к анализу Java‑кода.

Анализ Java‑кода

В нашем примере присутствует 4 класса и один файл MANIFEST.MF:

  1. KeygenForm — используется для визуализации приложения. Рассматриваться в рамках статьи не будет;

  2. Keygen — используется собственно для генерации ключей. Рассматриваться в рамках статьи не будет;

  3. Filter — подменяет ответы лицензионных серверов и криптографические операции для имитации результатов проверки лицензии;

  4. Loader — модифицирует Java‑код «на лету», патчит методы стандартных библиотек и логики сервисного слоя приложения. Наш основной объект исследования;

  5. MANIFEST.MF — служит «инструкцией» для JVM, определяя особое поведение при запуске и возможности для «горячего патчинга» приложения. Это специальный файл метаданных, который входит в состав jar‑архива Java‑приложения. 

Разберем подробнее интересующие нас файлы.

  1. MANIFEST.MF

    В первую очередь обратим внимание на содержимое файла MANIFEST.MF:

    Manifest-Version: 1.0
    Can-Retransform-Classes: true
    Main-Class: com.targetloaderkeygen.KeygenForm
    Premain-Class: com.targetloaderkeygen.Loader

    Из MANIFEST.MF видно следующую структуру нашего зловреда:

    Can-Retransform-Classes: true — это атрибут в MANIFEST.MF файле Java-приложения, который указывает, что Java-агент может повторно трансформировать уже загруженные классы.

    Main-Class: com.targetloader.keygen.KeygenForm → Графический интерфейс для генерации ключа. Это главный класс приложения, который содержит метод main() – точка входа при запуске программы обычным способом (java -jar your_app.jar).

    Premain-Class: com.targetloader.keygen.Loader → класс, который патчит target app при запуске, используемый для инструментирования Java-приложений через Java Agent

  2. Класс Loader

    Класс Loader является premain-классом, и в нем происходит основная «магия» модификации кода Target‑приложения.

    Ниже приведен листинг кода этого класса для ознакомления. Далее мы рассмотрим его подробнее.

class Loader
package org.example;


import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import java.util.List;
import jdk.internal.org.objectweb.asm.ClassReader;
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.tree.ClassNode;
import jdk.internal.org.objectweb.asm.tree.InsnList;
import jdk.internal.org.objectweb.asm.tree.InsnNode;
import jdk.internal.org.objectweb.asm.tree.IntInsnNode;
import jdk.internal.org.objectweb.asm.tree.JumpInsnNode;
import jdk.internal.org.objectweb.asm.tree.LabelNode;
import jdk.internal.org.objectweb.asm.tree.LdcInsnNode;
import jdk.internal.org.objectweb.asm.tree.MethodInsnNode;
import jdk.internal.org.objectweb.asm.tree.MethodNode;
import jdk.internal.org.objectweb.asm.tree.TypeInsnNode;
import jdk.internal.org.objectweb.asm.tree.VarInsnNode;


public class Loader implements ClassFileTransformer {
   public Loader() {
   }


   public byte[] app_patch2(String className, byte[] classBytes) {
       if (!className.startsWith("target_app/") || classBytes.length < 110000) {
           return null;
       }
       ClassReader cr = new ClassReader(classBytes);
       ClassNode cn = new ClassNode();
       cr.accept(cn, 0);
       for (MethodNode method : cn.methods) {
           if (method.desc.equals("([Ljava/lang/Object;Ljava/lang/Object;)V") && method.instructions.size() > 20000) {
               InsnList insnList = method.instructions;
               int j = 0;
               for (int i = insnList.size() - 1; i > 0; i--) {
                   if (insnList.get(i) instanceof TypeInsnNode) {
                       TypeInsnNode typeInsnNode = insnList.get(i);
                       if (typeInsnNode.getOpcode() == 187 && "java/lang/Exception".equals(typeInsnNode.desc)) {
                           j++;
                           if (j == 2) {
                               for (int k = 0; k < 6; k++) {
                                   if (insnList.get(i - k) instanceof JumpInsnNode) {
                                       JumpInsnNode jumpInsnNode = insnList.get(i - k);
                                       method.instructions.insert(insnList.get(i - k), new JumpInsnNode(167, jumpInsnNode.label));
                                   }
                               }
                           }
                       }
                   }
               }
           }
       }
       ClassWriter writer = new ClassWriter(3);
       cn.accept(writer);
       return writer.toByteArray();
   }


   public byte[] client_patch(String className, byte[] classBytes) {
       if (!className.equals("feign/okhttp/OkHttpClient")) {
           return null;
       }
       ClassReader cr = new ClassReader(classBytes);
       ClassNode cn = new ClassNode();
       cr.accept(cn, 0);
       List<MethodNode> methods = cn.methods;
       for (MethodNode method : methods) {
           if ("toFeignResponse".equals(method.name) && "(Lokhttp3/Response;Lfeign/Request;)Lfeign/Response;".equals(method.desc)) {
               InsnList srcList = method.instructions;
               for (MethodInsnNode methodInsnNode : srcList.toArray()) {
                   if ((methodInsnNode instanceof MethodInsnNode) && methodInsnNode.getOpcode() == 182) {
                       MethodInsnNode methodInsnNode2 = methodInsnNode;
                       if (methodInsnNode2.owner.equals("feign/Response$Builder") && methodInsnNode2.name.equals("build") && methodInsnNode2.desc.equals("()Lfeign/Response;")) {
                           InsnList insnList = new InsnList();
                           insnList.add(new VarInsnNode(25, 1));
                           insnList.add(new MethodInsnNode(182, "feign/Request", "url", "()Ljava/lang/String;", false));
                           insnList.add(new VarInsnNode(25, 1));
                           insnList.add(new MethodInsnNode(182, "feign/Request", "body", "()[B", false));
                           insnList.add(new MethodInsnNode(184, "com/target_apploaderkeygen/Filter", "LicenceFilter", "(Ljava/lang/String;[B)[B", false));
                           insnList.add(new VarInsnNode(58, 2));
                           insnList.add(new VarInsnNode(25, 2));
                           LabelNode outLabel = new LabelNode();
                           insnList.add(new JumpInsnNode(198, outLabel));
                           insnList.add(new VarInsnNode(25, 2));
                           insnList.add(new MethodInsnNode(182, "feign/Response$Builder", "body", "([B)Lfeign/Response$Builder;", false));
                           insnList.add(new IntInsnNode(17, 200));
                           insnList.add(new MethodInsnNode(182, "feign/Response$Builder", "status", "(I)Lfeign/Response$Builder;", false));
                           insnList.add(outLabel);
                           srcList.insertBefore(methodInsnNode2, insnList);
                       }
                   }
               }
           }
       }
       ClassWriter writer = new ClassWriter(3);
       cn.accept(writer);
       return writer.toByteArray();
   }


   public byte[] bigint_patch(String className, byte[] classBytes) {
       if (!className.equals("java/math/BigInteger")) {
           return null;
       }
       try {
           ClassReader reader = new ClassReader(classBytes);
           ClassNode node = new ClassNode();
           reader.accept(node, 0);
           for (MethodNode mn : node.methods) {
               if ("oddModPow".equals(mn.name) && "(Ljava/math/BigInteger;Ljava/math/BigInteger;)Ljava/math/BigInteger;".equals(mn.desc)) {
                   InsnList instructions = new InsnList();
                   instructions.add(new LdcInsnNode("2464649455065463415875363469"));
                   instructions.add(new VarInsnNode(25, 2));
                   instructions.add(new MethodInsnNode(182, "java/math/BigInteger", "toString", "()Ljava/lang/String;", false));
                   instructions.add(new MethodInsnNode(182, "java/lang/String", "equals", "(Ljava/lang/Object;)Z", false));
                   LabelNode label = new LabelNode();
                   instructions.add(new JumpInsnNode(153, label));
                   instructions.add(new TypeInsnNode(187, "java/math/BigInteger"));
                   instructions.add(new InsnNode(89));
                   instructions.add(new LdcInsnNode("24363643532616265602004787593990571998354762769517556824947462364764746856"));
                   instructions.add(new MethodInsnNode(183, "java/math/BigInteger", "<init>", "(Ljava/lang/String;)V", false));
                   instructions.add(new VarInsnNode(58, 2));
                   instructions.add(label);
                   mn.instructions.insert(instructions);
                   ClassWriter writer = new ClassWriter(3);
                   node.accept(writer);
                   return writer.toByteArray();
               }
           }
           return null;
       } catch (Exception e) {
           System.out.println(e);
           return null;
       }
   }


   public byte[] app_patch1(String className, byte[] classBytes) {
       if (className.startsWith("target_app/") && classBytes.length > 110000) {
           ClassReader cr = new ClassReader(classBytes);
           ClassNode cn = new ClassNode();
           cr.accept(cn, 0);
           for (MethodNode method : cn.methods) {
               if (method.desc.equals("([Ljava/lang/Object;Ljava/lang/Object;)V") && method.instructions.size() > 20000) {
                   InsnList insnList = method.instructions;
                   insnList.clear();
                   insnList.add(new VarInsnNode(25, 0));
                   insnList.add(new MethodInsnNode(184, "com/target_apploaderkeygen/Filter", "TargetAppFilter", "([Ljava/lang/Object;)V", false));
                   insnList.add(new InsnNode(177));
                   method.exceptions.clear();
                   method.tryCatchBlocks.clear();
               }
           }
           ClassWriter writer = new ClassWriter(3);
           cn.accept(writer);
           return writer.toByteArray();
       }
       return null;
   }


   @Override
   public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classBytes) {
       byte[] result = bigint_patch(className, classBytes);
       if (result != null) {
           return result;
       }
       byte[] result2 = client_patch(className, classBytes);
       if (result2 != null) {
           return result2;
       }
       byte[] result3 = app_patch1(className, classBytes);
       return result3 != null ? result3 : app_patch2(className, classBytes);
   }


   public static void premain(String agentArgs, Instrumentation inst) {
       Loader loader = new Loader();
       inst.addTransformer(loader);
   }
}

Что такое Premain-Class, и зачем он нужен?

Premain-Class – это класс, который загружается до запуска основного приложения. Он реализует собственно механизм Java Agent, который позволяет модифицировать байт-код Java-программ «на лету».

Основные функции Java Agent:

  1. Трансформация классов (изменение байт-кода перед загрузкой в JVM). Например, можно добавить логирование, изменить поведение методов, внедрить дополнительные проверки.

  2. Мониторинг и профилирование. Например, измерение времени выполнения методов.

  3. Динамическая замена кода. Используется в некоторых хотфикс-решениях.

  4. Обход ограничений безопасности. Например, при взломе лицензионных ограничений.

Класс Loader имплементирует интерфейс ClassFileTransformer.

Механизм работы в исследуемом коде:

public static void premain(String agentArgs, Instrumentation inst) {
    Loader loader = new Loader();
    inst.addTransformer(loader);  // Регистрация трансформера
}

Т. е. запускается метод premain, который добавляет класс Loader как трансформер для нашего агента.

ClassFileTransformer — это интерфейс из Java Instrumentation API, который позволяет изменять байт-код классов во время их загрузки JVM.

Далее рассмотрим реализацию метода transform() интерфейса ClassFileTransformer, который злоумышленник имплементирует. Метод вызывается для каждого загружаемого класса, проверяет его, и, если класс попадает под критерий одного из методов-патчей, этот класс модифицируется:
Использует ASM Framework для манипуляции байт-кодом:

Метод transform
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classBytes) {
   byte[] result = bigint_patch(className, classBytes);
   if (result != null) {
       return result;
   }
   byte[] result2 = client_patch(className, classBytes);
   if (result2 != null) {
       return result2;
   }
   byte[] result3 = app_patch1(className, classBytes);
   return result3 != null ? result3 : app_patch2(className, classBytes);
}

В этом классе используются следующие сущности из ASM:

  • ClassReader/ClassWriter: ClassReader cr = new ClassReader(classBytes) для чтения и записи байт-кода;

  • ClassNode: ClassNode cn = new ClassNode() для представления класса как дерева;

  • MethodNode: для обхода дерева методов класса;

  • IInsnList: для манипуляции инструкциями через method.instructions;

  • VarInsnNode, MethodInsnNode, JumpInsnNode, LdcInsnNode: различные типы инструкций, которые изменяются в коде агента.

Реализуемые методы патчи:

1) bigint_patch – модифицирует класс стандартной библиотеки java/math/BigInteger.

bigint_patch

Ищет метод с сигнатурой "oddModPow".equals(mn.name) && "(Ljava/math/BigInteger;Ljava/math/BigInteger;)Ljava/math/BigInteger;".equals(mn.desc) и подставляет в ответ заранее вычисленную константу.

Метод модифицирует класс BigInteger для обхода криптографической проверки лицензии в Target-приложении. 

Это типичный метод взлома программ, использующих RSA для защиты лицензий, — вместо взлома самого алгоритма просто подменяются ключевые значения в процессе проверки.

2) client_patch – патчит HTTP-клиент feign/okhttp/OkHttpClient

client_patch
public byte[] client_patch(String className, byte[] classBytes) {
       if (!className.equals("feign/okhttp/OkHttpClient")) {
           return null;
       }
       ClassReader cr = new ClassReader(classBytes);
       ClassNode cn = new ClassNode();
       cr.accept(cn, 0);
       List<MethodNode> methods = cn.methods;
       for (MethodNode method : methods) {
           if ("toFeignResponse".equals(method.name) && "(Lokhttp3/Response;Lfeign/Request;)Lfeign/Response;".equals(method.desc)) {
               InsnList srcList = method.instructions;
               for (MethodInsnNode methodInsnNode : srcList.toArray()) {
                   if ((methodInsnNode instanceof MethodInsnNode) && methodInsnNode.getOpcode() == 182) {
                       MethodInsnNode methodInsnNode2 = methodInsnNode;
                       if (methodInsnNode2.owner.equals("feign/Response$Builder") && methodInsnNode2.name.equals("build") && methodInsnNode2.desc.equals("()Lfeign/Response;")) {
                           InsnList insnList = new InsnList();
                           insnList.add(new VarInsnNode(25, 1));
                           insnList.add(new MethodInsnNode(182, "feign/Request", "url", "()Ljava/lang/String;", false));
                           insnList.add(new VarInsnNode(25, 1));
                           insnList.add(new MethodInsnNode(182, "feign/Request", "body", "()[B", false));
                           insnList.add(new MethodInsnNode(184, "com/target_apploaderkeygen/Filter", "LicenceFilter", "(Ljava/lang/String;[B)[B", false));
                           insnList.add(new VarInsnNode(58, 2));
                           insnList.add(new VarInsnNode(25, 2));
                           LabelNode outLabel = new LabelNode();
                           insnList.add(new JumpInsnNode(198, outLabel));
                           insnList.add(new VarInsnNode(25, 2));
                           insnList.add(new MethodInsnNode(182, "feign/Response$Builder", "body", "([B)Lfeign/Response$Builder;", false));
                           insnList.add(new IntInsnNode(17, 200));
                           insnList.add(new MethodInsnNode(182, "feign/Response$Builder", "status", "(I)Lfeign/Response$Builder;", false));
                           insnList.add(outLabel);
                           srcList.insertBefore(methodInsnNode2, insnList);
                       }
                   }
               }
           }
       }
       ClassWriter writer = new ClassWriter(3);
       cn.accept(writer);
       return writer.toByteArray();
   }

Ищет метод "toFeignResponse".equals(method.name) && "(Lokhttp3/Response;Lfeign/Request;)Lfeign/Response;".equals(method.desc)

Находит вызов methodInsnNode2.owner.equals("feign/Response$Builder") && methodInsnNode2.name.equals("build").

Внедряет фильтрацию через new MethodInsnNode(184, "com/targetloaderkeygen/Filter", "LicenceFilter", "(Ljava/lang/String;[B)[B", false).

Т. е. в данном методе модифицируется OkHttpClient, который при запросе определенных URL начинает возвращать ответы, описанные в классе Filter вредоносного агента.

3) app_patch2 – более “хирургический” подход:

app_patch2
 public byte[] app_patch2(String className, byte[] classBytes) {
     if (!className.startsWith("target_app/") || classBytes.length < 110000) {
         return null;
     }
     ClassReader cr = new ClassReader(classBytes);
     ClassNode cn = new ClassNode();
     cr.accept(cn, 0);
     for (MethodNode method : cn.methods) {
         if (method.desc.equals("([Ljava/lang/Object;Ljava/lang/Object;)V") && method.instructions.size() > 20000) {
             InsnList insnList = method.instructions;
             int j = 0;
             for (int i = insnList.size() - 1; i > 0; i--) {
                 if (insnList.get(i) instanceof TypeInsnNode) {
                     TypeInsnNode typeInsnNode = insnList.get(i);
                     if (typeInsnNode.getOpcode() == 187 && "java/lang/Exception".equals(typeInsnNode.desc)) {
                         j++;
                         if (j == 2) {
                             for (int k = 0; k < 6; k++) {
                                 if (insnList.get(i - k) instanceof JumpInsnNode) {
                                     JumpInsnNode jumpInsnNode = insnList.get(i - k);
                                     method.instructions.insert(insnList.get(i - k), new JumpInsnNode(167, jumpInsnNode.label));
                                 }
                             }
                         }
                     }
                 }
             }
         }
     }
     ClassWriter writer = new ClassWriter(3);
     cn.accept(writer);
     return writer.toByteArray();
 }

Фильтрует классы, имена которых начинаются с «target/» и имеют размер менее 110 КБ.

Сканирует инструкции в обратном порядке.

Ищет создание объектов java/lang/Exception (опкод 187 = NEW).

При нахождении второго такого случая (j==2) модифицирует логику.

Добавляет безусловные переходы (GOTO, опкод 167) перед условными переходами, что, как я предполагаю, позволяет пропустить проверку лицензии.

4) app_patch1 — радикальная модификация классов Target-приложения:

app_patch1
public byte[] app_patch1(String className, byte[] classBytes) {
       if (className.startsWith("target_app/") && classBytes.length > 110000) {
           ClassReader cr = new ClassReader(classBytes);
           ClassNode cn = new ClassNode();
           cr.accept(cn, 0);
           for (MethodNode method : cn.methods) {
               if (method.desc.equals("([Ljava/lang/Object;Ljava/lang/Object;)V") && method.instructions.size() > 20000) {
                   InsnList insnList = method.instructions;
                   insnList.clear();
                   insnList.add(new VarInsnNode(25, 0));
                   insnList.add(new MethodInsnNode(184, "com/target_apploaderkeygen/Filter", "TargetAppFilter", "([Ljava/lang/Object;)V", false));
                   insnList.add(new InsnNode(177));
                   method.exceptions.clear();
                   method.tryCatchBlocks.clear();
               }
           }
           ClassWriter writer = new ClassWriter(3);
           cn.accept(writer);
           return writer.toByteArray();
       }
       return null;
   }

Фильтрует классы, имена которых начинаются с «target/» и которые имеют размер более 110 КБ. Ищет методы с сигнатурой:

method.desc.equals("([Ljava/lang/Object;Ljava/lang/Object;)V") && method.instructions.size() > 20000.

Полностью очищает существующий код: insnList.clear().

Заменяет на простой вызов:

insnList.add(new VarInsnNode(25, 0));  // aload_0
insnList.add(new MethodInsnNode(184, "com/target_apploaderkeygen/Filter", "targetFilter", "([Ljava/lang/Object;)V", false));
insnList.add(new InsnNode(177));  // return

Оригинальный метод содержит сложную логику проверки лицензии. Патч заменяет всю эту логику простым вызовом Filter.targetFilter().

Удаляются все try-catch блоки, чтобы избежать обработки ошибок проверки.

Вместо выполнения настоящей проверки лицензии Target-приложение вызывает подставной метод, который всегда разрешает работу программы.

Класс Filter

Класс Filter — система обхода лицензирования.

Листинг кода:

class Filter
package org.example;

import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.Signature;
import java.security.SignatureException;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPrivateKeySpec;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;


public class Filter {
   private static byte[] encryption_key = "keykeykey".getBytes();


   private static byte[] decrypt(byte[] data) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException {
       try {
           SecretKeySpec spec = new SecretKeySpec(encryption_key, "DES");
           Cipher cipher = Cipher.getInstance("DES");
           cipher.init(2, spec);
           return cipher.doFinal(data);
       } catch (Exception var3) {
           var3.printStackTrace();
           throw new RuntimeException(var3);
       }
   }


   public static void targetFilter(Object[] obj) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException {
       byte[] data = (byte[]) obj[0];
       byte[] decode = Base64.getDecoder().decode(data);
       byte[] decrypt = decrypt(decode);
       String str = new String(decrypt);
       String[] strs = str.split("\\\\");
       obj[0] = Arrays.copyOf(strs, strs.length - 2);
   }


   public static byte[] LicenceFilter
(String url, byte[] data) throws InvalidKeySpecException, NoSuchAlgorithmException, SignatureException, InvalidKeyException {
       if (url.equals("https://api.licensespring.com/api/v4/activate_license")) {
           String doSign = new String(data);
           String json = getText(doSign, "hardware_id") + "#" + getText(doSign, "license_key") + "#2099-12-31t14:58:33.b";
           return ("{\"license_signature\":\"" + getsign(json.toLowerCase()) + "\",\"license_type\":\"perpetual\",\"is_trial\":false,\"validity_period\":\"2099-12-31T20:28:33.213+05:30\",\"max_activations\":99,\"times_activated\":99,\"transfer_count\":99,\"prevent_vm\":false,\"customer\":{\"email\":\"email@email.email\",\"first_name\":\"name\")".getBytes();
       }
       if (url.equals("https://api.licensespring.com/api/v4/product_details?product=target_product")) {
           return "{\"product_name\":\"Product Name\",\"short_code\":\"target_product_code\",\"allow_trial\":false,\"trial_days\":0,\"authorization_method\":\"license-key\"}".getBytes();
       }
       if (url.startsWith("https://api.licensespring.com/api/v4/check_license?app_name=target_app")) {
           String json2 = "{\"license_signature\":\"" + getsign((getparam(url, "hardware_id") + "#" + getparam(url, "license_key") + "#2099-12-31t14:58:33.c").toLowerCase()) + "\",\"license_type\":\"perpetual\"";
           return json2.getBytes();
       }
       return null;
   }


   public static String getText(String json, String label) {
       String before = "\"" + label + "\":\"";
       int start = json.indexOf(before);
       if (start != -1) {
           int start2 = start + before.length();
           int end = json.indexOf("\"", start2);
           return json.substring(start2, end);
       }
       return null;
   }


   public static String getparam(String url, String label) {
       String before = label + "=";
       String[] strs = url.substring(url.indexOf(63) + 1).split("&");
       for (String str : strs) {
           if (str.startsWith(before)) {
               return str.substring(before.length());
           }
       }
       return null;
   }


   public static String getsign(String dover) throws InvalidKeySpecException, NoSuchAlgorithmException, SignatureException, InvalidKeyException {
       Signature sign = Signature.getInstance("SHA256withRSA");
       sign.initSign(getPriKeyByND("772466485972938442854944399195933", "772466485972938442854944399195933"));
       byte[] data = dover.getBytes(StandardCharsets.UTF_8);
       sign.update(data);
       byte[] signature = sign.sign();
       return Base64.getEncoder().encodeToString(signature);
   }


   public static RSAPrivateKey getPriKeyByND(String n, String d) throws InvalidKeySpecException, NoSuchAlgorithmException {
       RSAPrivateKeySpec spec = new RSAPrivateKeySpec(new BigInteger(n), new BigInteger(d));
       KeyFactory kf = KeyFactory.getInstance("RSA");
       return (RSAPrivateKey) kf.generatePrivate(spec);
   }
}

Это утилитарный класс, который выполняет следующие функции:

  • Перехват HTTP-запросов к серверу лицензий LicenseSpring;

  • Подмена ответов на заранее подготовленные JSON с валидными лицензиями;

  • Генерация цифровых подписей с помощью скомпрометированного приватного ключа;

  • Дешифровка/обработка внутренних лицензионных данных target app.

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

Основные методы и их функции:

1) targetFilter — обработка основных лицензионных данных target app;

targetFilter
   public static void targetFilter(Object[] obj) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException {
       byte[] data = (byte[]) obj[0];
       byte[] decode = Base64.getDecoder().decode(data);
       byte[] decrypt = decrypt(decode);
       String str = new String(decrypt);
       String[] strs = str.split("\\\\");
       obj[0] = Arrays.copyOf(strs, strs.length - 2);
   }

2) LicenceFilter — перехват HTTP-запросов к серверу лицензий;

LicenceFilter
public static byte[] LicenceFilter
(String url, byte[] data) throws InvalidKeySpecException, NoSuchAlgorithmException, SignatureException, InvalidKeyException {
       if (url.equals("https://api.licensespring.com/api/v4/activate_license")) {
           String doSign = new String(data);
           String json = getText(doSign, "hardware_id") + "#" + getText(doSign, "license_key") + "#2099-12-31t14:58:33.b";
           return ("{\"license_signature\":\"" + getsign(json.toLowerCase()) + "\",\"license_type\":\"perpetual\",\"is_trial\":false,\"validity_period\":\"2099-12-31T20:28:33.213+05:30\",\"max_activations\":99,\"times_activated\":99,\"transfer_count\":99,\"prevent_vm\":false,\"customer\":{\"email\":\"email@email.email\",\"first_name\":\"name\")".getBytes();
       }
       if (url.equals("https://api.licensespring.com/api/v4/product_details?product=target_product")) {
           return "{\"product_name\":\"Product Name\",\"short_code\":\"target_product_code\",\"allow_trial\":false,\"trial_days\":0,\"authorization_method\":\"license-key\"}".getBytes();
       }
       if (url.startsWith("https://api.licensespring.com/api/v4/check_license?app_name=target_app")) {
           String json2 = "{\"license_signature\":\"" + getsign((getparam(url, "hardware_id") + "#" + getparam(url, "license_key") + "#2099-12-31t14:58:33.c").toLowerCase()) + "\",\"license_type\":\"perpetual\"";
           return json2.getBytes();
       }
       return null;
   }

Возвращает поддельные JSON-ответы с:

"license_type":"perpetual" — бессрочная лицензия;

"validity_period":"2099-12-31T20:28:33.655+05:30" — срок до 2099 года;

"license_active":true,"license_enabled":true,"is_expired":false — активная лицензия.

3) getsign — генерация поддельных цифровых подписей. RSA-ключ из значений, вбитых хардкодом.

getsign
   public static String getsign(String dover) throws InvalidKeySpecException, NoSuchAlgorithmException, SignatureException, InvalidKeyException {
       Signature sign = Signature.getInstance("SHA256withRSA");
       sign.initSign(getPriKeyByND("772466485972938442854944399195933", "772466485972938442854944399195933"));
       byte[] data = dover.getBytes(StandardCharsets.UTF_8);
       sign.update(data);
       byte[] signature = sign.sign();
       return Base64.getEncoder().encodeToString(signature);
   }

Кратко о том, как работает код, который мы рассмотрели:

  1. target app обращается к серверу лицензий;

  2. Система перехватывает HTTP-запросы;

  3. Возвращает поддельные ответы с валидными лицензиями до 2099 года;

  4. Подписывает данные скомпрометированным приватным ключом;

  5. Обходит внутренние проверки путем модификации байт-кода.

Технический стек вредоносного агента:

  1. ASM Framework для манипуляции байт-кодом;

  2. Java Instrumentation API для перехвата загрузки классов;

  3. RSA/SHA256 для генерации цифровых подписей;

  4. DES-шифрование для обработки внутренних данных;

  5. HTTP-перехват через модификацию OkHttpClient.

Система представляет собой комплексный обход защиты, сочетающий модификацию исполняемого кода, криптографический взлом и подмену сетевого трафика.

Методы защиты

Рассмотрим методы защиты приложений от вредоносных агентов, подобных упомянутому выше.

Как, скорее всего, происходил взлом Target-приложения:

  • Приложение было запущено с недействительной лицензией;

  • Перехватывались моменты выброса исключений java/lang/Exception;

  • Байт-код метода был проанализирован через декомпилятор;

  • После декомпиляции кода была найдена логика, приводящая к блокировке функций;

  • Был создан агент, который патчит Target-приложение для обхода этих проверок.

Основные методы проверки Java-приложения на несанкционированные изменения:

1. Белый список агентов

Мы можем создать белый список агентов и отслеживать запуск приложения с иными агентами.
Строка запуска приложения с агентами выглядит так:

java -javaagent:agent1.jar -javaagent:agent2.jar -javaagent:agent3.jar -jar myapp.jar

Пример кода, который сигнализирует о том, что приложение запущено с неразрешенным агентом:

Метод detectSpecificAgents
public static boolean detectSpecificAgents() {
   Set<String> allowedAgents = Set.of("jacoco", "jprofiler", "yourkit");


   List<String> jvmArgs = ManagementFactory.getRuntimeMXBean().getInputArguments();
   for (String arg : jvmArgs) {
       if (arg.startsWith("-javaagent:")) {
           String agentPath = arg.substring("-javaagent:".length());


           boolean isAllowed = false;
           for (String allowedAgent : allowedAgents) {
               if (agentPath.contains(allowedAgent)) {
                   isAllowed = true;
                   break;
               }
           }


           if (!isAllowed) {
               return false; // Первый же неразрешенный агент = false
           }
       }
   }
   return true; // Все агенты проверены и разрешены
}

2. Проверка режима дебага:

Метод isDebuggerAttached
public static boolean isDebuggerAttached() {
    List<String> args = ManagementFactory.getRuntimeMXBean().getInputArguments();
    return args.stream().anyMatch(arg ->
        arg.contains("-agentlib:jdwp") ||
        arg.contains("-Xrunjdwp") ||
        arg.contains("-Xdebug") ||
        arg.contains("jdwp")
    );
}

Расшифровка параметров:

-agentlib:jdwp — подключает агент Java Debug Wire Protocol (JDWP). Это стандартный способ отладки приложений в JVM.

-Xrunjdwp — старый способ подключения JDWP, устарел после появления -agentlib.

-Xdebug — включает режим отладки JVM.

jdwp — общее упоминание Java Debug Wire Protocol, на случай других вариантов передачи параметров.

Если приложение обнаруживает подключенный отладчик, оно может завершить выполнение, изменить поведение или залогировать несанкционированный доступ.

Такая проверка легко обходится, если атакующий пользуется кастомными агентами, не включающими эти стандартные флаги. Однако это быстрый способ усложнить базовый reverse engineering и защититься от нецелевого анализа. Вы можете добавлять свои флаги и писать их в комментариях. :)

3. Серверная валидация

Создаем шедулер, который раз в какое-то время проверяет лицензию на сервере и в случае неудачной проверки начинает создавать проблемы пирату.

class LicenseValidator
public class LicenseValidator {
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private final long intervalMinutes;
    private final Runnable onInvalidLicense;
    public LicenseValidator(long intervalMinutes, Runnable onInvalidLicense) {
        this.intervalMinutes = intervalMinutes;
        this.onInvalidLicense = onInvalidLicense;
    }
    public void startPeriodicValidation() {
        scheduler.scheduleAtFixedRate(() -> {
            try {
                if (!validateWithServer()) {
                    onInvalidLicense.run();
                }
            } catch (Exception e) {
                // log error
            }
        }, 0, intervalMinutes, TimeUnit.MINUTES);
    }
    public void stop() {
        scheduler.shutdownNow();
    }
    private boolean validateWithServer() {
        // Проверка лицензии
        return true;
    }
}

4. Защита сетевых запросов

Создаём CertificatePinner, который требует, чтобы сервер «api.licensespring.com» предоставил сертификат с конкретным публичным ключом (hash ключа через SHA256).

При запросах к этому домену OkHttp сравнит сертификат с указанным отпечатком. Несовпадение приведёт к ошибке соединения.

Позволяет защититься от MITM-атак.

Пиннинг сертификатов
public static void configureCertificatePinning(OkHttpClient.Builder builder) {
    CertificatePinner certificatePinner = new CertificatePinner.Builder()
        .add("api.licensespring.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
        .build();
    builder.certificatePinner(certificatePinner);
}

// Проверка SSL-контекста
private static void validateSSLContext() throws Exception {
    SSLContext context = SSLContext.getDefault();
    // Проверка на подмену контекста
}

Что делать, если проверка показала, что имеет место несанкционированное вмешательство в работу программы:

1. Обфускация и защита от декомпиляции. Не позволяйте злоумышленнику читать ваш код слишком просто.

Очень общий пример
// Использование нативных методов для критических операций
public native boolean validateLicenseNative(String license);

// Динамическая генерация ключей
private static SecretKey generateDynamicKey() {
    long timestamp = System.currentTimeMillis();
    byte[] seed = ByteBuffer.allocate(8).putLong(timestamp / 10000).array();
    return new SecretKeySpec(Arrays.copyOf(seed, 16), "AES");
}

2. Градуированный ответ. В зависимости от серьезности подозрений выполняются различные по типу действия

class SecurityResponseManager
public class SecurityResponseManager {
    
    public enum ThreatLevel {
        LOW(1), MEDIUM(2), HIGH(3), CRITICAL(4);
        private final int severity;
        ThreatLevel(int severity) { this.severity = severity; }
    }
    
    public static void handleSecurityViolation(String violationType, ThreatLevel level) {
        switch (level) {
            case LOW:
                handleLowThreat(violationType);
                break;
            case MEDIUM:
                handleMediumThreat(violationType);
                break;
            case HIGH:
                handleHighThreat(violationType);
                break;
            case CRITICAL:
                handleCriticalThreat(violationType);
                break;
        }
    }
    
    private static void handleLowThreat(String violation) {
        // Логирование + предупреждение
        logSecurityEvent(violation, ThreatLevel.LOW);
        
        // Показать предупреждение пользователю
        showWarningDialog("Обнаружена подозрительная активность. " +
                         "Убедитесь, что не используете неавторизованные инструменты.");
        
        // Увеличить частоту проверок
        SecurityMonitor.increaseCheckFrequency();
    }
    
    private static void handleMediumThreat(String violation) {
        logSecurityEvent(violation, ThreatLevel.MEDIUM);
        
        // Ограничить функциональность
        FeatureManager.disableNonEssentialFeatures();
        
        // Требовать повторную аутентификацию
        requestReauthentication();
        
        // Уведомить сервер
        notifyServer(violation, ThreatLevel.MEDIUM);
    }
    
    private static void handleHighThreat(String violation) {
        logSecurityEvent(violation, ThreatLevel.HIGH);
        
        // Временная блокировка функций
        FeatureManager.enableRestrictedMode(Duration.ofHours(1));
        
        // Принудительная синхронизация с сервером
        forceLicenseRevalidation();
        
        // Создать "ловушку" для анализа
        deployHoneypot();
        
        notifyServer(violation, ThreatLevel.HIGH);
    }
    
    private static void handleCriticalThreat(String violation) {
        logSecurityEvent(violation, ThreatLevel.CRITICAL);
        
        // Немедленная остановка критических функций
        FeatureManager.shutdownCriticalSystems();
        
        // Очистка конфиденциальных данных
        SecurityUtils.wipeSensitiveData();
        
        // Заблокировать лицензию на сервере
        revokeLicenseRemotely();
        
        // Отложенное завершение работы
        scheduleGracefulShutdown();
    }
}

3. Скрытые контрмеры. Не сообщаем злоумышленнику прямо о том, что его план не удался, а просто делаем приложение неработоспособным. Метод спорный.

class StealthCountermeasures
public class StealthCountermeasures {
    
    // Незаметная деградация функциональности
    public static void gradualFunctionalityReduction() {
        new Thread(() -> {
            try {
                // Постепенно увеличиваем задержки
                for (int i = 1; i <= 10; i++) {
                    Thread.sleep(i * 1000);
                    PerformanceManager.introduceDelay(i * 100);
                }
                
                // Начинаем "случайные" сбои
                RandomFailureGenerator.setFailureRate(0.05f); // 5%
                
                // Постепенно снижаем качество результатов
                ResultQualityManager.reduceAccuracy(0.1f);
                
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }).start();
    }
    
    // Ложные сообщения об ошибках
    public static void injectDecoyErrors() {
        ErrorInjector.scheduleRandomErrors(Arrays.asList(
            "Network timeout occurred",
            "Temporary service unavailable", 
            "Configuration file corrupted",
            "Memory allocation failed"
        ));
    }
    
    // Создание ловушек для атакующего
    public static void deployDeceptionMeasures() {
        // Создаём ложные "уязвимости"
        FakeVulnerabilityManager.createDecoyWeakness();
        
        // Ложные ключи и конфигурации
        DecoyDataManager.injectFakeSecrets();
        
        // Мониторинг попыток использования ловушек
        TrapMonitor.enableTrapDetection();
    }
}

4. Детальное логирование инцидентов. Возможно, это поможет в будущем найти новые дыры.

class SecurityAuditLogger
public class SecurityAuditLogger {
    
    private static final Logger securityLogger = 
        LoggerFactory.getLogger("SECURITY");
    
    public static class SecurityEvent {
        private final String eventType;
        private final ThreatLevel severity;
        private final Instant timestamp;
        private final Map<String, Object> context;
        private final String sessionId;
        private final SystemFingerprint fingerprint;
        
        public SecurityEvent(String type, ThreatLevel severity) {
            this.eventType = type;
            this.severity = severity;
            this.timestamp = Instant.now();
            this.context = collectContextInfo();
            this.sessionId = SessionManager.getCurrentSessionId();
            this.fingerprint = SystemFingerprint.capture();
        }
        
        private Map<String, Object> collectContextInfo() {
            Map<String, Object> context = new HashMap<>();
            
            // Информация о системе
            context.put("os.name", System.getProperty("os.name"));
            context.put("java.version", System.getProperty("java.version"));
            context.put("user.timezone", System.getProperty("user.timezone"));
            
            // Информация о процессе
            RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
            context.put("process.pid", runtime.getName().split("@")[0]);
            context.put("jvm.arguments", runtime.getInputArguments());
            context.put("uptime.ms", runtime.getUptime());
            
            // Стек вызовов
            StackTraceElement[] stack = Thread.currentThread().getStackTrace();
            context.put("call.stack", Arrays.stream(stack)
                .limit(10)
                .map(StackTraceElement::toString)
                .collect(Collectors.toList()));
            
            // Информация о памяти
            MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
            context.put("memory.heap", memory.getHeapMemoryUsage());
            context.put("memory.nonheap", memory.getNonHeapMemoryUsage());
            
            return context;
        }
    }
    
    // Форензик-анализ окружения
    public static void performForensicsCapture() {
        try {
            Map<String, Object> forensics = new HashMap<>();
            
            // Снимок загруженных классов
            forensics.put("loaded.classes", captureLoadedClasses());
            
            // Активные потоки
            forensics.put("active.threads", captureThreadState());
            
            // Сетевые соединения
            forensics.put("network.connections", captureNetworkState());
            
            // Переменные окружения
            forensics.put("environment", System.getenv());
            
            // Системные свойства
            forensics.put("system.properties", System.getProperties());
            
            // Сохраняем дамп для анализа
            saveForensicsData(forensics);
            
        } catch (Exception e) {
            securityLogger.error("Failed to capture forensics data", e);
        }
    }
    
    private static List<String> captureLoadedClasses() {
        return Arrays.stream(
            java.lang.instrument.Instrumentation.class.getClasses()
        ).map(Class::getName).collect(Collectors.toList());
    }
}

Заключение

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

В завершении хочу поделиться личными впечатлениями о коде, который я исследовал.
Было очень интересно, так как ранее я видел либо коммерческий код, написанный по стандартам индустрии, либо код, написанный «студентами», изучающими язык Java. С присущими им ошибками и паттернами.

Здесь же был новый для меня опыт чтения кода, написанного человеком умным, но далеким от стандартов коммерческой разработки.

Методы были написаны то с большой, то с маленькой буквы (я заменил имена методов в листингах кода в статье, но сохранил эту особенность). Хотя имена всех переменных писались с маленькой буквы. Нейминг написан тоже не по стандартам Java. В названиях методов может отсутствовать глагол, присутствовать символ “_” в качестве разделителя. Переменные местами названы по типу “result1”, “result2” и т. д.

Разделение кода на классы также неочевидно. Для меня осталось непонятным, почему рассмотренных нами классов Loader и Filter именно два, а не один или пять.

Используются магические числа, строки и т. д.

При этом видно, что автор пишет на Java не впервые, соблюдает отступы, делит код на логические блоки.

И не пытается запутать возможного читателя, так как код легко декомпилируется, внутри есть предупреждение на китайском о том, что для коммерческого использования программу следует купить. А также пасхалка о том, что автор считает, что Тайвань — это Китай.

Надеюсь, что вам было интересно, до встречи.

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


  1. alexander-shustanov
    19.08.2025 07:37

    Осветить бы еще юридические риски использования таких средств) И конечно, вполне осязаемые риски безопасности, при использовании таких средств, загруженных из интернета)


  1. valery1707
    19.08.2025 07:37

    Используются магические числа, строки и т. д.
    При этом видно, что автор пишет на Java не впервые, соблюдает отступы, делит код на логические блоки.

    Вы же анализировали не оргинальный код агента, а, как указано в статье, код, полученный в результате его декомпиляции - большая часть из описанного скорее относится к особенностям работы декомпилятора, чем к оригинальному коду.


    1. BTRchik Автор
      19.08.2025 07:37

      Спасибо за комментарий. Я почитаю об этом и поправлю статью.


  1. poxvuibr
    19.08.2025 07:37

    В статье большое количество пунктов и выделений жирным. Обычно так пишет ИИ. Ещё немного и для хоть какой-то уверенности, что писал живой человек, надо будет просить приложить видео, как он пишет и правит текст.

    Сейчас я делаю вывод исходя из того, отметился ли автор в комментариях. Если нет, то точно писал ИИ


    1. BTRchik Автор
      19.08.2025 07:37

      Писал я, и выделял жирным тоже я ручками.
      Сильно рябит в глазах от выделений?


      1. lamert
        19.08.2025 07:37

        Мне зашло, и не рябит в глазах. Спасибо.


      1. poxvuibr
        19.08.2025 07:37

        Писал я, и выделял жирным тоже я ручками.

        Очень приятно, когда автор появляется в комментариях!

        Сильно рябит в глазах от выделений?

        Нет, но из-за них не понятно кто писал текст - человек или ИИ ((. Раньше не было такой проблемы. То, что сгенерировал ИИ я буду читать только по необходимости, то что писал человек совсем другое дело ))


        1. EvgenyVilkov
          19.08.2025 07:37

          Так у автора вроде не первая публикация


          1. poxvuibr
            19.08.2025 07:37

            Да и в комментариях он появился


  1. svcoder
    19.08.2025 07:37

    Для хакеров приложение на Java это как читать исходный код. Хотите сделать защину - выносите чувствительный функционал в отдельные нативные модули и там же делайте проверку лицензии


    1. kos9078
      19.08.2025 07:37

      Как в инсталляторе одного популярного инструмента который знаменит тем, что на gh распространяется с adware, а на патреоне без? Да-да, делайте так, пожалуйста)

      Поменять jz на jnz еще проще, чем расковыривать некоторые java-приложения чтобы такими агентами нейтрализовать стучалки с телеметрией.

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

      P.s. java-агент и args тоже можно скрыть... в статье достаточно для этого информации.

      P.p.s. это хорошая статья, но я бы ее скрыл от публики. Не надо учить скрипт-кидди обходам защит. Здесь слишком много примеров "как обойти проверку лицензий в java". У статьи прям упор на это. Заменить бы это на что-то более нейтральное...