Посмотреть вложение 285548
почему бы и нет?
Теория компиляции
Java - компилируемый язык, но что же это значит? Сначала узнаем про интерпретируемые языки. Например: когда вы в пишите в консоли
python main.py
питон читает ваш файл по строчкам и выполняет. Компилируемые языки же прежде чем выполнять код преобразуют (компилируют) исходный файл в более низкоуровневое представление (байткод). После компиляции для каждого файла создаётся структура класса в которую и помещается этот код. И далее эти классы уже выполняет непосредственно джава машина (jvm). То есть по сути джава машина и есть интерпретатор.
Байткод
Прежде чем писать обфускатор нужно научиться понимать байткод. Состоит он из
идущих по порядку. Посмотреть его можно через множество утилит (ByteEdit, Recaf, JByteMod, ThreadTear и другие) к которым мы вернёмся чуть позже, но пока, что нам хватит обычного
javap -c Class
, вывод для обычного System.out.println(1) будет таким:
Compiled from "Main.java"
public class Main {
public Main();
Code: // Bytecode
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code: // Bytecode
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iconst_1
4: invokevirtual #13 // Method java/io/PrintStream.println:(I)V
7: return
}
Переменная определяется внутри метода, а Поле - внутри класса. ЭТО ВАЖНО
Так как
!!поле!! System.
out является статическим, чтобы его получить компилятор использует
getstatic инструкцию. Далее это поле помещается на стек, за ним помещается iconst_1 - что означает константу int со значением 1. А после происходит выполнение функции println через
invokevirtual с аргументами из стека и затем стек очищается. Understand?
Это конечно всё интересно, скажите вы, но, что за
java/io/PrintStream.println:(I)V
и
java/lang/System.out:Ljava/io/PrintStream;
? Помимо названия класса и member'а (out, println) указывается ещё дополнительная информация: для поля out мы говорим что её тип -
Ljava/io/PrintStream;
, а для метода println указываем дескриптор. В скобочках пишутся типы аргументов метода, а далее возвращаемый тип (всё в
).
Основные инструкции
Каждую инструкцию байткода знать не обязательно, обойдёмся и основными.
- getfield <класс поля> <название> <дескриптор> - Получает поле
- getstatic <класс поля> <название> <дескриптор> - Получает статическое поле
- putfield <класс поля> <название> <дескриптор> - Помещает значение из стека в поле
- putstatic <класс поля> <название> <дескриптор> - Помещает значение из стека в статическое поле
- goto <метка> - Перейти на метку
- ifeq, ifne, iflt, ifge, ifgt, ifle <метка> - Перейти на метку
- aload <индекс> - Поместить локальную переменную на стек
- astore <индекс> - Поместить значение из стека в локальную переменную
- areturn - Вернуть значение из стека как результат
- aconst_null - null
- invokevirtual <класс метода> <название> <дескриптор> - Вызов метода из класса
- invokestatic <класс метода> <название> <дескриптор> - Вызов статического метода из класса
- invokespecial <класс метода> <название> <дескриптор> - Вызов метода из класса для вызовов superclass (наследованный класс)и для приватных методов
- invokeinterface <класс метода> <название> <дескриптор> - Вызов метода из интерфейса
- invokedynamic - Вызов метода из CallSite (это пока не затрагиваем, )
aload, astore, areturn - Используются только для объектов. Для примитивов (long, int, ...) такие же инструкции - но с префиксом типа (то есть для int iload, для long lload, для double dload).
Для того, чтобы поместить int есть такие условия:
- iconst_0, iconst_1, iconst_2, iconst_4, iconst_5 - От 0 до 5
- bipush <значение> - Если значение не больше минимального значения байта (Byte.MIN_VALUE -2⁷) и не меньше максимального значения байта (Byte.MAX_VALUE 2⁷-1)
- sipush <значение> - Если значение не больше минимального значения шорта (Short.MIN_VALUE) и не меньше максимального значения шорта (Short.MAX_VALUE)
Для long по другому:
- lconst_0, lconst_1 - От 0 до 1
В любых остальных случаях, а также для строк используется
ldc.
Ах да, забыл рассказать про метки. Метка - это просто грубо говоря "секция", которая группирует один список инструкций от других. Зачем это нужно? Покажу на примере:
if (somebool)
System.out.println(1);
else
System.out.println(2);
В байткоде это выглядит примерно так:
1: // Метка 1
aload somebool // Помещаем somebool на стек
ifeq 2 // Если somebool == false, то перемещаемся на метку 2
// Иначе остаёмся на этой метке и продолжаем код если somebool оказался true
// Байткод для System.out.println(1);
2:
// Байткод для System.out.println(2);
Практика
Подготавливаем проект
Для модификации байткода и парсинга класс файла мы возьмём библиотеку ow2.asm (также есть bytebuddy). ow2.asm:tree содержит основные классы для удобной работы с классами и остальными компонентами (ClassNode, MethodNode, ...)
Dont stop
Продолжаем. Читаем класс:
ClassReader classReader = new ClassReader(Files.readAllBytes(new File("...").toPath()));
ClassNode classFile = new ClassNode();
classReader.accept(classFile, 0);
System.out.printf("%s extends %s\n", classFile.name, classFile.superName);
Если сделали всё правильно должно выдать:
Main extends java/lang/Object
Выведем названия всех методов:
for (MethodNode method : classFile.methods)
System.out.println(method.name);
Вывод:
<init>
main
Небольшое отступление, существуют "специальные методы":
<init>
- Конструктор класса,
<clinit>
- Конструкция
static {}
выполняемая после загрузки класса.
Далее переберём все инструкции:
for (AbstractInsnNode instruction : method.instructions)
System.out.printf("\t%s - %d\n", instruction.getClass().getSimpleName(), instruction.getOpcode());
Теперь чтобы их фильтровать можно использовать либо опкод, либо класс инструкции. Как мы помним большинство инструкций предназначены, например, для действий с полями. В ASM их тоже отделяют:
// instruction class - opcode
LabelNode - -1
LineNumberNode - -1
InsnNode - 4
VarInsnNode - 54
LabelNode - -1
LineNumberNode - -1
VarInsnNode - 21
JumpInsnNode - 153
LabelNode - -1
Для всех опкодов есть свой класс Opcodes.
Давайте напишем самую тупую обфускацию - будем просто после каждого вызова (MethodInsnNode класс для инструкций invoke) помещать в переменную число 0:
if (instruction instanceof MethodInsnNode) { // Если инструкция invoke
LabelNode startNode = new LabelNode(); // Метка начала
LabelNode labelNode = new LabelNode(); // Создаём метку, она будет указана до какого момента локальная переменная будет "жить"
int index = method.localVariables.size() + 1; // Высчитываем новый индекс для переменной
method.localVariables.add(new LocalVariableNode("abobus"+method.localVariables.size(), "I", null, (LabelNode) method.instructions.getFirst(), labelNode, index)); // Добавляем переменную
// Добавляем инструкции
InsnList instructions = new InsnList();
instructions.add(startNode);
instructions.add(new InsnNode(Opcodes.ICONST_0)); // Помещаем 0 на стек
instructions.add(new VarInsnNode(Opcodes.ISTORE, index)); // Сохраняем значение 0 в переменную
instructions.add(endNode);
method.instructions.insert(instruction, instructions); // Вставляем наши новые инструкции после текущей (insertBefore чтобы вставить до)
}
Сохраняем класс файл:
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
classFile.accept(classWriter);
Files.write(new File("...").toPath(), classWriter.toByteArray());
Флаг
COMPUTE_MAXS автоматически пересчитывает максимальный размер для стека и переменных. Это необходимо из-за того, что мы добавили новую переменную. Можно делать это и вручную - через
method.maxLocals++
Запускаем:
Работает, теперь посмотрим, что нам покажет декомпилятор:
Посмотреть вложение 285551
Двигаемся дальше
Первая обфускация у нас уже есть, осталось сделать что-нибудь по сложнее.
Чистим код
Почистим немного код. Я создам интерфейс трансформера:
public interface Transformer {
void transformClass(ClassNode classNode);
void transformMethod(MethodNode methodNode);
}
Сервис:
public class TransformerService {
private final List<Transformer> transformers;
public TransformerService() {
this.transformers = new LinkedList<>();
}
public void add(Transformer transformer) {
this.transformers.add(transformer);
}
public void obfuscate(ClassNode classNode) {
this.transformers.forEach(transformer -> transformer.transformClass(classNode));
classNode.methods.forEach(methodNode -> {
this.transformers.forEach(transformer -> transformer.transformMethod(methodNode));
});
}
}
Используем:
TransformerService service = new TransformerService();
service.add(...);
service.obfuscate(classFile);
Вот теперь писать чуть легче.
Обфускация строк
Создадим трансформер под строки:
public abstract class StringTransformer implements Transformer {
abstract public InsnList generateEncrypt(AbstractInsnNode instruction, String string);
@Override
public void transformMethod(MethodNode methodNode) {
for (AbstractInsnNode instruction : methodNode.instructions) {
if (instruction instanceof LdcInsnNode ldcInsnNode && ldcInsnNode.cst instanceof String) {
methodNode.instructions.insert(instruction, generateEncrypt(instruction, (String) ldcInsnNode.cst));
methodNode.instructions.remove(instruction);
}
}
}
}
Метод через Base64
Кстати пока писал вспомнил, что у ASM есть удобное получение дескрипторов через Type#getDescriptor
public class Base64StringTransformer extends StringTransformer {
@Override
public InsnList generateEncrypt(AbstractInsnNode instruction, String string) {
InsnList instructions = new InsnList();
// new String(Base64.getDecoder().decode("encoded string"))
instructions.add(new InsnNode(Opcodes.DUP));
instructions.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/util/Base64", "getDecoder", "()Ljava/util/Base64$Decoder;"));
instructions.add(new LdcInsnNode(Base64.getEncoder().encodeToString(string.getBytes())));
instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/util/Base64$Decoder", "decode", "(Ljava/lang/String;)[B"));
instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"));
return instructions;
}
}
Смотрим:
Посмотреть вложение 285552
Метод через AES PKCS
Base64 это всё равно легко, а вот AES шифрование уже лучше. Джава из коробки поддерживает шифрование по ключу:
SecretKeySpec spec = new SecretKeySpec("aaaaaaaaaaaaaaaa".getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, spec, new IvParameterSpec(new byte[cipher.getBlockSize()]));
String encrypted = Base64.getEncoder().encodeToString(cipher.doFinal("text22".getBytes()));
System.out.println(encrypted);
cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, spec, new IvParameterSpec(new byte[cipher.getBlockSize()]));
String decrypted = new String(cipher.doFinal(Base64.getDecoder().decode(encrypted)));
System.out.println(decrypted);
aaaaaaaaaaaaaaaa - ключ. Он обязательно должен быть 16, 24 или 32 байта в зависимости от кодировки. Чтобы немножко поприкалываться можно использовать символы, которые клеятся друг на друга (
)
Реализуем:
public class AESStringTransformer extends StringTransformer {
@Override
public InsnList generateEncrypt(MethodNode methodNode, AbstractInsnNode instruction, String string) {
InsnList instructions = new InsnList();
try {
SecretKeySpec spec = new SecretKeySpec("x̊a".getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, spec, new IvParameterSpec(new byte[cipher.getBlockSize()]));
String encrypted = Base64.getEncoder().encodeToString(cipher.doFinal(string.getBytes()));
LabelNode startNode = new LabelNode();
LabelNode endNode = new LabelNode();
int index = methodNode.localVariables.size() + 1;
methodNode.localVariables.add(new LocalVariableNode("cipher"+index, Type.getDescriptor(Cipher.class), null, startNode, endNode, index));
instructions.add(startNode);
instructions.add(new LdcInsnNode("AES/CBC/PKCS5Padding"));
instructions.add(new MethodInsnNode(Opcodes.INVOKESTATIC, Type.getInternalName(Cipher.class), "getInstance", "(Ljava/lang/String;)Ljavax/crypto/Cipher;"));
instructions.add(new VarInsnNode(Opcodes.ASTORE, index));
instructions.add(new VarInsnNode(Opcodes.ALOAD, index));
instructions.add(new InsnNode(Opcodes.ICONST_2)); // Cipher.DECRYPT_MODE
instructions.add(new TypeInsnNode(Opcodes.NEW, Type.getInternalName(SecretKeySpec.class)));
instructions.add(new InsnNode(Opcodes.DUP));
instructions.add(new LdcInsnNode("x̊a"));
instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, Type.getInternalName(String.class), "getBytes", "()[B"));
instructions.add(new LdcInsnNode("AES"));
instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, Type.getInternalName(SecretKeySpec.class), "<init>", "([BLjava/lang/String;)V"));
instructions.add(new TypeInsnNode(Opcodes.NEW, Type.getInternalName(IvParameterSpec.class)));
instructions.add(new InsnNode(Opcodes.DUP));
instructions.add(new VarInsnNode(Opcodes.ALOAD, index));
instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, Type.getInternalName(Cipher.class), "getBlockSize", "()I"));
instructions.add(new IntInsnNode(Opcodes.NEWARRAY, Opcodes.T_BYTE));
instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, Type.getInternalName(IvParameterSpec.class), "<init>", "([B)V"));
instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, Type.getInternalName(Cipher.class), "init", "(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V"));
instructions.add(new TypeInsnNode(Opcodes.NEW, Type.getInternalName(String.class)));
instructions.add(new InsnNode(Opcodes.DUP));
instructions.add(new VarInsnNode(Opcodes.ALOAD, index));
instructions.add(new MethodInsnNode(Opcodes.INVOKESTATIC, Type.getInternalName(Base64.class), "getDecoder", "()Ljava/util/Base64$Decoder;"));
instructions.add(new LdcInsnNode(encrypted));
instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/util/Base64$Decoder", "decode", "(Ljava/lang/String;)[B"));
instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, Type.getInternalName(Cipher.class), "doFinal", "([B)[B"));
instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, Type.getInternalName(String.class), "<init>", "([B)V"));
instructions.add(endNode);
} catch (Exception exception) {
exception.printStackTrace();
}
return instructions;
}
}
Теперь есть проблема - если часто использовать эту строку (например: в читах в каждом кадре дешифровать по сотни строк), то он не оптимизирован. Поэтому мы создадим HashMap куда поместим хэш (рандом число для каждой строки) и дешифрованную строку. Пример как это будет выглядеть:
public class Main {
private static final Map<Long, String> hashes;
public static void main(String[] args) {
System.out.println(hashes.get(1337));
}
static {
hashes = new HashMap<>();
// метод дешифровки
hashes.put(1337, decrypted);
}
}
Также этим способом избавимся от -noverify. Кстати о
-noverify
, jvm проверяет инструкции каждого метода и если творить прям лютую дичь то он может дать невалид и -noverify флаг скипает эту проверку.
Реализуем (много кода, выложил на pastebin) -
Для прикола над декомпиляторами можно указать пробел в виде названия переменной или поля.
Смотрим:
Без -noverify!
Recaf с CFR декомпилятором:
Java decompiler GUI (jd-gui):
Flow
Строки это конечно интересно, но сильно они нас конечно не запутывают. Задача flow - насрать в код или изменить его настолько, чтобы оригинальный код отыскать было не так легко. Например: перед вызовом метода можно добавить switch с кучей рандомных значений и кейсов, где лишь одно правильное. Такая реализация есть в Bozar obfuscator:
Что-то серьёзное я делать не буду, просто после каждого вызова метода буду пихать
if (что-нибудь, что не выполнится) throw ...;
public class FlowTransformer implements Transformer {
@Override
public void transformClass(ClassNode classNode) {
}
@Override
public void transformMethod(MethodNode methodNode) {
for (AbstractInsnNode instruction : methodNode.instructions) {
if (instruction instanceof MethodInsnNode) {
InsnList instructions = new InsnList();
LabelNode startLabelNode = new LabelNode();
LabelNode labelNode = new LabelNode();
// 0*0 + 5 == 0
instructions.add(new InsnNode(Opcodes.ICONST_0));
instructions.add(new InsnNode(Opcodes.DUP));
instructions.add(new InsnNode(Opcodes.IMUL));
instructions.add(new InsnNode(Opcodes.ICONST_5));
instructions.add(new InsnNode(Opcodes.IADD));
instructions.add(new JumpInsnNode(Opcodes.IFNE, labelNode));
instructions.add(startLabelNode);
instructions.add(new TypeInsnNode(Opcodes.NEW, Type.getInternalName(RuntimeException.class)));
instructions.add(new InsnNode(Opcodes.DUP));
instructions.add(new LdcInsnNode("-"));
instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, Type.getInternalName(RuntimeException.class), "<init>", "(Ljava/lang/String;)V"));
instructions.add(new InsnNode(Opcodes.ATHROW));
instructions.add(new JumpInsnNode(Opcodes.GOTO, startLabelNode));
instructions.add(labelNode);
methodNode.instructions.insert(instruction, instructions);
}
}
}
}