Android用户字典侧信道信息泄露漏洞(CVE-2018-9375)

阅读量    263608 |

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

概述

前一段时间,我负责对Android智能手机进行审计,所有已安装的应用都在审计的范围之中。在要求的时间内,我决定对尽可能多的代码进行人工检查。正是在这次检查之中,我发现了一个漏洞,该漏洞在较新版本的Android系统上存在,允许我们与本应受到保护的内容提供者(Content Provider)——个人字典进行交互,“个人字典”中保存了用户在输入过程中自定义的词语拼写。
理论上,用户“个人字典”只应该允许特权账户访问,包括输入法编辑器(IME)和拼写检查程序。但事实上,我发现存在一种途径能够绕过限制,使恶意应用程序能够更新/删除个人字典中的内容,甚至还能够检索字典中的全部内容,这一过程无需任何权限,可以在用户不知情的情况下完成。
该漏洞被评定为中危漏洞,归类为特权提升类,并在2018年6月完成修复,漏洞影响6.0、6.0.1、7.0、7.1.1、7.1.2、8.0和8.1版本的Android系统。

 

用户的个人字典

Android中提供了一个“自定义字典”,可以由用户手动定义,也可以从用户的输入过程中自动学习。通常,用户可以从“设置 – 语言和键盘 – 个人字典”中访问该字典。考虑到该字典可以根据用户的输入而自动生成,那么其中很有可能包含用户的敏感信息,包括姓名、地址、电话号码、电子邮件、密码、企业名称、特殊关键词(例如感染疾病名称、服用药品名称、专业术语等),甚至还有可能包括信用卡号码。

用户还可以为每个单词或句子定义“快捷输入方式”,举例来说,在定义后,用户可以直接输入快捷方式名称(例如“myhome”),输入法就能自动识别并将其替换为完整的家庭地址。

在内部,这些单词都保存在SQLite数据库中,除“android_metadata”之外,该数据库只包含一个名为“words”的表,该表的结构共包含6个字段:

_id (INTEGER, PRIMARY KEY)
word (TEXT)
frequency (INTEGER)
locale (TEXT)
appid (INTEGER)
shortcut (TEXT)

我们主要进行研究的对象就是其中的“word”字段,因为顾名思义,这一字段中包含了所有的自定义单词。但除此之外,其他的所有字段和表也都可以利用同样的方式访问。

 

漏洞的技术细节

在以前版本的Android中,对个人字典的读写访问权限受到以下权限保护:

android.permission.READ_USER_DICTIONARY
android.permission.WRITE_USER_DICTIONARY

然而,在新版本中,原有的保护机制将不再适用。根据官方文档中的说明,从API 23版本开始,用户字典只能通过IME和拼写检查程序访问。因此理论上,只有特权用户(例如root和system)的IME和拼写检查程序才能访问作为内容提供者的个人字典(content://user_dictionary/words)。
我们可以检查AOSP代码库,重点分析修改后的版本中引入的名为canCallerAccessUserDictionary的新私有函数。此外,我们还关注从UserDictionary内容提供者进行调用的过程,以弄明白如何实现标准的查询、插入、更新和删除操作,以及如何防止未经授权的调用。
根据我们的分析,授权检查能够有效地对查询和插入操作进行校验,但针对更新和删除操作则没有及时地进行校验,从而引入了一个安全漏洞。借助该漏洞,任意应用程序都能够通过公开的内容提供者调用受影响的函数,从而绕过不及时的授权检察。
在UserDictionaryProvider class3的以下代码中,我们可以发现其中存在的问题,并且能够看到授权检查是在数据库已被更改后进行的:

@Override

public int delete(Uri uri, String where, String[] whereArgs) {
   SQLiteDatabase db = mOpenHelper.getWritableDatabase();
   int count;
   switch (sUriMatcher.match(uri)) {
      case WORDS:
          count = db.delete(USERDICT_TABLE_NAME, where, whereArgs);
          break;

      case WORD_ID:
          String wordId = uri.getPathSegments().get(1);
          count = db.delete(USERDICT_TABLE_NAME, Words._ID + "=" + wordId
               + (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""), whereArgs);
          break;

       default:
          throw new IllegalArgumentException("Unknown URI " + uri);
   }

   // Only the enabled IMEs and spell checkers can access this provider.
   if (!canCallerAccessUserDictionary()) {
       return 0;
   }

   getContext().getContentResolver().notifyChange(uri, null);
   mBackupManager.dataChanged();
   return count;
}


@Override

public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
   SQLiteDatabase db = mOpenHelper.getWritableDatabase();
   int count;
   switch (sUriMatcher.match(uri)) {
      case WORDS:
         count = db.update(USERDICT_TABLE_NAME, values, where, whereArgs);
         break;

      case WORD_ID:
         String wordId = uri.getPathSegments().get(1);
         count = db.update(USERDICT_TABLE_NAME, values, Words._ID + "=" + wordId
+ (!TextUtils.isEmpty(where) ? " AND (" + where + ')' : ""), whereArgs);
         break;

      default:
         throw new IllegalArgumentException("Unknown URI " + uri);
   }

   // Only the enabled IMEs and spell checkers can access this provider.
   if (!canCallerAccessUserDictionary()) {
      return 0;
   }

   getContext().getContentResolver().notifyChange(uri, null);
   mBackupManager.dataChanged();
   return count;
}

