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 ; flagUChara = (int )flagUChar >> 3 ; srcPosa = srcPos + 1 ; srcBuf[srcPosa] = flagUChara & 3 | srcBuf[srcPosa] & 0xFC ; flagUCharb = (int )flagUChara >> 2 ; ++srcPosa; srcBuf[srcPosa] = flagUCharb & 1 | srcBuf[srcPosa] & 0xFE ; ++srcPosa; srcBuf[srcPosa] = ((int )flagUCharb >> 1 ) & 3 | srcBuf[srcPosa] & 0xFC ; srcPos = srcPosa + 1 ; } fwrite(srcBuf, srcLength, 1uLL , dstBmp); free (flagBuf);free (srcBuf);return 0 ;
我们来具体分析一下四次赋值具体发生了什么。首先第一次将 flagUChar
与 7
(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 () { 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); 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 题目给了 loader
和 flag_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; entry_struct *entry_struct; if (argc == 2 ) { filename = argv[1 ]; fp = fopen(filename, "rb" ); if (fp) { start_pos = (unsigned int )locate_start(fp); fseek(fp, start_pos, SEEK_SET); some_hdr = (file_hdr *)read_basic_info(fp); 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); pos = ftell(fp); run_section(fp, entry_struct); fseek(fp, pos, 0 ); 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; char printout; int data_offset; int data_size; };
程序在打开目标程序(文件)之后,首先调用了 locate_start
函数,从目标文件偏移量 60 处读取了 4 个字节的整数并返回,接着把它传递给 fseek
函数设置指针位置。
__int64 __fastcall locate_start (FILE *fp) { unsigned int ptr; unsigned __int64 v3; v3 = __readfsqword(0x28 u); fseek(fp, 60LL , 0 ); fread(&ptr, 1uLL , 4uLL , fp); 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; ptr = new_hdr(0x34 uLL); fread(ptr, 1uLL , 0x34 uLL, fp); return ptr; } void *__fastcall new_hdr (size_t a1) { void *s; s = malloc (a1); memset (s, 0 , a1); return s; }
在这里简单介绍一下结构体的识别和分析,下面是未修改过的原始伪代码,可以看到 read_basic_info
函数的返回指针保存到了变量 v10
,使用 printf
函数打印出了其指向缓冲区的一些信息,通过 printf
的第一个参数可以知道 v10
、v10 + 16
、v10 + 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); pos = ftell(fp); run_section(fp, entry_struct); fseek(fp, pos, 0 ); free_hdr(entry_struct); } entry_struct *__fastcall read_entry (FILE *a1) { entry_struct *ptr; ptr = (entry_struct *)new_hdr(0xC uLL); fread(ptr, 1uLL , 0xC uLL, 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 ) { run_as_code_section(); } else { perror("Unknown type" ); } } } void __fastcall read_section (FILE *fp, entry_struct *entry_struct) { if (entry_struct->data_size <= 0x1000 u ) { fseek(fp, (unsigned int )entry_struct->data_offset, SEEK_SET); fread(bytes_buf, 1uLL , 0x1000 uLL, fp); } ... }
其中 run_as_ascii_art
函数只是简单的将读入的数据作为字符串打印出来,控制了一下换行。run_as_code_section
函数逻辑很复杂,看起来像是个加密或者解密算法,但不需要分析所以没有贴出来。
int run_as_ascii_art () { int result; signed __int8 c; int i; 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 没有半点关系啊哈?