PHP
WordPress
セキュリティ
脆弱性

Webアプリケーションの脆弱性ケーススタディ(WordPress編)

CMSの世界で圧倒的なシェアを誇る「WordPress」。個人ブログから企業サイトまで幅広い用途で利用され、そのシェアは約60%にまで達すると言われています。最近では米国のホワイトハウスのサイトがDrupalからWordPressに移行したことでも話題になりました。

それだけ多くのサイトで使われている故にハッカーの標的になりやすく、セキュリティリリースもたびたび行われています。普段はあまり気にせずに管理画面の更新ボタンを押すだけかもしれませんが、どのような脆弱性があり、どのような対応が行われたのかを詳しく知ることは、Webアプリケーションのセキュリティを学ぶ上で有意義なことだと思います。

そこで、この記事ではWordPressの脆弱性のケーススタディを5つ取り上げてみたいと思います。なお、ここで取り上げるケースは世にある脆弱性事例の極々一部ですので、IPAの資料をまだ読んだことがないという方は一読されることをお勧めいたします。

「安全なウェブサイトの作り方:IPA 独立行政法人 情報処理推進機構」
https://www.ipa.go.jp/security/vuln/websecurity.html

徳丸先生のブログも大変参考になりますので定期的にチェックしてみてください。

「徳丸浩の日記」
https://blog.tokumaru.org/

:spy: Stored XSS(Cross Site Scripting)

CWE-79によると、Stored XSSは以下のように記載されています。

The application stores dangerous data in a database, message forum, visitor log, or other trusted data store. At a later time, the dangerous data is subsequently read back into the application and included in dynamic content. From an attacker's perspective, the optimal place to inject malicious content is in an area that is displayed to either many users or particularly interesting users. Interesting users typically have elevated privileges in the application or interact with sensitive data that is valuable to the attacker. If one of these users executes malicious content, the attacker may be able to perform privileged operations on behalf of the user or gain access to sensitive data belonging to the user. For example, the attacker might inject XSS into a log message, which might not be handled properly when an administrator views the logs.

要約すると、攻撃者からのリクエストに含まれる危険なコード(スクリプト文字列など)が、適切な処理が施されずにデータベースやログなどに保存されてしまい、アプリケーションでデータを出力した時に危険なコードが実行され、ユーザーに被害を与えてしまう脆弱性です。

今回ご紹介するStored XSSは2015年に発見されたもので、投稿にコメントを残す機能に脆弱性がありました。

コメント入力フォーム

なお、コメント入力欄は次のHTML タグと属性のみが使え、それ以外のタグや属性は全て除去されるようになっています。

<a href="" title=""> <abbr title=""> <acronym title=""> <b> 
<blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> 
<q cite=""> <s> <strike> <strong>

<a>タグのhref属性にはhttp / https /ftp / mailto / telなどの特定のプロトコルしか記述することを許可されておらず、javascript:は除去されスクリプトは実行できないようになっています。一見するとスクリプトを混入させる余地がないようにも見えますが、一体どこに脆弱性があったのでしょう:thinking:

この脆弱性に対する修正は以下のURLから確認できます。
https://github.com/WordPress/WordPress/commit/5c2b420b294082c055327aa49243c1da137c694d

これを見るとprocess_fieldsという関数にprocess_field_lengthsという処理が追加されています。これはフィールドのデータ型の最大長より入力された値が大きい場合はエラーにするという処理を行っているのですが、なぜこの処理が必要だったかお分かりでしょうか?

これはWordPressのある仕様が関係しています。WordPressではデフォルトで以下のSQLモードが非対応モードとなっており、例えSTRICTモードがセットされていたとしても、アンセットされるようになっています。

(参考) MySQL :: MySQL 5.6 リファレンスマニュアル :: 5.1.7 サーバー SQL モード

wp-includes/wp-db.php
/**
 * A list of incompatible SQL modes.
 *
 * @since 3.9.0
 * @var array
 */
protected $incompatible_modes = array(
  'NO_ZERO_DATE',
  'ONLY_FULL_GROUP_BY',
  'STRICT_TRANS_TABLES',
  'STRICT_ALL_TABLES',
  'TRADITIONAL',
);

