CVE-2021-39798 Bitmap_createFromParcel反序列化漏洞分析


漏洞简介

Android Bitmap 背景知识

  • Bitmap就是”位图“的概念,是图片在内存中的表示形式,这种格式并不是JPEG、PNG这类编码格式,这种格式通过直接描述每个像素的信息来表示图片,同时也是一种为未压缩的格式。可以将Bitmap转换为各种编码格式,有的格式是有损转换而有的格式是无损的。
  • 在Android系统中,如果两个进程要分享一张图片,首选的是采用通过Binder IPC传递Bitmap的方法。当然也可以将图片以JPEG等格式保存到磁盘上,再通过IPC将文件名称发送给对方,不过直接使用内存来传递Bitmap明显比保存文件这种需要磁盘I/O的方法更具有效率。同时,在Android系统中负责屏幕缓冲区管理的surfaceflinger,在和App通信时也不可能使用保存图片的方式,所以必须采用将bitmap通过Binder IPC传递的方法。
  • 在Android 4.0之前,Android的设计是非常简单的,只能通过Binder IPC直接传递Bitmap,也就是说只能将Bitmap写入Parcel缓冲区,然后再通过内核转发给其他进程,由于每个进程的Binder缓冲区存在大小的限制(一般只有几MB大小),所以早于Android 4.0的系统中,传递大于Binder缓冲区大小的Bitmap会出现Out Of Memory错误而导致无法传递。
  • 为了解决Binder IPC传递Bitmap的效率问题,Android在4.0版本引入了通过ashmem的方式传递Bitmap的方法,也就是说利用匿名共享内存,直接把Bitmap写入这块共享内存中,然后再将fd发送给其他进程,这样一来就无需通过内核转发这块内存,也不会受Binder缓冲区大小的限制了。当然Android只会在Bitmap大于一定大小(IN_PLACE_BLOB_LIMIT)的时候才使用ashmem传递,如果是小Bitmap的话依旧使用Binder缓冲区直接进行传输,这种方式称为Blob传输。

漏洞分析

  • 漏洞位于在Bitmap_createFromParcel中,是一个典型的从Parcel对象创建一个Bitmap的函数
static jobject Bitmap_createFromParcel(JNIEnv* env, jobject, jobject parcel) {
#ifdef __ANDROID__ // Layoutlib does not support parcel
    if (parcel == NULL) {
        jniThrowNullPointerException(env, "parcel cannot be null");
        return NULL;
    }
    ScopedParcel p(env, parcel);
    //...
    const int32_t width = p.readInt32();  // [1]
    const int32_t height = p.readInt32();
    const int32_t rowBytes = p.readInt32();
    const int32_t density = p.readInt32();
    //...
    auto imageInfo = SkImageInfo::Make(width, height, colorType, alphaType, colorSpace);
    size_t allocationSize = 0;
    //...
    if (!Bitmap::computeAllocationSize(rowBytes, height, &allocationSize)) {  // [2]
        jniThrowExceptionFmt(env, RuntimeException,
                             "Received bad bitmap size: width=%d, height=%d, rowBytes=%d", width,
                             height, rowBytes);
        return NULL;
    }
    sk_sp<Bitmap> nativeBitmap;
    binder_status_t error = readBlob(  // [3]
            p.get(),
            // In place callback
            [&](std::unique_ptr<int8_t[]> buffer, int32_t size) {
                nativeBitmap = Bitmap::allocateHeapBitmap(allocationSize, imageInfo, rowBytes); // [4]
                if (nativeBitmap) {
                    memcpy(nativeBitmap->pixels(), buffer.get(), size);  // [5]
                }
            },
            // Ashmem callback
            [&](android::base::unique_fd fd, int32_t size) {
               //...
            });
    //...
}
  • 在[1]处,从Parcel读取了width,height,rowBytes,density等数值,这些数值是发送者可控的
  • 在[2]处会计算分配空间的大小,实际上就是rowBytes * height,这个函数里面做了完善的整数溢出检查,不存在问题。
  • 计算好了allocationSize之后会在[3]处调用readBlob去读取Parcel中的blob数据,并且返回读取到的缓冲区指针buffer和读取的大小size。接下来在[4]处allocateHeapBitmap会根据allocationSize来分配存储Bitmap的堆空间,然后在[5]处将buffer中的内容全数拷贝到nativeBitmap→pixels(),也就是这块堆空间中。
  • 这里面需要解释一下这个回调函数中size大小的来源:可以看到,回调函数中的size实际上来源于data结构体中的size成员,而这个data.size则来源于AParcel_readByteArray回调中的length,查看AParcel_readByteArray函数的实现可以知道,length实际上是总size除以单元大小得到的,这里面的单元是int8——他,也就是1个字节,所以length就相当于总长度。这里面AParcel_readByteArray并不会关心Bitmap那边分配的allocationSize的大小,而是完全按照通用Parcel读取的逻辑去实现的。
  • 所以这里面存在的漏洞是,由于allocationSize是根据用户的输入计算出的,同时我们还可以控制blob的数据大小,如果我们指定一个较小的rowBytes和height,也就是计算出一个较小的allocationSize,但是放入很大的blob数据,使得AParcel_readByteArray识别出来的size大于allocationSize的话,则会造成堆缓冲区溢出,这里面看起来溢出的长度(size – allocationSize)和写入的内容(blob在allocationSize位置之后的内容)都是我们可控的。
  • 不过我们不能放入超过Binder缓冲区大小的数据,否则会出现Parcel传输错误。

