Write-up | NeSE 丙组 202202

2022 年 2 月丙组月赛,黄队没有 pwn 手出题,所以硬着头皮做了 babybmp 和 wind0ws 两道逆向题,还挺好玩。

babybmp

题目叫 baby 了也不算很难,搞清楚程序做了什么就可以,但是由于有个大聪明把 7 写成 0x111 然后搞了半天没有发现问题在哪,我不说他是谁,希望大家不要学我。

程序文件名叫 bmpencrypt, 然后给了一张很好看的风景图片,名字为 dst.bmp ,所以看起来是把什么东西通过 bmpencrypt 程序加密得到了图片文件。下面是核心逻辑之前的一些准备代码,打开了 3 个图片文件,确定了 flag.bmp 和 src.bmp 的大小,为它们各自分配了缓冲区,把两个文件的内容全部拷贝到缓冲区中。

flagBmp = fopen("flag.bmp", "r");
srcBmp = fopen("src.bmp", "r");
dstBmp = fopen("dst.bmp", "w");
fseek(flagBmp, 0LL, SEEK_END);
fseek(srcBmp, 0LL, SEEK_END);
flagLength = ftell(flagBmp);
srcLength = ftell(srcBmp);
flagBuf = (unsigned __int8 *)malloc(flagLength);
srcBuf = (unsigned __int8 *)malloc(srcLength);
fseek(flagBmp, 0LL, SEEK_SET);
fseek(srcBmp, 0LL, SEEK_SET);
fread(flagBuf, flagLength, 1uLL, flagBmp);
fread(srcBuf, srcLength, 1uLL, srcBmp);

接下来就是核心的加密算法部分,先将 src 的位置指针设置为 54 ,然后遍历 flag 文件的每一个字节。通过循环结束后的 fwrite 函数调用我们可以得知 srcBuf 中就是最终要写入到 dst.bmp 文件的内容,那么我们可以关注循环体中对 srcBuf 缓冲区的修改,在一次循环中发生 4 次(我已在伪代码的注释中标出),计算一下 srcPosa 变量可知四次修改的分别是 srcBuf[srcPos]srcBuf[srcPos + 1]srcBuf[srcPos + 2]srcBuf[srcPos + 3] ,那么也就是说每一次循环处理 src 图片中连续的 4 个字节。

然后看一下 4 次对 srcBuf 缓冲区赋值的来源,比较统一,都是把 flagUChar 变量和 srcBuf 缓冲区当前位置的值进行一些运算,基本上可以推测出 src 图像中的每 4 个字节包含 flag 图像中一个字节的信息。

srcPos = 54;
for (flagPos = 0; flagLength > flagPos; ++flagPos )
{
flagUChar = flagBuf[flagPos];
srcBuf[srcPos] = flagUChar & 7 | srcBuf[srcPos] & 0xF8;// 1: (flagUChar>>0)&0x0111
flagUChara = (int)flagUChar >> 3;
srcPosa = srcPos + 1;
srcBuf[srcPosa] = flagUChara & 3 | srcBuf[srcPosa] & 0xFC;// 2: (flagUChar>>3)&0x0011
flagUCharb = (int)flagUChara >> 2;
++srcPosa;
srcBuf[srcPosa] = flagUCharb & 1 | srcBuf[srcPosa] & 0xFE;// 3: (flagUChar>>5)&0x0001
++srcPosa;
srcBuf[srcPosa] = ((int)flagUCharb >> 1) & 3 | srcBuf[srcPosa] & 0xFC;// 4: (flagUChar>>6)&0x0011
srcPos = srcPosa + 1;
}
fwrite(srcBuf, srcLength, 1uLL, dstBmp); // srcBuf -> dstBmp
free(flagBuf);
free(srcBuf);
return 0;

我们来具体分析一下四次赋值具体发生了什么。首先第一次将 flagUChar7 (0b00000111) 进行按位与运算,也就是取其最低 3 位;然后把 srcBuf[srcPos]0xF8 (0b11111100) 进行按位与运算,也就是清空其最低 3 位。最后再把两部分结果进行按位或。第二次将 flagUChar 右移了 3 位,再跟 3 (0b00000011) 进行按位与,第三次在前面的基础上再右移 2 位,相当于把 flagUChar 总共右移 5 位,与 1 (0b00000001) 进行按位与,第四次在前面基础上再右移 1 位,也就是总共右移 6 位,跟 3 (0b00000011) 进行按位与。要能够通过 src 图片还原出 flag 图片,那么四个字节的 srcBuf 缓冲区中必须包含一个字节的 flagUChar 的全部信息。

