— Как украсть приложение для Android?
— Берешь и крадешь.

Уровень пиратства в экосистеме Android таков, что говорить об этом нет никакого смысла. Приложение не только легко украсть — его легко взломать, отвязать от сервисов проверки, отключить рекламу или даже внедрить в него бэкдор. Выкладывая свое творение в Play Store, ты рассчитываешь получить прибыль, а в результате даришь любителям вареза еще один хороший продукт. К счастью, с этим вполне можно бороться.

Для рубрики «Взлом» я написал цикл статей, в которых наглядно показал, насколько на самом деле легко взламываются приложения для Android. Для этого не нужен даже дизассемблер, достаточно поверхностных знаний Java и языка Smali. Поэтому, если твое приложение будет достаточно популярно, знай: его украдут и путем нехитрых манипуляций активируют платные функции. А если ты решил монетизировать его с помощью рекламы — ее отключат.

Защитить приложение сложно, но можно. Во-первых, стоит сразу отказаться от модели распространения Pro/Lite. Приложение очень легко вытащить со смартфона, поэтому вору будет достаточно один раз купить приложение, и дальше его можно распространять as is. Во-вторых, необходимо позаботиться о защите кода от реверса. Декомпиляция Java-кода — дело простое, а изменение бинарного кода не требует каких-то особых навыков или инструментов. В-третьих, нужно сделать так, чтобы в случае даже успешного взлома приложение просто не стало работать. Тогда взломщику придется решать сразу две задачи: взломать приложение и заставить взломанную версию работать.

Итак, отказываемся от Pro-версии и начинаем борьбу.

 

Скрываем и запутываем код

Лучший способ защиты кода приложения от реверса — это обфускация, другими словами — запутывание байт-кода так, чтобы реверсеру было невыносимо трудно в нем разобраться. Существует несколько инструментов, способных это сделать. Наиболее простой, но все же эффективный есть в составе Android Studio. Это ProGuard.

Для его активации достаточно добавить в раздел android → buildTypes → release файла build.gradle строку minifyEnabled true:

android {
    ...
    buildTypes {
        release {
            minifyEnabled true
        }
        ...
    }
}

После этого Android Studio начнет пропускать все «релизные» сборки через ProGuard. В результате приложение станет компактнее (благодаря удалению неиспользуемого кода), а также получит некоторый уровень защиты от реверса. «Некоторый» в том смысле, что ProGuard заменит имена всех внутренних классов, методов и полей на одно-двухбуквенные сочетания. Это действительно существенно затруднит понимание декомпилированного/дизассемблированного кода.

Так выглядят классы в декомпиляторе JADX после применения ProGuard
Так выглядят классы в декомпиляторе JADX после применения ProGuard

Следующий шаг — шифрование строк. Это особенно полезно в том случае, если внутри приложения ты хранишь какие-либо сенситивные данные: идентификаторы, ключи, REST API endpoints. Все это поможет взломщику сориентироваться в твоем коде или вычленить из него важную информацию.

Зашифровать строки можно разными способами, например используя инструменты Stringer или DexGuard. Преимущество: полностью автоматизированная модификация уже имеющегося кода с целью внедрения шифрования строк. Недостаток: цена, которая доступна компаниям, но слишком высока для независимого разработчика.

Поэтому мы попробуем обойтись своими силами. В простейшем случае шифрование строк средствами Java выполняется так:

public static byte[] encryptString(String message, SecretKey secret) throws Exception {
    Cipher cipher = null;
    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 = null;
    cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, secret);
    return new String(cipher.doFinal(cipherText), "UTF-8");
}

Для генерации ключа достаточно одной строки:

public static SecretKey generateKey(String password) throws Exception {
    return secret = new SecretKeySpec(password.getBytes(), "AES");
}

Смысл в том, чтобы написать простенькое настольное/мобильное приложение на Java, которое возьмет на вход все твои строки и выдаст на выходе их зашифрованные варианты. Далее ты вставляешь эти строки в основное приложение вместо оригинальных и в местах, где происходит к ним обращение, вызываешь функцию decryptString().

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

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