この仕様により、TEXT型のフィールドに64KB(65535byte)以上の文字をインサートしようとすると、エラーにならずに64KBに切り詰めてインサートされます。実はこの挙動が脆弱性につながります。例えば以下のような64KBを超える文字列をコメント欄に入力してみます。(AAAAの部分が64KBを超えるようにズラズラとつなげる)

<a title='x onmouseover=alert(unescape(/xss%20success!/.source)) style=position:absolute;left:0;top:0;width:100%;height:3000px;border:none AAAAAAAAA....A'></a>

<a>タグとtitle属性だけなので入力規則的にはOKとなりエスケープ処理されないままDBに登録しようとします。しかし、TEXT型の最大長を超える部分はカットされ、実際には以下のように後半が切れた中途半端な状態で登録されます。

<a title='x onmouseover=alert(unescape(/xss%20success!/.source)) style=position:absolute;left:0;top:0;width:100%;height:3000px;border:none AAAAAAAAA....

この登録された文字列がコメント画面に以下のように出力され、onmouseoverに書いたJavaScriptが実行されるようになります。style属性でレイヤーが画面を覆うようにしているので、ページにカーソルを置いた時点でイベントが発火しJavaScriptが実行されます。

<a title=&#8217;x onmouseover=alert(unescape(/xss%20success!/.source)) style=position:absolute;left:0;top:0;width:100%;height:3000px;border:none  AAAAAAAAA....

XSS

詳しくは書きませんが、管理者権限を持ったユーザーがログインした状態でアクセスした時に外部JavaScriptを読み込ませてバックドアを仕込むこともでき、極めて深刻な脆弱性だったと言えます。

:spy: DOM-based XSS(Cross Site Scripting)

CWE-79によると、DOM-based XSSは以下のように記載されています。

In DOM-based XSS, the client performs the injection of XSS into the page; in the other types, the server performs the injection. DOM-based XSS generally involves server-controlled, trusted script that is sent to the client, such as Javascript that performs sanity checks on a form before the user submits it. If the server-supplied script processes user-supplied data and then injects it back into the web page (such as with dynamic HTML), then DOM-based XSS is possible.

要約すると、前述したStored XSSのようにサーバ側から不正なスクリプトを含んだレスポンスがクライアントに送信され実行されてしまうタイプとは異なり、クライアント側でのJavaScriptによる動的な処理の結果として不正なスクリプトが実行されてしまう脆弱性です。

今回ご紹介するDOM-based XSSは2015年に発見されたもので、WordPressのTwenty Fifteen テーマに含まれるGenericonsパッケージに起因するものです。

Genericonsとはフリーのアイコン フォント セットで、テーマ配下のgenericonsフォルダの中にTTFなどのフォントファイルが格納されているのですが、アイコンの一覧が確認できるようにexample.htmlという見本ページがあり、このページに脆弱性がありました(現在はexample.htmlは削除されています)。

このページは、使いたいアイコンをページに挿入するためのCSSコードやHTMLコードをコピーできるようにする機能をもっており、その部分がJavaScriptで実装されています。

Genericons

アイコンをクリックすると下のようにURLのフラグメント識別子が切り替わります。

report.gif

そして、実際に問題になったソースコードを抜粋したものが以下になります。