我在下面列出了 4 次运算中两边各自保留下来的信息位,1 表示信息保留下来,0 表示信息被丢弃,同时左边还表示了 flagUChar 进行移位和按位与运算之后的结果,省略了前面应该补充的 0 。第一次运算中,flagUChar 保留了低 3 位,而 srcBuf 刚好清空了低 3 位,两者进行按位或,那么 flagUChar 最低三位的值就保存在 srcBuf 第一个字节的低三位中。第二次运算,flagUChar 右移三位然后保留最低两位,也就是保留下面标号为 4 和 5 的两位(要注意这两位现在在最低位上),而 srcBuf 刚好清空了最低两位,那么 flagUChar 的 4 和 5 两位的信息就保存在 srcBuf 第二个字节的最低两位中。后面两次以此类推,也都是刚好对应上。

1 2 3 4 5 6 7 8		1 2 3 4 5 6 7 8
---------------- ----------------
0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0
0 0 0 1 1 1 1 1 1 1 1 0 0
0 0 1 1 1 1 1 1 1 1 0
1 1 1 1 1 1 1 1 0 0

那么问题就解决了,我们每次从 dst 图片中读取 4 个字节,分别从每个字节中提取 flag 图片一个字节不同位置的值,将它们组合起来,就可以得到 flag 图片了,以下为解密程序。

#include <stdio.h>
#include <stdlib.h>

int main() {
/* 模仿 bmpencrypt 程序进行类似的文件读取操作 */
FILE *fp_dst = fopen("dst.bmp", "r");
FILE *fp_flag = fopen("flag.bmp", "w");

fseek(fp_dst, 0, SEEK_END);
long dst_length = ftell(fp_dst);
fseek(fp_dst, 0, SEEK_SET);
char *flag_buf = malloc(dst_length);
char *dst_buf = malloc(dst_length);
fread(dst_buf, dst_length, 1, fp_dst);

/**
* 每次读取 4 个字节,从中提取出 flag 图片一个字节的值。
* 不知道 flag 图片是多长,但是没有关系 BMP 头里有图片长度的信息,不影响图片显示。
*/
int flag_pos = 0;
for (int dst_pos = 54; dst_pos < dst_length; dst_pos += 4) {
char flag_byte;
flag_byte = dst_buf[dst_pos] & 0b111 |
(dst_buf[dst_pos + 1] & 0b11) << 3 |
(dst_buf[dst_pos + 2] & 0b1) << 5 |
(dst_buf[dst_pos + 3] & 0b11) << 6;
flag_buf[flag_pos++] = flag_byte;
}

fwrite(flag_buf, flag_pos - 1, 1, fp_flag);
free(dst_buf);
free(flag_buf);
fclose(fp_dst);
fclose(fp_flag);

return 0;
}

wind0ws

题目给了 loaderflag_inside.exe 两个文件,还有这样一段如下的提示,说需要用 loader 程序去加载 flag_inside.exe 程序,然后我们需要去逆向分析 loader 程序,只需要修改一个字节就可以把 flag 打印出来,但是没有说是修改哪个程序。

Usage : loader flag_inside.exe

Reverse the loader to figure out the details of binary file -- 'flag_inside'

Modify one byte to get flag printed

flag{.......}

其中 loader 是一个 64 位的 ELF 可执行程序,而 flag_inside.exe 显示是一个 MS-DOS 可执行程序。但是在将 flag_inside.exe 拖进 IDA Pro 时它提示这是一个 “packed” 程序,加载之后并没有显示出什么程序信息,可能这是一个加壳的程序,或者这可能压根不是个可执行程序只是伪造了一个文件头,没有关系我们直接以 binary file 加载看看(在 “Load a new file” 窗口选择 “Binary file” 而不是 “MS-DOS executable(EXE)”)。

$ file loader
loader: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=617291874ad36461d9b439abf71e53a9d4df8fe0, for GNU/Linux 4.4.0, not stripped
$ file flag_inside.exe
flag_inside.exe: MS-DOS executable

按照提示运行,得到下面的输出。

$ ./loader flag_inside.exe
[Name]: hello.exe
[Info]: compiled by AUx
[Build]: bili:672328094
Hellow world

_ _ _____ _ _______ _____ _____

| | | |_ _| \ | | _ \ _ / ___|

| | | | | | | \| | | | | |/' \ `--.

| |/\| | | | | . ` | | | | /| |`--. \

\ /\ /_| |_| |\ | |/ /\ |_/ /\__/ /

\/ \/ \___/\_| \_/___/ \___/\____/

首先分析 loader 程序,逻辑还是比较复杂的。

