买了ctfshow的题包1,模块是前置基础,以前没有怎么了解过pwn,涉及到一些前置知识还都是从逆向分析/恶意软件分析了解到的。好像不能像做web题那样当脚本小子了,web方向攻击点直观&工具自动化程度高,基本可以实现半自动化了;pwn没有通用Exploit,也没法一键利用,汇编好难……
操作系统与体系结构- x86/x64 寄存器
https://blog.csdn.net/luoganttcc/article/details/126629152
覆盖 EIP 意味着劫持了程序的控制流,在 32 位栈溢出攻击中非常常见。当缓冲区写入过多数据,导致栈上返回地址(EIP)被覆盖,从而改变程序执行流。在 32 位模式下,函数返回时会从栈中取出 4 字节作为下一条指令的地址(EIP)。如果我们通过溢出把这个 EIP 改写成自己想要的地址,例如某段恶意代码(或 ROP 链)的起始位置,那么程序就会跳转过去执行,形成“控制流劫持”。
function vulnerable_function(inputData):
# 栈帧大致示意:
#
# +-----------------------+
# | 旧的 EBP 值 | <-- 保存调用者的基址指针
# +-----------------------+
# | 返回地址(EIP) | <-- 函数返回时,会从栈这里弹出EIP
# +-----------------------+
# | 局部变量 ... |
# | buffer[16] | <-- 占用16字节
# +-----------------------+
#
# 如果我们输入超过 16 字节的数据,就会覆盖到“返回地址”区域
# 1) 声明一个16字节的缓冲区 (在栈上)
local char buffer[16]
# 2) 将 inputData 拷贝到 buffer (没有检查长度)
copy(buffer, inputData)
# 若 inputData > 16 字节,写出 buffer 边界 -> 覆盖 EBP 和 EIP
# 3) 当函数运行结束时:
# pop EIP <-- 从栈顶弹出返回地址放到 EIP(指令指针)
# 如果 EIP 被攻击者控制,
# 下一条执行的指令就跳到攻击者指定的代码位置
return
64 位模式下寄存器名称变成了 RIP(Instruction Pointer),它也是函数返回时会从栈上弹出的地址,最常见的还是通过栈溢出去覆盖 RIP,配合编写 ROP 链(把若干 gadget 拼凑起来),或直接跳到我们的 shellcode 从而取得对程序的完全控制。
function vulnerable_function(inputData):
# 栈帧示意:
#
# +------------------------+
# | 旧的 RBP 值 |
# +------------------------+
# | 返回地址(RIP) |
# +------------------------+
# | 局部变量 ... |
# | buffer[32] | <-- 占用32字节
# +------------------------+
local char buffer[32]
copy(buffer, inputData)
# 如果 inputData 超过 32 字节,就会覆盖到 RBP/RIP
# 函数返回时,从栈上弹出存到 RIP
# 如果被覆盖,就可跳转到任意地址
return
SysV AMD64 调用约定
https://blog.csdn.net/xkdlzy/article/details/117857239
; -- 调用者准备好函数参数的伪代码 --
; 1) 把前6个参数放进对应的寄存器
RDI = a ; foo 的第1个参数
RSI = b ; foo 的第2个参数
RDX = c ; foo 的第3个参数
RCX = d ; foo 的第4个参数
R8 = e ; foo 的第5个参数
R9 = f ; foo 的第6个参数
; 2) 其余参数(如 第7个g、第8个h...) 依次放到栈上
push h
push g
; 3) 保证栈对齐(常需调一下栈指针满足16字节对齐)
; 4) 最后执行call指令
call foo
; -- 在foo执行时 --
; 内部通过访问RDI, RSI, RDX, ... 来取得参数a, b, c, ...
SysV AMD64关键在于函数参数是如何进寄存器的,和32位 x86是不一样的——不再是往栈里 push,而是前 6 个参数先走 RDI, RSI, RDX, RCX, R8, R9,然后剩下的参数才放栈上。
为什么pwn要关注不同系统版本和不同 libc 版本的差异
不同操作系统或不同 Linux 发行版,默认开启的安全机制(ASLR、PIE、Canary 等)层级可能不同。某些版本中地址随机化可能更激进,或者栈默认不可执行(NX)等。进程启动时加载库的基地址也可能因系统不同而有偏差。
不同的内核版本可能对/proc和系统调用接口做了改动,一些高级漏洞利用可能需要特定内核版本的特征或已知漏洞点。有时同一份代码在不同 Linux 发行版下的动态链接过程略有差异,包括一些默认链接选项、默认搜索库路径、加载顺序等,都可能导致最终内存分布不同。
在利用* ret2libc* 或* ROP* 时,需要在目标环境下找到* system() 或 *puts() 等函数的确切地址以及* /bin/sh *字符串的位置。但是不同版本的 libc(比如 libc-2.23.so vs libc-2.27.so vs libc-2.31.so)里,这些函数的偏移量可能不一样,就算是同一个函数内部符号位置也会不同。
新版本的 libc 可能对 malloc、free、tcache 的实现有更新,导致堆结构或攻击手法需要调整。例如tcache是在 glibc 2.26+ 才出现的新机制,如果手段基于 fastbin attack,而目标系统的* glibc* 有* tcache,那么分配策略可能不一样,需要考虑 *tcache 相关的利用方法。
ROP(Return-Oriented Programming)
https://www.youtube.com/watch?v=8zRoMAkGYQE
返回导向编程是一种利用已有代码片段(Gadgets)构造恶意执行流程的攻击技术。它的出现是因为NX保护机制,使攻击者无法直接执行栈上的 shellcode。在早期的栈溢出攻击中,攻击者可以直接把 shellcode(比如 /bin/sh)写入栈,然后让程序跳转到这段代码并执行。但后来操作系统引入 NX 保护,禁止栈、堆等数据段的代码执行。这使得攻击者不能简单地在栈上执行 shellcode 了。
ROP 的思路为既然不能执行自己写的代码,那就利用程序已有的代码来做坏事,找到程序已有的代码片段让这些代码片段按攻击者设计的方式执行。
如何绕过 NX 保护执行任意代码? ROP 通过利用程序已有的代码段执行任意命令,而不是直接执行 shellcode,从而绕过 NX 保护。
假设程序中有这样一段指令:
pop rdi
ret
如果攻击者能让* rsp* 指向这段代码,并在栈上放入* /bin/sh* 的地址,执行* ret 后就被正确赋值。这样就可以调用 *execve(“/bin/sh”, NULL, NULL) 来打开 shell。
ROP Gadget 是指那些对攻击者有用的代码片段,通常以 ret 结尾。这些代码片段可以通过 ROPgadget 工具或者 objdump 来寻找。
靶机1
提供的镜像安装不了,试了reboot进了initramfs mount /dev/sda1 /mnt,fsck修复未果,备用超级块用不了且重建超级块也不行……网上下了中文版的Ubuntu凑合用。
checksec一下
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
amd64-64-little,二进制程序是 64 位 x86 架构,Little Endian。
RELRO: Full RELRO GOTGlobal Offset Table表被完全保护,所有条目都变成只读无法修改,所以不能通过 GOT 重写来劫持执行流,system() 这样的劫持方法无法直接使用。
Stack: No canary found:栈保护机制没有启用,栈溢出变得更容易利用可以直接覆盖返回地址而不会触发 canary 保护。
PIE: PIE enabled : 程序的加载地址随机化,每次执行时基础地址都不同,所有代码段的地址都是动态的,程序的 main() 地址、plt 地址、libc 地址每次执行都不同。
system(“cat /ctfshow_flag”);
这是最重要的利用点,程序执行 system(“cat /ctfshow_flag”),它会在 shell 中运行 cat /ctfshow_flag。
靶机2
还是checksec一下,也是仅关闭Canary,也是64位程序。
IDA打开,这次system函数里面的字符串变成了/bin/sh,直接运行之后给了我们一个shell,查看验证一下。发现他指向dash。
system()函数先fork一个子进程,在这个子进程中调用/bin/sh -c来执行command指定的命令。/bin/sh在系统中一般是个软链接,指向dash或者bash等常用的shell,-c选项是告诉shell从字符串command中读取要执行的命令(shell将扩展command中的任何特殊字符)。父进程则调用waitpid()函数来为变成僵尸的子进程收尸,获得其结束状态,然后将这个结束状态返回给system()函数的调用者。那么也就是说执行完这个后它就会返回一个shell给我们,执行cat /ctfshow_flag完事。
靶机3
Full RELRO:GOT 表不可修改,不能简单通过覆盖 GOT 表来劫持函数。 Canary:栈上存在栈保护,常见的栈溢出会被 canary 检测到。 NX:不可在栈或数据段直接执行 shellcode。 PIE:可执行文件基址随机化,需要在利用时先泄露基址或有别的绕过方式。
打开IDA康康main函数,调用了两次* setvbuf,用于设置输入输出的缓冲是为了让输入输出在 CTF 环境中更可控,不用担心缓冲延迟。打印了功能表,直接 *system(‘cat /ctfshow_flag’)——因为是最基础的pwn题所以没有设置钓鱼,直接给你了。
但是基于这类可能存在的攻击思路:
菜单越界跳转:在汇编中有一条* cmp eax, 8 后配合 ja,是无符号比较),如果输入大于 8,走默认分支;等于或小于 8 就进入跳表。负数在无符号比较下往往会被视为“大于 8”(因为负数的高位是 1,在无符号下会被当成一个很大的数)。如果题目作者没有做“负数检查”,输入负值也会进入 *default case。但是进入“默认分支”并不意味着就能任意跳到内存其它位置。要看 default case 里怎么写的。如果只是* puts(“invalid choice”),那也就没用;如果不小心写了 *(jump_table[choice])() 那就有越界调用漏洞。
靶机4
IDA打开main,这段代码是一个密码验证程序,是比较用户输入的字符串和预设的密码,如果匹配则调用后门函数来执行 /bin/sh 获得一个 shell。
int __fastcall main(int argc, const char **argv, const char **envp)
__fastcall:调用约定,表示部分参数可能通过寄存器传递。
argc:命令行参数个数。
argv:指向命令行参数的指针数组。
envp:指向环境变量的指针数组。
char s1[11]; // [rsp+1h] [rbp-1Fh] BYREF char s2[12]; // [rsp+Ch] [rbp-14h] BYREF unsigned __int64 v6; // [rsp+18h] [rbp-8h]
s1[11]:用于存储正确的密码,大小为 11 字节,存储字符串** CTFshowPWN** s2[12]:用于存储用户输入的字符串 v6:存储从 __readfsqword(0x28u) 读取的值,保存栈保护值,检测栈溢出攻击。
strcpy(s1, “CTFshowPWN”); logo(); puts(“find the secret !”);
execve_func();
如果输入的字符串与CTFshowPWN一致,调用 execve_func() 函数。这个函数内部会调用 execve *系统调用来执行 */bin/sh,从而获取一个 shell,这实际上就是一个后门功能。
但是这里的execve本身并不是一个后门函数,实际上execve是一个标准的系统调用函数,用于在 Linux中执行一个新的程序。它的原型如下:
intexecve(constcharfilename, charconstargv[], charconstenvp[]);*
该函数接受三个参数: filename:要执行的程序的文件名或路径。 argv:一个以 NULL 结尾的字符串数组,表示传递给新程序的命令行参数。 envp:一个以 NULL 结尾的字符串数组,表示新程序的环境变量。当调用execve函数时,它会将当前进程替换为新程序的代码,并开始执行新程序。新程序接收argv和envp作为命令行参数和环境变量。在加入某些参数后就可以达到我们所需要的后门函数的效果。