OSSに空いたセキュリティーホールと「脆弱性のふさぎかた」 その対策方法を調べる

第5回(最終回) PHPの脆弱性 ~型混同~

はじめに

いよいよ本連載も今回で最終回です。最終回ということで、近年報告件数が増加傾向にある「型混同の脆弱性」[1]を取り上げたいと思います。具体的には、2016年にPHP にて発見されたCVE-2016-3185を解説します。PHPは前回(本誌2021年5月号)も取り上げましたが、今回はそれとはまったく別の種類の脆弱性になります。その点を頭の片隅に入れつつお読みください。また補足になりますが、今回の脆弱性はすべてPHP 7.0.3で検証・解説しています。

今回の脆弱性:CVE-2016-3185

CVE-2016-3185はPHPのインタプリタ内に実装されているmake_http_soap_request関数の中に存在する型混同の脆弱性です。前回のおさらいになりますが、PHPのインタプリタ自体に脆弱性が存在した場合、不正なPHPのプログラムを解釈・実行させるとその脆弱性が発現します。

実際にそのようなプログラム(CVE-2016-3185.php)を筆者のほうで書いて実行した様子が次です(ファイルの中身はのちほど紹介⁠⁠。

$ php CVE-2016-3185.php
Segmentation fault (core dumped)

「Segmentation fault (core dumped)」と表示され、PHPが異常終了しているのがわかります。

このプログラムの内部では、いったいどのような処理がなされているのか、読者としては気になるところだと思います。ですが、中身の解説を行う前に、その理解を促すためにも、まずは「型混同の脆弱性とは何か」について説明します。

型混同の脆弱性(C言語での例)

一般的に型混同の脆弱性は、型が存在するプログラミング言語(C/C++やPHPなど)で実装されたソフトウェアにおいて、おもに開発者の実装の不備により生まれます。この脆弱性は、ざっくりと言うと「とある特定のリソースに対して、当初の(想定した)型とは別の型の変数などでアクセス(利用)できること」に起因します。

プログラミング言語によって型や変数の取り扱い方法が異なるため、一言に「型混同の脆弱性」といっても、実際はかなり多様な形態を取ります[2]。それらを網羅的に紹介するのは誌面の都合的にも難しいため、本稿ではC言語上での例を見せます。リスト1がC言語のプログラムで型混同の脆弱性が発生している例です。これは、共用体(union)を利用した例です。共用体とは、構造体のように定義・利用できるデータ型ですが、中で定義されているメンバは、構造体と違ってメモリ領域を共有しているのが最大の特徴です。言い換えると、異なる型のメンバがあったとしても、それらのデータが保管されているメモリ領域は同じなのです。

リスト1 型混同の脆弱性の例(C言語での例)
#include <stdio.h>

struct Person {
  int age;
  union {
    char *name;
    int name_id;
  };
};

int main(){

  struct Person me;
  me.age = 25;
  me.name = "Hanako";


  if(me.age >= 20){
    printf("You are an adult!\n");
    me.name_id = 100;
    //return 0;
  }

  printf("Your name is %s\n", me.name);

  return 0;
}

リスト1の場合はPersonという名前の構造体に、int型のageというメンバに加え、内部にunionで、charポインタ型のnameと、int型のname_idという2つのメンバを定義しています。

プログラム自体は至極単純で、Person構造体の変数meに入っているageの数値が20以上だった場合、⁠You are an adult!」というメッセージとともに、name_idに100を代入します。そしてageの数値が20未満の場合、me.nameに格納されている名前(今回の場合Hanako)が表示されることを期待して実装しているのですが、実は違います。ソースコードをよく読むと、me.name_id = 100;の直後にあるreturn文がコメントアウトされているのが見えます。つまり、ageが20以上の場合も、最終的にはme.nameに格納されている名前が表示されます。この部分に実は型混同の脆弱性があります。

脆弱性の突き方

いったいどこが問題なのでしょうか。それは、printf関数を用いて名前を出力する際、me.nameの中身が当初想定しているHanakoではない別のものに置き換わっていることです。おさらいになりますが、nameとname_idは共用体のメンバです。そのためname_idに100を代入すると、nameに100を代入していることと同義になります。

つまり、printf関数でme.nameに格納されている名前を表示するとき、⁠Hanakoの文字列が入ったメモリのアドレス」ではなく「100」が参照されるのです。ですが、100は有効なアドレスではありません。そのため、プログラムはここで異常終了します。これが型混同の脆弱性の一例です。

脆弱性を悪用する際は、このような挙動が利用されます。たとえば先ほどのプログラムのname_idに、メモリ上に存在する重要情報を指し示すアドレス(パスワードなどの文字列)の数値を代入できたらどうでしょうか。その場合、printf関数でその情報が表示されてしまいます。

そのほかにも、共用体のメンバに、仮に関数ポインタがあった場合はどうでしょうか。型混同の脆弱性が存在した場合、たとえばint型などいったん別の型を利用し、関数ポインタが持つアドレスを別の関数(または攻撃が用意した攻撃コードなど)のアドレスに書き換えてしまえるのです。そのあと、関数ポインタ経由で関数呼び出しを行えば、その書き換え先のアドレスの関数(やコード)が実行されてしまいます。

CVE-2016-3185.phpの中身

では、型混同の脆弱性の概念について理解できたところで、いよいよPHPを異常終了させたプログラムであるCVE-2016-3185.phpの中身を見ていきます。⁠脆弱性を発現させるために、難しいプログラムを書いてるんだろう」と思う方も多いと思いますが、実は高度なことはいっさい行っていません。リスト2のとおり、CVE-2016-3158.phpは非常に短いPHPのプログラムです。では、このたった10行程度のプログラムで何をやっているのでしょうか。理解を促すにあたり、まずは背景技術から説明します。

リスト2 脆弱性を発現させるPHPのプログラム(CVE-2016-3158.php)
<?php

$client = new SoapClient(null, array('location' => "http://localhost/a.xml", 'uri' => "a"));

$value = array ('test' =>
   array (
    0 => 'test',
    1 => 123456789,
    2 => 123456789,
  ),
);
$client->_cookies = $value;
$client->__doRequest('test','http://localhost/','test', 1.0);

?>

SOAP HTTPリクエストとCookieについて

そもそも、今回の脆弱性が存在するmake_http_soap_request関数は、PHPのプログラム内でSOAP HTTPリクエストを行う際に利用するものです。SOAP[3]とは、アプリケーション間で構造化されたデータを交換するための仕様で、おもにHTTPを利用してXML形式のデータ(SOAPメッセージ)を送受信します。

今回の脆弱性は、このSOAPメッセージを送受信する際の、HTTP周りの実装に存在していました。具体的には送受信の際、クライアント側(ユーザー側、今回の場合PHP)からWebサーバに対し、HTTPリクエストと呼ばれる要求を発行するのですが、PHP内部の実装に一部問題がありました。その部分とは、クライアント側が持つ「Cookie」の情報を処理する部分です。

ご存じの方も多いと思いますが、CookieとはWebブラウザなどでWebサイトに関する情報を一時的に保存するしくみです。Cookieには、各Webサイトで利用する情報(訪問履歴など)が保存されており、HTTPリクエストの際に必要であれば送信されます図1⁠。

図1 HTTPリクエストのイメージ(Cookieを付与する場合)
図1

以上までわかったところで、CVE-2016-3185.phpを1行ずつ解説していきます。

プログラムの解説

CVE-2016-3185.phpでは、はじめにSOAP通信を行うための初期化・初期設定を行っています。具体的にはSoapClientのオブジェクトを生成し、client変数にそれを格納しています。ちなみにSoapClientの引数は、どのようなデータでも脆弱性の検証をするうえでは問題ないため、説明を割愛します。

そのあと、HTTP リクエストで送信するCookieの情報を、value変数に一度格納し、それを最終的にSoapClientのオブジェクトに格納(リスト2)しています。

次に、Cookieの中身であるvalue変数を見ていきます。ここでは要素数1の連想配列の中に、図2のような3つの要素を持つ配列を格納しています。この配列では、配列の0番めの要素に、文字列型となるstring型で「test」という文字列が格納されています。そして1番めと2番めの要素には、整数型となるint型で「123456789」という数字が格納されています。

図2 Cookieの情報を持つ配列
図2

最後に__doRequest関数で、SOAP HTTPリクエストを発行しています。このときPHPインタプリタの内部では、今回の脆弱性箇所であるmake_http_soap_request関数が呼ばれています。ちなみに__doRequest関数に渡された引数は、どのようなデータでも脆弱性の検証をするうえでは変わらないので、説明を割愛します。

何が問題なのか?

一見なにも問題がないように見えるこのプログラムですが、実行すると最初見せたようにPHPが異常終了します。いったい何が問題なのでしょうか。実はCookieのデータの型に問題がありました。Cookieの情報はすべて、文字列を表すstring型で格納することを、PHPの開発者は想定していたのです図3⁠。

図3 要素1と2は、開発者はstring型を想定して

今回は、配列の1番めと2番めの要素には、int型で「123456789」という整数が格納されています。つまり、内部では文字列として処理を進めていたにもかかわらず整数であったため、正常に処理を進められなくなってしまったのです。これが今回の脆弱性の原因です。

実際の脆弱性箇所について

脆弱性の報告者によると、今回の脆弱性はphp_http.cに実装されているmake_http_soap_requestという関数内の、リスト3の箇所に存在していました。この箇所は、SOAP HTTPリクエストを送信する際に、送信するべきCookieの情報のチェックとパースを行っている部分の抜粋です。ここでは、Cookieのデータを保持している配列の1番めと2番めの要素の中身を、if文を利用してチェックしています。

リスト3 脆弱性箇所(php_http.c)

配列のデータの取り扱いに問題

リスト3の❶の白文字部分が、1番めの配列の要素をチェックしている部分です。前提として、配列のデータはdataという引数に格納されています。最初の行では、zend_hash_index_findという関数[4]を利用して配列から1番めの要素のデータを取り出し、それをtmpという名前の変数に格納しています。そのあと、簡単に言えばstrncmpという文字列を比較するための関数を利用して指定の文字列とtmpを比較しています。

ここで問題なのは、⁠PHP上の定義としては)tmpに入っているデータがstring型であるという前提で処理を行っている点です。tmpの中には、実際には整数型で「123456789」が入っているのにもかかわらず、それが文字列型である前提でstrncmp関数を利用しているのです。より詳しく説明すると、tmp変数に格納されているデータを、Z_STRVAL_Pというマクロ(のちほど解説)を利用し、文字列としてstrncmp関数の第2引数に渡しています。つまり、簡単に言えば、本来ならば文字列を指すアドレスが引数として渡っているはずが、整数値「123456789」が文字列のアドレスとしてstrncmpに渡されているのです。そして「123456789」は、メモリアドレスとして有効ではないため、ここでプログラムが異常終了してしまうのです。

