一个2022年国赛初赛的LLVM PASS类pwn题,当时还完全没有接触过,所以直接放弃掉了,初赛结束之后决定入门一下这方面知识,看这篇题解之前最好先看看之前写的这篇入门文章:
LLVM PASS类pwn题入门
然后我们正式开始这道题,首先从readme入手:
从这个readme我们可以得到的信息还是不少的,首先由于题目没给opt(这里必须大声谴责出题人,劳资换了三个虚拟机编译了不下五次llvm-12,编译出来的opt全都load不了这个mbaPass.so,后来发现原来直接apt install llvm-12就可以,可是我最常用的虚拟机是ubuntu18,它最高只支持到apt install llvm-10,搞得我以为12及以上必须自己用源码编译,我真的吐了啊 )所以我们首先要知道用的是什么版本的opt,然后给了一个如何运行的命令,之后的内容告诉了我们这个PASS做了什么,显式的来看,其功能就是压缩优化IR指令,并且这个so限制了IR指令只能是add,sub或者ret
接下来把so文件拖进IDA中,首先肯定是找到runOnFunction:
可以看到首先是对函数里的参数和基本块数量做了限制,必须有且仅有一个参数和一个基本块,然后是通过handle函数对IR代码进行处理,处理后执行callcode,先来看callcode:
发现是直接执行this[4],再回过头来看,发现this[4]首先通过mprotect开辟了一块可读可写的内存,然后调用handle,那这个函数必然是往这里写shellcode,然后再把这块内存变成可读可执行,然后用callcode去执行,所以关键就在于handle是如何把IR变成shellcode的,这个过程其实可以理解成是一种JIT(即时编译),所以this[4]就是一个用于JIT的缓冲区。
接下来上重头戏——handle函数:
这里可以分析出v30其实是被当做了buf的结尾,然后遍历每一条IR指令,this+5指向当前shellcode,this+4指向缓冲区头部,然后通过调用writeMovImm64或者writeRet这一类指令向ptr指向的位置写shellcode并移动ptr,我们来看看上述函数怎么实现的:
从名字上来看这应该是一个通过mov写寄存器的指令,首先写一个0x48代表什么?这里就要求大家对机器码有一定的熟悉才能反应过来,当然反应不过来没关系,我们来试试就知道了,首先是0x48 然后写 0xbb或者0xb8 ,然后写一个八字节的数,这个64位的数从哪来:
可以看到是第三个参数,而这个参数来自IR指令
所以我们写一个例子,比如0x48 0xbb 0x1122334455667788,然后反编译一下:
可以看到写进去的内容在callcode执行的时候会当做
这条指令来执行,同理我们能够得知,当0xbb变成0xb8的时候写的是movabs rax,xxx
writeRet函数显然就是写一个ret指令进去,其他一些函数对机器码不熟悉的话都可以通过上述这种操作调试出来。
所以现在我们知道了,程序在做的事情就是把IR代码编译成机器码并运行,我们简单的调试一下看看,我们将断点断在callcode那里,然后exp.ll里面随便写上几句合法的指令:
exp.ll
然后看一下当程序运行到即将调用buf的时候,buf里的shellcode长啥样:
到这里我们应该已经算是彻底搞懂这个程序的主要功能了,要注意一下,我们写的IR指令和弄出来的shellcode是倒序的关系,因为里面用了stack。
接下来开始打这个题。
首选先要找到漏洞点在哪里,大致猜测一下,既然能执行shellcode,那是不是就要通过一定的手段来做到在buf上执行可控的shellcode,能做到这一点显然程序就好打了,但是现在的情况是,我们只能写这么几种已知的指令,只有数据是可控的,比如mov rbx,xxx中的xxx是可控的。
此时注意到这里:
明明缓冲区的大小是0x1000,这里凭什么就直接把buf+0xff0当做结束点?那这么做会引发什么问题呢?当生成的shellcode长度超过0xff0的时候就不再进行解析了,但是执行的时候并不会在0xff0停止,仍然会向下继续执行,并且对每个函数进行JIT的时候并没有清空缓冲区,也就是说当前函数在执行callcode的时候,buf里是可能会存有以前生成的shellcode,最后一点,我们的立即数是有条件写到0xff0以后的位置的,比如已经生成了0xff0-2这么长的shellcode,此时你来一个add rax,xxx,那么这个xxx就会写到0xff0~0xff7处。
梳理一下,现在0xff0之后有八个字节可控,假设我们第一次JIT的时候,在0xff0处把一个短跳转指令当做数据写进去,然后第二次JIT的时候让shellcode长度刚好等于0xff0,这样当第二次JIT生成的shellcode执行结束,就回去执行我们写的jmp指令,这样就成功控制了执行流,我们来尝试一下
首先是第一次JIT,0xff0处的八字节装的是movabs rbx,xxx里的数据,然后buf的末尾填充的是ret指令,执行起来没有任何问题
但是如果直接把0xff0处当做shellcode来看:
会发现我们输入的数据其实是一个短跳转指令,前两个为什么用nop填充是因为每种指令的机器码长度都是固定的,不一定能够凑出来刚好0xff0大小的shellcode,所以留两字节空位给正常的shellcode。
接下来是第二次JIT,我们让程序生成0xff2大小的shellcode:
这样就达到了我们想要的效果,成功将jmp指令注入到了JIT生成的shellcode中。
接下来要考虑的问题是让shellcode跳到哪里,我们可控的区域只有每个movasb指令中的立即数部分,每个是八字节,也就是说我们只需要再安排几个movabs指令,让其中两个字节用作短跳转,六个字节用作shellcode,就可以完成一串正常的shellcode了,虽然想想有点麻烦,但是道理上是可行的。
在写的时候要注意,不能有任何一条汇编指令长度大于6,否则写不进去,并且由于shellcode写起来很麻烦,所以还是推荐自己写shellcode,不要直接shellcraft.sh,那个生成出来的多少有点长。
由于对指令长度有限制,所以在构造/bin/sh的时候要先给寄存器低位赋值,然后把寄存器左移再相加
可以看到通过这种方式可以将一片一片分开的shellcode连接到一起,最后执行到syscall:
成功打通:
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)