Содержание статьи
Начнем с того, что, несмотря на обещание показать эффективные методы скрытия информации в приложении, я все-таки настоятельно рекомендую этого не делать, по крайней мере до тех пор, пока не станет ясно, что без этого просто не обойтись. Какие бы изощренные методы скрытия информации ты ни применял, ее все равно удастся извлечь. Да, ты можешь применить множество техник обфускации, использовать шифрование или скрытые внутри приложения файлы (обо всем этом мы поговорим), но если кто-то поставит себе цель вскрыть секреты твоего приложения, то при наличии достаточной квалификации он это сделает.
Так что все пароли, ключи шифрования и другую действительно важную информацию засовывать в код приложения уж точно не стоит. Нужно дать приложению доступ к какому-то веб-сервису? Используй его API для получения токена сервиса в момент подключения к нему. Приложение использует специальный скрытый API твоего сервиса? Сделай так, чтобы оно запрашивало его URL у самого сервиса и этот URL был уникальным для каждой копии приложения. Делаешь приложение для шифрования файлов? Запрашивай пароль шифрования у пользователя. В общем, любыми средствами сделай так, чтобы внутри приложения не было никакой информации, которая может привести к взлому твоих аккаунтов, твоего веб-сервиса или данных пользователя.
А если ты все-таки решил вшить важные данные в код приложения и не хочешь, чтобы их увидели, есть несколько рецептов, как это сделать, от простейших до действительно сложных.
Сохраняем строки в strings.xml
Это, наверное, простейший метод скрытия строк. Смысл метода в том, чтобы вместо размещения строки внутри константы в коде, что приведет к ее обнаружению после декомпиляции, разместить ее в файле res/values/strings.xml
:
<resources>
...
<string name="password">MyPassword</string>
...
</resources>
А из кода обращаться через getResources():
String password = getResources().getString(R.string.password);
Да, многие инструменты для реверса приложений позволяют просматривать содержимое strings.xml
, поэтому имя строки (password
) лучше изменить на что-то безобидное, а сам пароль сделать похожим на диагностическое сообщение (что-то вроде Error 8932777
), да еще и использовать только часть этой строки, разделив ее с помощью метода split()
:
String[] string = getResources().getString(R.string.password).split(" ");
String password = strings[1];
Естественно, переменным тоже лучше дать безобидные имена, ну или просто включить ProGuard, который сократит их имена до одно-двухбуквенных сочетаний типа a
, b
, c
, ab
.
Разбиваем строки на части
Ты можешь не только использовать части строк, но и дробить их, чтобы затем собрать воедино. Допустим, ты хочешь скрыть в коде строку MyLittlePony. Совсем необязательно хранить ее в одной-единственной переменной, разбей ее на несколько строк и раскидай их по разным методам или даже классам:
String a = "MyLi";
String b = "ttle";
String c = "Pony";
...
String password = a + b + c;
Но здесь есть опасность столкнуться с оптимизацией компилятора, который соберет строку воедино для улучшения производительности. Поэтому директивы static
и final
к этим переменным лучше не применять.
Кодируем данные с помощью XOR
Для еще большего запутывания реверсера строки можно поксорить. Это излюбленный метод начинающих (и не только) вирусописателей. Суть метода: берем строку, генерируем еще одну строку (ключ), раскладываем их на байты и применяем операцию исключающего ИЛИ. В результате получаем закодированную с помощью XOR строку, которую можно раскодировать, вновь применив исключающее ИЛИ. В коде это все может выглядеть примерно так (создай класс StringXOR и помести в него эти методы):
// Кодируем строку
public static String encode(String s, String key) {
return Base64.encodeToString(xor(s.getBytes(), key.getBytes()), 0);
}
// Декодируем строку
public static String decode(String s, String key) {
return new String(xor(Base64.decode(s, 0), key.getBytes()));
}
// Сама операция XOR
private static byte[] xor(byte[] a, byte[] key) {
byte[] out = new byte[a.length];
for (int i = 0; i < a.length; i++) {
out[i] = (byte) (a[i] ^ key[i%key.length]);
}
return out;
}
Придумай вторую строку (ключ) и закодируй с ее помощью строки, которые ты хочешь скрыть (для примера пусть это будут строки password1
и password2
, ключ 1234
):
String encoded1 = StringXOR.encode("password1", "1234");
String encoded2 = StringXOR.encode("password2", "1234");
Log.e("DEBUG", "encoded1: " + encoded1);
Log.e("DEBUG", "encoded2: " + encoded2);
Открыв Android Monitor в Android Studio, ты найдешь строки вида:
encoded1: RVRCRQ==
encoded2: ACHBDS==
Это и есть закодированные с помощью XOR оригинальные строки. Добавь их в код вместо оригинальных, а при доступе к строкам используй функцию декодирования:
String password1 = StringXOR.decode(encodedPassword1, "1234");
Благодаря этому методу строки не будут открыто лежать в коде приложения, однако раскодировать их тоже будет совсем нетрудно, так что всецело полагаться на этот метод не стоит. Да и ключ тоже придется как-то прятать.
Шифруем данные
Окей, XOR — это уже кое-что. Но что, если пойти дальше и применить к строкам реальное шифрование? Вскользь я уже затрагивал этот вопрос в статье «Как защитить свое приложение для Android от реверса и дебага», сейчас же разберемся более детально. Во-первых, нам понадобятся функции шифрования и дешифрования строк:
public static byte[] encryptString(String message, SecretKey secret) throws Exception {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secret);
return cipher.doFinal(message.getBytes("UTF-8"));
}
public static String decryptString(byte[] cipherText, SecretKey secret) throws Exception {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, secret);
return new String(cipher.doFinal(cipherText), "UTF-8");
}
Во-вторых, функция генерации случайного 128-битного ключа:
public static SecretKey generateKey() throws Exception {
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(128);
return keyGen.generateKey();
}
В-третьих, функции для перевода ключа в строку и обратно:
public static String keyToString(SecretKey secretKey) {
return Base64.encodeToString(secretKey.getEncoded(), Base64.DEFAULT);
}
public static SecretKey stringToKey(String stringKey) {
byte[] encodedKey = Base64.decode(stringKey.trim(), Base64.DEFAULT);
return new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
}
Так же как и в случае с XOR’ом, добавь куда-нибудь в начало приложения код, генерирующий ключ, а затем выводящий его в консоль с помощью Log
(в примере подразумевается, что все криптографические функции ты разместил в классе Crypto):
try {
SecretKey key = Crypto.generateKey();
Log.e("DEBUG", "key: " + Crypto.keyToString(key));
catch (Exception e) {}
На экране ты увидишь ключ, c помощью которого сможешь зашифровать строки и точно так же вывести их в консоль:
// Твой ключ
String key = "...";
SecretKey secretkey = stringToKey(key);
// Шифруемая строка
String password = "test";
// Шифруем и выводим на экран
byte[] encrypted = encryptString(password, secretkey);
Log.e("DEBUG", "password: " + Base64.encodeToString(encrypted, Base64.DEFAULT));
Так ты получишь в консоль зашифрованную строку. Далее уже в таком виде ты сможешь вставить ее в код приложения и расшифровывать на месте:
String key = "...";
String encryptedPassword = "...";
SecretKey secretkey = stringToKey(key);
String password = decryptString(Base64.decode(encodedPassword, Base64.DEFAULT), secretkey);
Чтобы еще больше запутать реверсера, ты можешь разбить ключ и пароль на несколько частей и поксорить их. При включенном ProGuard такой метод превратит весь твой код сборки и расшифровки строк в запутанную мешанину, в которой с наскоку будет не разобраться.
Храним данные в нативном коде
Наконец, самый хардкорный и действенный метод скрытия данных — разместить их в нативном коде. А если быть точным — коде, который компилируется не в легко декомпилируемый для простоты изучения язык Java, а в инструкции ARM/ARM64. Разобрать такой код намного сложнее, декомпиляторов для него нет, сам дизассемблированный код сложен для чтения и понимания и требует действительно неплохих навыков от реверсера.
В Android, как и в случае с настольной Java, нативный код обычно пишут на языках C или C++. Так что для нашей задачи мы выберем язык C. Для начала напишем класс-обертку, который будет вызывать наш нативный код (а именно ARM-библиотеку с реализацией функции getPassword()
):
public class Secrets {
static {
System.loadLibrary("secret");
}
public native String getPassword();
}
Тела самой функции в коде нет, оно будет располагаться в написанной на C библиотеке (под названием secret
). Теперь создай внутри каталоговой структуры проекта подкаталог jni
, а в ней файл с именем secret.c
и помести в него следующие строки:
##include <string.h>
##include <jni.h>
jstring Java_com_example_secret_Secrets_getPassword(JNIEnv* env, jobject javaThis) {
return (*env)->NewStringUTF(env, "password");
}
Это, так сказать, референсный вариант библиотеки, которая просто возвращает обратно строку password
. Чтобы Android Studio понял, как эту библиотеку скомпилировать, нам нужен Makefile
в том же каталоге:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE:= secret
LOCAL_SRC_FILES := secret.c
include $(BUILD_SHARED_LIBRARY)
Можешь не вдаваться в его смысл, это просто инструкция по компиляции файла secret.c
в бинарный (библиотечный) файл secret.so
.
В целом это все. За одним исключением: хоть саму нативную библиотеку разобрать будет сложно, для извлечения из нее пароля достаточно достать библиотеку из APK-файла и применить к ней команду strings
(в Linux-системах):
$ strings secret.so
...
password
...
А вот если применить к ней все описанные выше техники разбиения строки, XOR, шифрование и так далее, все станет намного сложнее и ты сразу отобьешь желание ковырять свое приложение у 99% реверсеров. Однако и писать все эти техники защиты придется на языках C/C++.
Выводы
Корректно «зашить» в приложение конфиденциальную информацию можно, но делать это стоит только в очень крайних случаях. Даже последний описанный метод можно обойти, если запустить твое приложение под отладчиком и поставить брейк-пойнт на строку, содержащую вызов метода getPassword
.