これは配列の2番めの要素に対しても同様です。リスト3の❷の白文字部分が2番めの配列の要素をチェックしている部分です。こちらでも、まずzend_hash_index_find関数を用いて、配列の2番めの要素のデータを取り出し、tmpに格納しています。そのあとin_domainという、内部で文字列比較などを行っている関数の第2引数に、tmpのデータを文字列として解釈して渡しています。

PHPの内部を追う

先ほどの箇所を要約すると「配列の要素をtmpという変数に格納したあと、それがどのようなデータであっても、Z_STRVAL_Pというマクロを利用して文字列として扱う」ということになります。言い換えると、これが今回の型混同の脆弱性の原因です。

この時点で脆弱性箇所の解説を終わらせても、次に解説する脆弱性の修正方法については理解できます。しかし、利用されていたtmp変数やZ_STRVAL_Pマクロは、いったい何者なのでしょうか? 気になりますよね。そこで本節では、筆者と同じように細部まで気になるという方のためにも、これらを深掘りして説明していきます。⁠PHPの内部には興味がない」という方は、⁠脆弱性の修正方法」まで読み飛ばしても問題ありません。

ではまずtmpから解析します。リスト3の1行目を見ると、zval *tmp;という形で変数宣言が行われています。そして、この部分から上にさかのぼってソースコードを読んでいくと、zvalは、_zval_structという構造体であることがわかります。つまりこのtmpは、_zval_structという構造体のポインタ変数なのです。