Также ты можешь попытаться разбить свое приложение на множество небольших модулей, как я уже писал в статье Пишем модульные приложения для Android. Сам по себе это не метод защиты, и он почти не затруднит работу реверсера. Но зато обломает различные автоматизированные системы кракинга приложений. Они просто не смогут понять, где искать находящийся в модуле код.

Ну и последнее: из кода необходимо обязательно удалить (закомментировать) все обращения к логгеру, то есть все вызовы Log.d(), Log.v() и так далее. Иначе взломщик сможет использовать эту информацию, чтобы понять логику работы приложения.

 

Крашим взломанное приложение

Окей, жизнь реверсеру мы немного подпортили. Настало время сделать это еще раз! Но как узнать, было ли приложение взломано? Точнее, как оно само может это выяснить? Ведь понятия «взломанное» и «не взломанное» существуют только в наших с тобой головах, то есть это понятия достаточно высокого порядка, которые не описать алгоритмически.

Так оно, да не так. Дело в том, что внутри APK-файла есть набор метаданных, которые хранят контрольные суммы абсолютно всех файлов пакета, а сами метаданные подписаны ключом разработчика. Если изменить приложение и вновь его запаковать, метаданные пакета изменятся и пакет придется подписывать заново. А так как твоего ключа разработчика у реверсера нет и быть не может, он использует либо случайно сгенерированный, либо так называемый тестовый ключ.

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

 

Сверяем цифровую подпись

Собственно, метод довольно простой. Тебе необходимо вставить в приложение код, который будет получать хеш ключа текущей цифровой подписи пакета и сравнивать его с ранее сохраненным. Совпадают — приложение не было перепаковано (и взломано), нет — бьем тревогу.

Для начала вставь следующий кусок кода в приложение (чем глубже ты его запрячешь, тем лучше):

public static String getSignature(Context context) {
    String apkSignature = null;
    try {
        PackageInfo packageInfo = context.getPackageManager().getPackageInfo(
            context.getPackageName(),
            PackageManager.GET_SIGNATURES
        );
        for (Signature signature : packageInfo.signatures) {
            MessageDigest md = MessageDigest.getInstance("SHA");
            md.update(signature.toByteArray());
            apkSignature = Base64.encodeToString(md.digest(), Base64.DEFAULT);
            Log.e("DEBUG", "SIGNATURE: " + apkSignature);
        }
    } catch (Exception e) {}
    return apkSignature;
}

Собери, запусти приложение и посмотри лог исполнения. Там ты увидишь строку SIGNATURE: 478uEnKQV+fMQT8Dy4AKvHkYibo=. Это и есть хеш. Его необходимо не просто запомнить, а поместить в код приложения в виде константы, например под именем SIGNATURE. Теперь убери строку Log.e... из кода и добавь следующий метод:

public static boolean checkSignature(Context context) {
    return SIGNATURE.equals(getSignature(context));
}

Он как раз и будет сверять сохраненный хеш с хешем ключа, которым в данный момент подписано приложение. Функция возвращает true, если цифровая подпись твоя (приложение не было пересобрано), и false — если оно подверглось модификации. Что делать во втором случае — решать тебе. Ты можешь просто завершить приложение с помощью os.exit(0) либо «уронить» его, например вызвав метод неинициализированного объекта или обратившись к несуществующему значению массива.

Но запомни: взломщик может просто вырезать твой код сверки цифровой подписи и он никогда не сработает (это справедливо и в отношении кода, приведенного далее). Поэтому спрячь его в неочевидном месте, а хеш оригинального ключа зашифруй, как было показано выше.

Искомый хеш ключа
Искомый хеш ключа
 

Проверяем источник установки

Еще один метод защиты — выяснить, откуда было установлено приложение. Тут логика простая: если источник установки — Play Store, то все нормально, это оригинальное неперепакованное приложение. Если нет — варез, скачанный с форума и установленный с карты памяти или из «черного маркета».

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

public static boolean checkInstaller(Context context) {
    final String installer = context.getPackageManager().getInstallerPackageName(context.getPackageName());
    return installer != null && installer.startsWith("com.android.vending");
}

Как обычно: true — все нормально, false — Хьюстон, у нас проблемы.

 

Определяем эмулятор