int __cdecl main(int argc, const char **argv, const char **envp)
{
...
file_hdr *some_hdr; // [rsp+30h] [rbp-10h]
entry_struct *entry_struct; // [rsp+38h] [rbp-8h]

if (argc == 2 )
{
filename = argv[1];
fp = fopen(filename, "rb");
if (fp)
{
start_pos = (unsigned int)locate_start(fp);// integer at offset 60
fseek(fp, start_pos, SEEK_SET);
some_hdr = (file_hdr *)read_basic_info(fp);// a struct, size:0x34
printf("[Name]: %s\n[Info]: %s\n[Build]: %s\n", some_hdr->name, some_hdr->info, some_hdr->build);
entry_num = some_hdr->entry_num;
if (entry_num <= 0x10 )
{
for (i = 0; entry_num > i; ++i )
{
entry_struct = read_entry(fp); // another struct with size 0xC, right after basic info
pos = ftell(fp);
run_section(fp, entry_struct);
fseek(fp, pos, 0); // point to next entry struct
free_hdr(entry_struct);
}
free_hdr(some_hdr);
fclose(fp);
result = 0;
}
...
}

在伪代码中我已经定义好了两个结构体,它们的定义如下。这两个结构体的组成成分根据对后面各个函数的引用得出的,字段名字是根据代码中的字符串以及加上些许猜测得出的。在 IDA Pro 中可以打开 “Local Types” 窗口,按下 insert 键添加自定义的结构体声明,然后右击刚添加的结构体,点击 “Synchorize to idb” ,它就会被同步到 “Structures” 窗口中,然后我们就可以在修改变量类型时使用了。

struct some_hdr {
char name[16];
char info[16];
char build[16];
int entry_num;
};

struct entry_struct {
char ascii_or_code; // 1 or 2
char printout; // 1 or 0
// 2 bytes' padding
int data_offset;
int data_size;
};

程序在打开目标程序(文件)之后,首先调用了 locate_start 函数,从目标文件偏移量 60 处读取了 4 个字节的整数并返回,接着把它传递给 fseek 函数设置指针位置。

__int64 __fastcall locate_start(FILE *fp)
{
unsigned int ptr; // [rsp+14h] [rbp-Ch] BYREF
unsigned __int64 v3; // [rsp+18h] [rbp-8h]

v3 = __readfsqword(0x28u);
fseek(fp, 60LL, 0);
fread(&ptr, 1uLL, 4uLL, fp); // read a four bytes' integer from offset 60
return ptr;
}

通过在 flag_inside.exe 文件中查看偏移 60 处的值我们发现其定义的指针位置为 0x100

seg000:0000003B                 db    0
seg000:0000003C start_pos dd 100h
seg000:00000040 db 0Eh
seg000:00000041 db 1Fh

然后程序调用 read_basic_info 函数从这个偏移量处读取了 0x34 个字节。

void *__fastcall read_basic_info(FILE *fp)
{
void *ptr; // [rsp+18h] [rbp-8h]

ptr = new_hdr(0x34uLL);
fread(ptr, 1uLL, 0x34uLL, fp);
return ptr;
}
void *__fastcall new_hdr(size_t a1)
{
void *s; // [rsp+18h] [rbp-8h]

s = malloc(a1);
memset(s, 0, a1);
return s;
}

在这里简单介绍一下结构体的识别和分析,下面是未修改过的原始伪代码,可以看到 read_basic_info 函数的返回指针保存到了变量 v10 ,使用 printf 函数打印出了其指向缓冲区的一些信息,通过 printf 的第一个参数可以知道 v10v10 + 16v10 + 32 处都是字符串,可以认为它们是字符数组,大小都是 16 ,且三个字段的名字也可以从中得知。在后面的代码中还有一处 printf 打印了变量 off_4 的值,我们可以从中得知它是一个 4 字节的整数类型,且其含义是 entries 的数量,从 flag_inside.exe 中可知其 entries 数量为 4, 这个数据在后面比较关键。

v10 = read_basic_info(stream);
printf(
"[Name]: %s\n[Info]: %s\n[Build]: %s\n",
(const char *)v10,
(const char *)(v10 + 16),
(const char *)(v10 + 32));
off_4 = *(_DWORD *)(v10 + 48);
...
printf("Error, too many entries : %d\n", off_4);

当 entries 数量不大于 16 时,程序接着调用 read_entry 函数,从刚刚 basic info 之后的位置读取 0xC 个字节,保存到 entry_struct 结构体,结构体字段的识别分析不再赘述。

for (i = 0; entry_num > i; ++i )
{
entry_struct = read_entry(fp); // another struct with size 0xC, right after basic info
pos = ftell(fp);
run_section(fp, entry_struct);
fseek(fp, pos, 0); // point to next entry struct
free_hdr(entry_struct);
}

entry_struct *__fastcall read_entry(FILE *a1)
{
entry_struct *ptr; // [rsp+18h] [rbp-8h]

ptr = (entry_struct *)new_hdr(0xCuLL);
fread(ptr, 1uLL, 0xCuLL, a1);
return ptr;
}

最终我们来到关键的 run_section 函数,可以看到它根据 entry_struct 结构体的 ascii_or_code 字段决定要执行 run_as_ascii_art 函数还是 run_as_code_section 函数,而且 printout 字段会直接影响是否进行下面这些操作(实际上是控制是否打印出来)。read_section 函数根据结构体中的偏移量和大小将数据读取到 bytes_buf 全局变量中。

void __fastcall run_section(FILE *fp, entry_struct *entry_struct)
{
if (entry_struct->printout)
{
read_section(fp, entry_struct);
if (entry_struct->ascii_or_code == 1 )
{
run_as_ascii_art();
}
else if (entry_struct->ascii_or_code == 2 )// currently selected
{
run_as_code_section();
}
else
{
perror("Unknown type");
}
}
}
void __fastcall read_section(FILE *fp, entry_struct *entry_struct)
{
if (entry_struct->data_size <= 0x1000u )
{
fseek(fp, (unsigned int)entry_struct->data_offset, SEEK_SET);
fread(bytes_buf, 1uLL, 0x1000uLL, fp);
}
...
}

其中 run_as_ascii_art 函数只是简单的将读入的数据作为字符串打印出来,控制了一下换行。run_as_code_section 函数逻辑很复杂,看起来像是个加密或者解密算法,但不需要分析所以没有贴出来。

int run_as_ascii_art()
{
int result; // eax
signed __int8 c; // [rsp+7h] [rbp-9h]
int i; // [rsp+8h] [rbp-8h]

for (i = 0; i <= 4095; ++i )
{
result = bytes_buf[i];
c = bytes_buf[i];
if (!c)
break;
putchar(c);
result = i % 61;
if (!(i % 61) )
result = putchar('\n');
}
return result;
}

flag_inside.exe 文件对应偏移位置我们可以看到 4 个 entries 各字段的值(此处可以结合结构体和数组进行定义)。分别到四个 entries 对应的 data_offset 查看数据内容,entry 3 和 entry 4 分别是一个组成了类似佛像和一个组成 Windows 字母的字符画,根据其 ascii_or_code 字段值得知它们通过 run_as_ascii_art 函数输出,但是 entry 3 的 printout 字段为 0 ,所以实际不会被输出,这与前面实际运行结果是一致的。那么如果 entry 1 对应的是在字符画之前输出的 “Hellow world” 字符串,那么 entry 2 可能就是我们要的 flag 了。所以最初提示的修改一个字节,可能就是需要我们修改 entry 2 的 printout 字节为 1 ,这样 entry 2 的数据就能被 run_as_code_section 函数解密并打印出来。

seg000:00000134                 db 2                    ; ascii_or_code ; entry 1 
seg000:00000134 db 1 ; printout
seg000:00000134 db 2 dup(0)
seg000:00000134 dd 1000h ; data_offset
seg000:00000134 dd 1000h ; data_size
seg000:00000140 db 2 ; ascii_or_code ; entry 2
seg000:00000140 db 0 ; printout
seg000:00000140 db 2 dup(0)
seg000:00000140 dd 2000h ; data_offset
seg000:00000140 dd 1000h ; data_size
seg000:0000014C db 1 ; ascii_or_code ; entry 3
seg000:0000014C db 0 ; printout
seg000:0000014C db 0D8h, 24h
seg000:0000014C dd 3000h ; data_offset
seg000:0000014C dd 1000h ; data_size
seg000:00000158 db 1 ; ascii_or_code ; entry 4
seg000:00000158 db 1 ; printout
seg000:00000158 db 2 dup(0)
seg000:00000158 dd 4000h ; data_offset
seg000:00000158 dd 1000h ; data_size

修改字节操作可以使用 IDA Pro 提供的 “Edit” -> “Patch program” -> “Change byte” ,但是似乎有一些问题,我改过来改回去最终有一部分修改并没有生效,最后直接用 hexedit 工具,定位到相应的偏移量直接进行修改。修改后再次运行,flag 被打印出来。

……所以这题跟 Windows 没有半点关系啊哈?

作者

ChinaNuke

发布于

2022-02-12

许可协议

评论