Coming Heart 资源分析(四)

这次我们就来看看上次提到的重头戏吧,篇幅比较长,预计会分为两期, 不过这期依然是重头戏。

前情提要

我们知道了 fcn_00401000 是按位读取,而调用 fcn_00401000 的函数是 fcn_004010f0, 而且在 fcn_004010f0 开头的部分是读取调色板,那么接下来我们应该就会看到读取图像的实际部分了。

读取流程

完整的读取流程分为三步,读取调色板,准备图像内存,以及读取和绘制图像:

1. 从文件中读取调色板

文件的前 768 个字节是调色板,这点我们之前已经提到了, 使用到的调色板也是比较正常的用法,以 RGB 的顺序排列, 每个颜色 1 个字节(8 bit),共 256 种颜色。不过根据我们之前的经验, 这些都不是重点,因为也就是六种组合,试也可以试出来。

2. 准备图像内存

0x0040118a      mov     edi, dword [var_24h]
0x0040118e      xor     eax, eax
0x00401190      add     edi, 0x400 ; 1024
0x00401196      mov     ecx, 0xfa00 ; 64000
0x0040119b      rep     stosd dword es:[edi], eax
0x0040119d      mov     eax, dword [var_24h]
0x004011a1      add     eax, 0x3ff ; 1023
0x004011a6      mov     dword [var_10h], eax

这一段比较简单,简单翻译一下,热一热身:

  1. eax = 0;
  2. stosdes:edi4 字节内容设为 eax
  3. repecx 表示这个循环要执行 64000

所以我们可以知道,程序清理出了一块 64000 x 4 的空间出来, 可能变换一下形式会更加清楚,这块空间的大小是 640 x 400,所以我们知道了图像的大小。

事实上如果我们直接看反编译的代码,这段非常简单:

edi = var_24h;
eax = 0;
edi += 0x400;
ecx = 0xfa00;
memset (edi, eax, ecx);

是的,就是 memory set。

3. 读取和绘制图像

前两步比较简单,我们重点讲这个。读取和绘制图像的过程,整体说来,分为两个部分, 首先,程序从文件中读取出颜色索引和多个偏移量, 然后根据偏移量把颜色索引写入图像的内存空间。全部颜色索引和偏移量都读出后, 文件虽然读取完成了,但是图像却还没有绘制完成,程序还需要执行一个填充过程, 根据一定的填充规则,把之前写入的颜色索引扩展到整个图像。

读取和填充两步中,较为复杂的是读取,限于篇幅,这次我们只讲读取。 读取逻辑整体上还是比较清晰的,大致的步骤可以这样细分:

  1. 读取初始偏移量,并加入累计偏移量。
  2. 根据累计偏移量,确定填入像素的初始位置
  3. 读取填入像素的颜色索引,填入初始位置
  4. 读取出该颜色索引的全部偏移量,根据偏移量填入颜色索引
  5. 从 1 开始循环,直至读到初始偏移量为 0 时,终止循环。

不过这也只是大致的步骤,还有一些细节:

1. 两位字典偏移量:

程序首先会读取 2 位,然后根据读取的内容查表,然后再按位读取表中对应的长度, 比如,查表得到 6,则再读取 6 位作为偏移量, 这个逻辑在初始偏移量和颜色索引偏移量中都有用到。

2. 三位字典偏移量:

三位字典偏移量与两位类似,但细节不同, 三位字典偏移量只用于读取颜色索引的偏移量中,程序首先读取三位, 我们知道这个值应该是 0~7,但是其实这个值程序中是 0~6,然后又对应三种情况:

  1. 对于 1~5,程序会去查表,得到的值与宽度(640)相加,作为新的偏移量,写入当前像素,注意对于 35 的情况,表中数值是负数,如果在现代语言中我们是按无符号整数处理的话,则需要改变一下逻辑,做减法。
  2. 对于 6,程序会根据两位字典偏移量的逻辑再读取一个偏移量加到当前偏移量中,但是 不写入当前像素。
  3. 对于 0,表示全部偏移量已读取完毕,可以跳回读取初始偏移量的位置。

可能文字描述还是不太能精确描述程序的做法,而反编译的代码要么有很多 goto, 不方便理解,要么生成的是伪代码,会缺失一些细节,别担心,这个代码我已经调试好了, 不过注意有点长:

loop {
	// 读取两位后查表,确认需要读取的位数
    let bit2_count = get_bit2_count(&mut reader)?;
	// 读取初始偏移
    let color_offset = reader.get_bit_to_u32(bit2_count)?;
	// 如果偏移量为 0,则循环结束
    if color_offset == 0 {
        break;        
    }
    //info!("initial index: {}, offset: {}", initial_index, color_offset);
	// 初始偏移需要累计
    initial_index += color_offset as usize;
	// 由于初始偏移从 1 开始,所以当前索引位置需要减 1
    current_index = initial_index - 1;
	// 读取颜色索引
    current_color = reader.get_bit(8)?;
	// 颜色计数,统计用变量
    color_count += 1;
	// 当前位置设定为读取出的颜色索引
    image.data[current_index] = current_color;

    loop {
		// 读取 3 位
        let bit3_val = reader.get_bit(3)?;
        if bit3_val == 0 {
            break;
        }

        if bit3_val != 6 {
			// 一定不会超过 5
            assert!(bit3_val < 6);
			// 先下移一行
            current_index += 640;
			// 原值为 0,0,1,-1,2,-2,所以 3、5 需要做减法
            let offset_vec = vec![0, 0, 1, 1, 2, 2];
            let bit3_index = bit3_val as usize;
			// 根据查表结果修正偏移
            match bit3_index {
                1 | 3 | 5 => {
                    current_index -= offset_vec[bit3_index];
                }
                _ => {
                    current_index += offset_vec[bit3_index];
                }
            };
			// 涂色
            image.data[current_index] = current_color;
        } else {
			// 根据 2 位偏移量下移 n 行,注意,不涂色
            let bit2_count = get_bit2_count(&mut reader)?;
            let offset = reader.get_bit_to_u32(bit2_count)?;
            current_index += offset as usize * 640;
        }
    }
}

可能有读者会问,读表是如何确定的,对于我们这个例子,读表对应的是这行代码:

0x004011b5      mov ebx, dword [eax*4 + 0x414058]

很显然 0x414058 是一个数组,eax 存储的是偏移量,但是这个 0x414058 不是指内存位置,而是程序,我们可以用 cutter 切换到 hexdump 页签来查看:

Untitled

这样就不难看出,这是一个长度为 4 的数组,存储的值为 4, 6, 8, 20

总结

写出来感觉虽然有点复杂但是也不是很困难对不对,每次回顾完成代码的时候都会有这种感觉, 但是其实也知道中间经历了多少困难,时间一般都花在了读懂汇编和调试代码上了, 有思路的话真正写代码不会花很多时间。我们把目前的成果画出来作为收尾吧:

title