此外,AndroidManifest.xml文件也仅针对存在显式输出的内容提供者提供额外的保护(例如内容过滤器和授权检查),而“个人字典”则不在此列:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
       package="com.android.providers.userdictionary"
       android:sharedUserId="android.uid.shared">

   <application android:process="android.process.acore"
       android:label="@string/app_label"
       android:allowClearUserData="false"
       android:backupAgent="DictionaryBackupAgent"
       android:killAfterRestore="false"
       android:usesCleartextTraffic="false"
       >

       <provider android:name="UserDictionaryProvider"
          android:authorities="user_dictionary"
          android:syncable="false"
          android:multiprocess="false"
          android:exported="true" />

   </application>
</manifest>

正因如此,攻击者就可以轻松借助任何恶意的应用程序来调用以下代码,从而在不请求任何权限的情况下更新用户个人字典中的内容:

ContentValues values = new ContentValues();
values.put(UserDictionary.Words.WORD, "IOActive");

getContentResolver().update(UserDictionary.Words.CONTENT_URI, values,
        null, null);

同样,删除个人字典中的任何内容(甚至是全部个人字典)也非常轻松:

getContentResolver().delete(UserDictionary.Words.CONTENT_URI, null, null);

理论上,在执行更新和删除这两类方法之后,都应该返回受影响字段的数量。而实际中,如果进行了非法调用,其返回值将始终为0。这样一来,攻击者会难以根据内容提供者的信息来判断其中的内容。
到目前为止,我们已经知道,攻击者有能力对个人字典进行更新和删除操作。但一些攻击者可能不止局限于此,他们还希望能够访问个人数据。
尽管查询函数的校验机制不存在上述漏洞,但攻击者仍然可以利用基于时间的侧信道攻击方式,来转储其中的全部内容。由于where参数可以完全被攻击者控制,并且成功执行更新操作的语句与不执行任何操作的同一语句相比用时更长,所以这一攻击过程被证明是有效的。

 

简要概念证明

恶意应用程序可以在本地运行以下代码片段:

ContentValues values = new ContentValues();
values.put(UserDictionary.Words._ID, 1);

long t0 = System.nanoTime();
for (int i=0; i<200; i++) {
    getContentResolver().update(UserDictionary.Words.CONTENT_URI, values,
                    "_id = 1 AND word LIKE 'a%'", null);
}
long t1 = System.nanoTime();

在使用相同语句,进行足够多次调用(例如200次,这一数值取决于设备性能)之后,结果为“True”的SQL条件和结果为“False”的SQL条件将具有明显的时差(t1-t0),这样一来,攻击者就可以通过利用基于时间的SQL布尔盲注攻击,提取到受影响数据库中的所有信息。
因此,如果字典中第一个用户定义的单词以“a”字母开头,那么这一代码片段的执行所需时间将会更长(例如需要5秒),因此该SQL条件将被评估为“True”;假如字典中第一个用户定义的单词不以“a”字母开头,那么由于不会执行更新操作,这一代码片段的执行所需时间将会更短(例如需要2秒),因此该SQL条件将被评估为“False”。如果猜测错误,我们可以继续尝试“b”、“c”等字母;如果猜测正确,那我们就知道了单词的第一个字符,可以继续采用相同方式猜测出第二个字符。以此类推,最终攻击者能够转储整个字典,或者是其中任何可筛选的字段子集。
要避免更改数据库中的内容,我们可以更新检索到的单词“__id”字段,从而匹配其原始值,相关语句如下所示:

UPDATE words SET _id=N WHERE _id=N AND (condition)

如果条件为真,标识符为“N”的相关行都将以不实际更改其标识符的方式进行更新,它将被设置为其原始值,而行将保持不变。这是一种利用执行时间作为侧信道从而提取数据的非入侵式方法。
我们可以使用任何子选择语句来替换上面的条件,因此可以查询SQLite中支持的任何SQL表达式,例如:
字典中是否包含“something”这个特定词?
检索所有16个字符的单词(信用卡号经常为16位)
检索具有快捷方式的所有单词(用户经常会为地址创建快捷方式)
检索包含“点”的所有单词(例如网址、邮箱地址等)

 

