Android应用安全之Content Provider安全
android平台提供了Content Provider,将一个应用程序的指定数据集提供给其它应用程序。这些数据可以存储在文件系统、SQLite数据库中,或以任何其它合理的方式存储。其他应用可以通过ContentResolver类从该内容提供者中获取或存入数据。
Content Provider通过URI(统一资源定位符)来访问数据,URI可以理解为访问数据的唯一地址。
限制app对敏感Content Provider的访问
Content Provider类提供了一种机制用来管理以及与其它应用程序共享数据。在与其它应用程序共享Content Provider的数据时,应该实现访问控制,禁止对敏感数据未经授权的访问。
有三种方法来限制对内容提供者的访问:
- 公开的
- 私有的
- 内部的
公开的
通过在AndroidManifest.xml文件中指定android:exported属性为true,可以设置将Content Provider公开给其它应用程序。对于API Level低于17的Android应用程序,Content Provider默认是public的,除非显式指定android:exported="false"。例如:
<provider android:exported="true" android:name="MyContentProvider" android:authorities="com.example.mycontentprovider" />
如果一个Content Provider为公开的,Content Provider中存储的数据就可以被其它应用程序访问。因此,它只能用于处理非敏感信息。
私有的
通过在AndroidManifest. xml文件中指定android:exported属性为true,可以设置将Content Provider设置为私有的。从API Level17及以后,Content Provider默认是私有的,除非显式指定android:exported="true"。例如:
<provider android:exported="false" android:name="MyContentProvider" android:authorities="com.example.mycontentprovider" />
开发建议
- 如果不需要与其他应用程序进行数据共享,就应该在manifest文件中设置android:exported="false"。
- 但是值得注意的是,在API Level低于8时,即使显式地声明了android:exported="false",其它应用程序仍然可以访问你的Content Provider。
- 需要向外部提供数据的content provider则需设置访问权限,如:
下面的元素请求对用户词典的读权限:
<uses-permission android:name="android.permission.READ_USER_DICTIONARY">
申请某些protectionLevel="dangerous"的权限:
<uses-permission android:name="com.huawei.dbank.v7.provider.DBank.READ_DATABASE"/> <permission android:name="com.huawei.dbank.v7.provider.DBank.READ_DATABASE" android:protectionLevel="dangerous"></permission>
防止SQL注入
数据查询
传递给ContentProvider的参数应该被视为不可信的,不能直接用于sql查询。
一个程序要访问暴露的provider,首先要知道访问的目标地址,类似http协议,provider也有自己的规范,即类似content://com.aaaa.bbb.class/tablename
其中,com.aaaa.bbb为包名,class为类名,tablename为表名,一般是这样子,具体看自己定义了。
看一个查询例子:
Cursor cursor = contentResolver.query( Words_CONTENT_URI, new String[]{"user","pwd"}, null, null, null);
这是调用contentResolver的query方法进行数据库查询,返回一个cursor对象,即类似DataReader的东西,里面是返回结果。
来看看query的参数
Cursor android.content.ContentResolver.query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
uri 即content://com.aaaa.bbb.class/tablename
projection 即你要查询的列名
selection 和 selectionArgs 共同控制后面的sql语句中的where内容.
sortOrder 即order by xxx的内容。
那么例子中的查询整个构造的语句即:
select user,pwd from tablename;
2.Sql注入问题
综合上面的内容,我们可以看到,query里至少两个参数我们可控,一个是projection,一个是selection,这两个都会影响SQL与的组成,也就为注入提供了可能。
这里以某app为例,该app对外暴露了一个content provider,uri为:content://com.masaike.mobile.downloads/my_downloads,其中com.masaike.mobile为包名,downloads为库的名字,my_downloads为表名(不一定,可自定义的哦)。
现在语句这么写:
Cursor cursor = contentResolver.query("content://com.masaike.mobile.downloads/my_downloads", new String[]{"_id'","method"},null, null, null);
其中_id和method为两个字段名,我们在_id后面加个单引号,运行下看logcat内容:
从上图很容易看出来,SQL语句因为有个单引号,导致出错。所以注入是存在的。
而如果我们修改projection的内容为"* from sqlite_master where type='table';--",这样子即在闭合后面查询的情况下显示出来全部的表名。当然也可以构造其他语句了。
(参考cnrstar http://lcx.cc/?i=4462)
开发建议
1.传递给ContentProvider
的参数应该被视为不可信的输入,不应该在没有经过任何处理的情况下直接用于SQL查询。如果查询语句中包含SQL代码则可以返回数据或者允许攻击者未授权地访问应用数据库的数据。
2.使用ContentProvider
提供外部应用程序进行数据库存取时应使用带占位符的参数化查询防SQL注入。
3.SQLiteDatabase对象的部分内置方法是可以有效防SQL注入的,比如query(),insert(),update和delete(),另外,正确地使用rawQuery()也可以防止SQL注入,而execSQL()方法建议避免使用。
(1)、使用SQLiteDatabase对象自带的防SQL注入的方法,比如query(),insert(),update和delete():
DatabaseHelper dbhelper = new DatabaseHelper(SqliteActivity.this,"sqliteDB"); SQLiteDatabase db = dbhelper.getReadableDatabase();
/*查询操作,userInputID和userInputName是用户可控制的输入数据 */
Cursor cur = db.query("user", new String[]{"id","name"}, "id=? and name=?", new String[]{userInputID,userInputName}, null, null, null);
/* 插入记录操作*/
ContentValues val = new ContentValues(); val.put("id", userInputID); val.put("name", userInputName); db.insert("user", null, val);
/*更新记录操作*/
ContentValues val = new ContentValues(); val.put("id", userInputName); db.update("user", val, "id=?", new String[]{userInputID });
/*删除记录操作*/
db.delete("user", "id=? and name=?", new String[]{userInputID , userInputName });
(2)、正确地使用SQLiteDatabase对象的rawQuery()方法(仅以查询为例):
DatabaseHelper dbhelper = new DatabaseHelper(SqliteActivity.this,"sqliteDB"); SQLiteDatabase db = dbhelper.getReadableDatabase();
/* userInputID和userInputName是用户可控制的输入数据*/
String[] selectionArgs = new String[]{userInputID , userInputName }; String sql = "select * from user where id=? and name=?";//正确!此处绑定变量 Cursor curALL = db.rawQuery(sql, selectionArgs);
(3)、以下为错误案例!仅供参考:
DatabaseHelper dbhelper = new DatabaseHelper(SqliteActivity.this,"sqliteDB"); SQLiteDatabase db = dbhelper.getReadableDatabase();
/*案例1:错误地使用rawQuery(),未绑定参数*/
String sql = "select * from user where id=‘" + userInputID +"‘";//错误!动态拼接,未绑定变量 Cursor curALL = db.rawQuery(sql, null);
/*案例2:使用execSQL()方法*/
String sql = "INSERT INTO user values(‘"+userInputID +"‘,‘"+userInputName +"‘)";//错误同上 db.execSQL(sql);
4.ContentProvider
支持对指定的Uri分别设置读权限和写权限,建议只开放能完成任务的最小权限。
(参考http://shikezhi.com/html/2015/android_0819/134898.html)
规范ContentProvider的url
没有正确的覆写openFile方法,导致攻击者可以通过更改访问目录,遍历系统中所有文件。
通过使用ContentProvider.openFile()
方法,可以方便
其它应用访问你的应用程序数据。根据ContentProvider
的实现方式,使用
openFile
方法可以导致目录遍历漏洞。因此,当通过
ContentProvider
交换文件的时候,文件路径在使用之前应该被规范化。
不合规代码Example 1
在这个不合规代码示例中,试图通过调用android.net.Uri.getLastPathSegment()
获取
paramUri
路径的最后一段,即文件名称。此文件存在于预先配置的父目录
IMAGE_DIRECTORY
中。
private static String IMAGE_DIRECTORY = localFile.getAbsolutePath(); public ParcelFileDescriptor openFile(Uri paramUri, String paramString) throws FileNotFoundException { File file = new File(IMAGE_DIRECTORY, paramUri.getLastPathSegment()); return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); }
然而,当这个文件路径被URL编码后,就意味着被访问的这个文件可能会存在于预配置的父目录之外的一个不可预知的目录中。
从Android 4.3.0_r2.2开始, Uri.getLastPathSegment()
方法在内部调用了
Uri.getPathSegments()
:
public String getLastPathSegment() { // TODO: If we haven't parsed all of the segments already, just // grab the last one directly so we only allocate one string. List<String> segments = getPathSegments(); int size = segments.size(); if (size == 0) { return null; } return segments.get(size - 1); }
Uri.getPathSegments()
方法的部分代码如下
:
PathSegments getPathSegments() { if (pathSegments != null) { return pathSegments; } String path = getEncoded(); if (path == null) { return pathSegments = PathSegments.EMPTY; } PathSegmentsBuilder segmentBuilder = new PathSegmentsBuilder(); int previous = 0; int current; while ((current = path.indexOf('/', previous)) > -1) { // This check keeps us from adding a segment if the path starts // '/' and an empty segment for "//". if (previous < current) { String decodedSegment = decode(path.substring(previous, current)); segmentBuilder.add(decodedSegment); } previous = current + 1; } // Add in the final path segment. if (previous < path.length()) { segmentBuilder.add(decode(path.substring(previous))); } return pathSegments = segmentBuilder.build(); }
Uri.getPathSegments()
方法首先通过调用getEncoded()
获取了文件路径,然后使用
"/"作为分隔符将路径分割成几部分,任何被编码的部分都将通过decode()
方法进行URL解码。
如果文件路径被URL编码,那么分隔符就变成了"%2F",而不再是"/",getLastPathSegment()
就可能不会正确地返回路径的最后一段,从而导致目录遍历漏洞的产生。
如果Uri.getPathSegments()
在进行路径分割之前对路径进行解码,那么经过
URL
编码的路径就会被正确地处理,可惜没有这么实现。
不合规代码Example 2
在本不合规代码示例中,试图通过调用Uri.getLastPathSegment()
两次来修复第一个不合规代码示例中的漏洞。第一个调用意在进行
URL
解码,第二个调用是用于获取开发人员需要的字符串。
private static String IMAGE_DIRECTORY = localFile.getAbsolutePath(); public ParcelFileDescriptor openFile(Uri paramUri, String paramString) throws FileNotFoundException { File file = new File(IMAGE_DIRECTORY, Uri.parse(paramUri.getLastPathSegment()).getLastPathSegment()); return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); }
例如,当以下的URL编码字符串传递给content provider后会发生什么情况呢?
..%2F..%2F..%2Fdata%2Fdata%2Fcom.example.android.app%2Fshared_prefs%2FExample.xml
第一次调用Uri.getLastPathSegment()
函数会返回以下字符串:
../../../data/data/com.example.android.app/shared_prefs/Example.xml
字符串通过Uri.parse()
转换成了
Uri
对象
, 然后作为第二次调用Uri.getLastPathSegment()
函数的参数。
得到的结果如下:
Example.xml
这个字符串是用来创建一个文件对象的。然而,如果攻击者提供了一个特殊的字符串,该字符串在第一次调用Uri.getLastPathSegment()
时不能被解码,那么就获取不到分割路径的最后一段。这样的字符串可以使用双重编码技术创建:
双重编码
例如,下述双重编码字符串就能绕过该漏洞修复:
%252E%252E%252F%252E%252E%252F%252E%252E%252Fdata%252Fdata%252Fcom.example.android.app%252Fshared_prefs%252FExample.xml
第一次调用Uri.getLastPathSegment()
会将 "%25" 解码为"%",得到如下字符串:
%2E%2E%2F%2E%2E%2F%2E%2E%2Fdata%2Fdata%2Fcom.example.android.app%2Fshared_prefs%2FExample.xml
当把这个字符串传递给第二次调用的 Uri.getLastPathSegment()时,"%2E"和"%2F" 就会被解码,得到如下字符串:
../../../data/data/com.example.android.app/shared_prefs/Example.xml
从而导致目录遍历的可能。
仅仅通过解码字符串来防止示例中的目录遍历攻击是不够的,还必须检查解码后的路径是否在目标目录下。
PoC
以下恶意代码可对第一个不合规代码示例中的漏洞进行利用:
String target = "content://com.example.android.sdk.imageprovider/data/" + "..%2F..%2F..%2Fdata%2Fdata%2Fcom.example.android.app%2Fshared_prefs%2FExample.xml"; ContentResolver cr = this.getContentResolver(); FileInputStream fis = (FileInputStream)cr.openInputStream(Uri.parse(target)); byte[] buff = new byte[fis.available()]; in.read(buff);
PoC (Double Encoding)
以下恶意代码可对第二个不合规代码示例中的漏洞进行利用:
String target = "content://com.example.android.sdk.imageprovider/data/" + "%252E%252E%252F%252E%252E%252F%252E%252E%252Fdata%252Fdata%252Fcom.example.android.app%252Fshared_prefs%252FExample.xml"; ContentResolver cr = this.getContentResolver(); FileInputStream fis = (FileInputStream)cr.openInputStream(Uri.parse(target)); byte[] buff = new byte[fis.available()]; in.read(buff);
解决方案
在下述解决方案中,在使用文件路径前通过Uri.decode()
对其
进行了解码。同样的,在文件对象创建后,通过调用File.getCanonicalPath()
将路径进行了规范,同时检查它是否存在于
IMAGE_DIRECTORY
目录中。
通过使用规范化后的路径,即使文件路径被双重编码,目录遍历漏洞也可以得到缓解。
private static String IMAGE_DIRECTORY = localFile.getAbsolutePath(); public ParcelFileDescriptor openFile(Uri paramUri, String paramString) throws FileNotFoundException { String decodedUriString = Uri.decode(paramUri.toString()); File file = new File(IMAGE_DIRECTORY, Uri.parse(decodedUriString).getLastPathSegment()); if (file.getCanonicalPath().indexOf(localFile.getCanonicalPath()) != 0) { throw new IllegalArgumentException(); } return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); }
开发建议
ContentProvider.openFile()方法提供了一种方便其它应用程序访问自己的数据(文件)的方式,但是使用这个方法会导致一个目录遍历漏洞。因此,当通过ContentProvider访问一个文件的时候,路径应该被规范化。
1.使用ContentProvider.openFile()之前需要调用Uri.decode()
private static String IMAGE_DIRECTORY = localFile.getAbsolutePath(); public ParcelFileDescriptor openFile(Uri paramUri, String paramString) throws FileNotFoundException { String decodedUriString = Uri.decode(paramUri.toString()); File file = new File(IMAGE_DIRECTORY, Uri.parse(decodedUriString).getLastPathSegment()); if (file.getCanonicalPath().indexOf(localFile.getCanonicalPath()) != 0) { throw new IllegalArgumentException(); } return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); }
2.设置exported=“false”
3.设置恰当的访问权限
附:
使用adb命令测试content provider的方法
usage: adb shell content query --uri <URI> [--user <USER_ID>] [--projection <PROJECTION>] [--where <WHERE>] [--sort <SORT_ORDER>] <PROJECTION> is a list of colon separated column names and is formatted: <COLUMN_NAME>[:<COLUMN_NAME>...] <SORT_OREDER> is the order in which rows in the result should be sorted. Example: # Select "name" and "value" columns from secure settings where "name" is equal to "new_setting" and sort the result by name in ascending order. adb shell content query --uri content://settings/secure --projection name:value --where "name=\'new_setting\'" --sort "name ASC"