Злоупотребление возможностями ContactDirectory

Android большой. Настолько большой, что в нем можно найти весьма неочевидные функции, которые в неправильных руках могут привести к печальным последствиям. Одной из таких функций является механизм предоставления дополнительных контактов для отображения в результатах поиска через приложения «Контакты» или «Телефон».

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

/img/contactdirectory-abuse/kdpv.png

Механизм работы ContactDirectory

Сценарий, когда нужно предоставить дополнительные контакты для общего пула поиска в ОС, не является чем-то редким. Такая возможность есть, например, в email-клиенте Samsung Email:

<provider
    android:name="com.samsung.android.email.sync.ldap.LdapDirectoryProvider"
    android:permission="com.samsung.android.email.permission.ACCESS_PROVIDER"
    android:exported="true"
    android:authorities="com.samsung.android.email.directory.provider">
    <meta-data
        android:name="android.content.ContactDirectory"
        android:value="true"/>
</provider>
public class LdapDirectoryProvider extends ContentProvider {

    static final String AUTHORITY = "com.samsung.android.email.directory.provider";
    static final String ACCOUNT_TYPE = "com.samsung.android.email.ldap";

    private static final int DIRECTORIES = 0, FILTER = 1, ENTITIES = 2;
    private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
    static {
        MATCHER.addURI(AUTHORITY, "directories", DIRECTORIES);
        MATCHER.addURI(AUTHORITY, "contacts/filter/*", FILTER);
        MATCHER.addURI(AUTHORITY, "contacts/lookup/*/entities", ENTITIES);
    }

    @Override
    public Cursor query(Uri uri, String[] proj, String s, String[] a, String o) {
        switch (MATCHER.match(uri)) {
            case DIRECTORIES: return directories(proj);
            case FILTER:      return filter(uri, proj);
            case ENTITIES:    return entities(uri, proj);
            default:          return null;
        }
    }

    private Cursor directories(String[] proj) {
        MatrixCursor c = new MatrixCursor(proj);
        for (Account acc : AccountManager.get(getContext()).getAccountsByType(ACCOUNT_TYPE)) {
            Object[] row = new Object[proj.length];
            put(proj, row, Directory.ACCOUNT_NAME, acc.name);
            put(proj, row, Directory.ACCOUNT_TYPE, ACCOUNT_TYPE);
            put(proj, row, Directory.DISPLAY_NAME, acc.name);
            put(proj, row, Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_ANY_ACCOUNT);
            put(proj, row, Directory.SHORTCUT_SUPPORT, Directory.SHORTCUT_SUPPORT_NONE);
            c.addRow(row);
        }
        return c;
    }

    private Cursor filter(Uri uri, String[] proj) {
        String q = uri.getLastPathSegment();
        if (q == null || q.length() < 2) return null;

        MatrixCursor c = new MatrixCursor(proj);
        long id = 1;
        for (Entry e : search(uri.getQueryParameter("account_name"), q)) {
            Object[] row = new Object[proj.length];
            put(proj, row, Contacts._ID, id++);
            put(proj, row, Contacts.DISPLAY_NAME, e.name);
            put(proj, row, Contacts.LOOKUP_KEY, e.pack());
            c.addRow(row);
        }
        return c;
    }

