クライアントサイドのコードを保護してデータ収集の真正性を確保
目次
広く知られたことですが、Web アプリケーションや Web サイトを効率的に保護するためには、JavaScript を使用してクライアントサイドのデータをブラウザーから収集する必要があります。通常、このようなデータには、デバイスとブラウザーの特性やユーザー設定(フィンガープリント)に加え、マウスの動き、タッチ、キー押下など、デバイスとのユーザーインタラクションに関するデータ(テレメトリー)が含まれます。
Web セキュリティ担当者とベンダーは、さまざまな検知手法(単純なルールから高度な AI モデルまで)を使用してデータを処理し、人間が制御する正当なデバイスからの正当な要求である可能性を検証します。
異なるデータポイントを組み合わせることは、ユーザーの区別や時間経過に伴うアクティビティの評価にも役立ちます。ボット管理や不正検知の製品は、主にこのような原理に基づいて、Credential Stuffing、アカウントの乗っ取り、アカウントの不正作成、コンテンツスクレイピングなどの攻撃を検知しています。
データ収集の完全性
サイトに対するユーザーインタラクションを正確に評価し、脅威にフラグを付けるためには、データの真正性と完全性を確保することが重要です。クライアントサイドで実行されたものが改ざんされ操作される可能性があるとしたら、誰もデータの真正性や完全性を主張することはできません。
クライアントサイドで JavaScript コードを実行する際には、以下の 2 つの理由から、コードを十分に保護する必要があります。
1. JavaScript コードは組織の知的財産の一部です。脅威アクターや競合他社から、できる限り守る必要があります。
2. 環境とそのリスク要因を正しく理解するためにはデータの完全性が不可欠です。JavaScript が保護されていれば、純粋にこのスクリプトの実行によって収集されたデータは操作も変換も加えられていないため、データは信頼できるものとなります。
クライアントサイドのコードを保護してデータの真正性を確保する方法
他のセキュリティと同様、この問題も、単一のソリューションでは対処できません。JavaScript コードを保護して、その実行を強制し、収集されたデータの真正性を確保するために Akamai は以下の方法を使用しています。このブログ記事では、これらの方法についてまとめてご紹介します。
- コードの難読化
- データ完全性チェック
- VM 難読化
- 誤解釈へと導く余分なコードの挿入
- JavaScript コードのローテーション
- 動的なフィールドローテーション
- JavaScript ビルドパイプラインとデータ検証
同様の方法でコードを保護する際には、チーム、組織、技術スタックのニーズに応じ、これらの方法を組み合わせて使用することをお勧めします。
コードの難読化
難読化は、JavaScript コードの保護に使用される最も一般的な手法の 1 つです。難読化によってコードの追跡や解読が難しくなります。
健全な開発手法としては、機能や変数にできるだけ説明的な名前を付け、コードを論理的に構成してデバッグやメンテナンスを容易にすることが推奨されています。このようにすれば時間と労力を節約できますが、クリーンコードはリバースエンジニアリングの標的になりやすいという側面もあります。
難読化を適用すると、このような健全な開発手法が壊され、機能や変数の説明的な名前がランダムな名前に置き換えられます。また、名前の順序変更やエンコード、または一部のロジックの分割が施されることもあります。難読化しても、Web ブラウザーは問題なくコードを実行し、同じ結果が得られます。しかし、コードのリバースエンジニアリングを試みる者にとって、その作業はより困難なものとなります。
難読化する場合も、開発者は、メンテナンスや拡張のため、十分に構造化されたコードを使用します。新バージョンの完了後、リリース前に、コードに難読化エンジンを適用します。Code Beautify、JScrambler、Digital.ai などのフリー/オープンソース製品や市販製品を使えば、JavaScript コードをすばやく簡単に難読化できます。
図 1 は、フィンガープリントによく使用される単純な JavaScript 関数の例です。さまざまなデバイス特性を抽出するように設計されています。この例は難読化前の状態を示しています。
function getDeviceInfo() {
return {
userAgent: navigator.userAgent,
hardwareConcurrency: navigator.hardwareConcurrency || "unknown",
screenOrientation: screen.orientation.type,
};
}
図 1:難読化前のオリジナルコード
ご覧のとおり、オリジナルのコードはとてもわかりやすく構成されています。コーディングの知識が乏しい人でも、コードの目的や目標達成方法を理解できるのではないでしょうか。
図 2 は、同じ JavaScript 関数にオンラインツール Code Beautify を実行した後の状態です。
(function(_0xbf521e,_0x43c80b){var _0x4ad763=_0x3e09,_0x18fc85=_0xbf521e();while(!![]){try{var_0x40d2a7=parseInt(_0x4ad763(0xfc))/(0x18d1+-0xe6d+-0xa63)+-parseInt(_0x4ad763(0xf6))/(0x2*-0x7e4+0x171a+-0x750)+-parseInt(_0x4ad763(0xfb))/(-0x2e7*-0xb+0x6b*0x1f+-0x2cdf)*(parseInt(_0x4ad763(0xef))/(0x40f*-0x4+-0x897+0x18d7))+-parseInt(_0x4ad763(0xf3))/(0x3*-0xb5f+0x462+0x1dc*0x10)*(parseInt(_0x4ad763(0xf0))/(-0xb87*-0x1+0x18e8+-0x3*0xc23))+-parseInt(_0x4ad763(0xfa))/(0x2258+0x8f7+-0x2b48)*(-parseInt(_0x4ad763(0xee))/(0x3e9+-0xe93+0xab2))+parseInt(_0x4ad763(0xf1))/(0x1*-0x81e+0x525*-0x5+0x4*0x878)+parseInt(_0x4ad763(0xed))/(-0x59*-0x1f+0x779+-0x6f*0x2a);if(_0x40d2a7===_0x43c80b)break;else _0x18fc85['push'](_0x18fc85['shift']());}catch(_0x4460fc){_0x18fc85['push'](_0x18fc85['shift']());}}}(_0x1950,-0x1f*-0x38cb+0x17f2fa+-0x10aebf));function getDeviceInfo(){var _0x7a196=_0x3e09,_0x52340e={'VEDsL':_0x7a196(0xf8)};return{'userAgent':navigator[_0x7a196(0xf4)],'hardwareConcurrency':navigator[_0x7a196(0xf2)+_0x7a196(0xfd)]||_0x52340e[_0x7a196(0xf5)],'screenOrientation':screen[_0x7a196(0xf9)+'n'][_0x7a196(0xf7)]};}function _0x3e09(_0x56cbb3,_0x1167d0){var _0xddc250=_0x1950();return _0x3e09=function(_0x363b57,_0x27d74c){_0x363b57=_0x363b57-(-0x6d9+0x1316*0x1+-0xb50);var _0x1b2eec=_0xddc250[_0x363b57];return _0x1b2eec;},_0x3e09(_0x56cbb3,_0x1167d0);}function _0x1950(){var _0x1d7105=['ncurrency','20162890GviEyp','2488DLGTpn','4rCTHCm','65154TKsGUe','7673175smCphy','hardwareCo','670lOXWEG','userAgent','VEDsL','1749116JlgXKK','type','unknown','orientatio','12971xihUJr','2027775PnQRTc','487370FufNiT'];_0x1950=function(){return _0x1d7105;};return _0x1950();}
図 2:難読化後のコード(Code Beautify を使用)
その長さだけを見ても、難読化後のコードは明らかにわかりにくくなっています。このコードは複雑に見えるかもしれませんが、このような単純な難読化を元に戻す手段はあり、脅威アクターはそれを熟知しています。とはいえ、難読化によって解読のハードルを上げれば、知識が少なく、それほどレベルの高くない脅威アクターを抑止できます。
攻撃の成功に手間がかかりそうに見せるか、または実際に手間がかかるようにして、脅威アクターを疲弊させたり、標的候補としての魅力を失わせたりすることができれば、セキュリティの闘いは半分勝利したも同然です。
データ完全性チェック
これまで見てきたように、コードの難読化は着手点としては適切ですが、コードを元のフォーマットに戻す復元手法やツールがあるため、目的意識の高い脅威アクターを難読化だけで抑止することはできません。難読化手法に加えて、コードやデータの完全性チェック関数も実装すれば、収集された情報の完全性をさらに高めることができます。
コードとデータの完全性チェックでは、スクリプトによって生成された出力が実際に正しいものであることを確認するために、コード全体のさまざまな場所に小さな関数を追加します。通常、これらのチェック関数は、既存のコア JavaScript 関数の出力に加え、ユーザーセッションに固有のシードなど、複数の変数を使用して、二次的な出力を生成します。
図 3 は、入力に 3 つの変数を取る関数の例です。単純な数式とハッシュ関数でこれらの変数を使用し、結果を返します。変数 a と b は 2 つのコア関数の出力に対応し、変数 c は固有のシードであると考えられます。この例では、すべてのプロパティが数値であることが必要です。
function IntegrityCheck(a, b, c) {
const mathResult = a + b * c;
const stringResult = String(mathResult);
let hash = 0;
for (let i = 0; i < stringResult.length; i++) {
hash = (hash * 31 + stringResult.charCodeAt(i)) >>> 0;
}
return hash;
}
図 3:複数の変数を使用するデータ完全性チェックコードの例
さらに具体的に説明すると、図 3 の単純な関数では、screen.colorDepth と navigator.hardwareConcurrency という、どちらも数値を返すプロパティが変数 a と b として使用されている可能性があります。実際には、この関数のプロパティは、数値を返すものに限定されるわけではありません。どんな値でも、完全性チェック関数に渡される前にハッシュ化すれば、整数に変換できます。ここでは、例をシンプルにするために、このような形にしました。
図 4 の例のように、対応範囲を拡げるために、コア関数の出力をハッシュ化する完全性チェック関数もあります。
import { createHash } from 'crypto';
function hashTwoVariables(a, b) {
const concatenatedString = String(a) + String(b);
const hash = createHash('sha256').update(concatenatedString).digest('hex');
return hash;
}
図 4:ハッシュ出力の例
このような小さな関数が何十個も使用される場合もあります。それぞれの関数が異なる操作を実行し、コード内に散在するコア関数の異なる出力を消費しながら、主要なデータポイントを保護します。また、最終チェックとして、すべてのフィンガープリントデータと動作データ、および個々の完全性チェック関数の結果を含めたペイロード全体に「署名」することも可能です。そのための手段の 1 つとして、ペイロード全体をハッシュ化して、最初の出力を比較する方法があります。ハッシュが送信側と受信側の両方で一致していれば、そのペイロードは安全であり、変更されていないとみなされます。
VM 難読化
上記のような単純な完全性チェック関数は、そのままオープンにしておくことはできません。しかも単純な難読化手法では隠せません。ここで必要になるのが、高度な仮想マシン(VM)難読化技術です。この手法を使うと、脅威アクターが内部で実行されていることや、有効なペイロードの生成方法を把握することが困難になります。
VM 難読化では、コードが仮想マシンのバイトコードに変換されます。このようなバイトコードはマシンにとっては解釈可能なものですが、これを使うことで脅威アクターによるリバースエンジニアリングは非常に難しくなります。
VM 難読化手法を提供しているベンダーもありますが、あらゆるタイプの関数ロジックに対応しているとは限りません。VM 難読化を使用する場合は、ベンダーのガイドラインに従い、コードの回帰テストを徹底的に実施してください。
回帰テストは、VM 難読化に限らず、広く有用な手法であるため、セキュリティルーチンの一部として実装する価値があります。ただし、コード出力の複雑さを考慮すれば、VM 難読化と組み合わせることは特に有益と考えられます。
誤解釈へと導く余分なコードの挿入
追加レイヤーとして、実際には用途のないコードをコアロジックに加えると、コードのリバースエンジニアリングを試みる脅威アクターの作業を難しくすることができます。このようなコードは、脅威アクターを間違った方向へと誘導し、フラストレーションを与えて、試みを断念させるように設計されます。
同様に、完全性チェック関数の構造を変化させることで、難読化解除やリバースエンジニアリングをより困難にすることも可能です。これを達成する手段の 1 つとして、構造は異なるが同じ出力を生成する同等の関数をいくつか開発するという方法があります。
機能的に同一であっても構造的に異なる関数は、VM 難読化後のエンコード結果がそれぞれ異なります。そのため、このような関数はリバースエンジニアにとって非常に複雑なものとなります。
図 5 に例示する 3 つの関数は、それぞれ少しずつ異なっていますが、常に同じ出力を返します。
function IntegrityCheck_1(a, b) {
return a + b * 1;
}
function IntegrityCheck_2(a, b) {
return a + 0 + b;
}
function IntegrityCheck_3(a, b, c) {
return a + b + c * 0;
}
図 5:同じ出力を返す、3 つの異なるコードの例
JavaScript コードのローテーション
誤解釈へと導くコード、高度な難読化、完全性チェックを実装することは効果的ですが、脅威アクターが断念するとは限りません。スキルのあるリバースエンジニアが時間と労力をかければ、変化しないコードはいつか必ず解読されてしまいます。つまり、スクリプトに有効期限を設ける必要があるのです。
たとえば、新しい JavaScript コードがリリースされるたびに、同じ機能を持つ異なるコードバージョンを 1,000 個生成し、これらの各コードバージョンに異なる完全性チェック関数を用意します。各バージョンが使用される有効時間は 10~20 分とし、クライアントが新しいバージョンを定期的にリロードするようにコントロールすれば、古いバージョンはすぐに廃止され無効となります。
目的は、複雑さによって脅威アクターを圧倒し、彼らの作業速度では間に合わないようにすることです。結果的に脅威アクターにできるのはブラウザーを通じて JavaScript を実行することだけで、コードの機能はわからずじまいとなります。
動的なフィールドローテーション
コードの読み取りと解読が困難であっても、出力や、収集・送信されるデータを調べることで、コードの目的を推測できることはよくあります。サーバーに送信される情報の一部、特にデバイスやブラウザーの特性などの詳細情報が明らかになる場合もあります。
ただし、単にブール値を返す関数や、整数を返す完全性チェック関数は、意図を推測することが比較的困難です。
1 つの方法として、各収集データポイントのレポートに使用されるフィールドの名前とペイロード内の相対的な位置をバージョンごとに変更すれば、ペイロード構造が予測しにくくなり、脅威アクターにとって複雑さが増します。
前述のとおり、各 JavaScript バージョンには、それぞれ異なるコード完全性チェックセットが用意されます。さらに、そのペイロードに異なるフィールド名を使用し、バージョンごとに各データポイントの位置も変えるのです。
フィールド名とその位置は、事前定義されたアルゴリズムに基づいて JavaScript のビルド時に定義されます。このアルゴリズムを使用することで、データを処理するサーバーは、ボットや不正の正確な検知に不可欠な各種の情報を正しい位置から取得できます。
図 6 の例では、バージョンごとにフィールド名とその位置が変化しています。わかりにくくするためには、フィールド名に説明的でない名前を使用する必要があります。
Payload Iteration #1
mx01: [user-agent]
mx02: [display-mode]
mx03: [hardconcur]
mx04: [pixelDepth]
mx05: [language]
mx06: [WebGL_Rend]
mx07: [intg_chck_1]
Payload Iteration #2
yw01: [display-mode]
yw02: [intg_chck_1]
yw03: [user-agent]
yw04: [pixelDepth]
yw05: [hardconcur]
yw06: [WebGL_Rend]
yw07: [language]
Payload Iteration #3
za01: [language]
za02: [WebGL_Rend]
za03: [hardconcur]
za04: [pixelDepth]
za05: [intg_chck_1]
za06: [user-agent]
za07: [display-mode]
図 6:各バージョンのフィールド名の例
上記の例のように、出力内のフィールド数が 7 つだけであれば、バージョンごとに変化していることが簡単にわかりますが、データが収集されて返されるデータポイントが何百個もあるとしたら、どうでしょうか。
JavaScript ビルドパイプラインとデータ検証
多様な手法を使用して JavaScript コードを保護し、収集されたデータの完全性を確保するためには、複雑なビルドパイプラインとリリースプロセスを開発する必要があります。開発者はまず、適切にフォーマットされた生の JavaScript ファイルを更新し、その機能をテストして、さらに回帰テストを実行します。
次に、開発者はアルゴリズムを使用して、それぞれ以下の点が異なる何千ものバージョンを生成します。
- データ完全性チェック関数 - コア JavaScript のデータポイント、使用される数学/ハッシュ関数のデータポイント、ロジック全体での相対的な位置によるデータポイントを変化させます
- 誤解釈へと導くコードや用途のないコードのセット
- ペイロード出力のフィールド名
- ペイロード出力のフィールド順序
それぞれ異なる構成要素の生成後、JavaScript ファイルの各バージョンに以下のプロセスを実行します。
- VM を使用して、データ完全性チェック関数とその他の重要な関数を難読化
- コード全体を難読化
- 各バージョンを Web サーバーにアップロード
すべてのバージョンの生成とアップロードが完了したら、新しい JavaScript セットを本番環境で有効にする必要があります。この変更について、データを受信するボット/不正検知エンジンを実行しているサーバーと調整します。JavaScript ビルドシステムで使用されるアルゴリズムの一部を実行して、以下のことを可能にする必要があります。
- クライアントが送信しているのは、古い JavaScript バージョンのペイロードではなく、現行バージョンのペイロードであることを確認する
- ペイロードのさまざまなフィールドの解析を、そのペイロードが生成された JavaScript バージョンに従って実行する
- 同等の機能を実行して、コード完全性チェック値を検証する
最終的な難読化後の最終製品に対して、リリース前のプレプロダクション段階で、エンドツーエンドの徹底的なテストを実施し、すべてのコンポーネントが同期され、期待される結果が得られることを確認する必要があります。そのためには、ある程度複雑な JavaScript ビルドワークフローを構築することが必要です。
それでも、競合他社や脅威アクターからコンテンツを保護する必要があり、その出力がインターネットや Web サイトを訪問するユーザーのセキュリティに影響を及ぼすのであれば、この手間は価値あるものといえます。
結論
クライアントサイドで実行される JavaScript コードは、フィンガープリントとテレメトリーの収集に使用されます。また、ボットと不正の検知用に設計されたカスタムロジックは、セキュリティで保護する必要があります。コードとデータを保護する戦略はいくつかありますが、1 つまたは 2 つの手法を実装しても、最高レベルの脅威アクターに対する防御効果はごくわずかにすぎません。
クライアントサイドのコードとそのペイロードを保護するためには、複数の防御層とテクノロジーを含む複雑な戦略が必要です。たとえば、コードの難読化、誤解釈へと誘導するコードまたは用途のないコード、コードの完全性チェック関数と VM 難読化の組み合わせ、ペイロード構造のランダム化による予測可能性の低下、コードの定期的な更新などを組み合わせて使用します。
図 7 の式は、効率的な保護の確保を目的とした複雑な戦略の組み合わせを示したものです。
[JS Code obfuscation[
+ Misleading code
+ unused code
+ VM Obfuscation [code integrity check]
+ unique field names
+ field relative position shift]
x [Number of unique iterations]
+ Limited version validity (10 minutes)
+ Force JS reload]
図 7:JavaScript 保護戦略の方程式
このような組み合わせで、クライアントに JavaScript を実行させれば、最終的にデータが改ざんされたり検知エンジンが無効化されたりする可能性は小さくなります。開発作業を軽減するため、VM 難読化のような最も複雑ないくつかの段階には市販のソリューションを使用することを強く推奨します。 ただし、脅威アクターが難読化解除ツールを構築し利用できるようになった場合にも保護が維持されるようにするため、コードの完全性チェック、誤解釈へと誘導するコードスニペット、複数のバージョンなど、一部の戦略の構築、維持は社内で行う方がよいと考えられます。