Android ContentProvider 组件安全漏洞分析与防护
组件风险概述
ContentProvider 是 Android 系统中用于在不同应用程序之间共享数据的核心组件。默认情况下,该组件可能因配置不当而暴露内部数据库。特别是在 Android 4.1(API Level 16)及以下版本中,若未显式设置 android:exported 属性为 false,Provider 默认对外界开放。即便在更高版本中,若开发者为了兼容性或特定功能将其导出,却未配置严格的访问权限(如自定义 permission),恶意应用即可直接访问敏感数据,导致信息泄露或数据被篡改。
漏洞代码演示
以下代码展示了一个存在安全隐患的 ContentProvider 实现,用于管理员工的"备注信息"。
1. 实现自定义 Provider(NoteProvider.java)
public class NoteProvider extends ContentProvider {
private static final String AUTHORITY = "com.secure.company.provider.notes";
private static final String BASE_PATH = "notes";
public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + BASE_PATH);
public static final String _ID = "_id";
public static final String SUBJECT = "subject";
public static final String DETAIL = "detail";
private static final int NOTES_ALL = 1;
private static final int NOTE_ID = 2;
private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
sUriMatcher.addURI(AUTHORITY, BASE_PATH, NOTES_ALL);
sUriMatcher.addURI(AUTHORITY, BASE_PATH + "/#", NOTE_ID);
}
private DatabaseHelper mDbHelper;
private static class DatabaseHelper extends SQLiteOpenHelper {
DatabaseHelper(Context context) {
super(context, "NotesDB", null, 1);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE " + BASE_PATH + " (" +
_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
SUBJECT + " TEXT NOT NULL, " +
DETAIL + " TEXT NOT NULL);");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS " + BASE_PATH);
onCreate(db);
}
}
@Override
public boolean onCreate() {
mDbHelper = new DatabaseHelper(getContext());
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(BASE_PATH);
switch (sUriMatcher.match(uri)) {
case NOTES_ALL:
break;
case NOTE_ID:
queryBuilder.appendWhere(_ID + "=" + uri.getLastPathSegment());
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
SQLiteDatabase db = mDbHelper.getReadableDatabase();
Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
@Override
public String getType(Uri uri) {
switch (sUriMatcher.match(uri)) {
case NOTES_ALL:
return "vnd.android.cursor.dir/vnd.secure.notes";
case NOTE_ID:
return "vnd.android.cursor.item/vnd.secure.notes";
default:
throw new IllegalArgumentException("Unsupported URI: " + uri);
}
}
@Override
public Uri insert(Uri uri, ContentValues values) {
SQLiteDatabase db = mDbHelper.getWritableDatabase();
long rowId = db.insert(BASE_PATH, null, values);
if (rowId > 0) {
Uri noteUri = ContentUris.withAppendedId(CONTENT_URI, rowId);
getContext().getContentResolver().notifyChange(noteUri, null);
return noteUri;
}
throw new SQLException("Failed to insert row into " + uri);
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
SQLiteDatabase db = mDbHelper.getWritableDatabase();
int rowsDeleted;
switch (sUriMatcher.match(uri)) {
case NOTES_ALL:
rowsDeleted = db.delete(BASE_PATH, selection, selectionArgs);
break;
case NOTE_ID:
String id = uri.getLastPathSegment();
rowsDeleted = db.delete(BASE_PATH, _ID + "=" + id +
(!TextUtils.isEmpty(selection) ? " AND (" + selection + ')' : ""), selectionArgs);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return rowsDeleted;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
SQLiteDatabase db = mDbHelper.getWritableDatabase();
int rowsUpdated;
switch (sUriMatcher.match(uri)) {
case NOTES_ALL:
rowsUpdated = db.update(BASE_PATH, values, selection, selectionArgs);
break;
case NOTE_ID:
String id = uri.getLastPathSegment();
rowsUpdated = db.update(BASE_PATH, values, _ID + "=" + id +
(!TextUtils.isEmpty(selection) ? " AND (" + selection + ')' : ""), selectionArgs);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return rowsUpdated;
}
}
2. 数据操作 Activity(DashboardActivity.java)
public class DashboardActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_dashboard);
}
public void onAddNoteClick(View view) {
ContentValues record = new ContentValues();
EditText titleInput = findViewById(R.id.input_title);
EditText contentInput = findViewById(R.id.input_content);
record.put(NoteProvider.SUBJECT, titleInput.getText().toString());
record.put(NoteProvider.DETAIL, contentInput.getText().toString());
Uri newUri = getContentResolver().insert(NoteProvider.CONTENT_URI, record);
Toast.makeText(this, "Inserted: " + newUri, Toast.LENGTH_SHORT).show();
}
public void onViewNotesClick(View view) {
Uri fetchUri = NoteProvider.CONTENT_URI;
Cursor resultCursor = getContentResolver().query(fetchUri, null, null, null, NoteProvider.SUBJECT);
if (resultCursor != null && resultCursor.moveToFirst()) {
StringBuilder dataLog = new StringBuilder();
do {
int id = resultCursor.getInt(resultCursor.getColumnIndex(NoteProvider._ID));
String subject = resultCursor.getString(resultCursor.getColumnIndex(NoteProvider.SUBJECT));
String detail = resultCursor.getString(resultCursor.getColumnIndex(NoteProvider.DETAIL));
dataLog.append("ID: ").append(id)
.append("\nSubject: ").append(subject)
.append("\nDetail: ").append(detail)
.append("\n---\n");
} while (resultCursor.moveToNext());
Log.d("VULN_APP", dataLog.toString());
resultCursor.close();
}
}
}
3. 存在漏洞的 Manifest 配置
在 AndroidManifest.xml 中,Provider 被设置为导出,但未定义保护权限。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.secure.company">
<application ... >
<!-- 存在风险:exported 为 true,且未设置 permission -->
<provider
android:name=".NoteProvider"
android:authorities="com.secure.company.provider.notes"
android:exported="true" />
<activity android:name=".DashboardActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
漏洞利用验证
攻击者可以编写一个简单的恶意应用,无需任何特殊权限即可读取目标应用中的所有备注数据。
private void executeExploit() {
ContentResolver resolver = getContentResolver();
Uri targetUri = Uri.parse("content://com.secure.company.provider.notes/notes");
// 无需权限即可查询
Cursor cursor = resolver.query(targetUri, null, null, null, null);
if (cursor != null) {
while (cursor.moveToNext()) {
int id = cursor.getInt(0);
String subject = cursor.getString(1);
String detail = cursor.getString(2);
Log.e("ATTACKER", "Leaked Data -> ID: " + id + ", Title: " + subject + ", Content: " + detail);
}
cursor.close();
}
}
安全修复方案
为了防止此类数据泄露,开发者必须根据业务需求严格控制 ContentProvider 的访问权限:
- 私有使用限制: 如果 Provider 仅在应用内部使用,务必在
AndroidManifest.xml中设置android:exported="false"。这是最有效的隔离手段。 - 签名级权限保护: 如果必须将数据共享给同一开发者的其他应用,应定义自定义权限,并将保护级别设置为
signature。这确保只有使用相同签名密钥的应用才能访问该组件。
修复后的 Manifest 示例:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.secure.company">
<!-- 定义自定义权限 -->
<permission
android:name="com.secure.company.permission.READ_WRITE_NOTES"
android:protectionLevel="signature" />
<application ... >
<provider
android:name=".NoteProvider"
android:authorities="com.secure.company.provider.notes"
android:exported="true"
android:readPermission="com.secure.company.permission.READ_WRITE_NOTES"
android:writePermission="com.secure.company.permission.READ_WRITE_NOTES" />
</application>
</manifest>