android的ContentProvider 是用来在不同应用之间共享数据,它就像一个“数据中转站”,提供统一的接口(URI)让其它 App 读取或写入本应用托管的数据。
ContentProvider 的标准操作包括以下四类:
-
query() :查询数据(只读操作)
-
insert() :插入数据
-
update() :更新数据
-
delete() :删除数据
这四个方法构成了 ContentProvider 的标准 CRUD 能力,也是 Android 系统能够进行权限管控的操作范围。
除了这些标准操作外,Android还提供了ContentProvider.call(String method, String arg, Bundle extras),它是一个 通用 RPC(远程过程调用)接口,允许不同应用之间调用自定义功能。
它不属于标准的 CRUD(query / insert / update / delete),而是:
把应用内部的一段逻辑封装成一个“方法”对外开放,让别的 App 来执行。
1️⃣ 为什么会有 call()?
✔ 使用场景
“我想对外暴露某个能力,但不想把整张表/整块数据库都开放。”
典型例子:
- 相册:generateShareLink() 生成照片分享链接
- 通讯录:syncContacts() 触发同步任务
这些都是功能调用而非标准的 query/insert/update/delete。
2️⃣ call() 的工作方式
✔ 完全自定义 method 名称
比如:
-
generateToken
-
doPayment
-
getCardInfo
-
bindSim
-
getTrafficCardList
系统 不会校验这些方法是否危险,也不知道它们的作用。
✔ 参数和返回值都通过 Bundle 传输
代码示例
public class DemoProvider extends ContentProvider {
@Override public boolean onCreate() { return true; }
@Override
//此处的call方法是实现一个名为 "ping" 的自定义远程调用方法,并返回一个简单的响应
public Bundle call(String method, String arg, Bundle extras) {
if ("ping".equals(method)) {
Bundle out = new Bundle();
out.putString("msg", "pong-from-provider");
return out;
}
return super.call(method, arg, extras);
}
call方法相比较CRUD,灵活性高得多。
3️⃣ call() 为什么如此容易出漏洞?
⚠原因:Android 框架完全不知道 call() 要干什么,也无法做权限控制
和标准方法相对比:
| 操作类型 | 系统能否强制权限检查? | 受哪些权限约束? |
|---|---|---|
| query / openFile | ✔ 能检查读权限 | android:readPermission |
| insert / update / delete | ✔ 能检查写权限 | android:writePermission |
| call() | ✔可以,但是需要合理的设置 | 通过manifest合理的权限设置或者在call方法内部进行权限鉴定 |
对call方法,只要满足下面三个条件就会导致任何app都能随意调用call():
- android:exported="true"
- Provider在manifest中没有配置任何权限
- call() 内部没有做鉴权
4️⃣ 官方文档也明确警告过
Android 官方文档对 call() 有一句非常关键的警告:
系统不会对 call() 做任何权限检查,开发者必须在内部自行鉴权,否则会被未授权应用调用。
来源:developer.android.com/reference/a…
5️⃣ 测试call方法的权限保护
这里我们准备两个app,一个是提供call方法的app,一个是调用这个call方法的app,模拟任意app,注意两个app的签名要设置成不一样。它们的设置分别如下:
-》提供call方法的app
- call方法不做任何鉴权(这里在测试时一直不变)
public class DemoProvider extends ContentProvider {
@Override public boolean onCreate() { return true; }
// --- 核心:call 不做任何鉴权 ---
@Override
public Bundle call(String method, String arg, Bundle extras) {
if ("ping".equals(method)) {
Bundle out = new Bundle();
out.putString("msg", "pong-from-provider");
return out;
}
return super.call(method, arg, extras);
}
}
- 在manifest中声明的权限(这里就是测试设置的变量)
<!-- 声明两个权限(设为 signature 更能说明问题:第三方根本拿不到) -->
<permission
android:name="com.demo.perm.READ_DEMO"
android:protectionLevel="signature"/>
<permission
android:name="com.demo.perm.WRITE_DEMO"
android:protectionLevel="signature"/>
<permission
android:name="com.demo.perm.global"
android:protectionLevel="signature"/>
<application android:allowBackup="false" android:label="ProviderApp">
<provider
android:name=".DemoProvider"
android:authorities="com.demo.providerapp.demo"
android:exported="true"
//测试过程中将会分别对permission、readPermission、writePermission进行设置
android:permission="com.demo.perm.global"
android:readPermission="com.demo.perm.READ_DEMO"
android:writePermission="com.demo.perm.WRITE_DEMO"
android:grantUriPermissions="false"/>
</application>
-》调用call方法的App
- 权限申请 首先把提供call方法的app需要的权限都申请一遍(但其实和对方签名不一样,都是无法申请成功的)
<uses-permission android:name="com.demo.perm.READ_DEMO" />
<uses-permission android:name="com.demo.perm.WRITE_DEMO" />
<uses-permission android:name="com.demo.perm.global" />
<application
android:allowBackup="false"
android:label="CallerApp"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
- 跨进程调用call方法
private void doCall() {
tvResult.setText("正在调用 Provider.call("ping") ...");
Bundle extras = new Bundle();
extras.putString("msg", "hello from CallerApp");
try {
// method = "ping",arg 暂时传 null,extras 里放点测试数据
Bundle result = getContentResolver().call(
DEMO_URI,
"getTrafficCardConfig",
null,
extras
);
if (result == null) {
String msg = "调用成功,但返回 Bundle 为 null(可能 Provider 没有 put result)";
tvResult.setText(msg);
Log.i(TAG, msg);
} else {
// 假设你在 DemoProvider.call 里用 key "reply" 返回字符串
String reply = result.getString("trafficCardConfig");
String msg = "调用成功,返回 reply = " + result;
tvResult.setText(msg);
Log.i(TAG, msg);
}
} catch (SecurityException se) {
String msg = "SecurityException:权限不允许调用 call():\n" + se;
tvResult.setText(msg);
Log.e(TAG, msg, se);
} catch (Throwable t) {
String msg = "其他异常:\n" + t;
tvResult.setText(msg);
Log.e(TAG, msg, t);
}
}
-》测试结果
| 提供call方法的app的contentProvider权限设置 | 调用的call方法的app调用结果 | 测试结论 |
|---|---|---|
| 仅设置全局权限 | 无法调用成功 | 安全 |
| 仅设置写权限 | 可以调用成功 | 不安全 |
| 仅设置读权限 | 可以调用成功! | 不安全 |
| 不设置全局,同时设置读和写权限 | 无法调用成功! | 安全 |
6️⃣小结
由上述测试结果我们知道,要保护call方法,如果是在manifest中设置权限的话,只有以下两种是安全的,受到权限设置的保护:
- 设置permission权限,同时将该权限声明为signature级,这样只有与本应用 同签名的 APK 才能调用包括 call() 在内的所有入口
- 同时设置readPermission和writePermission,同时将他们的权限声明为signature级。
- 另外请注意:readPermission、writePermission优先级比permission高,如果混用请注意优先级。
除此之外,如果不在manifest中保护的话,还可以在call方法的内部进行权限保护,例如:
@Override
public Bundle call(String method, String arg, Bundle extras) {
// 1. 统一做权限校验(使用 signature 级自定义权限)
final String requiredPerm = "com.demo.app.PERM_SECURE_CALL";
final int uid = Binder.getCallingUid();
// 如果调用方没有声明/授予requiredPerm(本app自定义权限名称)权限,将直接抛出 SecurityException
getContext().enforceCallingPermission(
requiredPerm,
"Permission denied for uid=" + uid + " to call() on SecureProvider");
// 2. 可选:再按包名做一层白名单校验,进一步收紧调用范围
String callingPkg = getCallingPackage(); // 某些版本可能为 null,可结合 UID 反查
if (!isTrustedCaller(callingPkg)) {
throw new SecurityException("Untrusted caller package: " + callingPkg);
}
// 3. 根据 method 分发具体能力
if ("ping".equals(method)) {
Bundle out = new Bundle();
out.putString("msg", "pong-from-provider");
return out;
} else if ("getTrafficCardConfig".equals(method)) {
// TODO: 这里执行真实业务逻辑
return getTrafficCardConfig();
}
// 4. 未知方法直接拒绝,避免被枚举/探测
throw new IllegalArgumentException("Unknown call() method: " + method);
}
private boolean isTrustedCaller(String pkg) {
if (pkg == null) return false;
// 简单示例:只允许同家族应用调用
return "com.demo.app".equals(pkg)
|| "com.demo.app.partner".equals(pkg);
}