Недавно я начал разрабатывать приложение под Android и передо мной возникла задача защитить его от реверса. Беглый просмотр гугла позволил предположить, что ProGuard, входящий в состав Android Studio, с задачей справится. Результат меня действительно устроил за исключением одной маленькой детали — строки.
Программа обменивается с сервисом информацией с помощью Intent. Ключевой частью которых является строка действия. И если для взаимодействия с системой или другими приложениями строка должна иметь определённый формат, то для обменов внутри приложения достаточно её уникальности. Для удобства рекомендуется составлять данную строку из имени пакета и названия действия. Например:
Это удобно для отладки, но сильно снижает качество обсфускации кода. Хочется, чтоб в релизе программы вместо данной строки оказался, например, её MD5 хеш.
Под катом рассказано, как добиться такого поведения с помощью подручного велосипеда.
Я был сильно удивлён, узнав, что ProGuard не работает со строками. Из документации на официальном сайте удалось узнать, что со строками умеет работать продвинутая платная версия. Вот только она шифрует строки с целью их расшифровки в первоначальный вариант во время работы программы. Решения, позволяющего превратить строку в её MD5 значение, мне найти не удалось.
Попытки найти решение этой задачи вывели меня на статью, демонстрирующую чудеса оптимизирующих компиляторов C++: Вычисление CRC32 строк в compile-time. Но в Java аналогичный метод не взлетел. ProGuard достаточно сильно свернул методы, но споткнулся на получении массива байт из строки.
После этого я решил не тратить силы на попытки автоматизации и просто решить задачу руками:
Но когда я увидел на хабре статью Custom Annotation Preprocessor — создание на базе Android-приложения и конфигурация в IntelliJ IDEA, я понял, что это решение моей проблемы.
Изучение аннотаций по традиции началось с отсутствия нужной информации на русском языке. Большинство статей рассматривает применение Runtime аннотаций. Впрочем, подходящая статья нашлась на хабре: Подсчёт времени выполнения метода через аннотацию.
Для создания аннотации времени компиляции нам надо:
Описание аннотации может выглядеть так:
Target — определяет объекты, для которых применима аннотация. В данном случае аннотацию можно применить к объявлениям переменных в классе. К сожалению, к любым, но об этом позже.
Retention — время жизни аннотации. Мы указываем, что она существует только в исходном коде.
В самой аннотации мы заводим поле, определяющее метод хеширования. По умолчанию MD5.
Этого достаточно чтоб использовать в коде аннотацию, но от неё не будет никакого толку, пока мы не напишем обработчик аннотации.
Обработчик аннотации наследуется от javax.annotation.processing.AbstractProcessor. Минимальный класс обработчика выглядит так:
SupportedAnnotationTypes — определяет имена классов аннотаций, которые будут обрабатываться нашим процессором.
SupportedSourceVersion — поддерживаемая версия исходников. Смысл в том, чтоб процессор не сломал при обработке аннотаций языковые конструкции, которые появились в более новых версиях языка.
Вместо данных аннотаций можно переопределить методы getSupportedAnnotationTypes и getSupportedSourceVersion.
Метод process получает список необработанных поддерживаемых аннотаций и объект взаимодействия с компилятором. Если метод возвращает false — компилятор передаёт аннотацию на обработку следующему процессору, который поддерживает данный тип аннотаций. Если же метод вернул истину — аннотация считается обработанной и больше никуда не попадёт. Это нужно учитывать, чтоб случайно не прибить чужие аннотации.
Если в процессе работы любого процессора изменились или добавились исходные коды — компилятор пойдёт на следующий проход.
Для изменения исходного кода нам будет недостаточно RoundEnvironment поэтому мы переопределяем метод init и получаем из него JavacProcessingEnvironment. Данный класс позволяет получить доступ к исходным кодам, системе выброса предупреждений и ошибок компиляции и многое другое. Там же получим TreeMaker — вспомогательный инструмент для изменения исходных кодов.
Теперь нам остаётся перебрать наши аннотированные поля и заменить значения строковых констант. Код привожу в сокращении. Ссылка на GitHub в конце статьи.
В методе мы бежим по списку аннотаций (мы ведь помним, что в общем случае процессор обрабатывает больше чем одну аннотацию?), для каждой аннотации выбираем список элементов. После этого начинается магия. Мы используем инструменты из поставки com.sun.tools.javac чтоб преобразовать элементы в дерево исходного кода, у которого огромное число возможностей и по традиции полное отсутствие русскоязычной документации. Поэтому прошу не удивляться, что код работы с этим деревом далёк от идеала.
Когда мы получили объявление переменной в виде дерева JCTree.JCVariableDecl var — мы можем убедиться, что это именно строковая переменная. В моём случае данная проверка осуществляется костылём:
vartype — тип поля, который наверняка можно сравнить с какой-нибудь константой или определить его принадлежность определённому классу, но, как я уже говорил, документации нет, а быстрая проверка показала, что приведение к строке даёт нам имя типа.
Второй интересный момент — мы можем обработать только строки, аналогичные примеру из самого начала статьи. Всё дело в том, что на данном этапе мы работаем именно с исходным текстом. По этому если переменная инициализируется в конструкторе, то JCTree.JCExpression initializer = var.getInitializer(); вернёт нам null. Не менее неприятная ситуация получится если мы попытаемся обработать конструкции вида:
Для этого вводится вторая проверка (initializer instanceof JCTree.JCLiteral). Это отсечёт все описанные примеры, поскольку они являются не литералами в чистом виде и в дереве будут представлены выражением из нескольких элементов.
Дальнейший код очевиден. Берём строку, хешируем, заменяем, радуемся? Нет.
Комментариями отмечено несколько мест, в которых возникают очевидные ошибки. И в нашем случае их игнорирование не является корректным поведением. Для того чтобы сообщить пользователю об ошибке нам потребуется объект javax.annotation.processing.Messager. Он позволяет выбросить предупреждение, ошибку компиляции или просто информационное сообщение. Например, мы можем сообщить о недопустимом алгоритме хеширования:
При этом надо понимать, что выброс сообщения об ошибке не прерывает выполнение метода. Компилятор дождётся как минимум окончания нашего метода, прежде чем прервать процесс компиляции. Это позволяет выбросить сразу все ошибки применения аннотаций пользователю. Третий аргумент метода printMessage позволяет нам указать объект, на котором мы споткнулись. Он не является обязательным, но сильно облегчает жизнь.
Осталось сообщить компилятору, что мы такие есть и готовы принять аннотации на растерзание. Во многих статьях встречаются инструкции, как добавить свой процессор в <имя среды разработки>. Видимо корнями это уходит в далёкие времена, когда подобные вещи делались на коленках народными умельцами. Однако уже достаточно давно механизм обработки аннотаций является частью javac и, по сути, наш класс обработчик является плагином для javac. Это значит, что мы вполне стандартными средствами можем подключить нашу аннотацию к любой среде без шаманств с настройками.
Нам потребуется создать в каталоге META-INF подкаталог services, а в нём файл javax.annotation.processing.Processor. В сам файл нам необходимо поместить список наших классов процессоров. В конкретном случае com.example.annotation.HashedAnnotationProcessor. И всё. Теперь мы собираем нашу библиотеку содержащую аннотацию и её процессор. Подключаем эту библиотеку к проекту. И всё работает.
При этом ни сама библиотека, ни остатки аннотаций не попадут в скомпилированный код.
Аннотация готова. Строки хешируются. Вот только задача всё ещё не решена.
Если мы подключим аннотацию к проекту в таком виде — у нас строки будут хешироваться всегда. А нам надо только в релизе.
В Java понятие отладочной и релизной сборки очень условно и зависит от представлений пользователя. Поэтому добиваемся того, чтоб задача assembleDebug для Android проекта строки не хешировала, а во всех остальных случаях от строк оставались MD5 хеши.
Для решения этой задачи мы передадим нашему процессору аннотаций дополнительный параметр.
Сначала доработаем процессор:
Мы объявили, что ожидаем опцию «Hashed» и если она «disable», то ничего не делаем и выводим информацию пользователю. Сообщения типа Diagnostic.Kind.NOTE являются информационными и при настройках по умолчанию многие среды разработки эти сообщения вообще не покажут.
При этом мы сообщаем компилятору, что обрабатывать аннотацию не стали. Если в системе есть ещё процессоры, которые обрабатывают аннотации такого типа, или вообще не разбирают тип — они могут получить нашу аннотацию. Правда, я совершенно ничего не могу сказать о том, в каком порядке компилятор будет пытаться распорядиться аннотацией. Пока у нас только наша библиотека и ровно одна аннотация — это не актуально, но при использовании нескольких библиотек аннотаций будьте готовы к всплытию подводных камней.
Осталось передать эту опцию компилятору. Опции для процессоров передаются компилятору ключом "-A". В нашем случае "-AHashed=disable".
Остаётся только застваить Gradle передать эту опцию в нужный момент. И снова костыли:
Это для текущей версии Android Studio. Для более ранних tasks.withType(Compile).
Костыль, потому что данный блок вызывается для каждого типа сборки независимо от задачи. По идее должно быть что-то аналогичное buildTypes из блока android, но у меня уже не было никаких сил искать красивое решение. Все ведь уже догадались, что документации на русском традиционно нет?
В коде аннотации могут выглядеть так:
Метод может быть любым из поддерживаемых MessageDigest.
Задача решена. Конечно же, только для одного очень конкретного способа объявления констант, конечно, не самым эффективным способом, а у многих и сама постановка задачи вызовет больше вопросов, чем материал статьи. А я просто надеюсь, что кто-нибудь потратит меньше времени и нервов, если на его пути встретится похожая задача.
Но ещё больше я надеюсь, что кто-нибудь заинтересуется данной темой и хабр увидит статьи, в которых будет рассказано, почему вся эта магия работает.
И, конечно же, обещанный код: GitHub::DemoAnnotation
Программа обменивается с сервисом информацией с помощью Intent. Ключевой частью которых является строка действия. И если для взаимодействия с системой или другими приложениями строка должна иметь определённый формат, то для обменов внутри приложения достаточно её уникальности. Для удобства рекомендуется составлять данную строку из имени пакета и названия действия. Например:
public final class HandlerConst {
public static final String ACTION_LOGIN = "com.example.app.ACTION_LOGIN";
}
Это удобно для отладки, но сильно снижает качество обсфускации кода. Хочется, чтоб в релизе программы вместо данной строки оказался, например, её MD5 хеш.
public final class HandlerConst {
public static final String ACTION_LOGIN = "7f315954193d1fd99b017081ef8acdc3";
}
Под катом рассказано, как добиться такого поведения с помощью подручного велосипеда.
Немного лирики
Я был сильно удивлён, узнав, что ProGuard не работает со строками. Из документации на официальном сайте удалось узнать, что со строками умеет работать продвинутая платная версия. Вот только она шифрует строки с целью их расшифровки в первоначальный вариант во время работы программы. Решения, позволяющего превратить строку в её MD5 значение, мне найти не удалось.
Попытки найти решение этой задачи вывели меня на статью, демонстрирующую чудеса оптимизирующих компиляторов C++: Вычисление CRC32 строк в compile-time. Но в Java аналогичный метод не взлетел. ProGuard достаточно сильно свернул методы, но споткнулся на получении массива байт из строки.
После этого я решил не тратить силы на попытки автоматизации и просто решить задачу руками:
public final class HandlerConst {
public static final String ACTION_LOGIN;
static {
if (BuildConfig.DEBUG) ACTION_LOGIN = "com.example.app.ACTION_LOGIN";
else ACTION_LOGIN = "7f315954193d1fd99b017081ef8acdc3";
}
}
Но когда я увидел на хабре статью Custom Annotation Preprocessor — создание на базе Android-приложения и конфигурация в IntelliJ IDEA, я понял, что это решение моей проблемы.
Реализация аннотации
Изучение аннотаций по традиции началось с отсутствия нужной информации на русском языке. Большинство статей рассматривает применение Runtime аннотаций. Впрочем, подходящая статья нашлась на хабре: Подсчёт времени выполнения метода через аннотацию.
Для создания аннотации времени компиляции нам надо:
- Описать аннотацию;
- Реализовать наследника класса AbstractProcessor, который будет обрабатывать нашу аннотацию;
- Сообщить компилятору где искать наш процессор.
Описание аннотации может выглядеть так:
package com.example.annotation;
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.SOURCE)
public @interface Hashed {
String method() default "MD5";
}
Target — определяет объекты, для которых применима аннотация. В данном случае аннотацию можно применить к объявлениям переменных в классе. К сожалению, к любым, но об этом позже.
Retention — время жизни аннотации. Мы указываем, что она существует только в исходном коде.
В самой аннотации мы заводим поле, определяющее метод хеширования. По умолчанию MD5.
Этого достаточно чтоб использовать в коде аннотацию, но от неё не будет никакого толку, пока мы не напишем обработчик аннотации.
Обработчик аннотации наследуется от javax.annotation.processing.AbstractProcessor. Минимальный класс обработчика выглядит так:
package com.example.annotation;
@SupportedAnnotationTypes(value = {"com.example.annotation.Hashed"})
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class HashedAnnotationProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
return false;
}
}
SupportedAnnotationTypes — определяет имена классов аннотаций, которые будут обрабатываться нашим процессором.
SupportedSourceVersion — поддерживаемая версия исходников. Смысл в том, чтоб процессор не сломал при обработке аннотаций языковые конструкции, которые появились в более новых версиях языка.
Вместо данных аннотаций можно переопределить методы getSupportedAnnotationTypes и getSupportedSourceVersion.
Метод process получает список необработанных поддерживаемых аннотаций и объект взаимодействия с компилятором. Если метод возвращает false — компилятор передаёт аннотацию на обработку следующему процессору, который поддерживает данный тип аннотаций. Если же метод вернул истину — аннотация считается обработанной и больше никуда не попадёт. Это нужно учитывать, чтоб случайно не прибить чужие аннотации.
Если в процессе работы любого процессора изменились или добавились исходные коды — компилятор пойдёт на следующий проход.
Для изменения исходного кода нам будет недостаточно RoundEnvironment поэтому мы переопределяем метод init и получаем из него JavacProcessingEnvironment. Данный класс позволяет получить доступ к исходным кодам, системе выброса предупреждений и ошибок компиляции и многое другое. Там же получим TreeMaker — вспомогательный инструмент для изменения исходных кодов.
private JavacProcessingEnvironment javacProcessingEnv;
private TreeMaker maker;
@Override
public void init(ProcessingEnvironment procEnv) {
super.init(procEnv);
this.javacProcessingEnv = (JavacProcessingEnvironment) procEnv;
this.maker = TreeMaker.instance(javacProcessingEnv.getContext());
}
Теперь нам остаётся перебрать наши аннотированные поля и заменить значения строковых констант. Код привожу в сокращении. Ссылка на GitHub в конце статьи.
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if ( annotations == null || annotations.isEmpty()) {
return false;
}
for (TypeElement annotation : annotations)
{
// Выбираем все элементы, у которых стоит наша аннотация
final Set<? extends Element> fields = roundEnv.getElementsAnnotatedWith(annotation);
JavacElements utils = javacProcessingEnv.getElementUtils();
for (final Element field : fields) {
//Получаем аннотацию, потом возьмём из неё метод хеширования.
Hashed hashed = field.getAnnotation(Hashed.class);
//преобразовываем аннотированный элемент в дерево
JCTree blockNode = utils.getTree(field);
if (blockNode instanceof JCTree.JCVariableDecl) {
//Помним, что поле может оказаться не только строковым.
JCTree.JCVariableDecl var = (JCTree.JCVariableDecl) blockNode;
//получаем инициализатор (то что после знака = )
JCTree.JCExpression initializer = var.getInitializer();
//Проверка отсечёт поля с инициализацией в конструкторе, а так же конструкции вида:
// "" + 1
// new String("new string")
if ((initializer != null) && (initializer instanceof JCTree.JCLiteral)){
JCTree.JCLiteral lit = (JCTree.JCLiteral) initializer;
//получаем строку
String value = lit.getValue().toString();
try {
MessageDigest md = MessageDigest.getInstance(hashed.method());
//Для однообразия на разных платформах задаём локаль.
md.update(value.getBytes("UTF-8"));
byte[] hash = md.digest();
StringBuilder str = new StringBuilder(hash.length * 2);
for (byte val : hash) {
str.append(String.format("%02X", val & 0xFF));
}
value = str.toString();
lit = maker.Literal(value);
var.init = lit;
} catch (NoSuchAlgorithmException e) {
//ошибка компиляции: неверный алгоритм хеширования
} catch (UnsupportedEncodingException e) {
//ошибка компиляции: такое вообще возможно??
}
}else{
//Ошибка компиляции: неверное применение аннотации.
}
}
}
}
}
В методе мы бежим по списку аннотаций (мы ведь помним, что в общем случае процессор обрабатывает больше чем одну аннотацию?), для каждой аннотации выбираем список элементов. После этого начинается магия. Мы используем инструменты из поставки com.sun.tools.javac чтоб преобразовать элементы в дерево исходного кода, у которого огромное число возможностей и по традиции полное отсутствие русскоязычной документации. Поэтому прошу не удивляться, что код работы с этим деревом далёк от идеала.
Когда мы получили объявление переменной в виде дерева JCTree.JCVariableDecl var — мы можем убедиться, что это именно строковая переменная. В моём случае данная проверка осуществляется костылём:
if (!"String".equals(var.vartype.toString())){
//Ошибка компиляции: аннотация применима только к строкам.
continue;
}
vartype — тип поля, который наверняка можно сравнить с какой-нибудь константой или определить его принадлежность определённому классу, но, как я уже говорил, документации нет, а быстрая проверка показала, что приведение к строке даёт нам имя типа.
Второй интересный момент — мы можем обработать только строки, аналогичные примеру из самого начала статьи. Всё дело в том, что на данном этапе мы работаем именно с исходным текстом. По этому если переменная инициализируется в конструкторе, то JCTree.JCExpression initializer = var.getInitializer(); вернёт нам null. Не менее неприятная ситуация получится если мы попытаемся обработать конструкции вида:
public String demo1 = new String("habrahabr");
public String demo2 = "habra"+"habr";
public String demo3 = "" + 1;
Для этого вводится вторая проверка (initializer instanceof JCTree.JCLiteral). Это отсечёт все описанные примеры, поскольку они являются не литералами в чистом виде и в дереве будут представлены выражением из нескольких элементов.
Дальнейший код очевиден. Берём строку, хешируем, заменяем, радуемся? Нет.
Комментариями отмечено несколько мест, в которых возникают очевидные ошибки. И в нашем случае их игнорирование не является корректным поведением. Для того чтобы сообщить пользователю об ошибке нам потребуется объект javax.annotation.processing.Messager. Он позволяет выбросить предупреждение, ошибку компиляции или просто информационное сообщение. Например, мы можем сообщить о недопустимом алгоритме хеширования:
catch (NoSuchAlgorithmException e) {
javacProcessingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
String.format("Unsupported digest method %s", hashed.method()),
field);
}
При этом надо понимать, что выброс сообщения об ошибке не прерывает выполнение метода. Компилятор дождётся как минимум окончания нашего метода, прежде чем прервать процесс компиляции. Это позволяет выбросить сразу все ошибки применения аннотаций пользователю. Третий аргумент метода printMessage позволяет нам указать объект, на котором мы споткнулись. Он не является обязательным, но сильно облегчает жизнь.
Подключение процессора аннотаций
Осталось сообщить компилятору, что мы такие есть и готовы принять аннотации на растерзание. Во многих статьях встречаются инструкции, как добавить свой процессор в <имя среды разработки>. Видимо корнями это уходит в далёкие времена, когда подобные вещи делались на коленках народными умельцами. Однако уже достаточно давно механизм обработки аннотаций является частью javac и, по сути, наш класс обработчик является плагином для javac. Это значит, что мы вполне стандартными средствами можем подключить нашу аннотацию к любой среде без шаманств с настройками.
Нам потребуется создать в каталоге META-INF подкаталог services, а в нём файл javax.annotation.processing.Processor. В сам файл нам необходимо поместить список наших классов процессоров. В конкретном случае com.example.annotation.HashedAnnotationProcessor. И всё. Теперь мы собираем нашу библиотеку содержащую аннотацию и её процессор. Подключаем эту библиотеку к проекту. И всё работает.
При этом ни сама библиотека, ни остатки аннотаций не попадут в скомпилированный код.
Использование
Аннотация готова. Строки хешируются. Вот только задача всё ещё не решена.
Если мы подключим аннотацию к проекту в таком виде — у нас строки будут хешироваться всегда. А нам надо только в релизе.
В Java понятие отладочной и релизной сборки очень условно и зависит от представлений пользователя. Поэтому добиваемся того, чтоб задача assembleDebug для Android проекта строки не хешировала, а во всех остальных случаях от строк оставались MD5 хеши.
Для решения этой задачи мы передадим нашему процессору аннотаций дополнительный параметр.
Сначала доработаем процессор:
@SupportedOptions({"Hashed"})
public class HashedAnnotationProcessor extends AbstractProcessor {
private boolean enable = true;
@Override
public void init(ProcessingEnvironment procEnv) {
//Добавленный код
java.util.Map<java.lang.String,java.lang.String> opt = javacProcessingEnv.getOptions();
if (opt.containsKey(ENABLE_OPTIONS_NAME) && opt.get(ENABLE_OPTIONS_NAME).equals("disable")){
enable = false;
}
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (!enable){
javacProcessingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE,
"Annotation Hashed is disable");
return false;
}
//...
}
}
Мы объявили, что ожидаем опцию «Hashed» и если она «disable», то ничего не делаем и выводим информацию пользователю. Сообщения типа Diagnostic.Kind.NOTE являются информационными и при настройках по умолчанию многие среды разработки эти сообщения вообще не покажут.
При этом мы сообщаем компилятору, что обрабатывать аннотацию не стали. Если в системе есть ещё процессоры, которые обрабатывают аннотации такого типа, или вообще не разбирают тип — они могут получить нашу аннотацию. Правда, я совершенно ничего не могу сказать о том, в каком порядке компилятор будет пытаться распорядиться аннотацией. Пока у нас только наша библиотека и ровно одна аннотация — это не актуально, но при использовании нескольких библиотек аннотаций будьте готовы к всплытию подводных камней.
Осталось передать эту опцию компилятору. Опции для процессоров передаются компилятору ключом "-A". В нашем случае "-AHashed=disable".
Остаётся только застваить Gradle передать эту опцию в нужный момент. И снова костыли:
tasks.withType(JavaCompile) {
if (name == "compileDebug"){
options.compilerArgs << "-AHashed=disable"
}
}
Это для текущей версии Android Studio. Для более ранних tasks.withType(Compile).
Костыль, потому что данный блок вызывается для каждого типа сборки независимо от задачи. По идее должно быть что-то аналогичное buildTypes из блока android, но у меня уже не было никаких сил искать красивое решение. Все ведь уже догадались, что документации на русском традиционно нет?
В коде аннотации могут выглядеть так:
@Hashed
public static final String demo1 = "habr";
@Hashed (method="SHA-1")
public static final String demo2 = "habrahabr";
@Hashed(method="SHA-256")
public static final String demo3 = "habracadabra";
Метод может быть любым из поддерживаемых MessageDigest.
Итог
Задача решена. Конечно же, только для одного очень конкретного способа объявления констант, конечно, не самым эффективным способом, а у многих и сама постановка задачи вызовет больше вопросов, чем материал статьи. А я просто надеюсь, что кто-нибудь потратит меньше времени и нервов, если на его пути встретится похожая задача.
Но ещё больше я надеюсь, что кто-нибудь заинтересуется данной темой и хабр увидит статьи, в которых будет рассказано, почему вся эта магия работает.
И, конечно же, обещанный код: GitHub::DemoAnnotation