漏洞补丁

binder_status_t error = readBlob(
            p.get(),
            // In place callback
            [&](std::unique_ptr<int8_t[]> buffer, int32_t size) {
                if (allocationSize > size) {
                    android_errorWriteLog(0x534e4554, "213169612");
                    return STATUS_BAD_VALUE;
                }
                nativeBitmap = Bitmap::allocateHeapBitmap(allocationSize, imageInfo, rowBytes);
                if (nativeBitmap) {
                    memcpy(nativeBitmap->pixels(), buffer.get(), allocationSize);
                    return STATUS_OK;
                }
                return STATUS_NO_MEMORY;
            },
            // Ashmem callback
            [&](android::base::unique_fd fd, int32_t size) {
                //...
            });
  • 可以看到,虽然还是根据allocationSize来分配空间,但是这次不会将buffer的内容全数拷贝,而是只会拷贝allocationSize的大小,和rowBytes和height的信息相匹配的。同时还判断了allocationSize不能过大,不能超过实际blob数据的大小。

影响范围

  • 这个漏洞仅影响Android 12,我们看一下Android 11中相同地方的代码:
    const int         width = p->readInt32();
    const int         height = p->readInt32();
    const int         rowBytes = p->readInt32();
    const int         density = p->readInt32();

    if (kN32_SkColorType != colorType &&
            kRGBA_F16_SkColorType != colorType &&
            kRGB_565_SkColorType != colorType &&
            kARGB_4444_SkColorType != colorType &&
            kAlpha_8_SkColorType != colorType) {
        SkDebugf("Bitmap_createFromParcel unknown colortype: %d\n", colorType);
        return NULL;
    }

    std::unique_ptr<SkBitmap> bitmap(new SkBitmap);
    if (!bitmap->setInfo(SkImageInfo::Make(width, height, colorType, alphaType, colorSpace),
            rowBytes)) {
        return NULL;
    }

    // Read the bitmap blob.
    size_t size = bitmap->computeByteSize();
    android::Parcel::ReadableBlob blob;
    android::status_t status = p->readBlob(size, &blob);
    if (status) {
        doThrowRE(env, "Could not read bitmap blob.");
        return NULL;
    }

  • 这里也是类似的,读取四个int值,然后调用computeByteSize去计算大小,只不过这里的参数提前通过setInfo传进去了,然后他这里面readBlob的时候直接就是读取了计算出的大小
    // Copy the pixels into a new buffer.
    nativeBitmap = Bitmap::allocateHeapBitmap(bitmap.get());
    if (!nativeBitmap) {
        blob.release();
        doThrowRE(env, "Could not allocate java pixel ref.");
        return NULL;
    }
    memcpy(bitmap->getPixels(), blob.data(), size);
    // Release the blob handle.
    blob.release();
  • 在下面allocateHeapBitmap分配Bitmap空间的时候也是使用的Bitmap中现成的参数,和上面的一致,最后将blob的数据拷贝到Bitmap缓冲区中,也是使用size作为大小,这里面size最大也不会超过Binder缓冲区的大小,Parcel的实现中有比较完善的检查,所以不存在漏洞。

总结

  • Bitmap是Android系统的核心类型,在Android 12中这里面还可以出现一个漏洞是比较难以置信的。这个漏洞看起来是Google在代码重构的时候引入的,所以说有时候代码重构能消除漏洞(比如CVE-2020-0096在Android 10重构时消失),也可以引入新的漏洞,可以说是一把双刃剑。