Brief
这题名为 Loader,其本质也是从 .data
段中加载了程序的主要逻辑并运行,使用了无文件 PE 文件加载的相关技术。
因为这道题没加反调试等 check,所以比赛时我只是略扫了一下 load 的部分,主要精力都放在关键逻辑上了。但其实这题 loader 部分的 assembly 写得很有意思,于是赛后我又着重分析了一下相关部分的代码。
题目整体可以分为两部分:
- Loader 首先将
.data
段的权限设置为RWX
,.data
中数据的是一个进程的 dump,Loader 随后效仿 Windows 加载器来修改 IAT 并对该部分代码进行重定位,随后跳转到其中的main
函数 - 关键逻辑:由 nim 语言编译,将输入解析为 big num 再 check
加载过程分析
-
首先,将
.data
段存放的 shellcode 记为code
变量方便后续表示。 -
开头的
VirtualProtect
部分较为简单,直接略过。此时控制流来到code
处,此处的逻辑也很简单,就是通过修改栈顶保存的rip
让控制流来到code+0x34000
处,我们直接从此处开始分析。
Locate kernel32.dll
InMemoryOrderModuleList
- 读取
gs:[0x60]
处的数据。如 Win32 Thread Information Block - Wikipedia 所述,该位置着指向当前进程 PEB 的指针。
- 读取
PEB:[0x18]
处的数据。偏移及字段的关系可以参考 PEB (geoffchappell.com) ,可知这里是获取的是 LDR 的指针。
- 进一步读取
LDR:[0x20]
处的数据。该结构体的细节可以参考 PEB_LDR_DATA (geoffchappell.com) ,可知这里获取的是InMemoryOrderModuleList
的指针
LDR_DATA_TABLE_ENTRY
of kernel32.dll
InMemoryOrderModuleList
是一个双向链表,链接了若干结构体,每个结构体都记录了加载进当前进程空间的一个模块。对于该进程,其链接的顺序是可执行文件,ntdll.dll
,kernel32.dll
随后的三条指令实际上是就是沿着 FLink
遍历链表,找到第三个链表上的 node。该 node 就是 kernel32.dll
的 LDR_DATA_TABLE_ENTRY
, LDR_DATA_TABLE_ENTRY
的结构可以参考 LDR_DATA_TABLE_ENTRY (geoffchappell.com) , 此处读取了 0x10+0x20
处的 DllBase
字段并赋给 rbp
寄存器, 随后执行了 call
指令进入下一阶段
Load API
这部分代码的作用是调用 kernel32.dll
中的 LoadLibrary
和 GetProcAddress
,来加载后续关键逻辑中需要使用的 API。此时 rbp
指向了 kernel32.dll
的加载基址,也就是该 PE 文件在内存中的起始位置。
从系统里找到 kernel32.dll
文件然后放到 Detect It Easy 中可以更好的理解这部分。
Locate Export table
- 将调用处后面的地址保存到
rsi
中,看来此处的数据并非花指令
-
访问
IMAGE_DOS_HEADER
的e_lfanew
字段,该字段代表IMAGE_NT_HEADERS
与文件头的偏移 -
访问
IMAGE_NT_HEADERS
的子结构体IMAGE_OPTIONAL_HEADER
的IMAGE_DATA_DIRECTORY
字段,对于kernel32.dll
,该字段就是导出表的 offset -
此时
rbx
指向kernel32.dll
的导出表。后面大量使用的 0x20 偏移处的字段也就是其中的AddressOfNames
字段
GetProcAddress
and LoadLibraryA
后面的指令稍微有些复杂,并且由于寄存器使用得非常随意,伪代码可读性也很差,还是来分析汇编
- 首先注意到注意到其中使用了
0xEDB88320
这个 constant。上网搜索发现是一个 CRC 算法中的数字,该算法如下。
```c
unsigned long int compute_crc( unsigned char *input,
int len,
unsigned long divisor )
{
int i, k;
unsigned long crc = 0xFFFFFFFF;
for ( i = 0; i < len; i++ )
{
crc ^= ( input[ i ] );
for ( k = 8; k; k-- )
{
crc = crc & 1 ? ( crc >> 1 ) ^ divisor : crc >> 1;
}
}
return crc ^ 0xFFFFFFFF;
}
...
printf( "%lx\n", compute_crc( "ABC", 3, 0xEDB88320 ); // a3830348
```
rbx+0x20
指向了 Export table 的AddressOfNames
。该字段是一个列表,每个成员是 Export function name 与 PE 文件的偏移,因此此时rdi
指向了当前校验的函数名,eax
保存着checksum
- 当
checksum
与[rsi]
相等时执行后续代码,否则接着去校验后面的函数名
- 动调可以发现当 function name 为
GetProcAddress
时校验通过,此时rdx
寄存器即为GetProcAddress
的 index,再去访问 Export table 的AddressOfFunctions
并处理偏移即可得到该函数的绝对地址
-
此时
rax
即GetProcAddress
的地址,随后的push
操作将其压入栈中,lodsd
在取值的同时会让rsi+=4
,即指向了下一个要定位的函数的checksum
-
当再走完一遍上面的逻辑后,此时栈中已有
LoadLibraryA
和GetProcAddress
的绝对地址
Rewrite IAT
在该部分中 Loader 解析了 code
中的导入表,并利用前面定位到的 GetProcAddress
和 LoadLibraryA
加载 code
需要用到的 API。
这些 API 的信息记录在 INT 和 IAT 表中,正常情况下 Windows 在加载一个 PE 文件时,会从 INT 表中解析到 API 所在的 dll 和名称,将 dll 加载到内存中后把 API 的绝对地址覆写入 IAT 表中,此处自定的加载器也是按照该逻辑写的。
- 将
code
中的IMAGE_NT_HEADERS
的地址保存到rbp
寄存器中,并寻址到IMAGE_NT_HEADERS+0x90
处的 Import Table。Import Table 由若干IMAGE_IMPORT_DESCRIPTOR
构成,其FirstThunk
字段指向了所有待导入的 API。
- 加载 dll 文件并获取其
IMAGE_IMPORT_DESCRIPTOR
的地址
- 通过循环来加载该 dll 中所有被使用到的 API
- 该
IMAGE_IMPORT_DESCRIPTOR
处理完后再加载其他需要的 dll
Relocate
由于 ASLR 的存在, .data
段的地址也会变化,因此若想让 code
可以正常的运行还需要像正常加载 PE 文件一样对代码中的一些绝对地址进行改写
PE Header 中的 Base Relocation Table 专门用于记录哪些地址需要被改写,相关细节较多不在此展开,仅对该 Loader 的加载过程进行概述
在处理完 IAT 后接着会执行这部分代码
- 获取 Base Relocation Table 的绝对地址,存放到
rdi
中 - 遍历,对于每个要改写的地址,计算其绝对地址,随后将该地址的值改为
&code + offset
Execute from entrypoint
在重定位完成后,code
便可以被当作加载到进程中的 PE 文件执行。此时 Loader 从 IMAGE_NT_HEADER
中获取了 AddressOfEntryPoint
,并跳转到此处。
然后其实就是正常 init 再调用 main 函数的过程了,结合一些 PE 文件分析的经验和动调,可以定位到 main 函数在 code + 0x12850
处
关键逻辑
nim 的内存管理并不复杂,string、big num 这种数据结构也不太复杂,都形如
struct Data{
int64_t f0;
int64_t f1;
// data
}
通过动调,可以定位到 print
和 scanf
函数,然后在此处校验输入 flag 的格式
然后会将输入的 flag 拆分为两个 18-bytes 的字符串
此处调用了多个库函数,由于是高级语言编译而来所以不太容易看懂,但其参数均为数字字符串,不难想到是一个 atoi
的操作
此时上下文共有四个 int
big1 = 0x100000000000000
big2 = 0x1000000000000000
num1 = # input 1
num2 = # input 2
后面所调用的一些库函数也都是基于这四个 int 和其运算结果进行处理,通过黑盒和猜测,可梳理后面的逻辑如下
assert(big1 < num1)
assert(num1 < big2)
assert(num1*num1-11*num2*num2 == 9)
这里需要解一个 general pell equation,不太算是 re 的范畴了,遂交给 crypto 师傅来做
最终解题脚本
sage ./pell.sage
# pell.sage
cf = continued_fraction(sqrt(11))
cs = cf.convergents()
for each in cs:
x1, y1 = each.numer(), each.denom()
if x1^2 - 11*y1^2 == 1:
break
for each in cs[1:]:
x2, y2 = each.numer(), each.denom()
if x2^2 - 11*y2^2 == 1:
break
D = 11
big1 = 0x100000000000000
big2 = 0x1000000000000000
while True:
x = (x1 * x2 + D * y1 * y2)
y = (x1 * y2 + x2 * y1)
if big1 < x * 3 < big2:
break
x2, y2 = x1, y1
x1, y1 = x, y
num1,num2 = x*3,y*3
print('flag{%018d%018d}' % (num1, num2))
得到 flag flag{118936021352508390035860559716724409}
总结
这题咋说呢,re 套 crypto 有点捞(还是说只有我不会解 pell equation qaq),但是加载的部分还是挺有意思的,认真调一遍能对 PE 文件加载的过程有些更具体的认识。