Некоторые методы реверса приложений предполагают использование эмулятора. Поэтому нелишним будет внести в приложение код, проверяющий, не запущено ли оно в виртуальной среде. Сделать это можно, прочитав значение некоторых системных переменных. Например, стандартный эмулятор Android Studio устанавливает такие переменные и значения:

ro.hardware=goldfish
ro.kernel.qemu=1
ro.product.model=sdk

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

public static boolean checkEmulator() {
    try {
        boolean goldfish = getSystemProperty("ro.hardware").contains("goldfish");
        boolean emu = getSystemProperty("ro.kernel.qemu").length() > 0;
        boolean sdk = getSystemProperty("ro.product.model").contains("sdk");

        if (emu || goldfish || sdk) {
            return true;
        }
    } catch (Exception e) {}
    return false;
}

private static String getSystemProperty(String name) throws Exception {
    Class sysProp = Class.forName("android.os.SystemProperties");
    return (String) sysProp.getMethod("get", new Class[]{String.class}).invoke(sysProp, new Object[]{name});
}

Обрати внимание, что класс android.os.SystemProperties скрытый и недоступен в SDK, поэтому для обращения к нему мы используем рефлексию (о скрытых API Android я уже писал).

Также имей в виду, что существует огромное количество других эмуляторов Android и в них значения переменных могут отличаться. Данный код способен обнаружить только стандартный эмулятор Android.

 

Отладка

Еще один метод реверса — это запуск приложения под управлением отладчика. Взломщик может декомпилировать твое приложение, затем создать в Android Studio одноименный проект, закинуть в него полученные исходники и просто запустить отладку, не компилируя проект. В этом случае приложение само покажет ему свою логику работы.

Чтобы защититься от отладки, можно использовать следующий код:

public static boolean checkDebuggable(Context context){
    return (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
}
Так делать не стоит, код проверок необходимо раскидать по коду и продублировать
Так делать не стоит: код проверок необходимо раскидать по коду и продублировать
 

Выводы

Создать на 100% защищенное приложение у тебя не получится, можешь даже не пытаться. Но есть достаточно простые способы существенно усложнить жизнь среднестатистическому реверсеру. Да, приложение все равно рано или поздно взломают, но так у тебя хотя бы будет время, чтобы заработать на нем. Ну и стоит почаще обновлять свое творение, чтобы реверсерам жизнь медом не казалась.

7 комментариев

  1. Аватар

    AppMaster

    26.12.2016 в 20:19

    Большое спасибо!!! Автор красавчик!

  2. Аватар

    Khazhinov

    29.12.2016 в 16:08

    Да, есть за что поблагодарить)

  3. Аватар

    Groff

    04.01.2017 в 11:09

    Статья супер! Из-за ваших рубрик взял подписку и ни на мгновенье не пожалел =)

  4. Аватар

    horhomun

    13.03.2018 в 03:16

    Ваш метод: public static String getSignature() возвращает строку длинной 96 символов, так есть после ключа «478uEnKQV+fMQT8Dy4AKvHkYibo=» еще идет 68 пробелов.

    Поэтому надо дописать в методе: return apkSignature.trim; (уберет все пробелы)

    И еще допишите что тем кто перешел на Google Play App Signing (подпись хранится на сервере Google) надо взять в Панель управления Google->Управление релизом->Подписи приложений:

    Цифровой отпечаток сертификата SHA-1 в (Сертификат для подписи приложения), он будет формата «E3:BF:2E:12:72:90:57:E7:CC:41:3F:03:CB:80:0A:BC:79:18:89:BA» (это в HEX) и перегнать его в (Base64) например тут: http://tomeko.net/online_tools/hex_to_base64.php?lang=en и получим нашу привычную строку: «478uEnKQV+fMQT8Dy4AKvHkYibo=»

  5. Аватар

    wkbapka

    26.07.2019 в 10:45

    Вы пишите:
    ->> Смысл в том, чтобы написать простенькое настольное/мобильное приложение на Java, которое возьмет на вход все твои строки и выдаст на выходе их зашифрованные варианты. Далее ты вставляешь эти строки в основное приложение вместо оригинальных и в местах, где происходит к ним обращение, вызываешь функцию decryptString()

    это никогда не будет работать. Т.к просто не реально из массива байтов получить корректную строку для расшифровки никакими методами

Оставить мнение