实际漏洞利用

上述过程在进行优化后,完全可以自动化执行。因此,我开发了一个简单的Android应用程序,来证明该漏洞可以利用,并测试其有效性。
我们之前的概念验证(PoC)应用程序是基于这样的假设:攻击者可以通过内容提供者,盲目地对UserDictionary数据库中的任意行执行更新操作。如果UPDATE语句影响了其中的一行或多行,那么该操作的执行将会花费更多的时间。根据这一时间,攻击者就可以推断出SQL条件是否大概率为真。
但是,在这里,攻击者没有任何关于其内容的信息(甚至都没有内部标识符的值),所以只能遍历所有可能的标识符值。我们将从标识符值最小的那一行开始,逐一猜测其“frequency”字段的值,可以使用多种有效的方法来完成这一步骤。
由于多个共享的进程会同时在Android中运行,因此执行相同调用的总用时将受到多个因素的影响。然而,从统计角度来看,如果重复相同的调用,大量的重复过程应该会让我们得到一个非常平均的数值,这也就是要调整每个设备和迭代次数的原因。
在之前,我尝试的是一种比较复杂的方法,但其实还存在一种更为简单的方法来获得准确可靠地结构。只需要重复始终评估为“True”(例如WHERE 1 = 1)或“False”(例如WHERE 1 = 0)的相同数量的请求,并将平均时间作为区分二者的判断阈值,就可以实现区分。如果实际时间大于阈值,则为“True”,反之则为“False”。在这里,K.I.S.S.原则能够很好地应用,并且被证明确实有效。

在我们有方法区分正确和错误的假设之后,转储整个数据库就变得迎刃而解了。尽管在上一节中提出的示例很容易理解,但这并不是提取信息的最有效方法。在我们的PoC中,我们将使用二进制搜索算法替代任何数字查询,具体方法如下:
确定表的行数(可选)
SELECT COUNT(*) FROM words
确定最小标识符
SELECT MIN(_id) FROM words
确定具有该标识符的单词的字符数
SELECT length(word) FROM words WHERE _id=N
以迭代的方式,逐字符进行提取
SELECT unicode(substr(word, i, 1)) FROM words WHERE _id=N
确定最大标识符,该标识符大于我们获得的标识符,并且是重复的
SELECT MIN(_id) FROM words WHERE _id > N
请注意,我们无法直接检索任何数值或字符串值,因此就需要将这些表达式转换成一组布尔查询,并根据其执行时间将其评估为True或False。这就是二进制搜索算法的工作原理,我们不是直接查询数字,而是去查询它是否大于X。在每次迭代中调整X的值,直到我们在log(n)次查询后找到正确的值。举例来说,如果要检索的当前值是97,那么我们对算法执行跟踪,如下所示:

 

PoC工具

上述过程已经在我们的PoC工具中实现,如下所示。大家可以从我们的GitHub中找到此PoC的源代码和已经编译过的APK: https://github.com/IOActive/AOSP-ExploitUserDictionary
接下来,我们来看看该工具的用户界面和特性。

应用程序所做的第一件事就是尝试直接访问个人字典的内容提供者,并查询条目数。在正常情况下(不以root等身份运行),我们是不应该具有访问权限的。
如前文所述,只需要调整两个参数:
初始迭代次数:重复同一次调用的次数;
最小时间阈值(以毫秒为单位):将被视为最低可接受值的时间。
尽管当前版本的工具会自动调整这两个值,但在最开始的阶段,一切都要手动进行。
从理论上说,这些值越大,我们获得的准确度就越高,但提取速度也会相对慢一些;这些值越小,运行的速度将会越快,但结果可能就不是那么准确。正因如此,我们设置的默认迭代次数为10次,默认最小时间阈值为200毫秒。
在按下START按钮后,应用程序将开始自动调整参数。首先,它会运行一些查询并丢弃结果。因为最初的查询准确率较低,没有一定的代表性。随后,它将执行初始的迭代次数,并估算相应的阈值。如果获得的阈值高于我们配置的最小值,那么将会连续运行20次查询来测试其准确度,测试过程交替使用真假语句。如果准确度不够高(只允许出现1次错误),那么工具将会增加迭代次数,并重复该过程,直至调整为合理的参数。
在进程启动后,一些控件将会被禁用,我们能够在下面的日志窗口中看到详细的输出(也可以通过logcat),包括当前行的标识符、所有SQL子查询、总时间和评估的准确性。如果检索到任何字符,会立即出现在上面一行。

