PWN从0到0.∞1 - Test_your_nc

Posted by Closure on March 12, 2025

买了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函数时,它会将当前进程替换为新程序的代码,并开始执行新程序。新程序接收argvenvp作为命令行参数和环境变量。在加入某些参数后就可以达到我们所需要的后门函数的效果。