次にこの_zval_structについて筆者のほうで調べた結果、PHPの個々の変数のデータを保持・管理するための構造体でした。言い換えるとPHP上で扱う変数は、内部ではこの_zval_struct構造体で表されているということです。リスト4がその定義です。

リスト4 _zval_structの定義(zend_types.h
struct _zval_struct {
    zend_value value;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar type,
                zend_uchar type_flags,
                zend_uchar const_flags,
                zend_uchar reserved)
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t var_flags;
        (..略..)
    } u2;
};

この構造体では、PHP上の変数のデータをvalueで、そしてそのデータにひもづく型をu1という名前のメンバ内で管理しています。今回の場合「123456789」というデータがvalueに、そして整数型であるという情報はu1(のtype内)にて保存されているということです。

PHP上の型の内部表現

前述のとおり、型の情報自体はu1に格納されています。具体的には、ここでは型に対応している数字が格納されています。たとえばPHP上の変数がstring型であった場合、対応する数値である「6」リスト5IS_STRINGが格納されます。

リスト5 型の定義を一部抜粋(zend_types.h
#define IS_UNDEF   0
#define IS_NULL    1
#define IS_FALSE   2
#define IS_TRUE    3
#define IS_LONG    4
#define IS_DOUBLE  5
#define IS_STRING  6
#define IS_ARRAY   7
#define IS_OBJECT  8

今回の場合は整数型で、少しまぎらわしいですが、簡単に言えばC言語のlong型のサイズまでの整数値を入れられるということで、⁠4」IS_LONGがu1のtypeに入っています。PHP内部では基本的に、このu1内に保持している型の情報に基づきvalueのデータの扱いを変えています。このように実装することで、動的型付け言語でもあるPHPの変数を表現しているのです。

zend_value共用体の内部

では次に、⁠123456789」がどのように保持されているのかを見ていきます。前述のとおりデータ自体は_zval_structという構造体の中の、value内に格納されています。ではvalueが何者であるかと言うと、zend_valueという名の共用体の変数にあたりますリスト6⁠。

リスト6 zend_value共用体の定義(zend_types.h
typedef union _zend_value {
    zend_long         lval;
    double            dval;
    zend_refcounted  *counted;
    zend_string      *str;
    zend_array       *arr;
    zend_object      *obj;
    zend_resource    *res;
    zend_reference   *ref;
    zend_ast_ref     *ast;
    zval             *zv;
    void             *ptr;
    zend_class_entry *ce;
    zend_function    *func;
    struct {
        uint32_t w1;
        uint32_t w2;
    } ww;
} zend_value;

zend_value共用体は大雑把に言えば、それぞれのメンバで、PHP上の変数のデータに対しての型ごとの扱いなどを定義しているのです。今回の場合は整数型のため、値は本来ならばzend_long lval経由でアクセスする必要があります。しかし、後ほど解説しますが、実際には内部でstring型の扱いを定義したzend_string *strを利用してアクセスされていたのです。

補足ですが、本当に文字列であった場合は、zend_string(構造体)の中で定義されている、valという名前のchar型配列経由で読み取られます。これは、後ほどの説明でも出てくるので、頭の片隅に入れておいてください。

Z_STRVAL_Pマクロ

最後に、脆弱性箇所で頻繁に利用されていた、Z_STRVAL_Pマクロについて説明します。PHPでは、valueの中に保持されている値を、指定の型で簡単にアクセスするためのマクロが用意されており、Z_STRVAL_Pもそのうちの1つです。

おさらいになりますが、脆弱性箇所ではこのマクロを利用してtmp変数の中に入っているデータをstring型として扱い、文字列にアクセスしようとしていましたね。そこで「マクロの中身では何をやっているのか」リスト7です。

リスト7 Z_STRVAL_Pに関連するマクロ(zend_types.hzend_string.hから抜粋)
#define ZSTR_VAL(zstr)        (zstr)->val
#define Z_STR(zval)       (zval).value.str
#define Z_STRVAL(zval)        ZSTR_VAL(Z_STR(zval))
#define Z_STRVAL_P(zval_p)     Z_STRVAL(*(zval_p))

まず一番下の行がZ_STRVAL_Pマクロです。そしてこのZ_STRVAL_Pでは、tmpのような_zval_struct構造体のポインタ変数を引数に、Z_STRVALマクロを呼び出しています。このZ_STRVALマクロも同様にたどっていくと、最終的には前述したように、srting型のデータを表現するzend_string構造体の中でも、文字列を表すvalにアクセスしています(zstr)->val⁠。

ここまでのまとめ

駆け足になりましたが、以上が脆弱性箇所に関係するPHPの内部構造の話でした。本稿前半では、脆弱性の原因は「配列の要素をtmpという変数に格納したあと、それがどのようなデータであっても、Z_STRVAL_Pというマクロを利用して文字列として扱っている」と書きました。

PHPの内部構造をふまえたうえで、これを言い換えると次のようになります。⁠tmp変数の中には、整数型で123456789が格納されており、本来ならばzend_value共用体のlval経由でアクセスする必要があったところ、格納されているのが文字列であると勝手に決めつけ、型を確認せずに、Z_STRVAL_Pマクロを使ってstring型を表すstr(のchar型配列val)としてアクセスしてしまったことで、型混同が発生してしまった⁠⁠。

脆弱性の修正方法

では、この脆弱性が実際にどのように修正されたかを見ていきます[5]リスト8が修正後のソースコードです。修正の方針としては至極単純で、string型か否かをチェックする箇所を追加しています。

リスト8 脆弱性修正後のソースコード
zval *tmp;
if (((tmp = zend_hash_index_find(Z_ARRVAL_P(data), 1)) == NULL ||
+       Z_TYPE_P(tmp) != IS_STRING || ←★
    strncmp(phpurl->path?phpurl->path:"/",Z_STRVAL_P(tmp),Z_STRLEN_P(tmp)) == 0) &&
    ((tmp = zend_hash_index_find(Z_ARRVAL_P(data), 2)) == NULL ||
+        Z_TYPE_P(tmp) != IS_STRING || ←★
    in_domain(phpurl->host,Z_STRVAL_P(tmp))) &&
    (use_ssl || (tmp = zend_hash_index_find(Z_ARRVAL_P(data), 3)) == NULL)) {

具体的には、string型が前提の処理(strncmpの行など)を実行する直前に、string型か否かの検査条件(リスト8★)が追加されています。このチェックの時点でデータがstring型でないと判明した場合、string型の引数が必須の処理には実行が移りません。そのため異常終了も起きません。言い換えると、この修正を施すことで、入力データがstring型ではなかった場合でも問題なく処理が続行できるようになります。


最後になりますが、この脆弱性はPHPの次のバージョンで確認されています。該当するバージョンを利用している方は、最新版にアップデートすることをお勧めします。

  • PHP 5.4.44未満
  • PHP 5.5.28未満の5.5.x
  • PHP 5.6.12未満の5.6.x
  • PHP 7.0.4未満の7.x

連載のおわりに

本連載は今回で終わりとなりますが、いかがでしたでしょうか[6]。連載開始時にも述べましたが「脆弱性」はサイバー攻撃の根本的な原因の1つであり、平和なサイバー空間の実現のためには、脆弱性を減らすことが非常に大事です。

そのため、本連載を通じて読者のみなさんが、脆弱性を生み出さない、もしくは適切に修正するための知見を少しでも身につけ、そして結果として少しでも脆弱性を世界から減らすことができれば、著者冥利に尽きます。

それではみなさん、本連載をご愛読いただきありがとうございました!

おすすめ記事

記事・ニュース一覧