jQuery(document).ready(function() {

  // pick random icon if no permalink, otherwise go to permalink
  if ( window.location.hash ) {
    permalink = "genericon-" + window.location.hash.split('#')[1];
    attr = jQuery( '.' + permalink ).attr( 'alt' );
    cssclass = jQuery( '.' + permalink ).attr('class');
    displayGlyph( attr, cssclass );
  } else {
    pickRandomIcon();
  }
----(省略)----

これを見ると、ページ読み込み時にフラグメント識別子があった場合は該当のアイコンを表示するようになっています。(example.html#hogeでアクセスされたらgenericon-hogeクラスを持つ要素からフォントコードなどを取得)

では、このページに以下のようなフラグメント識別子をつけてアクセスしてみます。

http://(略)/example.html#<img src=dummy onerror=alert(unescape(/xss%20success!/.source))>

xss success

プログラムを見るとpermalink = "genericon-<img src=dummy...."のようになってjQueryで値が取れずに終了するだけで問題にならないような気もしますがjavascriptが実行されてしまいました。確かに、変数のattrcssclassundefinedになり後続の処理でエラーになります。しかし、このプログラムではjQueryのバージョン1.7.2を使用しているのですが、このバージョンにちょっとした問題があります(検証した限りだと1.8系も同様で1.9.1で解消)。

ちょっと本筋とは逸れてしまうのですが、せっかくなので問題の部分を見てみましょう。以下はjQueryのinitの処理の一部を抜粋したものです。

jquery-1.7.2.js
init: function( selector, context, rootjQuery ) {
----(省略)----
  // Handle HTML strings
  if ( typeof selector === "string" ) {
    // Are we dealing with HTML string or an ID?
    if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) {
      // Assume that strings that start and end with <> are HTML and skip the regex check
      match = [ null, selector, null ];
    } else {
      match = quickExpr.exec( selector );
    }

    // Verify a match, and that no context was specified for #id
    if ( match && (match[1] || !context) ) {

      // HANDLE: $(html) -> $(array)
      if ( match[1] ) {
        context = context instanceof jQuery ? context[0] : context;
        doc = ( context ? context.ownerDocument || context : document );

        // If a single string is passed in and it's a single tag
        // just do a createElement and skip the rest
        ret = rsingleTag.exec( selector );

        if ( ret ) {
          if ( jQuery.isPlainObject( context ) ) {
            selector = [ document.createElement( ret[1] ) ];
            jQuery.fn.attr.call( selector, context, true );
          } else {
            selector = [ doc.createElement( ret[1] ) ];
          }
        } else {
          ret = jQuery.buildFragment( [ match[1] ], [ doc ] );
          selector = ( ret.cacheable ? jQuery.clone(ret.fragment) : ret.fragment ).childNodes;
        }

        return jQuery.merge( this, selector );

      // HANDLE: $("#id")
      } else {
 ----(省略)----     
    // HANDLE: $(expr, $(...))
    } else if ( !context || context.jquery ) {
      return ( context || rootjQuery ).find( selector );

    // HANDLE: $(expr, context)
    // (which is just equivalent to: $(context).find(expr)
    } else {
      return this.constructor( context ).find( selector );
    }
----(省略)----

上記の処理を簡単に説明すると$('セレクタ')のセレクタの部分にhtmlタグが指定されたのか、IDが指定されたのか、それともクラスセレクタなど他のセレクタが指定されたのかで処理を分けているのですが、今回のケースでいうとjQuery('.genericon-<img src=dummy....')のようになってクラスセレクタの方に処理が行くはずです。

しかし、以下の正規表現の判定の部分に問題があります。

// quickExpr = /^(?:[^#<]*(<[\w\W]+>)[^>]*$|#([\w\-]*)$)/
match = quickExpr.exec( selector );

正規表現を見ると分かるようにhtmlタグもしくは#idに一致するか、いずれにも一致しないかで処理を振り分けています。しかし、このhtmlタグ判定の部分を見るとタグの前後に文字が入ることを許可した仕様になっています。つまり、本来クラスセレクタの方にいくはずの処理がhtmlタグとして認識され、タグの方の処理に行ってしまいます。

そして、この後に以下の処理が実行されます。

ret = jQuery.buildFragment( [ match[1] ], [ doc ] );

このbuildFragmentの中でDocumentFragment(親ノードの無い最小限度の文書オブジェクト)が作成され、そこにフラグメント識別子に仕込んだ以下のimgタグがinnerHTMLで追加されることによりonerrorイベントが発火してjavascriptが実行されることになります。

<img src=dummy onerror=alert(unescape(/xss%20success!/.source))>

jQueryの仕様によるものなので発見は難しいかもしれませんが、prettyPhotoなどの人気ライブラリでも同様の脆弱性が過去に発見されており、サードパーティ製のライブラリを利用する際は注意が必要です。

:spy: SSRF(Server-Side Request Forgery)

CWE-918によるとSSRFは以下のように記載されています。

By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.

要約すると、ファイアウォールなどで外部からの攻撃をプロテクトしているサーバに対して、別のサーバを経由することで攻撃できてしまう脆弱性です。

今回ご紹介するSSRFは2016年に発見されたもので、「Press This」という機能に脆弱性がありました。

Press This

「Press This」は引用したいサイトのコンテンツを記事に取り込み簡単に投稿できるようにするためのブックマークレットなのですが、指定したURLをスキャンして取り込む機能も持っています。

Press This

WordPressではnonceを使ったCSRF対策を行っているのですが、このスキャンの実行に関してはCSRF対策がされておらず、URL直叩きで実行できる状態になっていました。

http://xxxx/wp-admin/press-this.php?u=(スクレイピングしたいURL)&url-scan-submit=スキャン

外部からのアクセスをプロテクトしていたとしても、WordPressサーバからアクセスできる状態であれば、この脆弱性を突いて情報を窃取することができてしまいます。

攻撃者 ---:heavy_multiplication_x:---> プロテクトされたサイト
攻撃者 ---:o:---> WordPress ---:o:---> プロテクトされたサイト

加えて、この脆弱性にはもう一つ別の問題が発見されています。該当箇所のソースコードを抜粋したものが以下になります。

wp-includes/http.php
function wp_http_validate_url( $url ) {
  ----省略----
  if ( $ip ) {
    $parts = array_map( 'intval', explode( '.', $ip ) );
    if ( 127 === $parts[0] || 10 === $parts[0]
      || ( 172 === $parts[0] && 16 <= $parts[1] && 31 >= $parts[1] )
      || ( 192 === $parts[0] && 168 === $parts[1] )
    ) {
      if ( ! apply_filters( 'http_request_host_is_external', false, $host, $url ) )
        return false;
    }
  }
  ----省略----
}

これはWordPressのHTTPクライアント(WordPress本体やサードパーティ・プラグインなどが利用するユーティリティクラス)がリクエストを送信する際に、それがローカルアドレス宛だったら安全でないリクエストとしてリクエストをキャンセルする処理になります。

IANA IPv4 Special-Purpose Address Registryを見ると

Address Block Name
10.0.0.0/8 Private-Use
127.0.0.0/8 Loopback
172.16.0.0/12 Private-Use
192.168.0.0/16 Private-Use

となってますのでルールに従って実装しているように見えるのですが、一つ見落としがあります。既にお気付きかもしれませんが、Linuxサーバ上でping 0.0.0.0を叩くと127.0.0.1から結果が返ってくることから分かるように、0.0.0.0でlocalhostにアクセスできてしまいます。

http://xxxx/wp-admin/press-this.php?u=http://0.0.0.0:8080/&url-scan-submit=スキャン

レアケースかもしれませんが、仮にWordPressと同じサーバ内に開発サイトや社内ポータルなどをlocalhostで動かしていた場合にアクセスされる危険性がありました。

:spy: Path Traversal(ディレクトリトラバーサル)

CWE-22によるとPath Traversalは以下のように記載されています。

Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin", which may also be useful in accessing unexpected files. This is referred to as absolute path traversal.In many programming languages, the injection of a null byte (the 0 or NUL) may allow an attacker to truncate a generated filename to widen the scope of attack. For example, the software may add ".txt" to any pathname, thus limiting the attacker to text files, but a null injection may effectively remove this restriction.

要約すると、ファイル名を要求するようなプログラムに対して、リクエストパラメータに"../"のような相対パスもしくは"/usr/local/bin"のような絶対パスを含めることで、本来アクセスできないファイルの実行・閲覧・取得が出来てしまう脆弱性です。

今回ご紹介するPath Traversalは2016年に発見されたもので、管理者APIのAjaxハンドラに脆弱性がありました。該当箇所のソースコードを抜粋したものが以下になります。

wp-admin/includes/ajax-actions.php
function wp_ajax_update_plugin() {
  global $wp_filesystem;

  $plugin = urldecode( $_POST['plugin'] );

  $status = array(
      'update'     => 'plugin',
      'plugin'     => $plugin,
      'slug'       => sanitize_key( $_POST['slug'] ),
      'oldVersion' => '',
      'newVersion' => '',
  );

  $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin );
  if ( $plugin_data['Version'] ) {
      $status['oldVersion'] = sprintf( __( 'Version %s' ), $plugin_data['Version'] );
  }

  if ( ! current_user_can( 'update_plugins' ) ) {
      $status['error'] = __( 'You do not have sufficient permissions to update plugins on this site.' );
      wp_send_json_error( $status );
  }

  check_ajax_referer( 'updates' );

この関数wp_ajax_update_pluginは管理画面でプラグインの更新を実行したときのAjaxリクエストを処理する関数なのです。始めにインストールしているプラグインファイル(.php)をプラグインディレクトリから読み込んで、ファイルに記述してあるバージョン情報を取得しています。その後でユーザーの権限チェックやnonceチェックを行っています。

POSTで受け取ったプラグインファイル名をそのままget_plugin_dataに渡しており、ここがPath Traversalの脆弱性になっていることはお気付きになった方も多いかと思います。ただ、この処理の後で権限チェックやCSRF対策も行ってますし、fopenでファイルを開いてバージョン情報を読み込んでいるだけの処理なので、不正な文字列を送ってもエラーになるだけで実際には問題ないのではと思った方も多いと思います。

確かに/etc/passwdの中身を窃取するようなことは難しいかもしれません。しかし、もし悪意のある人間がsubscriber(購読者)権限を入手できる状態であれば、以下のような攻撃が可能になります。

curl --cookie-jar "testcookie" \
  --data "log=xxxx&pwd=xxxx&wp-submit=Log+In&redirect_to=%2f&testcookie=1" \
  "http://xxxxxx/wp-login.php" \
  >/dev/null 2>&1

curl --cookie "testcookie" \
  --data "plugin=../../../../../dev/random&action=update-plugin" \
  "http://xxxxxx/wp-admin/admin-ajax.php" \
  >/dev/null 2>&1

上記は最初にsubscriber権限を持ったユーザーが自分のログインユーザー名、パスワードでcookieを作り、そのcookieを使ってupdate-pluginにリクエストを送っています。ここで、パラメータのpluginに../../../../../dev/randomを指定しているのですが、これが何を意味しているかお分かりでしょうか?

ご存知の方も多いと思いますが、/dev/randomはランダムな文字列を発生させる時に使用される擬似デバイスファイルです。そして、この/dev/randomは乱数種をエントロピープールから取り出してランダムな文字列を生成します。エントロピープールは有限なので使い切ってしまうと一定のエントロピーに達するまで処理を中断(ブロック)してしまいます(ちなみに/dev/urandomは過去に使った乱数種を再利用するため中断しない)。

仮にphp.iniのsession.entropy_file(PHP 7.1.0で廃止)で/dev/randomを指定していた場合、エントロピープールが枯渇するとセッションの生成に遅延が生じます。また、他のアプリケーションで使用していた場合にも影響が出てしまいます。

実際にリクエストを複数回送信した時のエントロピープールの状態は以下の通りです。最初2945だった数値が38まで低下しています。

エントロピープール

なお、脆弱性があった部分は以下のようにスラッシュを削除しサニタイズする処理に変更されました。

$plugin = urldecode( $_POST['plugin'] );
$plugin = plugin_basename( sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) );

:spy: Open Redirect

CWE-601によるとOpen Redirectとは以下のように記載されています。

An http parameter may contain a URL value and could cause the web application to redirect the request to the specified URL. By modifying the URL value to a malicious site, an attacker may successfully launch a phishing scam and steal user credentials. Because the server name in the modified link is identical to the original site, phishing attempts have a more trustworthy appearance.

要約すると、https://xxxxx/login.php?url=(リダイレクト先URL)のようなパラメータにリダイレクト先URLを指定するプログラムがあった場合に、攻撃者がhttps://xxxxx/login.php?url=(フィッシングサイトのURL)のようなURLを標的者に踏ませて悪意のあるサイトに誘導できる脆弱性です。

今回ご紹介するOpen Redirectは2016年に発見されたもので、WordPressのログイン画面に脆弱性がありました。WordPressのログイン画面(wp-login.php)にはredirect_toというパラメータがhiddenフィールドで設置されています。

<input type="hidden" name="redirect_to" value="http://xxxxxx/wp-admin/" />

いろいろな用途でこのパラメータは使われているのですが、ログイン成功後のリダイレクト先としても使われています。

では実際に、問題があった箇所のソースコードを見てみましょう。

wp-includes/pluggable.php
function wp_validate_redirect($location, $default = '') {
  $location = trim( $location );
  // browsers will assume 'http' is your protocol, and will obey a redirect to a URL starting with '//'
  if ( substr($location, 0, 2) == '//' )
      $location = 'http:' . $location;

  // In php 5 parse_url may fail if the URL query part contains http://, bug #38143
  $test = ( $cut = strpos($location, '?') ) ? substr( $location, 0, $cut ) : $location;

  $lp  = parse_url($test);

  // Give up if malformed URL
  if ( false === $lp )
      return $default;

  // Allow only http and https schemes. No data:, etc.
  if ( isset($lp['scheme']) && !('http' == $lp['scheme'] || 'https' == $lp['scheme']) )
      return $default;

  // Reject if scheme is set but host is not. This catches urls like https:host.com for which parse_url does not set the host field.
  if ( isset($lp['scheme'])  && !isset($lp['host']) )
      return $default;

  $wpp = parse_url(home_url());
  $site = parse_url( site_url() );

  $allowed_hosts = (array) apply_filters( 'allowed_redirect_hosts', array( $wpp['host'], $site['host'] ), isset( $lp['host'] ) ? $lp['host'] : '' );

  if ( isset($lp['host']) && ( ! in_array( $lp['host'], $allowed_hosts ) && ( $lp['host'] != strtolower( $wpp['host'] ) || $lp['host'] != strtolower( $site['host'] ) ) ) )
      $location = $default;

  return $location;
}

上記関数はリダイレクトに指定されたURLの妥当性を検証しているのですが、parse_urlでURLを構成要素に分解し、hostが①「許可されているもの」 ②「自分自身」 の場合のみ有効なURLとみなしています。処理を見る限りでは自分で許可しない限りは大丈夫そうに見えるのですが、どこに問題があるかお分かりでしょうか:thinking:

これにはphp5.6.28で修正されたparse_urlのバグが関係しています。

http://php.net/ChangeLog-5.php#5.6.28

例えば以下のようなURLでparse_urlを実行すると不具合が発生します。

parse_url('http://example.com#@xxxxx.com/')

まずphp5.6.28以降のバージョンで実行してみます。

array(3) {
  ["scheme"]=>
  string(4) "http"
  ["host"]=>
  string(11) "example.com"
  ["fragment"]=>
  string(11) "@xxxxx.com/"
}

続いてphp5.6.28より古いバージョンで実行してみます。

array(4) {
  ["scheme"]=>
  string(4) "http"
  ["host"]=>
  string(9) "xxxxx.com"
  ["user"]=>
  string(12) "example.com#"
  ["path"]=>
  string(1) "/"
}

hostが正しく解釈されていないことが分かるかと思います。URLにはBasic認証の情報をhttp://user:password@example.com/のような形で埋め込めるのですが、php5.6.28より古いバージョンだと上記のようなケースでhostを正しく解釈できないようです。

つまり、以下のようなURLをredirect_toに設定すれば「host=正当なURL」となり、wp_validate_redirectをバイパス出来てしまいます。

http://(悪意のあるサイトのURL)#@(正当なURL)/

実際には、以下のようなURLをユーザーに踏ませると、hiddenフィールドに値が設定されログイン成功後に別ドメインにリダイレクトされてしまいます。

http://xxxxx.com/wp-login.php?redirect_to=http://example.com%23@xxxxx.com/

Open Redirect

なお、どのような修正が行われたのかは以下のURLからご確認いただけます。

https://core.trac.wordpress.org/changeset/36444

:spy: ブラインドSQLインジェクション

OWASPにるとブラインドSQLインジェクション(Blind SQL Injection)は以下のように記載されています。

Blind SQL (Structured Query Language) injection is a type of SQL Injection attack that asks the database true or false questions and determines the answer based on the applications response. This attack is often used when the web application is configured to show generic error messages, but has not mitigated the code that is vulnerable to SQL injection.When an attacker exploits SQL injection, sometimes the web application displays error messages from the database complaining that the SQL Query's syntax is incorrect. Blind SQL injection is nearly identical to normal SQL Injection, the only difference being the way the data is retrieved from the database. When the database does not output data to the web page, an attacker is forced to steal data by asking the database a series of true or false questions. This makes exploiting the SQL Injection vulnerability more difficult, but not impossible. .

要約すると、ブラインドSQLインジェクションはSQLインジェクションの一種ですが、検索される方法に違いがあります。通常のSQLインジェクションのようにUNION SELECTなどで本来表示されないデータを画面に表示させる方法とは異なり、クエリの結果が画面に表示されない場合にクエリの成功・失敗やクエリ実行の時間差を使って1ビットの情報を取得し、それを何回も繰り返していくことで最終的にデータを窃取する方法になります。

詳しい解説は徳丸先生の以下の記事をご覧ください。

今回ご紹介するブラインドSQLインジェクションは、Loginizerというサードパーティ製のセキュリティプラグインに見つかった脆弱性になります。このプラグインはアクセス元のIPアドレスをもとにログイン失敗の情報を記録したり、ブラックリストに登録されたIPアドレスを拒否したりしてくれるのですが、このIPアドレスの部分の処理に問題がありました。

問題があった箇所を抜粋したものが以下になります。functions.phpを見るとX-Forwarded-Forヘッダーが指定されている場合、そのヘッダーからIPアドレスを取得していることが分かります。そして、取得したIPアドレスはプレースホルダーを使わずにWHERE条件に指定されています。つまり、このX-Forwarded-ForヘッダーにSQLインジェクションの脆弱性があることになります。

loginizer/functions.php
function lz_getip(){

    global $loginizer;

    $ip = _lz_getip();

    $loginizer['ip_method'] = (int) @$loginizer['ip_method'];

    if(isset($_SERVER["REMOTE_ADDR"])){
        $ip = $_SERVER["REMOTE_ADDR"];
    }

    if(isset($_SERVER["HTTP_X_FORWARDED_FOR"]) && @$loginizer['ip_method'] == 1){
        $ip = $_SERVER["HTTP_X_FORWARDED_FOR"];
    }

    if(isset($_SERVER["HTTP_CLIENT_IP"]) && @$loginizer['ip_method'] == 2){
        $ip = $_SERVER["HTTP_CLIENT_IP"];
    }

    return $ip;

}
loginizer/init.php
function loginizer_can_login(){

    global $wpdb, $loginizer, $lz_error;

    // Get the logs
    $result = lz_selectquery("SELECT * FROM `".$wpdb->prefix."loginizer_logs` WHERE `ip` = '".$loginizer['current_ip']."';");

では、sqlmapというペネトレーションテストツールを使って実際に試してみましょう。

python sqlmap.py -u "http://xxxxxxxx/wp-login.php" \
--data "user_login=1&user_pass=1&wp-submit=Login&testcookie=1" \
--headers="X-Forwarded-For: *" --level=5 --risk=3 --dbms=MySQL

上記のようなコマンドをコンソール上で実行すると以下のような結果が返って来ました。

sqlmapの結果

Typeの項目を見るとTime-based SQL Injectionの脆弱性があることが分かります。

Type: AND/OR time-based blind

Payloadの項目に実際にリクエストされた値が表示されています。

Payload: ' AND 2418=BENCHMARK(5000000,MD5(0x48515358))-- PYWm

つまり、以下のようなSQLが実行されているということになります。

select * from `wp_loginizer_logs` where `ip` = '' AND 2418=BENCHMARK(5000000,MD5(0x48515358))-- PYWm';

BENCHMARK関数は式の処理速度を計測する場合などに使われるものです。つまり、Vectorに記載されている内容から分かるように、特定条件の場合のみBENCHMARK関数が実行されるようにし、その時間差を計測することを繰り返せばデータを取り出すことが可能になります。

Vector: AND [RANDNUM]=IF(([INFERENCE]),BENCHMARK([SLEEPTIME]000000,MD5('[RANDSTR]')),[RANDNUM])

まとめ

正直なところ、この記事を書くまでは自分自身ではセキュリティ対策をやっているつもりでしたが、いろいろ調べる中でその認識が甘かったことを痛感しました。技術のトレンドは目まぐるしく動き、ついついトレンドを追うことに目を奪われがちですが、今一度基本に立ち返ってセキュリティのことを考えることが大切ではないでしょうか。(この記事は自分で動作検証した上で書いておりますが、内容に間違いなどありましたらコメントでご指摘頂けると幸いです)

:warning:悪用されないように古い脆弱性を取り上げましたが、WordPressやプラグインをずっと更新していないという方は更新されることをお勧めいたします。