漏洞定位
在漏洞分析的初始阶段,由于缺乏现成的 PoC 或详细的分析报告,我们首先尝试阅读并理解webmproject/libwebp 上游存储库中的 CVE-2023-4863补丁代码。但WebM项目的官方补丁相对复杂,我们很难准确定位漏洞的根本原因。
于是,我们将目光转向苹果官方发布的CVE-2023-41064补丁,并使用BinDiff对更新前后的ImageIO框架进行了对比。我们注意到苹果的补丁涉及的代码更改更少,并且更容易理解。
简而言之,Apple 的修复在 WebP 解码器中引入了额外的检查:如果在构造 Huffman Table 时出现越界写入,则直接返回错误,而不是继续解码过程。
diff --git a/src/dec/vp8l_dec.c b/src/dec/vp8l_dec.c
index 45012162..06b142bc 100644
--- a/src/dec/vp8l_dec.c
+++ b/src/dec/vp8l_dec.c
@@ -438,6 +438,7 @@ static int ReadHuffmanCodes(VP8LDecoder* const dec, int xsize, int ysize,
goto Error;
}
+ bound = &huffman_tables[num_htree_groups * table_size];
huffman_table = huffman_tables;
for (i = 0; i < num_htree_groups_max; ++i) {
// If the index "i" is unused in the Huffman image, just make sure the
diff --git a/src/utils/huffman_utils.c b/src/utils/huffman_utils.c
index 90c2fbf7..13054715 100644
--- a/src/utils/huffman_utils.c
+++ b/src/utils/huffman_utils.c
@@ -191,6 +191,7 @@ static int BuildHuffmanTable(HuffmanCode* const root_table, int root_bits,
}
code.bits = (uint8_t)(len - root_bits);
code.value = (uint16_t)sorted[symbol++];
+ if (bound && &table[key >> root_bits + table_size] >= bound) return 0;
ReplicateValue(&table[key >> root_bits], step, table_size, code);
key = GetNextKey(key, len);
}
因此,该漏洞极有可能是由于在构造哈夫曼表时缺乏对输入数据的有效性检查,导致为该表分配的内存溢出,即发生缓冲区溢出。
漏洞分析
该漏洞的根源在于处理 WebP 图像的代码逻辑。解析无损 WebP 图像时,解码器使用顺序数据压缩 (LZ77)、前缀编码和颜色缓存来压缩图像的 ARGB 数据。对于这个过程的具体细节,Google 在其技术文档中提供了详尽的描述,并提供了 WebP 文件格式的清晰的结构规范。
在这个过程中,解码器首先从图像数据流中读取前缀编码数据,并利用这些数据构建完整的霍夫曼编码表。然后,解码器根据该表对流中的压缩数据进行解码,恢复原始图像。由于关于霍夫曼编码的算法细节已经有很多介绍文章,本文不再进一步阐述。
遵循规范哈夫曼编码算法,构建哈夫曼编码表时,使用一级表,用于查询长度小于N位(默认为8位)的哈夫曼码;如果有超过N位的编码,则解码器分配二级表来查询这些扩展编码。
在为霍夫曼编码表分配内存时,解码器会预留足够的空间来同时容纳所有主表和副表,并且内存大小是固定的:
// Memory needed for lookup tables of one Huffman tree group. Red, blue, alpha
// and distance alphabets are constant (256 for red, blue and alpha, 40 for
// distance) and lookup table sizes for them in worst case are 630 and 410
// respectively. Size of green alphabet depends on color cache size and is equal
// to 256 (green component values) + 24 (length prefix values)
// + color_cache_size (between 0 and 2048).
// All values computed for 8-bit first level lookup with Mark Adler's tool:
// https://github.com/madler/zlib/blob/v1.2.5/examples/enough.c
#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
};
const int table_size = kTableSize[color_cache_bits];
huffman_tables = (HuffmanCode*)WebPSafeMalloc(num_htree_groups * table_size, sizeof(*huffman_tables));
从这段代码片段可以看出,该表由五个部分组成,分别对应红、绿、蓝、alpha和距离通道的查找表。红色、蓝色和 Alpha 通道的表大小固定为 630,距离通道的表大小固定为 410,而绿色通道的表大小取决于颜色缓存的大小。这些大小的总和构成了整个表的总大小。此外,作者还提供了 zlib 库中的一个工具ough.c,它可以在给定指定代码长度和颜色缓存大小的情况下计算查找表的最大可能大小。
问题在于,解码器默认假设图像中存储的哈夫曼表数据是合理的,因此它根据这个假设预先计算最大内存长度。这里的核心前提是编码后的数据必须符合规范哈夫曼编码的标准,也就是说哈夫曼树应该是一棵二叉树,每个叶子节点对应一个前缀码,并且没有未使用的悬挂叶子节点。然而,由于哈夫曼表数据来自不受信任的来源,并且可能是攻击者任意构造的,因此解码器在没有验证这些数据的情况下解析出不完全二叉树,这可能会分配过多的二级表,导致总内存使用量超过预分配的大小并导致堆缓冲区溢出。
以绿色通道为例,当颜色缓存大小为0时,其霍夫曼表的最大大小为654。使用该工具,enough我们可以得到其霍夫曼表的可能结构(参考lifthrasiir的表达式):
Len Code range # Root entry Overhead #
--- ------------------------------------ --- ----------------- -------- ---
1 0 1 0xxxxxxx 0 128
9 10000000:0 .. 11110110:1 238 10000000-11110110 2^1 119
11110111:0 1 11110111 2^2 1
10 11110111:10 .. 11110111:11 2
11111000:00 .. 11111110:11 28 11111000-11111110 2^2 7
11111111:00 .. 11111111:10 3 11111111 2^7 1
11 11111111:110 1
12 11111111:1110 1
13 11111111:11110 1
15 11111111:1111100 .. 11111111:1111111 4
在本例中,霍夫曼表的内存大小为 256(第一个表)+ 2*119 + 4*8 + 128 = 654,这与硬编码的最大值精确匹配。
然而,如果我们构造一个不完整的霍夫曼树,包含大量长代码,解码器将在不进行边界检查的情况下分配二级表:
Len Code range # Root entry Overhead #
--- ------------------------------------ --- ----------------- -------- ---
9 00000000:0 1 00000000 2^7 1
10 00000000:10 1
11 00000000:110 1
12 00000000:1110 1
13 00000000:11110 1
14 00000000:111110 1
15 00000000:1111110 .. 00000000:1111111 2
00000001:0000000 .. 00000010:1111111 256 00000001-00000010 2^7 2
00000011:0000000 .. 00000011:0001111 1 00000011 2^7 1
在这种情况下,霍夫曼表的内存大小应该是256(第1张表)+128*4=768,这无疑超过了硬编码的最大大小。
随后,解码器会调用该BuildHuffmanTable函数在最初分配的内存空间中构建哈夫曼表,通过该函数将反转后的前缀码写入内存ReplicateValue。由于分配的二级表数量超出预期,会发生越界写入,这可能导致攻击者执行任意代码。
// Fill in 2nd level tables and add pointers to root table.
for (len = root_bits + 1, step = 2; len <= MAX_ALLOWED_CODE_LENGTH;
++len, step <<= 1) {
// ... snip ...
for (; count[len] > 0; --count[len]) {
// ... snip ...
code.bits = (uint8_t)(len - root_bits);
code.value = (uint16_t)sorted[symbol++];
ReplicateValue(&table[key >> root_bits], step, table_size, code); // overflow here
key = GetNextKey(key, len);
}
}
漏洞利用
如何构建PoC并绕过检查?
我们很自然地想到,如果我们能够直接构造一个足够大的哈夫曼表,是不是会导致程序分配的内存空间溢出呢?
答案是不。因为单个哈夫曼表的溢出长度是有限的,不足以覆盖整个huffman_tables。而且,每次构造完哈夫曼表后,程序都会检查哈夫曼树的完整性,不完整的树会导致解码过程中断:
// Check if tree is full.
if (num_nodes != 2 * offset[MAX_ALLOWED_CODE_LENGTH] - 1) {
return 0;
}
因此,一种可行的方法是构造四个分别对应绿、红、蓝、alpha通道的正态哈夫曼表,并保证它们在保证WebP图像格式合规的情况下能够达到各自的最大分配空间。然后,在距离通道中构建不完整的霍夫曼树,使得所有霍夫曼表的累积大小超过预定的内存容量,导致内存溢出。
如何触发崩溃?
我们发现,虽然PoC可能会触发ASAN的错误报告或导致glibc工具崩溃dwebp,但不会影响Safari、Chrome等主流浏览器。
经过一番调试,我们发现Huffman表的默认大小是2954个表项,分配的空间是2954*4=11816(0x2e28)字节。然而,在Chromium和WebKit中,malloc(0x2e28)最终会分配0x3000字节的内存空间,而样本的溢出长度约为0x190字节,这意味着我们的溢出不足以跨越分配的内存边界,因此不会触发碰撞。
因此,我们需要使分配的Huffman表的大小尽可能接近0x3000。我们如何实现这一目标?在漏洞分析部分,我们提到了绿色通道空间kTableSize是可变长度的,其大小受颜色缓存的影响。如果我们将颜色缓存的大小设置为6,则霍夫曼表的大小变为(630 * 3 + 410 + 718) * 4 = 12072 = 0x2f28字节,允许我们覆盖相邻堆块的内容。
如何构建原语?
为了实现可控且稳定的堆越界写入,我们需要构造一个强大的利用原语,需要达到攻击者可以在任意指定的堆偏移处写入可控数据的效果。
然而,在WebP漏洞中实现这两点都存在一些困难。一方面,霍夫曼表写入的单位是HuffmanCode,其中只有部分数据可以被攻击者控制;另一方面,HuffmanCode并不是按顺序写入Huffman表,其索引是根据逆Huffman编码的规则计算的,因此不能直接控制OOB写入的位置。
如何控制数据写入?
我们首先关注写入数据的解构。Huffman表实际上是一个包含多个HuffmanCode的数组,其中每个HuffmanCode的结构如下:
typedef struct {
uint8_t bits; // number of bits used for this symbol
uint16_t value; // symbol value or table offset
} HuffmanCode;
该结构体的内存布局如下:
| bits (1 byte) | padding (1 byte) | value (2 bytes) |
该结构体中,该bits字段代表当前HuffmanCode的码长,该value字段代表值。每个HuffmanCode占用4个字节的内存。的范围bits受前缀码长度限制,取值范围为[1, 15];whilevalue是当前HuffmanCode的实际编码数据,可以被攻击者控制,其范围取决于编码数据的范围,例如对于RGB颜色编码,范围是[0, 255]。
由于我们是在距离通道构造溢出样本,编码符号个数为40,所以可以写一个4字节的对象,其中高地址的2个字节是可控的,取值范围为[0, 39]。
如何控制书写位置?
阅读代码,我们发现函数中写入了HuffmanCode到内存ReplicateValue,而函数中计算了写入的位置GetNextKey:
// Returns reverse(reverse(key, len) + 1, len), where reverse(key, len) is the
// bit-wise reversal of the len least significant bits of key.
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;
}
code.bits = (uint8_t)(len - root_bits);
code.value = (uint16_t)sorted[symbol++];
ReplicateValue(&table[key >> root_bits], step, table_size, code);
可以看到HuffmanCode并不是按顺序写入Huffman表的;其索引是通过反向前缀码计算得到的。为了控制写入的索引,我们计算了15位前缀码长度(编码字段为 )下二级哈夫曼表中每个哈夫曼码的索引序列2^(15-8)=128:
0x0 0x40 0x20 0x60 0x10 0x50 0x30 0x70 0x8 0x48 0x28 0x68 0x18 0x58 0x38 0x78
0x4 0x44 0x24 0x64 0x14 0x54 0x34 0x74 0xc 0x4c 0x2c 0x6c 0x1c 0x5c 0x3c 0x7c
0x2 0x42 0x22 0x62 0x12 0x52 0x32 0x72 0xa 0x4a 0x2a 0x6a 0x1a 0x5a 0x3a 0x7a
0x6 0x46 0x26 0x66 0x16 0x56 0x36 0x76 0xe 0x4e 0x2e 0x6e 0x1e 0x5e 0x3e 0x7e
0x1 0x41 0x21 0x61 0x11 0x51 0x31 0x71 0x9 0x49 0x29 0x69 0x19 0x59 0x39 0x79
0x5 0x45 0x25 0x65 0x15 0x55 0x35 0x75 0xd 0x4d 0x2d 0x6d 0x1d 0x5d 0x3d 0x7d
0x3 0x43 0x23 0x63 0x13 0x53 0x33 0x73 0xb 0x4b 0x2b 0x6b 0x1b 0x5b 0x3b 0x7b
0x7 0x47 0x27 0x67 0x17 0x57 0x37 0x77 0xf 0x4f 0x2f 0x6f 0x1f 0x5f 0x3f 0x7f
我们的想法是:在溢出的Huffman表中构造四个15位编码,使其第四个HuffmanCode写入0x60处的索引,使其能够覆盖下一个heap chunk的数据。另外,我们可以构造多个9位编码(其中二级哈夫曼表的大小为2),让这个索引以2为增量进行调整,从而实现索引的可控。如下图所示:
因此,我们最终得到的原始效果是攻击者可以写入部分可控的4字节数据,其偏移量是8字节的倍数。
第 2 部分:深入研究 Chrome Blink
介绍
当我们在真实环境中检查第三方库漏洞时,我们经常会遇到漏洞上下文中存在的许多复杂变量。利用这样的漏洞并不像人们想象的那么容易。
以下是我们所知道的信息:
-
溢出的变量huffman_tables的大小为 0x2f28。
-
堆块在渲染器的线程池中分配,而大多数对象在主线程中分配。
-
我们可以写入一个部分受控的 4 字节整数,其偏移量是 8 字节的倍数。
在Chrome中,不同大小的堆块存储在单独的桶中,隔离不同大小的对象以确保安全。通常,在 Chrome 中实现堆利用需要识别相同大小的对象以进行布局,然后利用释放后使用 (UAF) 或越界 (OOB) 技术来操纵其他对象,从而导致信息泄露或控制。流量劫持。接下来,我们将分享我们发现的对象,并尝试绕过此机制。
信息泄露
寻找对象
我们首先要寻找的是一个合适的可以被OOB覆盖的对象。由于我们的OOB写不能很好的控制值,所以写指针基本被排除。最好的情况是更改“长度”等字段,该字段对值没有精确的要求,但它应该会触发可以更好地利用的进一步内存问题。
在 libwebp 中,HuffmanCode是使用 分配的malloc,而在 Chrome 中,它实际上是通过 PartitionAlloc 分配的。渲染器中有四个分区LayoutObject partition:、Buffer partition、ArrayBuffer partition和FastMalloc partition。FastMalloc实际上malloc最后还是调用了,所以我们要找的对象可以使用FastMalloc来分配。
我们首先使用Man Yue Mo在他的博文中提到的codeql查询。由于溢出发生在0x3000桶中,因此可用的对象大小范围是0x2800-0x3000。不幸的是,查询结果为空,这种大小的对象几乎不存在。另一种想法是使用溢出对象本身,但是改变这个对象不会产生任何特殊效果,并且libwebp中没有其他好的候选对象。到了这一步,似乎有些无望了,利用的第一步已经被堵住了。
那么我们还有什么其他的想法呢?一种想法是使用变长对象,比如AudioArray满月沫提到的,但这个对象是纯数据,改变它是没有用的。在检查了所有 FastMalloc 调用后,我们终于找到了这个对象。
class CORE_EXPORT CSSVariableData : public RefCounted<CSSVariableData> {
USING_FAST_MALLOC(CSSVariableData);
该对象的大小是动态的
wtf_size_t bytes_needed =
sizeof(CSSVariableData) + (original_text.Is8Bit()
? original_text.length()
: 2 * original_text.length());
// ... snip ...
void* buf = WTF::Partitions::FastMalloc(
bytes_needed, WTF::GetStringWithTypeName<CSSVariableData>());
该对象代表CSS 中的变量。它可以通过以下方式定义:
element {
foo: var(--my-var, bar);
}
CSSVariableDataBlink会根据CSS变量的字符串内容动态分配内存。另一个好消息是 CSS 变量也可以在 JavaScript 中轻松操作。
// add a CSS variable
element.style.setProperty('foo', 'bar');
// remove a CSS variable
element.style.removeProperty('foo');
// get the value of a CSS variable
getComputedStyle(element).getPropertyValue('foo');
跨线程堆占用
我们可以控制 的大小CSSVariableData,使其分配在与 相同大小的桶中HuffmanCode。一个自然的计划是分配一堆CSSVariableData,然后释放其中一个,并分配HuffmanCode以占据该空位,如下图所示。
这个想法很有希望,但实际上,PartitionAlloc 使用ThreadCache。对象分配和释放优先在 ThreadCache 内处理。由于这两个对象不是分配在同一个线程中的,所以我们需要想办法CSSVariableData从ThreadCache中删除。通过阅读ThreadCache的源码,我们找到了方法。
uint8_t limit = bucket.limit.load(std::memory_order_relaxed);
// Batched deallocation, amortizing lock acquisitions.
if (PA_UNLIKELY(bucket.count > limit)) {
ClearBucket(bucket, limit / 2);
}
当桶满时,一半的槽会被移出到原来的SlotSpan。CSSVariableData对于大小为 0x3000 的桶,限制为 16。因此,我们可以通过释放 16CSSVariableData来触发 来占用 的空间ClearBucket,然后分配HuffmanCode。为了保证CSSVariableData我们想要改变的位于后面HuffmanCode,我们需要每隔几次就释放它们。下图说明了这一点(在实际利用中,它每 7 次发布一次)。
从 OOB 到 UAF
现在我们已经成功分配HuffmanCode给freed了CSSVariableData。我们需要调查该对象的哪些字段值得修改。
内存布局CSSVariableData:
回想一下我们的漏洞的原语 – 在 8 字节倍数的偏移量处写入 4 字节。修改对象内的字符串不会带来任何好处,因此我们唯一可以更改的就是字段ref_count_。我们能做什么ref_count_?一个自然的想法是将这个原语转换为 UAF 以便进一步利用。通过OOB写入减少 的值ref_count_,然后触发减少 的操作ref_count_,我们就可以得到一个UAF对象。
但是OOB write写入的值并不是完全可控的。我们首先需要找到一种方法将 增加到ref_count_特定CSSVariableData值。
let rs = getComputedStyle(div0);
// add ref_count with kOverwriteRef
for (let i = 0; i < kOverwriteRef; i++) {
rs.getPropertyValue(kTargetCSSVar);
}
测试表明,调用会暂时getPropertyValue增加. 但GC后,暂时增加的又恢复了。因此,创建UAF对象需要执行以下步骤:ref_count_CSSVariableDataref_count_
-
分配以2CSSVariableData开头。ref_count_
-
调用getPropertyValuekOverwriteRef 次,这将增加ref_count_to kOverwriteRef + 2。
-
触发webp漏洞覆盖ref_count_kOverwriteRef。
-
触发GC,并CSSVariableData会被释放。
-
再次调用getPropertyValue会触发UAF。
在 中getPropertyValue,blink 根据 构造一个字符串length_并将其返回给 JavaScript。因此,我们只需要分配一个完全可控数据的对象,例如AudioArray,并伪造length_字段CSSVariableData即可实现堆上的 OOB 读取。
跨桶分配
我们已经将 OOB 变成了 UAF,但是 UAF 对象只能导致堆上的 OOB 读取。假设这可以解决信息泄露的问题(目前实际上还没有解决),进一步的利用仍然是不可能的。
在之前的闪烁堆漏洞利用中,注意力通常集中在相同大小的对象上,因为它们自然地分配在一起。然而,目前 0x3000 存储桶内没有更好的对象可供利用。我们可以攻击其他尺寸的物体吗?根据我们的调查,答案是肯定的。
PartitionAlloc 将SlotSpanMetadata堆的元数据 ( ) 放置在隔离页上,用户分配的堆槽上唯一剩余的管理信息是空闲列表指针。如果我们可以改变这个指针,我们就可以实现任意地址分配。SlotSpan中的常规免费操作中有双重免费检查。
PA_ALWAYS_INLINE void SlotSpanMetadata::Free(uintptr_t slot_start,
PartitionRoot* root)
// ... snip ...
auto* entry = static_cast<internal::EncodedNextFreelistEntry*>(
SlotStartAddr2Ptr(slot_start));
// Catches an immediate double free.
PA_CHECK(entry != freelist_head);
在ThreadCache中,没有双重释放检查,我们可以多次释放同一个地址。然而,在分配过程中,会进行检查,正如评论中明确指出的那样。
PA_ALWAYS_INLINE static bool IsSane(const EncodedNextFreelistEntry* here,
const EncodedNextFreelistEntry* next,
bool for_thread_cache) {
// Don't allow the freelist to be blindly followed to any location.
// Checks two constraints:
// - here and next must belong to the same superpage, unless this is in the
// thread cache (they even always belong to the same slot span).
// - next cannot point inside the metadata area.
//
// Also, the lightweight UaF detection (pointer shadow) is checked.
我们想要实现任意分配的地方不能属于元数据(之前这种方法用于获取任意内存读写,参考),并且必须与原始槽位于同一个超级页内,这两个条件都可以轻松满足。
因此,假设CSSVariableData是A,占用的AudioArray是B(其中A和B实际上代表的是同一个地址),我们就可以进行经典的fastbin攻击来实现任意地址分配。
-
免费(A)
-
免费(B)
-
malloc(C),并修改freelist地址为0xdeadbeef
-
内存分配(D)
-
malloc(E),为E分配的地址将为0xdeadbeef
但我们要分配到哪里呢?
PartitionAlloc通过bucket管理不同大小的对象,bucket管理由相同大小的slot组成的SlotSpans。SlotSpan的基本单位是Partition Page。有关概念和策略的更多详细信息,请参阅官方文档。例如,插槽大小为 0x3000 的 SlotSpan 由三个分区页组成,总大小为 0xc000,允许分配四个插槽。
不同槽大小的SlotSpan在内存中可能是相邻的。因此,我们只需要在槽大小为0x3000的SlotSpan附近分配包含我们感兴趣的对象的SlotSpan即可实现信息泄露和对象劫持。受到 Man Yue Mo 博文的启发,我们最终选择HRTFPanner(槽大小为 0x500)作为我们要攻击的对象。
我们通过以下方式在内存中喷射对象,修改freelist指针以实现从0x3000槽到0x500槽的分配。
远程代码执行
结合我们之前的所有知识,我们可以总结最终步骤:
-
在堆中喷射大量大小为 0x3000 和 0x500 的 SlotSpan。
-
触发webp漏洞并转换为CSSVariableDataUAF。
-
CSSVariableData利用占用空闲空间AudioArray,利用UAF实现信息泄露。
-
执行跨桶分配HRTFPanner并伪造HRTFPanner对象。
-
触发破坏HRTFPanner实现任意代码执行。
结论
在这篇博文中,我们详细讨论了如何利用 Chrome 中的 OOB 写入漏洞,尽管该漏洞的质量可能并不理想。进行此测试只是为了演示漏洞的可利用性,并没有优先考虑优化成功率。完整的利用代码可以在这里找到,测试环境基于使用此提交在 Ubuntu 22.04 上编译的 Chromium 。
在iOS环境下如何在PAC等缓解机制下利用该漏洞仍然是一个悬而未决的问题。从这个案例可以看出,随着各种缓解机制的引入,漏洞严重程度的评估变得越来越复杂,凸显了单一尺度评级的局限性。DARKNAVY公开分享了libwebp漏洞的漏洞定位、分析、预警、利用重现的闭环研究,旨在通过考虑攻击者视角和环境特征的对抗性评估研究,推动科学漏洞评估的发展。
原文地址:
https://blog.darknavy.com/blog/exploiting_the_libwebp_vulnerability_part_1/
https://blog.darknavy.com/blog/exploiting_the_libwebp_vulnerability_part_2/
感谢您抽出
.
.
来阅读本文
点它,分享点赞在看都在这里
原文始发于微信公众号(Ots安全):CVE-2023-4863 利用 libwebp 漏洞分析