安卓应用安全指南 4.6.3 处理文件 高级话题
原书:Android Application Secure Design/Secure Coding Guidebook
译者:飞龙
协议:CC BY-NC-SA 4.0
4.6.3.1 通过文件描述符的文件共享
有一种方法可以通过文件描述符共享文件,而不是让其他应用访问公共文件。 此方法可用在内容供应器和服务中。 对方的应用可以通过文件描述符读取/写入文件,这些文件描述符通过在内容供应器或服务中,打开私人文件来获得。
其他应用直接访问文件的共享方式,与文件描述符的共享方式的比较如下表 4.6-2。 优点是访问权限的变化,以及允许访问的应用范围。 特别是从安全角度来看,这是一个很大的优点,可以详细控制允许访问的应用。
表 4.6-2 应用内文件共享方式的比较
文件共享方式 |
验证或者访问权限设置 |
允许访问的应用范围 |
允许其他应用直接访问的文件共享 |
读、写、读写 |
给予所有应用同等访问权限 |
通过文件描述符的文件共享 |
读、写、仅添加、读写、读+添加 |
可以控制是否将权限授予应用,它们尝试独立和暂时访问内容供应器和服务。 |
在上述两种文件共享方法中,这是很常见的,因为向其他应用提供文件写入权限时,文件内容的完整性很难得到保证。 当多个应用并行写入时,可能会破坏文件内容的数据结构,导致应用无法正常工作。 因此,在与其他应用共享文件时,只允许只读权限。
以下是通过内容供应器的文件共享的实现示例,及其示例代码。
要点:
1) 源应用是内部应用,因此可以保存敏感信息。
2) 即使是由内部的内容供应器产生的结果,也要验证结果数据的安全性。
InhouseProvider.java
package org.jssec.android.file.inhouseprovider;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import org.jssec.android.shared.SigPerm;
import org.jssec.android.shared.Utils;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
public class InhouseProvider extends ContentProvider {
private static final String FILENAME = "sensitive.txt";
private static final String MY_PERMISSION = "org.jssec.android.file.inhouseprovider.MY_PERMISSION";
private static String sMyCertHash = null;
private static String myCertHash(Context context) {
if (sMyCertHash == null) {
if (Utils.isDebuggable(context)) {
sMyCertHash = "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255";
} else {
sMyCertHash = "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA";
}
}
return sMyCertHash;
}
@Override
public boolean onCreate() {
File dir = getContext().getFilesDir();
FileOutputStream fos = null;
try {
fos = new FileOutputStream(new File(dir, FILENAME));
fos.write(new String("Sensitive information").getBytes());
} catch (IOException e) {
android.util.Log.e("InhouseProvider", "failed to read file");
} finally {
try {
fos.close();
} catch (IOException e) {
android.util.Log.e("InhouseProvider", "failed to close file");
}
}
return true;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode)
throws FileNotFoundException {
if (!SigPerm.test(getContext(), MY_PERMISSION, myCertHash(getContext()))) {
throw new SecurityException(
"In-house-defined signature permission is not defined by in-house application.");
}
File dir = getContext().getFilesDir();
File file = new File(dir, FILENAME);
int modeBits = ParcelFileDescriptor.MODE_READ_ONLY;
return ParcelFileDescriptor.open(file, modeBits);
}
@Override
public String getType(Uri uri) {
return "";
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
return null;
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs) {
return 0;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
return 0;
}
}
InhouseUserActivity.java
package org.jssec.android.file.inhouseprovideruser;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import org.jssec.android.shared.PkgCert;
import org.jssec.android.shared.SigPerm;
import org.jssec.android.shared.Utils;
import android.app.Activity;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.net.Uri;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.view.View;
import android.widget.TextView;
public class InhouseUserActivity extends Activity {
private static final String AUTHORITY = "org.jssec.android.file.inhouseprovider";
private static final String MY_PERMISSION = "org.jssec.android.file.inhouseprovider.MY_PERMISSION";
private static String sMyCertHash = null;
private static String myCertHash(Context context) {
if (sMyCertHash == null) {
if (Utils.isDebuggable(context)) {
sMyCertHash = "0EFB7236 328348A9 89718BAD DF57F544 D5CCB4AE B9DB34BC 1E29DD26 F77C8255";
} else {
sMyCertHash = "D397D343 A5CBC10F 4EDDEB7C A10062DE 5690984F 1FB9E88B D7B3A7C2 42E142CA";
}
}
return sMyCertHash;
}
private static String providerPkgname(Context context, String authority) {
String pkgname = null;
PackageManager pm = context.getPackageManager();
ProviderInfo pi = pm.resolveContentProvider(authority, 0);
if (pi != null)
pkgname = pi.packageName;
return pkgname;
}
public void onReadFileClick(View view) {
logLine("[ReadFile]");
if (!SigPerm.test(this, MY_PERMISSION, myCertHash(this))) {
logLine(" In-house-defined signature permission is not defined by in-house application.");
return;
}
String pkgname = providerPkgname(this, AUTHORITY);
if (!PkgCert.test(this, pkgname, myCertHash(this))) {
logLine(" Destination (Requested) Content Provider is not in-house application.");
return;
}
ParcelFileDescriptor pfd = null;
try {
pfd = getContentResolver().openFileDescriptor(
Uri.parse("content://" + AUTHORITY), "r");
} catch (FileNotFoundException e) {
android.util.Log.e("InhouseUserActivity", "no file");
}
if (pfd != null) {
FileInputStream fis = new FileInputStream(pfd.getFileDescriptor());
if (fis != null) {
try {
byte[] buf = new byte[(int) fis.getChannel().size()];
fis.read(buf);
logLine(new String(buf));
} catch (IOException e) {
android.util.Log.e("InhouseUserActivity", "failed to read file");
} finally {
try {
fis.close();
} catch (IOException e) {
android.util.Log.e("ExternalFileActivity", "failed to close file");
}
}
}
try {
pfd.close();
} catch (IOException e) {
android.util.Log.e("ExternalFileActivity", "failed to close file descriptor");
}
} else {
logLine(" null file descriptor");
}
}
private TextView mLogView;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mLogView = (TextView) findViewById(R.id.logview);
}
private void logLine(String line) {
mLogView.append(line);
mLogView.append("¥n");
}
}
4.6.3.2 为目录设置访问权限
以上所解释的安全考虑,重点在于文件。 还需要考虑作为文件容器的目录的安全性。 以下说明了目录的访问权限设置的安全性考虑。
在 Android 中,有一些方法可以在应用目录中获取/创建子目录。 主要如表 4.6-3。
表 4.6-3 在应用目录中获取/创建子目录的方法
规定其它应用的访问权限 |
删除文件 |
Context#getFilesDir() |
不可能(只有执行权限) |
Context#getCacheDir() |
不可能(只有执行权限) |
`Context#getDir(String name, |
|
int MODE)| 可以对
MODE设置如下:
MODE_PRIVATE
MODE_WORLD_READABLE
MODE_WORLD_WRITEABLE` | 设置=>应用=>选择目标应用=>清除数据 |
这里特别需要注意的是Context#getDir()
的访问权限设置。 正如文件创建中所说明的,从安全设计的角度来看,目录基本上也应该设置为私有的。 当信息共享取决于访问权限设置时,可能会产生意想不到的副作用,所以应采取其他方法用于信息共享。
MODE_WORLD_READABLE
这是一个标志,为所有应用提供目录的只读权限。 所以所有应用都可以获取目录中的文件列表,和单个文件属性信息。 由于秘密文件可能不会被放置在这些目录中,所以通常不能使用该标志 [15]。
MODE_WORLD_WRITEABLE
该标志位其他应用提供目录的写入权限。 所有应用都可以创建/移动/重命名/删除目录中的文件。 这些操作与文件本身的访问权限设置(读/写/执行)没有关系,所以需要注意的是,仅仅使用目录的写入权限就能执行操作。 此标志允许其他应用随意删除或替换文件,因此一般不能使用。
[15] MODE_WORLD_READABLE
和MODE_WORLD_WRITEABLE
在 API 17 和更高版本以及 API 24 和更高版本中弃用,使用它们将触发安全异常。
对于表 4.6-3 “用户删除”,请参考“4.6.2.4 应用应考虑文件范围而设计(必需)”。
4.6.3.3 共享首选项和数据库文件的访问权限设置
共享首选项和数据库也由文件组成。 对于访问权限设置,对文件解释的内容也会在这里解释。 因此,共享首选项和数据库都应该创建为私有文件,与文件相同,内容共享应该由 Android 的应用间联动系统来实现。
下面将展示共享首选项的使用示例。 通过MODE_PRIVATE
,共享首选项被设置为私有文件。
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
SharedPreferences preference = getSharedPreferences(
PREFERENCE_FILE_NAME, MODE_PRIVATE);
Editor editor = preference.edit();
editor.putString("prep_key", "prep_value");
editor.commit();
对于数据库,请参考“4.5 使用 SQLite”。
4.6.3.4 Android 4.4(API 级别 19)及更高版本中,外部存储访问的规范更改
自 Android 4.4(API Level 19)以来,外部存储访问的规范已更改为以下内容。
(1)如果应用需要读/写其外部存储器上的特定目录,则不需要使用<uses-permission>
声明WRITE_EXTERNAL_STORAGE
/READ_EXTERNAL_STORAGE
权限。(已更改)
(2)如果应用需要读取除外部存储器上特定目录以外的目录中的文件,则需要使用<uses-permission>
声明READ_EXTERNAL_STORAGE
权限。 (已更改)
(3)如果应用需要写入主外部存储器上的特定目录以外的目录中的文件,则需要使用<uses-permission>
声明WRITE_EXTERNAL_STORAGE
权限。
(4)应用无法写入次要外部存储器上的特定目录以外的目录中的文件。
在该规范中,根据 Android OS 的版本确定是否需要权限请求。 因此,如果应用支持包括 Android 4.3 和 4.4 在内的版本,则可能会导致应用需要用户不必要的许可。 因此,建议使用对应(1)的应用,如下所示使用<uses-permission>
的maxSdkVersion
属性。
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.jssec.android.file.externaluser" >
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:allowBackup="false" >
<activity
android:name=".ExternalUserActivity"
android:label="@string/app_name"
android:exported="true" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
4.6.3.5 Android 7.0(API Level 24)中的规范已修改,以便访问外部存储介质上的特定目录
在运行 Android 7.0(API Level 24)或更高版本的设备上,引入了一种称为作用域目录访问 API的新 API。 作用域目录访问允许应用在未经许可的情况下,访问外部存储器上的特定目录。 在作用域目录访问中,将Environment
类中定义的目录作为参数传递给StorageVolume#createAccessIntent
方法,来创建一个意图。 通过startActivityForResult
发送此意图,可以启动一个对话框,在终端屏幕上请求访问权限,并且 - 如果用户授予权限 - 每个存储卷上的指定目录都可以访问。
表 4.6-4 可以通过作用域目录访问来访问的目录
DIRECTORY_MUSIC |
通用音乐文件的标准位置 |
DIRECTORY_PODCASTS |
播客的标准目录 |
DIRECTORY_RINGTONES |
ringtone 的标准目录 |
DIRECTORY_ALARMS |
闹铃的标准目录 |
DIRECTORY_NOTIFICATIONS |
提醒的标准目录 |
DIRECTORY_PICTURES |
图片的标准目录 |
DIRECTORY_MOVIES |
电影的标准目录 |
DIRECTORY_DOWNLOADS |
用户下载的文件的标准目录 |
DIRECTORY_DCIM |
相机产生的图片/视频文件的标准目录 |
DIRECTORY_DOCUMENTS |
用户创建的文档的标准目录 |
如果应用要访问的位置位于上述目录之一,并且该应用正在 Android 7.0 或更高版本的设备上运行,则建议使用作用域目录访问,原因如下。 对于必须继续支持 Android 7.0 以下的设备的应用,请参阅“4.6.3.4 Android 4.4(API级别19)及更高版本中的外部存储访问的规范更改”中,列出的AndroidManifest
中的示例代码。
- 授予访问外部存储的权限时,应用可以访问预期目标以外的目录。
- 使用存储器访问框架来要求用户选择可访问的目录,会导致繁琐的过程,用户必须在每次访问时配置一个选择器。 另外,当访问外部存储器的根目录时,整个存储器变成可访问的。