详细分析libwebp漏洞CVE-2023-4863

1.介绍

这个漏洞网上也有好几篇分析文章,但是之前的文章感觉有点不详细,没有从可控参数到漏洞触发路径这样一直分析,感觉一头雾水,有点不适合新手阅读。在2013年aflfuzz发布之后二进制安全迎来fuzz时代,曾经的人工代码审计效率是比较低的,aflfuzz发布之后漏洞挖掘效率大幅提高。到了近几年迎来卷比时代,如今二进制漏洞数量大幅减少。所以CVE-2023-4863比较值得的学习,因为它既很难fuzz、代码审计也比较难发现。我们看一下漏洞介绍:

 

使用特定 Webp 无损格式文件,libwebp 可能将数据越界写入堆。ReadHuffmanCodes() 函数在分配 HuffmanCode 缓冲区时,其大小来自预先计算出的 size 数组:kTableSize。color_cache_bits 值的定义需要使用此 size。kTableSize 数组仅考虑到 8-bit 一级表查找,但未考虑到二级表查找的 size。而 libwebp 最多只允许 15-bit(MAX_ALLOWED_CODE_LENGTH) 的代码大小。因此当 BuildHuffmanTable() 尝试填充二级表时,可能会写入越界数据。对尺寸不足数组的越界写入发生在 ReplicateValue 当中。

再看第二个介绍:

 

该漏洞存在于对 WebP(有时称为 VP8L)的“无损压缩”支持中。无损图像格式可以以 100% 的精度存储和恢复像素,这意味着图像将以完美的精度显示。为了实现这一点,WebP 使用一种称为霍夫曼编码的算法。

尽管霍夫曼编码在概念上基于树数据结构,但现代实现已优化为使用表。该补丁表明,在解码不受信任的图像时,霍夫曼表可能会溢出。

只看描述是很难知道咋回事的,下面会从基础知识到漏洞原理进行分析。

2.影响

可能导致微信,腾讯会议,钉钉等支持webp格式的客户端app零点击远程代码执行。服务端的ffmpeg,imagemagick等也受影响。

3.演示

下载poc后打开,链接是:https://github.com/mistymntncop/CVE-2023-4863。

Example Image

可以看到崩溃了。

4.图片格式说明

在日常生活中我们经常能够接触到例如jpg,png,webp等格式的图片,那这些图片里面究竟是什么?实际上图片是由像素值组成的,像素值实际上就是RGB。R是红色,G是绿色,B是蓝色。因为显示器只能显示红绿蓝,其它颜色都需要这三种颜色组成。rgb总共是3个字节,也就是红绿蓝各占用1个字节,表示范围0-255。

 

Example Image

从这张图片我们可以看到r的值194,G和B值是0就显示红色了。R的值越大红色越深,越小越浅。

 

Example Image

当rgb的值组合起来就会显示白色,也就是红绿蓝组合起来以及它的颜色深浅就会得到红绿蓝之外的颜色。

这3个值占用3个字节。那么分辨率其实指的就是这些像素值的总共大小,比如一张图片的分辨率是1920*1080。那说明这张图片大小是1920乘以1080再乘以3,得到6,220,800个字节(像素值有6,220,800个)。

我们来算一下这张jpg格式分辨率在乘以3看大小对不对,754*1049*3得到2,372,838个字节,但是图片显示只有62069个字节。不对呀为什么小这么多,答案是因为jpg其实是一种压缩格式,和zip有点像,但是jpg的算法专门针对像素值压缩,压缩率和性能都不错。png和webp都一样也是压缩算法。webp格式图片就需要libwebp解压得到原来的像素值,在调用系统api显示像素。webp中还存在一种ARGB,ARGB就是在RGB基础上加了Alpha(透明度)这个值,Alpha占用1个字节,值越小越透明。

为什么数据f0005a...这样会导致溢出,我们分析代码。

5.代码分析

我们看看libwebp是如何解析bad.webp的。首先我们用ai生成c调用libwebp库解析webp文件的代码。

#include<src\webp\decode.h>
#include <iostream>