最后,右侧的“UPD”和“DEL”按钮负责实现对内容提供者的直接调用,以执行UPDATE操作和DELETE操作。它们被限制为仅能影响以“123”开头的单词,目的是为了避免意外删除任何个人字典。为了进行测试,我们需要手动添加这一条目。

 

演示

在下面视频中,我们使用了真实设备对这一工具进行了演示:
https://youtu.be/8i-oMcaJw40

 

其他需要考虑的因素

由于理论和实践之间会存在差距,所以我想在这里分享一下我在设计和开发这个PoC过程中所遇到的一些问题。首先,这个工具是一个快速编写而成的PoC,主要目的是证明这一漏洞的确实存在,因此该PoC具有一些限制,并且其中的代码没有遵循良好的编程原则。
在初始阶段,我并没有关注UI涉及,所有内容都被转储到Android日志中作为输出。当我决定在GUI中显示结果时,我必须在单独的线程中运行所有代码,以避免阻塞UI线程(这可能会导致应用程序被系统认为无响应而被强行结束)。在进行这一个简单的更改之后,其准确性大幅降低,原因在于该线程的优先级不高。因此,我将其设置为“-20”,这是系统允许的最大优先级,在设置完成后一切恢复正常。
如果从单独的线程更新UI,可能会导致崩溃。因此,为了显示日志消息,我必须使用runOnUiThread调用来对其进行调用。在真正的漏洞利用中,其实根本不需要UI。
如果个人字典为空,我们就不能强制任何行进行更新,因此所有查询的耗时将非常相似。在这种情况下,由于没有任何内容可以被提取,同时工具也不能调整参数,因此最终工具会停止运行。在一些特殊情况下,我们即使使用空数据库也可以随机进行校准,这时将会尝试提取垃圾数据或伪随机数据。
在一般的智能手机中,操作系统会在一段时间后进入睡眠模式,而在睡眠模式工具的性能会大幅降低,导致执行时间超过预期值,因此所有调用都会被评估为“True”。针对这一问题,我选择了一个比较简单的解决方案:保持屏幕开启,并通过电源管理器唤醒设备,以防止操作系统暂停应用程序。但我所发布的版本没有加入这一特性,只在这里特别提出。
同样,旋转屏幕也会造成一些问题,因此我强制该应用程序始终是横屏模式,避免发生自动旋转,同时横屏也有助于尽可能在一行中显示完整的信息。
在按下“START”按钮后,某些控件将会永久禁用。如果需要重新调整参数或者多次运行,那么需要将其关闭并重新打开。
并行的一些外部事件和执行(例如:同步电子邮件、接收推送消息)可能会干扰应用程序的行为,从而导致不准确的结果。如果发生了这种情况,需要在不被干扰的状态下再次尝试,例如可以禁用网络,或者关闭所有其他程序。
UI不支持国际化,该工具没有为提取Unicode中的单词而专门设计。同时,该工具故意设定成只能提取前5个单词并按照其内部标识符排序,以避免被用于非法用途。

 

漏洞修复

从源代码的角度来看,漏洞修复方法非常简单。只需要将负责检查权限的调用移动到受影响函数的开头就可以解决问题。除了提供漏洞信息之外,我们还向Google提供了一个补丁文件,其中包含建议的修复程序: https://android.googlesource.com/platform/packages/providers/UserDictionaryProvider/+/cccf7d5c98fc81ff4483f921fb4ebfa974add9c6
由于该漏洞已经被官方修复,因此作为最终用户,大家必须确保当前安装的安全补丁中包含CVE-2018-9375的补丁。例如,在Google Pixel/Nexus中,该补丁是在2018年6月发布的:https://source.android.com/security/bulletin/pixel/2018-06-01
如果由于任何原因,用户无法对设备进行更新,那么就应该检查个人字典中的内容,并确保其中不包含任何敏感信息。

 

总结

软件开发是一个复杂的过程,一个微小的错位就会导致漏洞的出现。这一漏洞的成因原本是为了提高个人字典的安全性,使其不易被访问,但却因为一个疏漏获得了截然相反的结果,并且近3年来都没有被注意到。
要发现这类漏洞,我们需要阅读并理解源代码,只需要遵循执行流程认真阅读。自动化测试可能会有助于在早期阶段发现此类问题,但这一过程通常并不是那么易于实现和维护。
此外,我们在这个过程中,还学会了如何从一个漏洞中获得最大的效益,这个漏洞原本只允许我们盲目地破坏或篡改数据,但我们利用了侧信道攻击方式,最终获取到了字典中全部的内容。
作为白帽,应该始终跳出固有的思维模式考虑问题,并且牢记:时间是最宝贵的资源之一,每一纳秒都很重要。

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多