像素画解码器中的调色板越界与内存涂色
漏洞分析
一个小小的像素索引,表面上只是选个颜色,背后却可能牵出一次越界读取。
> 一个小小的像素索引,表面上只是选个颜色,背后却可能牵出一次越界读取。 > > ## ▣ PIX-01|索引字节不是颜色 - 8-bit 像素图通常不会在像素流中直接保存 RGB 颜色,而是保存调色板索引。像素字节 `0x07` 本身不是颜色,只有结合文件头中的调色板起始索引、调色板长度和像素位宽,才能确定它是否对应某个合法颜色项。 - TGA 的 color-mapped 模式很适合观察这个问题:文件头声明调色板范围,像素流保存索引,解码器再把索引转换成颜色。如果解码器在取色前没有校验索引范围,文件中的一个像素字节就可以直接参与内存地址计算,从而导致调色板数组越界读取。 - 这个问题的核心不在文件格式本身,而在“文件索引”到“数组下标”的转换关系:合法索引必须落在 `idx ∈ [cmap_first, cmap_first + cmap_len)`,真正访问调色板数组时还要使用 `idx - cmap_first`。 **只要解码器跳过这一步,调色板越界读取就可能在普通构建中表现为错误涂色,在带检测器的构建中表现为明确的堆越界读。**  ▣ PIX-02|TGA 文件头如何约束调色板 ----------------------- TGA 文件头固定 18 字节。对于 color-mapped 图像,和调色板访问直接相关的字段如下。 | | | | | |---|---|---|---| | 偏移 | 长度 | 字段 | 含义 | | `0x01` | 1 | color map type | `1` 表示存在调色板 | | `0x02` | 1 | image type | `1` 表示未压缩索引色图像 | | `0x03` | 2 | color map first entry | 调色板起始索引,小端序 | | `0x05` | 2 | color map length | 调色板项数量,小端序 | | `0x07` | 1 | color map entry size | 单个调色板项位宽 | | `0x0c` | 2 | width | 图像宽度,小端序 | | `0x0e` | 2 | height | 图像高度,小端序 | | `0x10` | 1 | pixel depth | 单个像素索引位宽 | 如果文件头声明: ```php color map first entry = 0 color map length = 2 ``` 则有效调色板范围为: ```php [0, 2) ``` 合法索引只有 `0` 和 `1`。像素流中的 `2`、`7`、`255` 都不应进入调色板数组访问。 如果文件头声明: ```php color map first entry = 4 color map length = 2 ``` 则有效范围变为: ```php [4, 6) ``` 此时 `4` 和 `5` 是合法索引,但对应的数组下标分别是 `0` 和 `1`。因此,正确取色关系是: ```php idx 属于 [cmap_first, cmap_first + cmap_len) palette_array_index = idx - cmap_first ``` ▣ PIX-03|复现用的两个程序 ----------------- ### ▫ 03-01|生成索引色样本:`make_tga.py` ```php from pathlib import Path import struct def write_tga(path: str, first: int, indexes: bytes) -> None: """写入一个未压缩索引色 TGA 文件。""" header = struct.pack( "<BBBHHBHHHHBB", 0, # id_len: 没有 Image ID,调色板紧跟在 18 字节文件头之后 1, # cmap_type: 1 表示存在调色板 1, # img_type: 1 表示未压缩 color-mapped 图像 first, # cmap_first: 调色板起始索引,由测试用例传入 2, # cmap_len: 调色板包含 2 个颜色项 24, # cmap_depth: 每个颜色项为 24-bit BGR 0, 0, # x_origin, y_origin 2, 2, # width, height: 构造 2x2 图像,共 4 个像素 8, # pixel_depth: 每个像素索引用 1 字节表示 0, # img_desc ) # TGA 24-bit 调色板按 BGR 顺序存储。 palette = bytes([ 0x00, 0x00, 0x00, # pal[0] = black -> rgb=000000 0xff, 0x00, 0xff, # pal[1] = magenta -> rgb=ff00ff ]) if len(indexes) != 4: raise ValueError("2x2 image requires exactly 4 pixel indexes") Path(path).write_bytes(header + palette + indexes) # 合法样本:调色板范围为 [0,2),像素索引均为 0 或 1。 write_tga("good.tga", 0, bytes([0, 1, 1, 0])) # 越界样本:只把第三个像素改为 7,超过 [0,2)。 write_tga("bad.tga", 0, bytes([0, 1, 7, 0])) # 起始索引不为 0 的合法样本:调色板范围为 [4,6),像素索引为 4 或 5。 write_tga("offset_good.tga", 4, bytes([4, 5, 4, 5])) for name in ("good.tga", "bad.tga", "offset_good.tga"): print(name, Path(name).stat().st_size, "bytes") ``` ### ▫ 03-02|带安全开关的解码器:`tga_palette.c` ```php #include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #pragma pack(push, 1) typedef struct { uint8_t id_len, cmap_type, img_type; uint16_t cmap_first, cmap_len; uint8_t cmap_depth; uint16_t x_origin, y_origin, width, height; uint8_t pixel_depth, img_desc; } TgaHeader; #pragma pack(pop) typedef struct { uint8_t b, g, r; } BGR; static uint16_t le16(uint16_t x) { /* TGA 字段为小端序。这里按字节重组,避免依赖主机端序。 */ uint8_t *p = (uint8_t *)&x; return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); } static int reject(const char *msg) { fprintf(stderr, "reject: %s\n", msg); return 1; } int main(int argc, char **argv) { if (argc != 3 || (strcmp(argv[1], "--unsafe") && strcmp(argv[1], "--safe"))) { fprintf(stderr, "usage: %s --unsafe|--safe file.tga\n", argv[0]); return 2; } int safe = strcmp(argv[1], "--safe") == 0; /* 关闭 stdout 缓冲,便于在 ASan 报错前看到已经输出的像素。 */ setvbuf(stdout, NULL, _IONBF, 0); FILE *fp = fopen(argv[2], "rb"); if (!fp) { perror("fopen"); return 1; } TgaHeader h; if (fread(&h, 1, sizeof(h), fp) != sizeof(h)) { fclose(fp); return reject("short header"); } /* 如果存在 Image ID,需要跳过后再读取调色板。 */ if (h.id_len && fseek(fp, h.id_len, SEEK_CUR) != 0) { fclose(fp); return reject("invalid image id"); } uint32_t first = le16(h.cmap_first); uint32_t len = le16(h.cmap_len); uint32_t end = first + len; uint16_t w = le16(h.width); uint16_t height = le16(h.height); if (h.cmap_type != 1 || h.img_type != 1) { fclose(fp); return reject("not color-mapped TGA"); } if (h.cmap_depth != 24 || h.pixel_depth != 8) { fclose(fp); return reject("need BGR888 palette and 8-bit index"); } if (w == 0 || height == 0 || len == 0) { fclose(fp); return reject("empty image or palette"); } /* 8-bit 像素索引只能表达 0..255,声明范围不能超过该空间。 */ if (first > 255 || end > 256 || end < first) { fclose(fp); return reject("palette range exceeds 8-bit index space"); } printf("type=%u cmap=[%u,%u) size=%ux%u pixel_depth=%u\n", h.img_type, first, end, w, height, h.pixel_depth); BGR *pal = calloc(len, sizeof(BGR)); if (!pal) { fclose(fp); return reject("palette allocation failed"); } if (fread(pal, sizeof(BGR), len, fp) != len) { free(pal); fclose(fp); return reject("short palette"); } uint64_t pixels = (uint64_t)w * (uint64_t)height; if (pixels > SIZE_MAX) { free(pal); fclose(fp); return reject("image size overflow"); } size_t n = (size_t)pixels; uint8_t *idx = malloc(n); if (!idx) { free(pal); fclose(fp); return reject("index allocation failed"); } if (fread(idx, 1, n, fp) != n) { free(idx); free(pal); fclose(fp); return reject("short pixel data"); } for (size_t i = 0; i < n; i++) { if (safe && ((uint32_t)idx[i] < first || (uint32_t)idx[i] >= end)) { fprintf(stderr, "reject: pixel[%zu] index %u outside palette [%u,%u)\n", i, idx[i], first, end); free(idx); free(pal); fclose(fp); return 1; } /* * safe 模式:先把文件索引转换为调色板数组偏移。 * unsafe 模式:故意直接使用文件索引,保留漏洞行为。 */ size_t off = safe ? (size_t)((uint32_t)idx[i] - first) : (size_t)idx[i]; /* 越界读就发生在这里:off 未校验时,pal[off] 可能越过调色板数组。 */ BGR c = pal[off]; printf("pixel[%zu] idx=%u rgb=%02x%02x%02x\n", i, idx[i], c.r, c.g, c.b); } free(idx); free(pal); fclose(fp); return 0; } ``` ▣ PIX-04|从样本生成到越界触发 ------------------- ### ▫ 04-01|生成三个对照样本 ```php python3 make_tga.py ```  三个文件大小均为 28 字节,由以下部分组成: ```php 18 字节 TGA 文件头 + 6 字节调色板 + 4 字节像素索引流 = 28 字节 ``` ### ▫ 04-02|对比一个字节的差异 ```php od -Ax -tx1 -v good.tga ``` `good.tga` 的字节内容:  ```php od -Ax -tx1 -v bad.tga ``` `bad.tga` 的字节内容:  两者文件头完全一致,大小也相同。差异只出现在偏移 `0x1a`,也就是第三个像素索引。 | | | | |---|---|---| | 文件 | 像素索引区 `0x18-0x1b` | 第三个像素 | | `good.tga` | `00 01 01 00` | `1` | | `bad.tga` | `00 01 07 00` | `7` |  文件头偏移 `0x05-0x06` 为 `02 00`,表示调色板只有 2 项。对于 `cmap_first = 0` 的样本,合法范围是 `[0,2)`,因此 `bad.tga` 的第三个像素索引 `7` 明确越界。 ### ▫ 04-03|用 AddressSanitizer 编译 ```php gcc -O0 -g -fsanitize=address -fno-omit-frame-pointer tga_palette.c -o tga_palette ```  这个版本用于确认越界访问的发生位置。`-O0` 和 `-g` 便于把报错对应回源码;`-fno-omit-frame-pointer` 便于生成更清晰的调用栈。 ### ▫ 04-04|合法输入只访问两个颜色项 ```php ASAN_OPTIONS=detect_leaks=0 ./tga_palette --unsafe good.tga ```  - 四个像素索引分别为 `0、1、1、0`。 - 所有索引都落在 `[0,2)`。 - 即使使用 `--unsafe`,本输入也只会访问 `pal[0]` 和 `pal[1]`,因此不会触发越界。 - 调色板中两个 BGR 项分别是: ```php 00 00 00 -> rgb=000000 ff 00 ff -> rgb=ff00ff ``` ### ▫ 04-05|第三个像素把取色带出调色板 ```php ASAN_OPTIONS=detect_leaks=0 ./tga_palette --unsafe bad.tga 2>&1 | head -18 ```  可以先看到前两个像素正常,随后第三个像素取色触发堆越界读。 越界过程可以拆开看: - `READ of size 3` 与 `BGR` 结构体大小一致,说明越界发生在读取颜色项阶段。 - 触发点是取色语句: ```php BGR c = pal[off]; ``` - `bad.tga` 的第三个像素索引为 `7`。 - 不安全路径中,程序直接使用文件索引: ```php off = idx[i] = 7 sizeof(BGR) = 3 read offset = 7 * 3 = 21 ``` - 实际调色板只有 2 项: ```php palette length = 2 allocated size = 2 * 3 = 6 bytes valid byte offsets = 0..5 ``` 因此 `pal[7]` 会越过调色板分配区域。文件里只改动了一个像素字节,但该字节在取色时被放大为 `index * sizeof(BGR)` 的内存偏移。 ### ▫ 04-06|普通构建会把越界内存当颜色 ```php gcc -O0 -g tga_palette.c -o tga_palette_plain ./tga_palette_plain --unsafe bad.tga ```  `bad.tga` 确实进入了不安全取色路径。 第三个像素是: ```php pixel[2] idx=7 rgb=000000 ``` 但当前调色板范围是: ```php cmap=[0,2) ``` 合法索引只有 `0` 和 `1`,所以 `idx=7` 是越界索引。 这里的“内存涂色”不是图像特效,而是解析器把调色板之外的内存误当作颜色数据继续输出。 ### ▫ 04-07|在取色前拒绝越界索引 ```php ASAN_OPTIONS=detect_leaks=0 ./tga_palette --safe bad.tga 2>&1 ```  - 第三个像素在取色前被拒绝,没有进入 `pal[off]`。 - 修复点必须位于“索引转颜色”之前。 - 正确逻辑不是先输出颜色再检查,而是先验证索引范围,再计算数组下标。 关键修复逻辑如下: ```php if ((uint32_t)idx[i] < first || (uint32_t)idx[i] >= end) { fprintf(stderr, "reject: pixel[%zu] index %u outside palette [%u,%u)\n", i, idx[i], first, end); return 1; } size_t off = (size_t)((uint32_t)idx[i] - first); BGR c = pal[off]; ``` ### ▫ 04-08|起始索引不为 0 时的正确映射  *图 3:`cmap\_first` 是文件索引起点,不是调色板数组下标。* ```php od -Ax -tx1 -v offset_good.tga ```  ```php color map first entry = 04 00 = 4 color map length = 02 00 = 2 color map entry size = 18 = 24-bit ``` 所以调色板声明的是: ```php 有效索引范围:[4,6) 合法索引:4、5 ``` 后面的像素数据:  这说明 4 个像素索引全部合法。 安全路径运行: ```php ASAN_OPTIONS=detect_leaks=0 ./tga_palette --safe offset_good.tga ```  不安全路径运行: ```php ASAN_OPTIONS=detect_leaks=0 ./tga_palette --unsafe offset_good.tga 2>&1 | head -16 ``` 出现堆越界读:  可以确认: - `offset_good.tga` 是一个合法样本。 - 合法索引 `4` 应映射到数组下标 `0`,合法索引 `5` 应映射到数组下标 `1`。 - 不安全路径直接访问 `pal[4]`,因此即使输入合法,也会发生越界。 - 这个样本证明修复不能写成 `idx < len`,也不能直接使用 `pal[idx]`。 正确关系必须同时满足: ```php idx ∈ [first, first + len) off = idx - first ``` ### ▫ 04-09|扫描边界值 把 `good.tga` 的第三个像素替换为几个代表性值 ```php python3 - <<'PY' from pathlib import Path base = bytearray(Path("good.tga").read_bytes()) for value in (0, 1, 2, 7, 255): mutated = bytearray(base) mutated[0x1a] = value Path(f"idx_{value:03}.tga").write_bytes(mutated) PY for f in idx_000.tga idx_001.tga idx_002.tga idx_007.tga idx_255.tga; do echo "== $f ==" ASAN_OPTIONS=detect_leaks=0 ./tga_palette --safe "$f" 2>&1 | tail -1 done ```  可以看出: | | | | |---|---|---| | 第三个像素索引 | 结果 | 原因 | | `0` | 接受 | 落在 `[0,2)` | | `1` | 接受 | 落在 `[0,2)` | | `2` | 拒绝 | 等于上界,半开区间不包含上界 | | `7` | 拒绝 | 超过调色板上界 | | `255` | 拒绝 | 虽然是 8-bit 最大值,但不属于当前调色板范围 | `idx_000.tga` 和 `idx_001.tga` 能完整解码,所以最后一行是第四个像素的输出。其他样本会在第三个像素处被拒绝。 ▣ PIX-05|检查必须发生在取色之前 -------------------- 索引色图像解码通常包含三个阶段: ```php 文件头解析 -> 调色板加载 -> 像素索引转颜色 ``` 文件头解析只能得到调色板范围,不能证明后续每个像素索引都合法。颜色输出后再检查也没有意义,因为越界读取已经发生。 边界检查应放在像素索引转颜色之前:  对于 8-bit palette index,还需要确保文件头声明的调色板范围没有超过索引空间: ```php uint32_t first = le16(h.cmap_first); uint32_t len = le16(h.cmap_len); uint32_t end = first + len; if (first > 255 || end > 256 || end < first) { return reject("palette range exceeds 8-bit index space"); } ``` 如果解码器后续支持 16-bit 索引,需要在读取像素索引时处理小端序,并把索引空间上限调整为 `65536`。如果支持 RLE,则应在每个像素索引展开后执行同样的边界检查。压缩方式只改变索引到达取色代码的路径,不改变调色板边界关系。 ▣ PIX-06|进一步验证 -------------- ### ▫ 06-01|上界不属于合法范围 用边界值扫描中生成的 `idx_002.tga` 可以直接验证半开区间的上界规则。该文件只把第三个像素改成 `2`,而当前调色板范围是 `[0,2)`。 运行安全路径时会得到: ```php reject: pixel[2] index 2 outside palette [0,2) ``` 这说明 `[first, end)` 包含下界,不包含上界。判断条件应写成 `idx >= end`,不能写成 `idx > end`。 ### ▫ 06-02|调色板起始索引不是数组下标 `offset_good.tga` 的文件头声明 `cmap_first = 4`、`cmap_len = 2`,因此 `4` 和 `5` 都是合法文件索引。 ```php ASAN_OPTIONS=detect_leaks=0 ./tga_palette --safe offset_good.tga ```  ```php ASAN_OPTIONS=detect_leaks=0 ./tga_palette --unsafe offset_good.tga 2>&1 | head -16 ```  安全路径会正常输出 4 个像素;不安全路径会在第一个像素就触发越界读取。原因是文件索引 `4` 应映射到数组下标 `0`,也就是 `4 - 4 = 0`。直接访问 `pal[4]` 会越过只有 2 项的调色板数组。 ### ▫ 06-03|普通构建的输出没有稳定语义 普通构建可以用来观察错误涂色,但不能用来判断输入是否安全: ```php ./tga_palette_plain --unsafe bad.tga ``` 程序可能继续输出第三个像素的颜色,也可能在不同环境中表现不同。越界读属于未定义行为;没有崩溃不代表没有漏洞,打印出来的颜色也不具备稳定语义。验证这类问题时,应结合 AddressSanitizer、边界测试和源码审计。 ### ▫ 06-04|文件头异常也要拒绝 像素索引检查是防止调色板越界的关键,但文件头本身也要做基本约束。可以继续构造以下异常样本,确认解码器能在取色前拒绝输入。 | | | | |---|---|---| | 测试项 | 示例 | 应有行为 | | 空调色板 | `cmap_len = 0` | 拒绝 | | 超出 8-bit 索引空间 | `cmap_first = 255, cmap_len = 2` | 拒绝 | | 非 24-bit 调色板 | `cmap_depth = 32` | 拒绝 | | 非 8-bit 像素索引 | `pixel_depth = 16` | 当前演示解码器拒绝 | | 像素数据不足 | 文件尾截断 | 拒绝 | 这些检查不能替代逐像素索引校验,但可以避免明显不符合当前解析能力的输入继续进入解码流程。 ▣ PIX-07|证据链 ------------ | | | |---|---| | 技术判断 | 可观察证据 | | 越界样本只改动了一个像素索引 | `od` 显示偏移 `0x1a` 从 `01` 变为 `07` | | 调色板只有 2 项 | 文件头偏移 `0x05-0x06` 为 `02 00` | | 越界发生在取色阶段 | ASan 报告 `READ of size 3`,对应 `BGR c = pal[off]` | | 像素字节参与地址计算 | 不安全路径中 `off = idx[i]`,访问偏移为 `idx * sizeof(BGR)` | | 普通构建可能继续输出错误颜色 | 无 ASan 版本可能打印 `pixel[2] idx=7 rgb=...` | | 安全路径阻断越界 | `--safe bad.tga` 在第三个像素拒绝 `index 7 outside palette [0,2)` | | `cmap_first` 不能忽略 | `[4,6)` 样本在安全路径正常,在不安全路径访问 `pal[4]` 越界 | ▣ PIX-08|加固规则 -------------  解析外部图片时,文件扩展名、MIME 类型、图像尺寸都不能证明像素索引落在调色板范围内。真正需要约束的是取色前的数组下标关系。 加固时应落实以下规则: 1. 解析文件头后,计算有效索引范围:`[cmap_first, cmap_first + cmap_len)`。 2. 对每个像素索引执行范围检查,拒绝小于 `cmap_first` 或大于等于 `cmap_first + cmap_len` 的值。 3. 通过检查后,再计算数组下标:`palette_array_index = idx - cmap_first`。 4. 根据像素位宽限制调色板声明范围,8-bit 索引不能超过 `[0,256)`。 5. 对 RLE、批量解码、SIMD 解码等路径保持同样的检查语义,避免只修复单一路径。 6. 对文件截断、空调色板、尺寸溢出、不支持的位深等输入进行独立拒绝。 最终安全条件可以压缩为两行: ```php idx ∈ [cmap_first, cmap_first + cmap_len) palette_array_index = idx - cmap_first ``` 只要缺少这一步,文件中的一个像素字节就不再只是颜色选择,而可能变成调色板数组之外的内存读取偏移。 ▣ PIX-09|一个字节的边界感 ----------------- 索引色图像里,像素字节并不直接代表颜色,它只是通往调色板的一把钥匙。问题在于,这把钥匙能不能开门,必须先看它是否落在文件头声明的范围内。 调色板越界读的危险之处,正在于它看起来不像一次明显的破坏:文件大小没变,图像还能解码,程序也未必崩溃。但只要解码器把外部索引直接当成数组下标,调色板之外的内存就可能被误读成颜色。 最终的修复并不复杂,却必须足够靠前: ```php 先检查 idx 范围,再计算 idx - cmap_first,最后读取颜色。 ``` 一个像素字节可以选择颜色,但不应该决定内存边界。
发表于 2026-08-04 17:36:53
阅读 ( 63 )
分类:
二进制
0 推荐
收藏
0 条评论
Dracarys
信息安全工程师
7 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!