    private Cursor entities(Uri uri, String[] proj) {
        PackedString p = new PackedString(uri.getPathSegments().get(2));
        MatrixCursor c = new MatrixCursor(proj);

        Object[] row = new Object[proj.length];
        put(proj, row, Data.CONTACT_ID, 1L);
        put(proj, row, Data.RAW_CONTACT_ID, 1L);
        put(proj, row, Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
        put(proj, row, Email.ADDRESS, p.get("emailAddress"));
        put(proj, row, Email.TYPE, Email.TYPE_WORK);
        put(proj, row, Data.IS_READ_ONLY, 1);
        c.addRow(row);
        return c;
    }

    private static void put(String[] proj, Object[] row, String col, Object val) {
        for (int i = 0; i < proj.length; i++) if (proj[i].equals(col)) row[i] = val;
    }

    @Override public boolean onCreate() { return true; }
    @Override public String getType(Uri u) { return null; }
    @Override public Uri insert(Uri u, ContentValues v) { throw new UnsupportedOperationException(); }
    @Override public int update(Uri u, ContentValues v, String s, String[] a) { throw new UnsupportedOperationException(); }
    @Override public int delete(Uri u, String s, String[] a) { throw new UnsupportedOperationException(); }
}

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

На самом деле все даже хуже. При установке любого приложения (а вернее, сразу после получения бродкаста PACKAGE_ADDED / PACKAGE_REPLACED) операционная система делает запрос на URI content://<authority>/directories, что в свою очередь неизбежно приводит к выполнению кода внутри ContentProvider. Т. е. до фактического запуска приложения. Этой возможностью и злоупотреблял троян MoqHao, описанный в статье MoqHao evolution: New variants start automatically right after installation.

При установке приложения с таким провайдером происходит буквально следующее:

  1. Пользователь устанавливает приложение
  2. PackageInstaller кидает бродкаст PACKAGE_ADDED
  3. ContactDirectoryManager получает бродкаст и запускает сканирование нового пакета в поисках метаданных. Если находит ContactDirectory, то считает, что это приложение может предоставлять дополнительные контакты
  4. ContactDirectoryManager делает запрос на content://<authority>/directories
  5. Чтобы выполнить этот запрос, операционная система должна создать экземпляр ContentProvider, который в свою очередь может быть создан только внутри процесса приложения. Поэтому операционная система запускает процесс приложения как обычно, через форк Zygote
  6. Срабатывает Application.onCreate() -> ContentProvider.onCreate() -> ContentProvider.query()

Следовательно, есть целых три места, где можно выполнить код с правами приложения сразу после установки и до его фактического запуска.

Исправление

Исправление было сделано в этом коммите. Важно понимать, что это не security fix, поэтому в бюллетень безопасности он не попал. Версия Android, с которой злоупотребление этим механизмом больше не работает: android-14.0.0_r29.

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

@@ -418,7 +421,8 @@
      * Scans the specified package for content directories and updates the {@link Directory}
      * table accordingly.
      */
-    private List<DirectoryInfo> updateDirectoriesForPackage(
+    @VisibleForTesting
+    List<DirectoryInfo> updateDirectoriesForPackage(
             PackageInfo packageInfo, boolean initialScan) {
         if (DEBUG) {
             Log.d(TAG, "updateDirectoriesForPackage  packageName=" + packageInfo.packageName
@@ -427,6 +431,15 @@
 
         ArrayList<DirectoryInfo> directories = Lists.newArrayList();
 
+        if (Flags.stayStopped()
+                && (packageInfo.applicationInfo != null
+                 && ((packageInfo.applicationInfo.flags & ApplicationInfo.FLAG_STOPPED) != 0))) {
+            if (DEBUG) {
+                Log.d(TAG, "Package " + packageInfo.packageName + " is in stopped state");
+            }
+            return null;
+        }
+
         ProviderInfo[] providers = packageInfo.providers;
         if (providers != null) {
             for (ProviderInfo provider : providers) {

До выполнения цикла, в котором делаются запросы ContentResolver.query к content://<authority>/directories, был добавлен код, проверяющий, запущено приложение или нет. Если не запущено, то метод updateDirectoriesForPackage возвращает null, что приводит к важному побочному эффекту: пакет не попадает в список сохраняемых элементов (keep-list) объекта deleteWhereBuilder. В результате устаревшие записи о директориях этого пакета удаляются в ходе операции удаления, выполняемой в конце метода scanAllPackages. Что в свою очередь приводит к удалению записей о contact directories остановленного приложения. И они там не появятся, пока приложение не будет запущено снова.

Выводы

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

Бороться с этим на уровне систем защиты можно, но это уже требует модификации системных компонентов (как минимум PackageInstaller), а это возможно далеко не всегда.

Зачем вообще обходить подобные системы защиты? Мой вариант ответа такой: это полезно в рамках работ по анализу защищенности конкретных устройств и конкретных реализаций защит.


Ссылки

Tags: android security 

Смотрите также