#pragma comment(lib,"libwebp_debug_dll.lib")

int main() {
  

    const char* filename = "C://CVE-2023-4863-main//bad.webp";
    FILE* file = fopen(filename, "rb");
    if (!file) {
        printf("Error: Could not open file \n");
        return 1;
    }
    myFunction();
    // 获取文件大小  
    fseek(file, 0, SEEK_END);
    long filesize = ftell(file);
    rewind(file);

    // 分配内存来读取整个文件  
    uint8_t* data = (uint8_t*)malloc(filesize);
    if (!data) {
        fprintf(stderr, "Error: Could not allocate memory for file data\n");
        fclose(file);
        return 1;
    }

    // 读取文件内容  
    size_t bytesRead = fread(data, 1, filesize, file);
    if (bytesRead != filesize) {
        fprintf(stderr, "Error: Could not read entire file\n");
        free(data);
        fclose(file);
        return 1;
    }

    // 关闭文件  
    fclose(file);

    // 解码 WebP 图像  
    int width, height;
    uint8_t* rgb_data = WebPDecodeRGB(data, filesize, &width, &height);
    if (!rgb_data) {
        fprintf(stderr, "Error: Could not decode WebP image\n");
        free(data);
        return 1;
    }

    // 在这里,你可以使用 rgb_data 进行操作,例如将其保存为图像文件或显示在屏幕上  
    // 注意:rgb_data 指向的内存由 WebPDecodeRGB 分配,不需要手动释放  

    // 假设我们只是打印宽度和高度  
    printf("Decoded image size: %d x %d\n", width, height);

    // 释放原始文件数据  
    free(data);

    // 注意:rgb_data 的释放由 WebPFree() 完成,但 WebPDecodeRGB 不返回需要释放的句柄  
    // 因此,在这里我们不需要(也不能)释放 rgb_data  

    return 0;

由于漏洞的复杂程度,需要用vs2022断点进行动态调试(如果参数到处传还是不要静态审计,不然晕头转向...)。解析webp文件的函数是WebPDecodeRGB,从漏洞介绍中我们得知触发溢出的函数是ReplicateValue 。WebPDecodeRGB到ReplicateValue 调用堆栈如下图:

 

 

 

从WebPDecodeRGB下断点,这里可控参数data。

发现WebPDecodeRGB也没干什么,只是调用Decode。

从调用堆栈图中我们能够看到WebPGetInfo是解析webp文件头部的,WebPGetInfo是调用ParseHeadersIntern函数解析webp头部的,代码太多了,从这里开始一些不重要的代码我们跳过了。

可以看到可控参数data传递给了ParseHeadersIntern,然后又传递给了  ParseRIFF,进ParseRIFF看看。

这里我们看到代码!memcmp(*data, "RIFF", TAG_SIZE),这里TAG_SIZE是4,也就是比较data前4个字节数据是否为字符RIFF,如果是的话!memcmp函数返回0,所以这里有个!就是让0变成1使得if内代码执行。然后memcmp(*data + 8, "WEBP", TAG_SIZE)(data指针提高8)就是比较data的8-12个字节是否为字符webp,如果不是就会返回错误。所以我们就知道为啥poc构造第1-4个字节为RIFF,第8-12个字节是webp。然后代码  *data += RIFF_HEADER_SIZE;这里RIFF_HEADER_SIZE值是12,data+12就是指向了字符vp8l这个位置,以便让下面的函数继续解析。在第12个至16个字节如果标识vp8 这表示使用有损压缩,标识vp8l表示使用无损压缩,无损压缩使用霍夫曼编码,漏洞在霍夫曼编码解析中。下面还有ParseVP8Header和ParseVP8X函数继续解析头部的,我们就不看了,和ParseRIFF逻辑差不多。

注意这里在DecodeInto函数里面data赋值给了io。

调用VP8LDecodeHeader传递了两个参数dec,io。

看一下dec->br的结构体:(br是dec的成员)

typedef uint64_t vp8l_val_t;

typedef struct {

  vp8l_val_t     val_;        // pre-fetched bits //占用8个字节

  const uint8_t* buf_;        // input byte buffer

  size_t         len_;        // buffer length

  size_t         pos_;        // byte position in buf_

  int            bit_pos_;    // current bit-reading position in val_

  int            eos_;        // true if a bit was read past the end of buffer

} VP8LBitReader;

这里比较重要,执行到这里的时候是io->data指向webp文件的第20个字节位置,指针io->data赋值给了br->buf_,再把io->data指向的数据赋值给了br->val_。这时候指针br也是可控参数了,接下来的解析霍夫曼编码或者读取webp文件的数据都是读取指针br的。

这里的pos_会赋值8,就是赋值给br->val_的大小。以后的代码都会br->val_=*br->buf_+pos_这样读取webp文件数据,读取完数据后pos_+读取字节的大小,以便下次读取到新的位置数据。

这里ReadHuffmanCodes关键来了开始读取霍夫曼编码,注意两个参数dec和color_cache_bits,这两个参数跟触发bug有关。下一步让我们了解霍夫曼编码。

6.霍夫曼编码

很多人都知道霍夫曼树,开头中介绍有这样一句话:霍夫曼编码在概念上基于树数据结构,但现代实现已优化为使用表.那么霍夫曼表是什么?在网上搜索了一下,得到一些结果。

可以看到霍夫曼表是统计每一个字符或byte有多少个,比如有字符串aabbb,那么a的频率就是2了。再根据这些构建树。这时候又有疑问了:像素值总共256个,也就是表的大小是固定的,这怎么可能会溢出呢?经过多次搜索我在b站看到了一个视频BV1vT4y1s7uK,原来webp中的霍夫曼表和霍夫曼树是有些不一样的。

input:"abcdaabaaabaaa"

code   data

0      a

10     b

110    c

111    d

字符a占用1个字节(8位),a的二进制表示0,b是10,c是110。a在内存表示是00000000,前面7位的空间都浪费了,我们可以把b和c都填充到那7位空间去。这时候变成00110100,本来3个字节的字符串压缩成1个字节。但是霍夫曼表采用了另一种方式。

output:"010110111001000010000"

Index    Index(binary)    Code    Bits required

0        000              a       1

1        001              a       1

2        010              a       1

3        011              a       1

4        100              b       2

5        101              b       2

6        110              c       3

7        111              d       3

 

在这个示例中output是压缩数据,下面是霍夫曼表。假设每次取出3位,首先取出前面3位010,010转换成整数就是2,取出index为2的code和bits required。code就是解压后的字符、这里是a,取出bits required的值是1,示意让input最开始位置右移1位,output右移1位得到101,就是整数5作为索引取出code和bits required,bits required的值为2,在右移2位得到110在从表取值。可以看到7位的压缩数据010110解压后得到占用3个字节的abc。如果觉得文字描述模糊可以看看下面图片示例。这时候我们大概知道为什么解析霍夫曼表可能会溢出,因为霍夫曼表的大小取决于压缩数据的大小。webp还采用了一种叫二级表的设计(有两张霍夫曼表),以防止表的大小过大。

7.ReadHuffmanCodes

//整个解析霍夫曼编码的代码有点绕,建议读者反复上下文联系或者也搭建动态调试环境调试一下。

这里我们需要注意到huffman_tables,这里存放霍夫曼表,也是发生堆溢出的地方,HuffmanCode结构如下:

typedef struct {

  uint8_t bits;     // number of bits used for this symbol

  uint16_t value;   // symbol value or table offset

} HuffmanCode;

bits对应霍夫曼表的bits required,value对应霍夫曼表的code。这里咋一看占用3个字节,实际上因为字节对齐会占用4个字节。

WebPSafeMalloc只是对malloc简单封装而已,WebPSafeMalloc会将第一个参数乘以第二个参数在传进malloc,huffman_tables分配到堆区,大小是num_htree_groups * table_size*sizeof(*huffman_tables),num_htree_groups 值是1,我们看一下table_size的值怎么来:

 const int table_size = kTableSize[color_cache_bits];
//查找一个霍夫曼树组的表所需的内存。红色、蓝色、阿尔法
//并且距离字母是恒定的(256表示红色、蓝色和阿尔法,40表示
//距离),并且在最坏情况下它们的查找表大小为630和410
分别地绿色字母表的大小取决于颜色缓存的大小,并且相等
//至256(绿色分量值)+24(长度前缀值)
//+color_cache_size(介于0和2048之间)。
//使用Mark Adler的工具为8位一级查找计算的所有值:
#define FIXED_TABLE_SIZE (630 * 3 + 410)
static const uint16_t kTableSize[12] = {
  FIXED_TABLE_SIZE + 654,
  FIXED_TABLE_SIZE + 656,
  FIXED_TABLE_SIZE + 658,
  FIXED_TABLE_SIZE + 662,
  FIXED_TABLE_SIZE + 670,
  FIXED_TABLE_SIZE + 686,
  FIXED_TABLE_SIZE + 718,
  FIXED_TABLE_SIZE + 782,
  FIXED_TABLE_SIZE + 912,
  FIXED_TABLE_SIZE + 1168,
  FIXED_TABLE_SIZE + 1680,
  FIXED_TABLE_SIZE + 2704
};

之所以将color_cache_bits设置为0,就是为了分配huffman_tables最小的空间。huffman_tables会存放5张表,分别对应Alpha,红,绿,蓝以及颜色缓存。其中绿色比较特殊,占用 654*4个字节,每一张表又分为一级表和二级表。最后huffman_tables会分配11816个字节的堆空间。再将huffman_tables赋值给huffman_table。

ReadHuffmanCode函数将构建霍夫曼表,HUFFMAN_CODES_PER_META_CODE的值是5,循环5次解析5张霍夫曼表。看图我们得知bits required值是被压缩的,根据第1-5张霍夫曼表解压,

而且1-5张霍夫曼表也是被压缩的,1-5张霍夫曼表解压后的值将存放到数组code_lengths。我们发现没有webp文件中没有code,code是根据bits required生成的。这里的alphabet_size值是280,说明解析的第一张表是绿色。我们看一下ReadHuffmanCode的代码以及编码在webp中的位置。

在调试环境可以看到val_的值0xad6da8c000005a00就是第一张霍夫曼表,VP8LReadBits函数返回br->val前3位值,每调用一次VP8LReadBits,bit_pos_的值+3,VP8LReadBits函数读取位数时是第bit_pos位位置开始的。当bit_pos大于8时bit_pos-8并且pos_+1,pos_+1时val_右移8位并且将buf_+pos_的1个字节赋值给val_的高位字节。num_codes的值19,其实就是把val_的前54位值每3位赋值给整数数组code_length_code_lengths。如图所示:

看一下kCodeLengthCodeOrder的定义。

static const uint8_t kCodeLengthCodeOrder[NUM_CODE_LENGTH_CODES] = {
  17, 18, 0, 1, 2, 3, 4, 5, 16, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
};

执行完循环后经过kCodeLengthCodeOrder排序得到code_length_code_lengths的值{0, 5, 5, 0, 0, 0, 0, 0, 0, 4, 1, 2, 5, 5, 5, 5, 0, 0, 0},_val的值是0x0000000000333dad。

VP8LBuildHuffmanTable函数也没干什么,code_length_code_lengths参数传进去,解压的值赋值给table。LENGTHS_TABLE_BITS的值是7,这个参数是让一级表的大小为1<<7也就是128个字节。里面调用了BuildHuffmanTable,开始解压第一张霍夫曼表。

static int BuildHuffmanTable(HuffmanCode* const root_table, int root_bits,
                             const int code_lengths[], int code_lengths_size,
                             uint16_t sorted[]) {
  HuffmanCode* table = root_table;  // next available space in table
  int total_size = 1 << root_bits;  // total size root table + 2nd level table

  int len;                          // current code length
  int symbol;                       // symbol index in original or sorted table
  // number of codes of each length:
  int count[MAX_ALLOWED_CODE_LENGTH + 1] = { 0 };
  // offsets in sorted table for each length:
  int offset[MAX_ALLOWED_CODE_LENGTH + 1];

  assert(code_lengths_size != 0);
  assert(code_lengths != NULL);
  assert((root_table != NULL && sorted != NULL) ||
         (root_table == NULL && sorted == NULL));
  assert(root_bits > 0);

  // Build histogram of code lengths.
  for (symbol = 0; symbol < code_lengths_size; ++symbol) {
    if (code_lengths[symbol] > MAX_ALLOWED_CODE_LENGTH) {
      return 0;
    }
    ++count[code_lengths[symbol]];
  }

  // Error, all code lengths are zeros.
  if (count[0] == code_lengths_size) {
    return 0;
  }

  // Generate offsets into sorted symbol table by code length.
  offset[1] = 0;
  for (len = 1; len < MAX_ALLOWED_CODE_LENGTH; ++len) {
    if (count[len] > (1 << len)) {
      return 0;
    }
    offset[len + 1] = offset[len] + count[len];
  }

  // Sort symbols by length, by symbol order within each length.
  for (symbol = 0; symbol < code_lengths_size; ++symbol) {
    const int symbol_code_length = code_lengths[symbol];
    if (code_lengths[symbol] > 0) {
      if (sorted != NULL) {
        sorted[offset[symbol_code_length]++] = symbol;
      } else {
        offset[symbol_code_length]++;
      }
    }
  }

  // Special case code with only one value.
  if (offset[MAX_ALLOWED_CODE_LENGTH] == 1) {
    if (sorted != NULL) {
      HuffmanCode code;
      code.bits = 0;
      code.value = (uint16_t)sorted[0];
      ReplicateValue(table, 1, total_size, code);
    }
    return total_size;
  }
  {
    int step;              // step size to replicate values in current table
    uint32_t low = -1;     // low bits for current root entry
    uint32_t mask = total_size - 1;    // mask for low bits
    uint32_t key = 0;      // reversed prefix code
    int num_nodes = 1;     // number of Huffman tree nodes
    int num_open = 1;      // number of open branches in current tree level
    int table_bits = root_bits;        // key length of current table
    int table_size = 1 << table_bits;  // size of current table//一级表的大小
    symbol = 0;
    // Fill in root table.
    for (len = 1, step = 2; len <= root_bits; ++len, step <<= 1) {//这里开始对一级表赋值
      num_open <<= 1;
      num_nodes += num_open;
      num_open -= count[len];
      if (num_open < 0) {
        return 0;
      }
      if (root_table == NULL) continue;
      for (; count[len] > 0; --count[len]) {
        HuffmanCode code;
        code.bits = (uint8_t)len;
        code.value = (uint16_t)sorted[symbol++];
        ReplicateValue(&table[key], step, table_size, code);、//把code赋值table
        key = GetNextKey(key, len);//生成一个新的key值
      }
    }

这是构建一级表的代码,count数组将存放code_length(code_length_code_lengths)各值的数量,code_length的成员是{0, 5, 5, 0, 0, 0, 0, 0, 0, 4, 1, 2, 5, 5, 5, 5, 0, 0, 0},经过count统计后count的值是{10, 1, 1, 0, 1, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},code_length_code_lengths中有10个0,count在索引0中赋值10。数组Offset的值是这样的:

 

Offset存放count(除了索引为0的值)往所有低位成员相加的值。比如offset索引6的值是9,就是1+1+0+1+6+0这样得来。Sorted的值是这样的:

sorted

数组格式是:值作为code_lenths索引取出code_lenths值,再用这个值作为索引取出offset的值,这个值赋值给sorted作为索引值。//取出offset值+1。sorted的索引值是offset的值,sorted的值是offset值在offset数组的位置(这个位置值存储在code_lenths数组)的code_lenths值索引,类似哈希表。

在看一级表的代码,root_bits的值7。主要逻辑还是在内层循环中,内层循环第一次执行时step的值为2,count[len]的值是1,所以只会循环1次。Len和sorted的赋值给code,如果仔细阅读代码就知道len的值就是从code_lenths(或者说count)来的。最后调用ReplicateValue函数,这里传递的参数是table_size是128。

static WEBP_INLINE void ReplicateValue(HuffmanCode* table,
                                       int step, int end,
                                       HuffmanCode code) {
  assert(end % step == 0);
  do {
    end -= step;
    table[end] = code;
  } while (end > 0);
}

这里逻辑就是从table[126]开始把code值赋值给table,赋值之后都会end-step(2),这里会循环64次。我们看一下GetNextKey函数

static WEBP_INLINE uint32_t GetNextKey(uint32_t key, int len) {
  uint32_t step = 1 << (len - 1);
  while (key & step) {
    step >>= 1;
  }
  return step ? (key & (step - 1)) + step : key;
}

key的值初始化为0,它的逻辑是这样的:key的值是否大于2的len次幂,不大于的话将返回key+2的len次幂。

大于的话:key以二进制形式展开,将会把第len位bit的位置开始的1赋值0,然后循环右移1位如果也是1赋值0,直到遇到值为0再将其赋值成1就结束循环。举个例子:

比如key的值是000110000001,len的值是9,从第9位开始循环直到遇到bit为0结束循环,第9位是1赋值0变成000010000001,第8位也是1赋值0:000000000001,第7位是0将其赋值1:000001000001,结束循环,然后返回该值。

因为step值为1<<len,所以只会对table的偶数位索引赋值,&table[key]这样可以对奇数位索引赋值了。GetNextKey对高位数赋值0是可以让&table[key]这里key索引不会超过128。BuildHuffmanTable最未有一段代码:

if (num_nodes != 2 * offset[MAX_ALLOWED_CODE_LENGTH] - 1) {
      return 0;
}

这代码检查是否会溢出,但是发生在对霍夫曼表完成赋值之后才检查的。所以构造1-4张霍夫曼表都让它达到最大大小,再对第5张霍夫曼表构造溢出。执行完BuildHuffmanTable反回到ReadHuffmanCodeLengths开始根据table解压bits required。

  symbol = 0;
  while (symbol < num_symbols) {
    const HuffmanCode* p;
    int code_len;
    if (max_symbol-- == 0) break;
    VP8LFillBitWindow(br);
    p = &table[VP8LPrefetchBits(br) & LENGTHS_TABLE_MASK];
    VP8LSetBitPos(br, br->bit_pos_ + p->bits);
    code_len = p->value;
    if (code_len < kCodeLengthLiterals) {
      code_lengths[symbol++] = code_len;//把霍夫曼表的value赋值给code_lengths

LENGTHS_TABLE_MASK的值是127,VP8LPrefetchBits函数如下

VP8L_LBITS的值是64,也就是bit_pos_大于64会生成一个小于64的值。br->val_右移2位,因为刚才对code_length_code_lengths读取54位的值还残留2位的值。在&127得到107,127二进制表示01111111,其实就是取val_的前面7位,此时_val的值是0000000000333dad,二进制表示是001100110011110110101101,右移2位后前面7位110 1011就是整数107,table[107]赋值给p,此时p的值是bits=5 '\x5' value=1 。VP8LSetBitPos会把bit_pos_+p->bits,也就是2+5=7,也就是下次取7-14的值111 1011整数123,在取霍夫曼表的第123项bits=5 '\x5' value=2 ,那么下次会取19-26位的值。VP8LFillBitWindow函数将判断bit_pos是否大于32,如果大于32将把br->buf_指向后面4个字节的数据赋值给val_的低位四个字节,然后pos_+4。num_symbols的值是280,循环280次赋值给code_lengths。最后code_lengths的值是:

此时解压出来的bits required已经存放到code_lengths,再次调用VP8LBuildHuffmanTable根据bits required构建霍夫曼表,返回654,HUFFMAN_TABLE_BITS的值是8,那么一级表的大小是256个字节。等下返回ReadHuffmanCodes会huffman_table=huffman_table+654。为什么code_lengths这样的值能够达到最大大小654*4个字节我们就不分析了,第2,3,4张表也忽略、它们也会让size达到限制的最大值。直接查看第五张表代码如何解析。

8.第五张表

for (len = root_bits + 1, step = 2; len <= MAX_ALLOWED_CODE_LENGTH; //MAX_ALLOWED_CODE_LENGTH的值是15,root_bits的值是8,step的值是512,所有注释的值都是第一次循环的值

         ++len, step <<= 1) {
      num_open <<= 1;//num_open =8,左移1得到16
      num_nodes += num_open;//16+25=41
      num_open -= count[len];//16-11=5
      if (num_open < 0) {
        return 0;
      }
      if (root_table == NULL) continue;
      for (; count[len] > 0; --count[len]) {
        HuffmanCode code;
        if ((key & mask) != low) {//mask的值是255//key & mask舍弃大于8的高位,key的值31
          table += table_size;//第一次循环的时候table_size这个值就是一级表的大小,让table地址提高256*4
          table_bits = NextTableBitSize(count, len, root_bits);//该函数返回值最后赋值给 table_size,每次table地址提高不一样
          table_size = 1 << table_bits;
          total_size += table_size;//1<<1=2
          low = key & mask;//如果值大于255则舍弃高位
          root_table[low].bits = (uint8_t)(table_bits + root_bits);//不关注,和bug无关
          root_table[low].value = (uint16_t)((table - root_table) - low);//不关注,和bug无关

        }
        code.bits = (uint8_t)(len - root_bits);
        code.value = (uint16_t)sorted[symbol++];
        ReplicateValue(&table[key >> root_bits], step, table_size, code);
        key = GetNextKey(key, len);
      }
}
    // Check if tree is full.
    if (num_nodes != 2 * offset[MAX_ALLOWED_CODE_LENGTH] - 1) {
      return 0;
    }
  }

  return total_size;

由于一级表大小固定256*4字节,所以我们看二级表的逻辑。构建二级表的代码逻辑和构建一级表代码逻辑有较大不同。经过ReadHuffmanCodeLengths的处理conut的值:{0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 5, 1, 10, 4, 2, 2},因为颜色缓存表大小为40,所以conut成员相加的值不能大于40。conut数组大小是16,数组1-8的值都属于一级表。Step的值512就是在一级表中1<<9得来。代码中  num_open -= count[len];      if (num_open < 0) {return 0;}

这样的话限制了conut成员右边的值不能大过左边太多,conut的值不能随意大,二级表的值都很巧妙。第一次循环中 if ((key & mask) != low)是会执行里面的代码,因为low值没有初始化。阅读一下NextTableBitSize函数

static WEBP_INLINE int NextTableBitSize(const int* const count,
                                        int len, int root_bits) {
  int left = 1 << (len - root_bits);
  while (len < MAX_ALLOWED_CODE_LENGTH) {MAX_ALLOWED_CODE_LENGTH值15
    left -= count[len];
    if (left <= 0) break;
    ++len;
    left <<= 1;
  }
  return len - root_bits;

这里len的值是9,conut[len]值是11,root_bits的值是8。第一行代码1<<9-8,left的值2。然后第3行代码left=2-11。如果是0或负数那么len+1并且left左移1位。这个逻辑有点难理解。第一次循环就返回1。然后total_size=total_size1<<1,total_size=258。这时候low=31=key,low的值是不能大于255的,大于的话就以二进制展开舍弃高于8的1位。最后到ReplicateValue函数table[key >> root_bits]31右移8位的值是是0,step,table_size的值是2。显然只对table循环1次赋值。经过GetNextKey函数key=31+2^9(其实就是key二进制展开往len位赋值1),key=287。key以二进制展开000100011111,如果是table[key >> root_bits]这样右移8位得到整数1。所以我们就能知道if ((key & mask) != low)为啥这样写,因为key舍弃大于8的高位肯定是等于low的,所以if内的代码table += table_size不用执行。第二次循环经过GetNextKey由于大于2^9是会往第8位bit赋值1,也就是31+128此时就不等于low了。如果没有if ((key & mask) != low)内的代码就会重复对table索引0和1赋值。也得知if ((key & mask) != low)内代码循环(len-8)<<1就执行1次if内代码,举个例子假如len是9,if ((key & mask) != low)内代码每循环2次执行一次,len是10执行4次,len是11执行8次。这个逻辑与table[key >> root_bits]和GetNextKey相对应,比如len的值是10那么table索引里面的值会产生0-3的值,刚好在if ((key & mask) != low)内代码执行之前不会往table索引重复赋值。NextTableBitSize函数也与之对应,比如len是10一般返回2再左移1位得到4,table地址提高4*4。经过上面的解释我们重点关注NextTableBitSize,该函数有bug。我们构建一段代码,看看poc和NextTableBitSize的关系是怎么样的。

static  int NextTableBitSize(const int* const count,
    int len, int root_bits) {
    int left = 1 << (len - root_bits);
    while (len < 15) {
        left -= count[len];
        if (left <= 0) break;
        ++len;
        left <<= 1;
    }
    return len - root_bits;
}

int main()
{
    int table_bits;
    int len = 0;
    int g = 0;
    int data = 0;
    int size = 0;
    int count[16] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 5, 1, 10, 4, 2, 2 };
    for (len = 9; len <= 15; len++)
    {

        for (; count[len] > 0; --count[len]) {
            if (g == 0)
            {
              
                table_bits = NextTableBitSize(count, len, 8);
                table_bits = 1 << table_bits;
                g = table_bits;
                size = size + table_bits;
            }
            data++;
            g--;
        }
    }
    printf("%d", size);

}

 

可以看到最后一次调用NextTableBitSize将返回158,再看libwebp代码:table += table_size;第5张表最大大小是410项,但是一级表的大小256+158等于414,显然已经超出范围。接下来的ReplicateValue函数对table(huffman_tables)赋值导致越界写入。我们再算一次(654+630*3+530)*4=11832字节,table偏移11832字节写入数据,大于huffman_tables大小11816字节。我们看一下158这个值是怎么得到的。在看构建的代码里第一次循序也就是len=9的时候显然NextTableBitSize会返回1,那什么时候会返回大于1呢?可以看到if (left <= 0)那么len+1,显然会返回大于1的值,也就是经过多次内层循环count[9]=1的时候,left的值是2,2-1=1使得break不会执行而是执行len+1,这时候还会再次循环,这时候Left的值是2,因为len+1取出count[len]的值是5,2-5小于0跳出循环返回2。可以得出结论第一次内层循环size 会得到14,第二次内层循环是4。第二次内层循环count[10]=2的时候执行if内的代码(第一次内层循环count[9]=1的时候,left=2-1,所以len的值会是10,NextTableBitSize函数返回2,所以count[10]=2才会执行if内代码),调用NextTableBitSize因为left=4-2,break不会执行,left左移1位又得到4,再次循环left=4-1(count[10]),left左移1位得到6。再次循环由于6<count[11]就break了。返回15-11。此时size=14+4+16。也就是conut[13]=1第五次内层循环才会执行if内的代码。NextTableBitSize函数内left值为32,显然都要大于conut[13],conut[14],conut[15],所以经过多次len++会返回15-8,table_bits=1<<7,这时候size的值14+4+16+128=158。也就是说如果一个conut成员右边所有的值都比左边成员小很多,table_size将会得到一个大值128。下图是值的在调试时的变化:

构建完霍夫曼表后有这样一段代码:

if (num_nodes != 2 * offset[MAX_ALLOWED_CODE_LENGTH] - 1) {
      return 0;
}

检查是否会溢出,但是此时huffman_tables已经堆溢出了,代码仅仅只是return 0退出解码,而不是立即崩溃。依然有机会通过堆修饰避免崩溃实现rce。

最后debug模式下越界写入400个字节导致崩溃。

9.总结

可以发现该bug还是比较适合通过fuzz找到,不过即使专门剥离二级表代码fuzz最后一张表也需要很长时间。如今挖掘二进制漏洞也许代码审计和fuzz结合才是最佳方案。

posted @ 2024-07-09 14:01  qweqwe1qwe  阅读(1165)  评论(0)    收藏  举报