赞
踩
也许每个人出生的时候都以为这世界都是为他一个人而存在的,当他发现自己错的时候,他便开始长大
少走了弯路,也就错过了风景,无论如何,感谢经历
转移发布平台通知:将不再在CSDN博客发布新文章,敬请移步知识星球
感谢大家一直以来对我CSDN博客的关注和支持,但是我决定不再在这里发布新文章了。为了给大家提供更好的服务和更深入的交流,我开设了一个知识星球,内部将会提供更深入、更实用的技术文章,这些文章将更有价值,并且能够帮助你更好地解决实际问题。期待你加入我的知识星球,让我们一起成长和进步
Android安全付费专栏长期更新,本篇最新内容请前往:
安卓使用了类似Unix中的文件系统来进行本地数据存储,用到的文件系统有十几种,如FAT32、EXT等。在安卓系统中的一切皆文件,因此可以使用命令来查看对应的信息,例如查看文件系统详情:
adb shell cat /proc/filesystems
典型的文件系统根目录如下:
Android在filesystems这个文件中存储了许多详细信息,比如内置应用、通过谷歌Play商店安装的应用等。任何人如果拥有物理访问权限,都能轻易从中获得许多敏感信息,如照片、密码、GPS位置信息、浏览历史或者公司数据等。开发人员应该确保数据存储安全,要不然将会对用户及数据产生安全风险,甚至导致严重的黑客攻击。
例如:这里以一个vuls的APP为例,访问对应的/data/data加包名等信息,可以看到只有特定的用户(u0_a33)才能访问这个目录,其他的应用则不能,如下所示
adb shell ls -l /data/data/ddns.android.vuls
备注:基于/proc文件系统如上所述的特殊性,其内的文件也常被称作虚拟文件,并具有一些独特的特点。例如,其中有些文件虽然使用查看命令查看时会返回大量信息,但文件本身的大小却会显示为0字节。此外,这些特殊文件中大多数文件的时间及日期属性通常为当前系统时间和日期,这跟它们随时会被刷新(存储于RAM中)有关;为了查看及使用上的方便,这些文件通常会按照相关性进行分类存储于不同的目录甚至子目录中,如/proc/scsi目录中存储的就是当前系统上所有SCSI设备的相关信息,/proc/N中存储的则是系统当前正在运行的进程的相关信息,其中N为正在运行的进程(可以想象得到,在某进程结束后其相关目录则会消失);大多数虚拟文件可以使用文件查看命令如cat、more或者less进行查看,有些文件信息表述的内容可以一目了然,但也有文件的信息却不怎么具有可读性。不过,这些可读性较差的文件在使用一些命令如apm、free、lspci或top查看时却可以有着不错的表现
进程目录中的常见文件介绍
/proc目录下常见的文件介绍
/proc/sys目录详解:abi、crypto、debug、dev、fs、kernel、net、user、vm;与/proc下其它文件的“只读”属性不同的是,管理员可对/proc/sys子目录中的许多文件内容进行修改以更改内核的运行特性,事先可以使用“ls -l”命令查看某文件是否“可写入”。写入操作通常使用类似于“echo DATA > /path/to/your/filename”
的格式进行。需要注意的是,即使文件可写,其一般也不可以使用编辑器进行编辑
/proc/sys/debug 子目录:此目录通常是一空目录
/proc/sys/dev 子目录:为系统上特殊设备提供参数信息文件的目录,其不同设备的信息文件分别存储于不同的子目录中,如大多数系统上都会具有的/proc/sys/dev/cdrom和/proc/sys/dev/raid(如果内核编译时开启了支持raid的功能) 目录,其内存储的通常是系统上cdrom和raid的相关参数信息文件
存储应用数据有如下四种:
Android系统提供了以下四种Android应用本地存储方式:Shared Preferences、SQLite Databases、Internal Storage、External Storage
等存储方式。Shared Preferences是一种轻量级的基于XML文件存储的键值对(key-value)数据的数据存储方式,一般用于储存应用的配置等信息
除了外部存储方式,其他存储方式都将数据存放在/data/data目录下的文件夹中,其中包含缓存、数据库、文件以及共享首选项这四个文件夹。每个文件夹分别用于存放与应用相关的特定类型的数据:
lib -> /data/app-lib/eu.chainfire.supersu-1
:存放应用需要的或导入的so库文件共享首选项(Shared Preferences):
SQLite数据库:
内部存储:
外部存储:
本地存储会涉及到两大方面的安全问题,存储的数据和存储的方式。存储数据的安全主要是指,本地数据库中是否存储了敏感信息,包括身份证号码、银行卡号、账号密码等。对于敏感数据的定义根据不同的业务场景会有不同,读者可以根据自身情况自行把握。存储方式的安全,主要是指如何进行敏感数据存储的,如是否对敏感数据做了明文存储,是否存在本地sql注入,是否对数据的访问权限做了合理控制等。
以下是四种本地存储方式的介绍:
文件存储方式主要是使用IO流操作读写sdcard上的文件,比如应用程序数据文件夹下的某一文件被其他应用读取、写入等操作,其核心原理为: Context提供了两个方法来打开数据文件里的文件IO流 FileInputStream openFileInput(String name); FileOutputStream(Stringname , int mode),这两个方法第一个参数用于指定文件名,第二个参数指定打开文件的模式。不过权限需要在AndroidManifest.xml文件上进行配置:
<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
SQLite是轻量级嵌入式数据库引擎,支持 SQL 语言,并且只利用很少的内存就有很好的性能,是android等主流移动设备上的复杂数据存储引擎。SQLiteDatabase类为我们提供了很多种方法,支持增删查改等基本的数据库操作。程序运行生成的*.db
文件一般位于/data/data/<package name>/databases/*db
ContentProvider主要用于程序之间的数据交换,这些数据包括文件数据、数据库数据和其他类型的数据。一个程序可以通过实现一个Content Provider的抽象接口将数据暴露出去,其他的应用程序可以通过统一的接口保存、读取、修改、添加、删除此Content Provider的各种数据(涉及到一定权限)。Content Provider中使用的查询字符串有别于标准的SQL查询。很多诸如select, add, delete, modify
等操作我们都使用一种特殊的URI来进行,这种URI由3个部分组成"content://"
,代表数据的路径,和一个可选的标识数据的ID
该存储方式通常用来存储应用的配置信息,保存方式基于XML文件存储的key-value键值对数据,一般作为数据存储的一种补充。SharedPreferences对象本身只能获取数据而不支持存储和修改,存储修改是通过SharedPreferences.edit()获取的内部接口Editor对象实现。 SharedPreferences本身是一个接口,程序无法直接创建SharedPreferences实例,只能通过Context提供的getSharedPreferences(String name, intmode)
方法来获取实例。存储路径为:/data/data/<package name>/shared_prefs
目录下
以下是网络存储方式的介绍:
<uses-permission android:name="android.permission.INTERNET" />
Shared Preferences存储安全风险源于以下两方面:
MODE_PRIVATE、MODE_WORLD_READABLE以及MODE_WORLD_WRITEABLE
)进行权限控制"android:sharedUserId"
属性值,使得其他应用对该应用的Shared Preferences文件具备可写的权限。在本地信息存储方面,一般主要从SQLite数据库文件和SharedPreferances配置文件是否泄漏敏感信息进行安全测试。此外通过反编译APP,分析源代码获取数据存储过程——>存储路径——>敏感数据文件方面进行考量
Shared Preferences 通常被用来保存少量的数据,其文件默认被存储在应用目录下的shared_prefs目录下。Shared Preferences 文件其实就是 xml 文件,里面的数据一般以键值对的形式存储
可以使用SharedPreferences类来创建共享首选项。下面这段代码用于在account_dangerous.xml和account_safe.xml文件中存储用户名和密码:
获取SharedPreferences对象,获取SharedPreferences对象,第一个参数是指定 SharedPreferences 文件名,第二个参数是指定操作模式
getSharedPreferences (String name, int mode)
其操作模式有以下几种:
从上面的描述中可以发现,当存有敏感数据的 SharedPreferences 文件被赋予 MODE_WORLD_READABLE 或 MODE_WORLD_WRITEABLE 时,是会存在安全问题的。我们来查看一下对应的文件权限看看:
adb shell ls -al /data/data/ddns.android.vuls/shared_prefs
如上图可以看到 account_dangerous.xml 的权限为 -rw-rw-rw- ,代表了所有应用对其具有读写权限;account_safe.xml 的权限为-rw-rw---- ,代表了只能被当前APP或者与当前APP具有相同 user ID 的APP读取
先保存一个账号(11sw)+密码(11ws)
ls /data/data/ddns.android.vuls/shared_prefs
共享首选项文件下查看是否有刚刚我们填写的账号和密码:
cat /data/data/ddns.android.vuls/shared_prefs/account_dangerous.xml
cat /data/data/ddns.android.vuls/shared_prefs/account_safe.xml
或
adb pull /data/data/ddns.android.vuls/shared_prefs/account_dangerous.xml
adb pull /data/data/ddns.android.vuls/shared_prefs/account_safe.xml
大家看到的上面这个案例可能会以为这是演示的案例,在真实中是不会发生这种事情的,但往往相反,这里以一个PIN锁的APP来做为演示,如下:
Whatsapp Lock APK:https://apkpure.com/cn/lock-for-whatsapp/com.securechat.lockwhatsapp
先去创建一个PIN 码先,博主同学这里密码以1233为密码保存了
然后查看com.securechat.lockwhatsapp(该APP的包名)。进入shared_prefs下有一个xml文件,直接查看一下,哟吼还真的有我们刚刚保存的PIN码,PIN密码以明文存储
cat /data/data/com.securechat.lockwhatsapp/shared_prefs/MyCustomNamedPreference.xml
我们先输入一个错误的密码来看看会提示我们输入的PIne码错误
再输入缓存中的PIN密码试试,哟呵成功咯
该应用具有PIN码恢复功能,以便在忘记PIN码的情况下找回。但是你需要回答密保问题。密保问题及其答案同样以明文的方式直接存储在
然后只需要在对应的位置输入1111,即可绕过PIN码登录
SQLite数据库是基于文件的轻量级数据库,扩展名通常为.db或.sqlite。安卓系统完全支持SQLite数据库。应用中的其他类都能访问应用创建的数据库,但其他应用则不能访问。
通过编程的方式扩展SQLiteOpenHelper类,从而实现数据库的插入和读取,并将来自用户的数值插入一个名为accounts的表中,存储用户名和密码
查看com.securechat.lockwhatsapp(该APP的包名)下的.db文件,这里我们将其pull出来用SQLite打开,然后将.db拖放到浏览器窗口
SQLite下载:http://www.sqlitebrowser.org/blog/version-3-12-2-released/
adb shell ls /data/data/ddns.android.vuls/databases
adb pull /data/data/ddns.android.vuls/databases/account.db
可以看到Android APP在本地的account.db中存储了账号和密码
用户字典是大多数移动设备所具有的一个非常方便的功能,能够让键盘记住用户经常输入的词组。当使用键盘输入特定的词组时,它能自动提供一些补全建议。安卓系统同样具有这一功能,它将常用词组存放在/data/data/<应用包名>/databases下的一个名为user_dict.db的文件中。如果允许缓存输入安卓应用的敏感信息,那么任何人都可以通过浏览/data/data/<应用包名>/databases下的user_dict.db文件或使用其内容提供程序的URI访问这些数据
如果攻击者通过用户字典的内容提供程序访问里面的内容,就可以轻易读取和搜集其中的有用信息。这里的利用方式与SQLite数据库处理.db文件的方式一样,将user_dict.db数据库文件从设备中拉取到计算机上,然后丢到SQLite浏览器中
adb shell pull /data/data/<应用包名>/databases/user_dict.db
一般来讲,Android使用的数据库都是自带的SQLite数据库。Web重量级的MySQL、Orcacle、CouchDB、NoSQL等都不适用于Android项目的开发,只是说这些数据库不适用,但同样可以用于移动应用,其中MongoDB(属于NOSQL数据库)是当前最流行的NoSQL数据库产品之一,它使用类似于JSON(JavaScript对象表示法)的语法将数据存储为文档。与其它本地存储技术类似,如果NoSQL数据库通过不安全的方式存储数据,就可能会被利用
介绍一下,当前NoSQL数据库有哪些,如下:
MongoDB 是个面向文档的数据库,使用 JSON 风格的数据格式。它非常适合于网站的数据存储、内容管理与缓存应用,并且通过配置可以实现复制与高可用性功能。
MongoDB 具有很强的可伸缩性,性能表现优异。它使用 C++ 编写,基于文档存储。此外,MongoDB 还支持全文检索、跨 WAN 与 LAN 的高可用性、易于实现的复制、水平扩展、基于文档的丰富查询、在数据处理与聚合等方面具有很强的灵活性。
这是个 Apache 软件基金会的项目,Cassandra 是个分布式数据库,支持分散的数据存储,可以实现容错以及无单点故障等。换句话说,“Cassandra 非常适合于那些无法忍受数据丢失的应用”。
这也是 Apache 软件基金会的一个项目,CouchDB 是另一个面向文档的数据库,以 JSON 格式存储数据。它兼容于 ACID,像 MongoDB 一样,CouchDB 也可以用于存储网站的数据与内容,以及提供缓存等。你可以通过 JavaScript 在 CouchDB 上运行 MapReduce 查询。此外,CouchDB 还提供了一个非常方便的基于 Web 的管理控制台。它非常适合于 Web 应用。
Hypertable 模仿的是 Google 的 BigTable 数据库系统。Hypertable 的创建者将“成为高可用、PB 规模的数据库开源标准”作为 Hypertable 的目标。换言之,Hypertable 的设计目标是跨越多个廉价的服务器可靠地存储大量数据。
这是个开源、高级的键值存储。由于在键中使用了 hash、set、string、sorted set 及 list,因此 Redis 也称作数据结构服务器。这个系统可以帮助你执行原子操作,比如说增加 hash 中的值、集合的交集运算、字符串拼接、差集与并集等。Redis 通过内存中的数据集实现了高性能。此外,该数据库还兼容于大多数编程语言。
Riak 是最为强大的分布式数据库之一,它提供了轻松且可预测的伸缩能力,向用户提供了快速测试、原型与应用部署能力,从而简化应用的开发过程。
Neo4j 是一款 NoSQL 图型数据库,具有非常高的性能。它拥有一个健壮且成熟的系统的所有特性,向程序员提供了灵活且面向对象的网络结构,可以让开发者充分享受到拥有完整事务特性的数据库的所有好处。相较于 RDBMS,Neo4j 还对某些应用提供了不少性能改进。
HBase 是一款可伸缩、分布式的大数据存储。它可以用在数据的实时与随机访问的场景下。HBase 拥有模块化与线性的可伸缩性,并且能够保证读写的严格一致性。HBase 提供了一个 Java API,可以实现轻松的客户端访问;提供了可配置且自动化的表分区功能;还有 Bloom 过滤器以及 block 缓存等特性。
虽然 Couchbase 是 CouchDB 的派生,不过它已经成为了一款功能完善的数据库产品。它向文档数据库转移的趋势会让 MongoDB 感到压力。每个节点上它都是多线程的,这是个非常主要的可伸缩性优势,特别是当托管在自定义或是 Bare-Metal 硬件上时更是如此。借助于一些非常棒的集成特性,诸如与 Hadoop 的集成,Couchbase 对于数据存储来说是个非常不错的选择。
这是个分布式的键值存储系统,我们不应该将其与缓存解决方案搞混;相反,它是个持久化存储引擎,用于数据存储并以非常快速且可靠的方式检索数据。它遵循 memcache 协议。其存储后端用于 Berkeley DB 中,支持诸如复制与事务等特性。
RAVENDB 是第二代开源数据库,它面向文档存储并且无模式,这样就可以轻松将对象存储到其中了。它提供了非常灵活且快速的查询,通过对复制、多租与分片提供开箱即用的支持使得我们可以非常轻松地实现伸缩功能。它对 ACID 事务提供了完整的支持,同时又能保证数据的安全性。除了高性能之外,它还通过 bundle 提供了轻松的可扩展性。
这是个自动复制的分布式存储系统。它提供了自动化的数据分区功能,透明的服务器失败处理、可插拔的序列化功能、独立的节点、数据版本化以及跨越各种数据中心的数据分发功能。
项目地址:https://github.com/pilgr/Paper
NoSql在Android上应用得不多,Paper是目前刚出现的性能比较好而且比较小巧的一款
NoSQL是一种数据库技术,此时我们要想查看数据库目录,但这里只有files目录。缺少数据库目录是因为使用files目录存储数据库调用的模型。如下图:
貌似看起来是直接调用对应的模型去做的调用?Android 还是不太懂,那就先放放,咋们换下一个案例看看
项目地址:https://github.com/Reone/KVStorage
NoSQL,泛指非关系型的数据库。KVStorage属于键值(Key-Value)存储数据库。注意KVStorage并不完全属于NoSQL,其底层由sqlite实现。具体操作的细节跟SQlite数据库差不多,就不一 一复述,如下图:
一说内部存储,有人可能会和内存混淆在一起,其实这两个概念很好区分,内部存储是用于持久化存储的,属于ROM,手机关机或者退出App数据是不会丢失的,而内存是RAM,退出App或者关机之后数据就会丢失。所以,内部存储不是内存。所谓的内部存储,其实是手机ROM上的一块存储区域,主要用于存储系统以及应用程序的数据。内部存储在Android系统对应的根目录是 /data/data/,这个目录普通用户是无权访问的,用户需要root权限才可以查看
内部存储是Android APP存储数据的另一种方式,通常存放在/data/data/<应用包名>
中的文件夹里。以 某个APP为例,其本地存储位置为/data/data/eu.chainfire.supersu
/data/data
目录是按照应用的包名来组织的,每个应用都是属于自己的内部存储目录,而且目录的名称就是该应用的包名,这个目录是在安装应用的时候自动创建的,当应用被卸载后,该目录也会被系统自动删除。所以,如果你将数据存储于内部存储中,其实就是把数据存储到自己应用包名对应的内部存储目录中。每个应用的内部存储目录都是私有的,也就是说内部存储目录下的文件只能被应用自己访问到,其他应用是没有权限访问的。应用访问自己的内部存储目录时不需要申请任何权限。
lib -> /data/app-lib/eu.chainfire.supersu-1
:存放应用需要的或导入的so库文件那么有哪些API可以获取到内部存储目录呢,我们主要是使用Context类提供的接口来访问内部存储目录,如下:
//获取的目录是/data/user/0/package_name,
//即应用内部存储的根目录
getDataDir()
//获取的目录是/data/user/0/package_name/files,
//即应用内部存储的files目录
getFilesDir()
//获取的目录是/data/user/0/package_name/cache,
//即应用内部存储的cache目录
getCacheDir()
//获取的目录是/data/user/0/package_name/app_name,
//如果该目录不存在,系统会自动创建该目录
getDir(String name, int mode)
通过Context访问程序的私有目录:
Context提供的路径都有一个特点,都是当前App私有的,其他的App无权限访问。即这些目录是当前应用程序的私有目录
方法 | 解释 |
---|---|
getFilesDir | 获取的 data/data/程序包名/files 这个目录 |
getCatch | 获取的data/data/程序包名/catch 这个目录 |
getExternalCacheDir | 获取的是mnt/sdcard/Android/程序包名/catch 这个目录 |
getExternalFilesDir(type:String) | 获取指定类型的文件目录位于mnt/sdcard/Android/程序包名/files/<指定类型的目录(例如 Downlaod))> |
通过Environment类访问手机的公有目录:
通过Environment类获取目录是程序的公有目录,因为是操作SD卡,所以在需要有读写SD卡的权限,并且在Android 6.0 及以上的机器的时候,还需要动态申请权限
方法 | 解释 |
---|---|
Environment.getExternalStorageState() | 获取当前SD卡的状态 |
Environment.getExternalStoragePublicDirectory(type:String) | 获取SD卡指指定类型的目录 |
注:获取到对应的目录后,就可以对目录下的文件进行读写。但发现代码中获取的内部存储根目录是 /data/user/0
,并不是前面提到的/data/data
,这是怎么回事呢?因为在Android4.2以后增加了多用户的功能,为了适应多用户的功能,原来的/data/data/
相当于直接链接到当前用户文件夹的,变成了/data/user/0/
,所以在代码中打印出来的路径是/data/user/0
,而不是/data/data
,说白了/data/data
和/data/user/0/
是一个东西。内部存储空间容量有限,如果内部存储空间被用完,系统会报内存不足。所以,不要把所有的数据都放到内部存储中,所以开发人员会把较敏感的应用数据放在内部存储中,而其他的数据可以放在外部存储中
将手机上的文件拉到本地进行分析
adb pull /data/data/eu.chainfire.supersu
首先为了分析方便,可能需要在模拟器或手机设备上安装一下 busybox。因为 Android 系统虽然是基于linux的,但是很多基本命令都被阉割了,而busybox里面集成了常用的命令
下载对应的文件:https://busybox.net/
将busybox文件 push 到手机
adb push busybox /data/tmp/busybox
adb shell
cd/data/tmp
chmod 755 busybox
./busybox mount -o rw,remount /
./busybox cp busybox /sbin
*.so
可以使用ida进行反编译查看其中的信息,也可以使用 linux 下的strings 命令来提取字符串信息
strings libosal.so
*.xml
可以使用 notepad++进行分析。比如 mac.xml 文件中就存储了手机的 mac 地址信息
*.db
可以使用 SQLite browser 来浏览分析。其官方地址为http://sqlitebrowser.org/
*journa
为日志文件,一般无需分析。
其他类型文件可以使用 notepad++ 查看或者自行搜索对应文件的打开工具
那么,内部存储攻击,在攻击者可以物理接触设备时,攻击者就可以读取设备的个人信息和存储的数据。当攻击者获取到root权限之后会让攻击更复杂
内部文件存储通常用来存储一些比较大的数据,比如说图片、视频之类的。通过内部文件存储,你可以存储任意类型的数据,与 SharedPreferences 类似,其也有几种存储模式,如下:
当使用 MODE_WORLD_READABLE 或 MODE_WORLD_WRITEABLE 模式存储敏感信息的时候,就会造成安全问题,代码如下:
查看一下对应的文件权限,可发现account_dangerous.txt 的权限为 -rw-rw-rw- ,代表所有应用对其具有读写权限
adb shell ls -al /data/data/ddns.android.vuls/files
大家应该也知道只要使用了APP ,某些数据就会存储下来。在安卓 APP 的本地存储通常会在两个地方:内部存储和外部存储。内部存储通常是指手机本身的存储空间,而外部存储主要是指 sdcard 的存储空间
在内部存储中的数据对应用来说是私密的,用户和其他应用都没有访问权限,而外部存储中的数据是可以被其他应用或用户访问甚至删除的,用户可以通过USB方式和PC之间交互外部存储中的数据。我们平常在Android手机的文件管理工具下看到的目录其实就是外部存储。
在Android4.4以前,外部存储就是指SD卡,手机自带的存储就是内部存储;但是在Android4.4以后,随着手机机身存储越来越大,手机的机身存储已经可以满足大多数用户的需求,所以很多手机都不需要再安装SD卡。此时外部存储和内部存储都位于手机机身存储上,他们只是同一个存储介质上的不同存储区域。但是很多手机还是保留了SD卡插槽,方便用户自行拓展。如果手机安装了SD卡,那么很显然SD卡目录也属于外部存储目录。这时手机都有了两个外部存储空间,一个位于手机机身存储上,一个位于SD卡上。但是随着机身存储越累越大,SD卡一般可能只适用于转移文件,对于一般应用来说应该也不会把数据写到外置的SD卡上了,所以这里主要以机身存储为例来分析外部存储。
和内部存储不同的是,外部存储根据存储特点不同分为两种类型:外部私有存储和外部共有存储。先来看外部私有存储
外部存储是安卓系统中另一个重要的存储机制。一些知名的应用都将数据存储在外部存储(即SD卡)中。将数据存储到SD卡时需要特别注意,因为它是全局可读写的。用户甚至可以轻松将SD卡从设备上移除,然后挂载到另一台设备中,以便访问和读取其中的数据。
通常来说,应用涉及到的持久化数据一般分为两类:应用相关数据和应用无关数据。前者是指应用使用的数据信息,比如一些配置信息,调试信息,缓存文件等。当应用被卸载,这些信息也应该被随之删除,避免占用不必要的存储空间。例如下面两种场景:
对于问题一,我们可以直接把数据存储在内部存储中,但是考虑到内部存储空间有限,把这些数据存储到内部存储会浪费内部存储的空间。对于问题二,普通用户(指没有root权限的用户)无法直接查看其中的文件,把数据直接存储在内部存储中是行不通的。这些数据有一个共同点就是他们的生命周期和应用是一致的,而且不太适合于放在内部存储中。为了存储这种类型的数据,Android规定来一个专门的存储空间,这个空间被称为外部私有存储空间。外部私有存储空间属于外部存储,对于某个应用来说,外部私有存储的根目录(这里暂时不考虑SD卡)是 /storage/emulated/0/Android/data/package_name
,这个目录有点类似于内部存储目录,都是以包名来命名私有存储空间的
外部私有存储空间有以下特点:
/storage/emulated/0/Android/data/
目录下生成该应用的外部私有存储目录,只有在应用中调用API访问外部私有存储目录时,才会创建以package_name命名的私有存储目录file://
这种形式的 Uri 直接读写其他应用的外部私有存储目录,而是需要通过 FileProvider 访问可以通过以下方式来获取外部私有存储目录:
1.getExternalCacheDir() /*获取到的目录是/storage/emulated/0/Android/data/package_name/cache,如果该目录不存在,调用这个方法会自动创建该目录。*/ 2.getExternalFilesDir(String type) /* 1.如果type为"",那么获取到的目录是 /storage/emulated/0/Android/data/package_name/files 2.如果type不为空,则会在/storage/emulated/0/Android/data/package_name/files目录下创建一个以传入的type值为名称的目录,例如你将type设为了test,那么就会创建/storage/emulated/0/Android/data/package_name/files/test目录,这个其实有点类似于内部存储getDir方法传入的name参数。但是android官方推荐使用以下的type类型 public static String DIRECTORY_MUSIC = "Music"; public static String DIRECTORY_PODCASTS = "Podcasts"; public static String DIRECTORY_RINGTONES = "Ringtones"; public static String DIRECTORY_ALARMS = "Alarms"; public static String DIRECTORY_NOTIFICATIONS = "Notifications"; public static String DIRECTORY_PICTURES = "Pictures"; public static String DIRECTORY_MOVIES = "Movies"; public static String DIRECTORY_DOWNLOADS = "Download"; public static String DIRECTORY_DCIM = "DCIM"; public static String DIRECTORY_DOCUMENTS = "Documents";*/
外部存储目录还有一个存储空间就是外部共有存储目录,顾名思义,外部共有存储目录存储的数据无论对应用还是用户都是可见的应用只要有外部访问权限,就可以读取外部公共目录下的文件。外部公共目录主要存放和应用无关的数据,这些数据在卸载App的时候不会被删除
外部共有存储目录有以下特点:
可以通过以下方式来访问外部公共存储目录:
1.Environment.getExternalStorageDirectory() //获取到的目录是/storage/emulated/0,这个也是外部存储的根目录 2.Environment.getExternalStoragePublicDirectory(String type) /* 1.如果type为"",那么获取到的目录是外部存储的根目录即 /storage/emulated/0 2.如果type不为空,则会在/storage/emulated/0目录下创建一个以传入的type值为名称的目录,例如你将type设为了test,那么就在外部存储根目录下创建test目录,这个方法和getExternalFilesDir的用法一样。android官方推荐使用以下的type类型,我们在SK卡的根目录下也经常可以看到下面的某些目录。 public static String DIRECTORY_MUSIC = "Music"; public static String DIRECTORY_PODCASTS = "Podcasts"; public static String DIRECTORY_RINGTONES = "Ringtones"; public static String DIRECTORY_ALARMS = "Alarms"; public static String DIRECTORY_NOTIFICATIONS = "Notifications"; public static String DIRECTORY_PICTURES = "Pictures"; public static String DIRECTORY_MOVIES = "Movies"; public static String DIRECTORY_DOWNLOADS = "Download"; public static String DIRECTORY_DCIM = "DCIM"; public static String DIRECTORY_DOCUMENTS = "Documents";*/
要区分外部存储和内部存储,我们最好从逻辑上来理解这两个概念,而不是从物理上。虽然在Android4.4以前,逻辑上和物理上是统一的,但是Android4.4以后,随着外置SD卡的使用越来越少,内部存储和外部存储和物理介质的内外就没有任何关系了。首先通过一个图来说明下外部存储和内部存储与物理存储的关系
外部存储和内部存储的对比如下表所示
- | 内部存储 | 私有存储空间(外部存储) | 共有存储空间(外部存储) |
---|---|---|---|
生命周期 | 和宿主APP生命周期相同 | 和宿主APP生命周期相同 | 和宿主APP生命周期无关 |
宿主APP访问权限 | 直接访问,无需权限 | 直接访问,无需权限 | 需申请EXTERNAL_STORAGE读写权限 |
其它APP访问权限 | 无权限访问 | 7.0以前,通过file://形式的URI访问 7.0以后,通过FileProvider访问 | 需申请EXTERNAL_STORAGE读写权限 |
用户访问权限 | 需要root权限 | 直接访问,无需权限 | 直接访问,无需权限 |
存储数据特点 | 数据比较敏感且应用先关 | 数据不敏感且和应用相关 | 数据不敏感且和应用无关 |
API | getDataDir() getFilesDir() getCacheDir() getDir(name,mode) | getExternalCacheDir() getExternalFilesDir(type) | Environment.getExternalStorageDirectory() Environment.getExternalStoragePublicDirectory(type) |
Android 包含以下访问外部存储中的文件的权限:
READ_EXTERNAL_STORAGE
允许应用访问外部存储设备中的文件
WRITE_EXTERNAL_STORAGE
允许应用在外部存储设备中写入和修改文件。API 19 开始,拥有此权限的应用也会自动获得 READ_EXTERNAL_STORAGE 权限
从 Android 4.4(API 19)开始,在“应用特定的目录(“私有文件目录”)”中读取或写入文件不再需要任何与存储相关的权限
因此,如果您的应用支持 Android 4.3(API 18)及更低版本,并且您只想访问应用特定的目录(“私有文件目录”),则可以添加 maxSdkVersion 属性,声明仅在较低版本的 Android 上请求权限:(这里可能有坑,会和Android6.0权限有点小问题,慎用。)
<manifest>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="18" />
</manifest>
具体说明:
https://www.jianshu.com/p/1cf28923795b
那么在分析外部存储的时候,首先去反编译一下 APK 文件,在AndroidManifest.xml 文件中查看一下是否申请了外部存储相关权限,如下:
如果没有申请相关权限,就可以不用去分析外部存储的文件了。
一般外部存储的目录为 /sdcard/Android/data/app pacakge name,比如 vuls的外部存储目录为/sdcard/Android/data/ddns.android.vuls。其文件分析方法与内部存储一致
我们在进行敏感信息检查的时候,不要只局限于内部存储和外部存储。APK 文件本身包含的敏感信息我们也不应该错过,给出几个例子如下:
很多应用会在此文件中的 meta-data 标签中保存一些秘钥信息或者硬编码密码等等
比如说很多加密秘钥
例如,将应用数据存储在外部存储,也就是SD卡中
如上图,Android APP使用了Environment.getExternalStorageDirectory()
方法将账号密码存放在SD卡的/ddns/account.txt下。这样,任何恶意应用都能读取账号密码,如果能联网的情况下还能造成更大的危害,控制用户设备并把对应的APP外部存储文件下的账号密码发到攻击者在远程的服务器上。应用要访问外部存储,还需要在AndroidManifest.xml文件中声明WRITE_EXTERNAL_STORAGE权限,如下:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
前面说的许多存储例子基本上是Root的,接下来说说如何利用备份功能在未Root的设备上查看应用的内部存储。利用特定应用或设备的备份文件来检查其安全问题
之前写过一篇备份文件利用的文章,此处就不再讲解了,文章地址如下:
[车联网安全自学篇] Android安全之Android中allowBackup属性浅析【案例讲解】
从上面的了解中,大家不难看出敏感信息不应该以明文存储,如果想要安全地存储数据需要花费很大的精力。尽量不要将敏感信息存储到设备上,而应该将它放到服务器上。如果必须选择放到本地设备上的话,一定要在存储数据时使用加密算法,例如使用androidx.security库,帮我们对保存到共享首选项的数据进行加密,下载地址:https://developer.android.com/jetpack/androidx/releases/security
如果想要对SQLite数据库进行加密,可以选择SQLCipher,下载地址:https://www.zetetic.net/sqlcipher/
注:当使用类似AES等对称加密算法时,密钥管理是一个问题。此时,可使用基于密码加密(PBE)的方法,这样的话,密钥就会基于用户输入的密码生成;如果考虑使用散列来加密,那就选择一个强的散列算法并对其加盐
在Linux操作系统上,所有设备都是文件,文件的属性有读、写和可执行(先忽略强制位与冒险位),每个用户一个UID,root用户是超级管理员。没有经过权限许可的话,各个普通用户之间是不能互相操作对方的文件的,这样一来,当root用户给文件设置权限时,就需要说明这个属性是给属主定义的,还是给一组用户定义的,还是给属主和组之外的用户定义的。所以,在chmod这个设置文件属性的命令中,可以看到”chmod 777 filename“这样奇怪的命令,其意义是777三个位置,分别代表属主、组和其他用户,7这个数,本身又代表1+2+4,1代表可执行权限,2代表写权限,4代表读权限。所以这个命令是指将这个文件让所有用户拥有该文件的所有权限
Android作为Linux操作系统,其权限机制,其实是对Linux权限机制的封装和扩展。由于Linux的硬件就是文件,所以Android上面的对于操作硬件和读写文件的能力,都可以归结为对文件的权限。为了让每个进程都拥有自己独立的权限,Android让每个java程序都拥有一个java虚拟机,又让每个java虚拟机进程属于单独一个用户,这样进程之间就独立了,一个不拥有root权限的进程,是不可能访问其他进程的文件和资源的。所有,大部分Android提供的权限,其实都是使用文件权限来实现的。所以,在Android的框架代码中,根本看不到权限的检查代码,我之前找到了activityManagerService和PackageManagerService,以为其中的checkPermission就是权限检查的代码,但跟踪之后才发现,那些只不过是对外提供的SDK接口,并不被使用在Android程序执行时的权限检查,会在每次执行API的时候都去检查权限
由上所知,Andorid 是基于 Linux 开发的,在用户管理方面继承了 Linux 的部分特性,但是也有很大的不同。在传统的 Linux 中,很多个应用可能都是由一个 UID 运行的,但是在 Android 中,每个 APP 都以独立的用户身份运行在独立的沙盒中。系统会为每个 APP 创建单独的 UID 和 GID。APP在单独的进程中运行,并且只能够访问自己的资源
如下图,APP数据目录只属于自己的用户和组,并且无权访问其他应用的数据:
adb shell ls -al /data/data
通常 APP 会被分配 10000 到 99999 范围内的 uid 和 gid,并根据一定的规则映射为用户名和组名(uid 0是root用户,系统用户在不同版本Linux中的UID范围:UID:1~499或UID:1~999
,普通用户在不同版本Linux中的UID范围:UID:500-65535或UID:1000-65535)
比如下图中,uid 和 gid 都是 10057,而用户名和组名就为 u0_a40
现在同学们,应该也知悉了APP 要使用特定的功能,一般需要申请相应的权限,例如要读写sd卡的话就肯定会去申请 sd卡读写权限。如上图系统就会在 groups 里面添加对应权限的gid,这样应用就具有了 sdcard_rw 组的权限了,即拥有了 sd卡读写权限
查看ContentWrapper类的getSharedPreferences方法、Content类的getSharedPreferences方法、Activity类的getPreferences方法和PreferenceManager类的setSharedPreferencesMode方法中对文件权限的设置,如果设置了MODE_WORLD_READABLE或者MODE_WORLD_WRITEABLE,则该文件就可以被第三方应用所访问,就认为有该风险
MODE_WORLD_WRITEABLE
和MODE_WORLD_READABLE
模式创建进程间通信的文件,此处即为Shared Preferences"android:sharedUserId"
属性,建议不要在使用"android:sharedUserId"
属性的同时,对应用使用测试签名,否则其他应用拥有"android:sharedUserId"
属性值和测试签名时,将会访问到内部存储文件数据为了便于开发调试, Android 提供了用于日志打印输出的工具类: android.util.Log。日志输出分为不同的等级:VERBOSE(全部信息)、DEBUG(调试信息)、INFO(一般信息)、WARN(警告信息)、ERROR(错误信息)、ASSERT(断言信息)。对应的方法如下:
Log类方法 | 级别 | 作用 |
---|---|---|
v(tag, message) | VERBOSE | 显示全部信息 |
d(tag, message) | DEBUG | 显示调试信息 |
i(tag, message) | INFO | 显示一般信息 |
w(tag, message) | WARN | 显示警告信息 |
e(tag, message) | ERROR | 显示错误信息 |
println(Log.ASSERT, tag, message) | ASSERT | 显示断言信息 |
以上的日志级别从上到下依次升高,例如,当查看日志时,DEBUG 级别会输出 VERBOSE 级别的信息,而 VERBOSE 级别不会输出 DEBUG 级别的信息
优先级排序如下:
开发者在开发应用时,通常都会埋点(插入日志)进行调试,若没有对日志信息进行管理那么就有可能导致敏感信息泄露的风险,给攻击者提供了便利。例如:通信交互的日志,造成的网络数据安全,服务器安全;用户个人信息日志,造成账号和密码的泄露;其它关键日志信息,给攻击者提供攻击的便利。
检查日志敏感信息的方式分为两种:
从上面的两种方式来讲,使用静态方式加校验可以把java层的日志信息很好的去掉,但是对于native层(JNI)就显的无力,那么可以考虑使用动静结合方式进行综合的评测。
检测方法:
日志查看的方法:
Logcat 是一个命令行工具,用于转储系统消息日志,包括设备抛出错误时的堆栈轨迹,以及从您的应用使用 Log 类写入的消息。使用 Android Studio 自带的 Logcat 工具来查看日志在 Android Studio 中查看,或从DDMS Logcat 窗口查看日志消息,以及使用adb logcat
命令查看
通常开发人员在使用日志的过程中,常常在打包 release 版本的 apk 文件时,忘记关掉相关日志代码,此时就会造成安全隐患
对日志进行统一管理,当上架应用市场时,应生成一个无敏感日志信息的安装包
要读取系统日志,需要申请 android.permission.READ_LOGS
权限。该权限在 Android 4.1 版本之前是所有应用都可以申请的,代表普通应用能够轻易获取任意应用的日志信息。在 Android 4.1 版本之后,只有系统应用才能够申请这个权限,也就是说虽然普通应用无法获取其他应用日志信息,但是在 Root的手机上,恶意应用还是可以获取其他应用日志信息的。
其实更多的场景中,日志信息可能会泄漏整个应用的逻辑,为逆向分析提供便利。一种常见的日志使用错误就是配置日志开关,在 release 版本中,只是把开关关掉。但是虽然开发人员把日志打印的代码关闭了,在正常情况下不会打印输出日志,可是所有日志记录的代码还都在,攻击者只要反编译APK重新打包 apk 或者找到对应调用日志的smali代码hook 一下将日志开关打开,就可以再次看到日志输出。
当开发人员在做APP开发时,需要设置调试开关打印Log,下面列举出3种方法:
public static final boolean DEBUG = true;//false
public static final boolean DEBUG = BuildConfig.DEBUG;
public static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
当然这里可能还有很多别的设置方法,由于个人局限就没有写了。下面我们来看看上面3种方法各自的特点:
从三种方法的特点来看,第一种和第二种方法至少需要编译两个版本的软件用于发布和调试,第三种方法只需要编译一个版本既可以,在需要查看Log的时候,通过设置property即可查看Log
例如:以下代码,只要修改getDecideResult()
的返回值为 true 即可
//各个Log级别定义的值,级别越高值越大
public static final int VERBOSE = 2;
public static final int DEBUG = 3;
public static final int INFO = 4;
public static final int WARN = 5;
public static final int ERROR = 6;
public static final int ASSERT = 7;
private static final String TAG = “testDemo”;
private boolean VERBOSE = Log.isLoggable(TAG, Log.VERBOSE);
private boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
private boolean INFO = Log.isLoggable(TAG, Log.INFO);
private boolean WARN = Log.isLoggable(TAG, Log.WARN);
private boolean ERROR = Log.isLoggable(TAG, Log.ERROR);
private boolean ASSERT = Log.isLoggable(TAG, Log.ASSERT);
private boolean SUPPRESS = Log.isLoggable(TAG, -1);
adb shell setprop log.tag.testDemo D
public void onCreate(){
if (DEBUG){
Log.d(TAG, "onCreate WWW");
}
}
如上,当log.tag.testDeme值为D时,DEBUG在为true,Log.d()里的内容才能正常输出
这里以一个实际案例,如下,我们把isLoggable设置为-1时,属性设置为DEBUG时不会输出任何日志,如下:
当APP使用Log.isLoggable并定义为全局变量时,我们可以setprop后重启app打印相关的Log。那frameworks中如果有Log.isLoggable要怎么打印呢?只需要执行下面3步即可:
adb shell setprop log.tag.<TAG> D
adb shell stop
adb shell start
adb shell stop会杀掉zygote进程以及所有由zygote孵化而来的子进程。adb shell start则会重启zygote进程,再由zygote进程启动其它Android核心进程。当zygote重新启动时,会重新加载framework相关资源,而此时属性已经设置。
通过adb shell setprop
设置相应的级别和代码中Log.isLoggable设置的级别比较,当Log.isLoggable设置的级别大于或等于setprop设置的级别时,Log开关即打开,就可以打印Log了。同时,我们设置的S级别的Log,怎么样都不会打印Log。我们没有setprop任何Log级别时,默认打印的是设置Info级别的Log,从这里我们也可以知道,在实际代码Log开关定义中,最好设置成DEBUG级别,这样就可以通过setprop来设置是否需要打印Log
如下设置为DEBUG时的输出,如下:
代码给同学们附上,如下:
package com.example.testpoc4; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import android.util.Log; import android.widget.TextView; public class MainActivity extends AppCompatActivity { public static final String TAG = "Main"; public static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (DEBUG){ Log.d(TAG, "onCreate Orangey Love5"); } } }
可以通过在 ProGuard 中配置规则来删除对应的日志代码,例如vuls漏洞应用的代码中使用了 Log.* 和 System.out.println 记录了日志,如下:
可以通过添加以下 ProGuard 的规则来删除相应的代码,如下:
-assumenosideeffects class android.util.Log{
public static int v(...);
public static int i(...);
public static int w(...);
public static int d(...);
public static int e(...);
public static int println(...);
}
-assumenosideeffects class java.io.PrintStream {
public *** println(...);
public *** print(...);
}
此时,反编译后对应的log日志代码等都不再显示,如下:
TextView 是很常见安卓控件,如果不恰当使用也会造成一些安全风险。比如说以下界面,有几个安全隐患:
首先,密码框是明文显示的,容易被窥屏或者截屏获取敏感信息。可通过在 EditText 标签中指定 android:inputType 为 textPassword,来让密码进行掩码显示
不过在 EditText 标签中指定 android:inputType 为 textPassword,来让密码进行掩码显示并不能抵御截屏获取敏感信息。在 Android 5.0之前,要实现截屏需要有 root 权限,但Android 5.0 之后,Google 开放了录屏 API,无需 root 权限就能够截屏了。比如说以下图片中依然可以看到输入的密码明文,只是输入后变成了掩码,但只要能录屏,一样存在风险,如下图:
可以在承载的 Activity 中,加入以下代码来防止截屏获取敏感信息,如下:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE);
setContentView(R.layout.activity_sp);
etUsername = findViewById(R.id.et_sp_username);
etPassword = findViewById(R.id.et_sp_password);
}
为了验证效果,我们做以下实验来验证:
adb shell screencap -p /sdcard/1.png
进行截屏。adb shell screencap -p /sdcard/1.png
adb shell screencap -p /sdcard/2.png
进行截屏adb shell screencap -p /sdcard/2.png
看一下截图文件,发现 1.png 大小正常而 2.png 大小为 0 字节,说明成功阻止了屏幕截图的攻击,如下图:
在 Android 中,由于 Android 操作系统的规范或 Android 操作系统提供的功能,应用程序实现难以保证安全。如果这些功能被恶意第三方滥用或被用户随意使用,就有导致信息泄露等安全问题的风险
复制和粘贴是用户经常随意使用的功能。例如,许多用户使用这些功能将邮件或网页中需要记住的奇怪信息或重要信息存储在记事本中,或者从存储密码以防忘记的记事本中复制或粘贴密码。这些操作看起来非常非常正常,但实际上可能存在用户处理信息被盗的潜在风险。
此风险与 Android 系统中的复制和粘贴机制有关。用户或应用程序复制的信息会存储在名为剪贴板的缓冲区中。当用户或应用程序粘贴时,剪贴板中存储的信息将分发到其他应用程序。因此,此剪贴板功能存在导致信息泄露的风险。这是因为剪贴板实体在系统中仅有一个,任何应用程序都可以使用 ClipboardManager 随时获取剪贴板中存储的信息。这意味着用户复制/剪切的所有信息都有可能泄露给恶意应用程
在文本框中经常会使用到复制粘贴的功能,这个时候数据是保存在剪切板中的。而剪切板是安卓系统提供的功能,所有的应用都可以访问,并且无需特殊权限申请。如果在剪切板中存储了敏感信息,就存在泄漏的风险。比如以下的应用通过监控剪切板内容,成功获取了身份证号码
演示如下:
代码如下:
package com.example.testpoc4; import androidx.appcompat.app.AppCompatActivity; import android.content.ClipData; import android.content.ClipboardManager; import android.os.Bundle; import android.view.View; import android.widget.Button; import android.widget.Toast; public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 获取控件id Button button = findViewById(R.id.button); // 监听点击事件 button.setOnClickListener(new View.OnClickListener() { @Override// 获取剪贴板内容并在打印出来 public void onClick(View v) { ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); ClipData data = cm.getPrimaryClip(); ClipData.Item item = data.getItemAt(0); String content = item.getText().toString(); Toast.makeText(MainActivity.this,content,Toast.LENGTH_SHORT).show(); } }); } }
可以采用以下方法来禁用剪切板相关功能:
如果视图在应用程序中显示敏感信息,并且允许在视图(例如 EditText)中复制/剪切信息,则信息可能通过剪贴板泄露。因此,必须在显示敏感信息的视图中禁用复制/剪切
通过 TextView.setCustomSelectionActionMODECallback() 方法,可以自定义选择字符串时的菜单。通过使用此功能,如果在选择字符串时可以从菜单中删除复制/剪切项目,用户将无法再复制/剪切字符串。
大部分中文应用弹出的默认键盘是简体中文输入法键盘,在输入用户名和密码的时候,如果使用简体中文输入法键盘,输入英文字符和数字字符的用户名和密码时,会自动启动系统输入法自动更正提示,然后用户的输入记录会被缓存下来
系统键盘缓存最方便拿到的就是利用系统输入法自动更正的字符串输入记录。 缓存文件的地址应该是下面的这个路径:
导出该缓存文件,查看内容,一切输入记录都是明文存储的。因为系统不会把所有的用户输入记录都当作密码等敏感信息来处理。 一般情况下,一个常规用户高频率出现的字符串就是用户名和密码
所以,一般银行客户端app输入密码时都不使用系统键盘,而使用自己定制的键盘,主要原因如下:
在文本框输入的内容,有时候会被输入法缓存,以提升用户体验。但是在涉及到敏感信息输入框的时候,就可能会造成信息泄漏。可以指定 android:inputType 为 textNoSuggestions 来缓解该风险,如下:
注:上述涉及到的风险点通常需要根据应用的类型进行判断,例如如银行类 APP可能需要规避本文中的风险,而工具类 APP 则无需考虑大部分本文的风险点
参考链接:
https://blog.csdn.net/u010889616/article/details/80961161
https://www.jianshu.com/p/a39bc4b3a1a6
https://blog.csdn.net/qq_25518029/article/details/120033975
https://cloud.tencent.com/developer/article/1146619
https://blog.csdn.net/qq_35993502/article/details/119561898
https://www.jssec.org/dl/android_securecoding_cn/6_difficult_problems.html
https://mp.weixin.qq.com/s/bWjm9RRcKCuoo0NMOHC9NA
https://mp.weixin.qq.com/s/NwPjJyn0ZaCQAK3ue69wfg
https://mp.weixin.qq.com/s/QjP9Rk_ZKFjQs8dPXshnRA
https://mp.weixin.qq.com/s/f8tIbMGG6JsutcpT47k2dw
你以为你有很多路可以选择,其实你只有一条路可以走
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。