目次


セキュリティーに配慮した機械学習フロントエンドを作成する

Node.js アプリケーション内でのセキュリティー違反を識別する機械学習フロントエンドをプログラムする

Comments

この記事では、アプリケーションの適切な入力形式を自動的に学習するセキュリティー・フロントエンドを作成する方法を説明します。このフロントエンドは、学習した適切な入力形式の情報に基づいて異常な入力を識別し、その入力をブロックしたりアラートを起動したりできます。このシナリオは完全無欠のソリューションではありませんが、アプリケーションに及ぶリスクを大幅に軽減することができます。

この手法を説明するサンプル・アプリケーションは、Node.js を使用して作成されていて、Bluemix プラットフォーム上で動作しますが、動作環境を問わず、この同じ原則はどの Web アプリケーション・プラットフォームにも適用されます。

アプリケーションを作成するために必要なもの

アプリを実行するコードを入手する

アプリケーションを自作するのは大変ですか?

迅速なスタートを切るために、事前ビルドされた単純なアプリを今すぐ Bluemix にデプロイできるようになっています。アプリケーションをデプロイした後は、コードを自由に編集して何度でも再デプロイできます。

異常な入力の問題

インターネットからは有効な入力が送信されるものだと無邪気に思い込んでいるプログラマーは少なくありません。そうでなくても、自分で作成したアプリケーションのクライアント・サイドから渡されるような入力は有効であると決めかかるものです。それが理由で、受け取った入力が正当であるかどうかを スモーク・テスト 内で検証するのを怠りますが、一般にクライアントはブラウザーであり、ブラウザーはユーザーの制御下にあります。ハッカーにとって、アプリケーションを壊す可能性のある入力をサーバー・サイドに送信するのは難しいことではありません。

このような危険を回避する理想的な解決策は、プログラマーがより優れたコードを作成することです。けれども人の日頃の行いを変えるのは難しいことなので、プログラマーに代わって、アプリケーションが受け取る通常の入力を学習するフロントエンドを作成するほうが遥かに手っ取り早い方法です。さらに、異常な入力が渡されると、フロントエンドが状況に応じてアラートを送信したり、入力をブロックしたりするなどの動作によって対処するようにします。このソリューションは誤検出と検出漏れの両方が発生する可能性があるため完璧なものとは言えませんが、それでもアプリケーションのセキュリティーを強化することにはなります。

アプリケーションより先に Node.js の入力をキャプチャーする

この記事では、Node.js アプリケーションに変更を加えて、メイン・アプリケーションより先に入力を受け取って検証するモジュールを追加します。この変更は比較的簡単に行うことができます。それは、Node.js の express という HTTP サーバー・パッケージでは、 ミドルウェア と呼ばれるものをサポートしているためです。Node.js におけるミドルウェアとは、リクエストを受信し、そのリクエストを何らかの形で変更してから、以降の処理のためにリクエストを転送するコードを指します。Node.js ではミドルウェアという手段で、入力の解析を処理します。

Web アプリケーションに入力を渡すために使用する主な手段には、以下の 4 つがあります。

  1. 入力を URL のパスに含める
  2. 入力を URL のクエリーに含める (GET メソッドを使用した形)
  3. 入力を、リクエスト本体に URL にエンコードした値として含める (POST メソッドを使用した形)
  4. 入力を、クライアントから送信される JSON リクエストに含める (通常は、POST または PUT メソッドを使用して REST リクエストとして送信します)

アプリケーションで POST または JSON を使用する場合、アプリケーションはそのようなリクエストを解析するコードを含みます。以下のスニペットに、上記のそれぞれの手段を介して渡された入力を処理するコードを示します。

// Parse post and put requests as JSON or a POST Form as appropriate.
app.post("*", bodyParser.json({type: "application/json"}));
app.put("*", bodyParser.json({type: "application/json"}));
app.post("*", bodyParser.urlencoded({type: "application/x-www-form-urlencoded", extended: true}));

このサンプル・アプリケーションでは、これらのパーサーの後にリクエストを受け取る必要があります。それには、パーサーを呼び出す行の後に以下のコードを挿入します。

var reqLog = [];

// Catch all HTTP requests
app.all("*", function(req, res, next) {
	
	reqLog[reqLog.length] = {
		path: req.originalUrl,
		method: req.method,
		query: req.query,
		body: typeof req.body === "undefined" ? {} : req.body
	};
	
	if (req.query.length > 0)
		reqLog[reqLog.length-1].query = req.query;

	if (typeof req.body !== "undefined")
		reqLog[reqLog.length-1].body = req.body;	

	next();
});


// Show the request log
app.get("/reqLog", function(req, res) {
	res.send(JSON.stringify(reqLog));
	
	reqLog = [];
});

サンプル・アプリケーションを使用して、いくつかのリクエストを実行してから https://machine-learning-front-end.mybluemix.net/reqLog を表示して結果を確認してください。結果が期待する内容とは異なる場合、リクエストを実行して URL にアクセスする読者が多ければ多いほど、結果の不整合は避けられなくなるという点に留意してください。結果が整合しないとしたら、他の読者が同時にアプリケーションを使用している可能性があります。また、reqLog はアクセスされるたびにログを空にすることにも注意してください。最後に、結果を切り取って JSON フォーマッターに貼り付けると、結果を確認しやすくなります。

コードの仕組み

app.all("*", function(req, res, next) {…}) 呼び出しでは、送信されるリクエストのパスが指定のパスと一致するリクエストを受け取ります (この例の場合は、すべてのリクエスト)。ここでは .all を使用しているため、すべてのメソッドが対象になります。

関数パラメーター自体にも 3 つのパラメーターがあります。お馴染みの 2 つのパラメーター (reqres) は通常どおりの意味で使用されていますが、残りのパラメーター next はそれほど頻繁に使用されているものではありません。このパラメーターは、後続の処理用にリクエストを返す呼び出しです。一般的な app.get 呼び出しと app.post 呼び出しでもこのパラメーターを使用しています。この関数が、ブラウザーに返信する実際のレスポンスを用意するとしたら、このパラメーターは必要ありません。

パスとメソッドは HTTP リクエストに不可欠の部分であるため、この 2 つは常に存在します。Express によって自動的に解析される req.query も常に存在しますが、この値は空にすることもできます。最終値 req.body については、より複雑になってきます。最終値は bodyParser.json() または bodyParser.urlencoded() を使用して作成できますが、この 2 つのメソッドが呼び出されるのは、指定されている場合のみです。サンプル・アプリケーション内では、この 2 つが呼び出されるメソッドは一部に限られていて、しかもコンテンツ・タイプが application/json または application/x-www-form-urlencoded でなければ呼び出されません。

// Parse post and put requests as JSON or a POST Form as appropriate.
app.post("*", bodyParser.json({type: "application/json"}));
app.put("*", bodyParser.json({type: "application/json"}));
app.post("*", bodyParser.urlencoded({type: "application/x-www-form-urlencoded", extended: true}));

body パラメーターが常にハッシュ・テーブル内に存在するとすれば、値が未定義であるかをチェックするという方法 (typeof req.body === "undefined") のほうが body パラメーターを処理するには簡単です。値が未定義の場合は、body パラメーターに空のハッシュ・テーブル ({}) を格納します。

正常な入力を学習する

さまざまなパラメーターを定義した後は、次のステップとして、キャプチャーしたリクエストの情報に基づいて、実際にアプリケーションが期待する入力を調べます。ここでは、入力は一般に次の 3 つのカテゴリーに分類されることを前提とします。

  • 多項選択式入力
  • 最大値と最小値の範囲の数値
  • 自由形式テキスト

日付や電話番号などといった他の可能性もありますが、上記の 3 つに比べると、かなり使用頻度は低いと言えます。単純にするために、この記事では上記の 3 つ以外の入力を無視しますが、他の可能性についても気兼ねなく調べてください。

使用できる機械学習アルゴリズムは数多くありますが、このアプリケーションには単純なアリゴリズムを使用できます。

  1. まずは、すべての入力フィールドは多項選択式であるという前提で、受け取った値を追跡します。
  2. 値の数が特定のしきい値を上回っている場合、その値は (既存の値に基づく) 数値または自由形式テキストのいずれかということになります。

サンプル・アプリケーション内で、https://machine-learning-front-end.mybluemix.net/manual/<フィールド>/<値> を表示することで /manual URL に値を追加できます (ブラウザーからアクセスしている場合、通常は GET メソッドを使用します)。この URL は、入力テーブル inputValuesTable を表す JSON を使用して応答します。作成していないフィールドと値が示されているとしたら、別の読者が同時にサンプル・アプリケーションを使用している可能性があります。

入力テーブルは https://machine-learning-front-end.mybluemix.net/inputValuesTable から取得することもできます。テーブルを削除するには、https://machine-learning-front-end.mybluemix.net/reset を使用します。

値を inputValuesTable に追加する関数には、add2Input() という名前が付けられています。名前は長くても、概念的には単純な関数です。この関数については、ソース・コードを参照してください。

パスの構成要素

パスの構成要素はどれも入力フィールドとして使用できますが、通常は少なくとも 1 つの固定文字列の後に入力が続きます。複数の固定文字列がある場合もありますが、それらは (おそらく単一の値を持つ) 多項選択式入力として扱うことができます。

この手法は、 検出漏れ の原因になる場合があることに注意してください。例えば、/rest/int/:integer および /rest/str/:string という 2 つのパスがサービスとして機能している場合、アルゴリズムはパスの 2 番目の構成要素 (int または str) を多項選択式として学習し、3 番目の構成要素を自由形式テキストとして学習します。この処理は、その入力が数値であるとしても変わりません。けれども 2 番目の値が固定値として処理されると、例えば /rest/:str/:int の結果は大量の URL になります。後のほうの「プロトタイプから本番へ」のセクションで、より高度なアルゴリズムについて説明します。

パスの構成要素では、URL が最初の構成要素であり、他の構成要素にはその順序に応じてフィールド名が付けられます。パスの構成要素の順序を実装するには、app.all("*", function …) 呼び出し内で以下のコードを使用します。

	// Treat path components as input fields
	var pathComponents = req.path.split("/");	
	for(var i=2; i<pathComponents.length; i++)
		processInput(req.method, "/" + pathComponents[1], i, pathComponents[i]);

URL のパスは常にスラッシュで始まるため、req.path.split("/") が返す配列の最初の値は常に空になります。2 番目の値は実際の最初の構成要素であり、残りの構成要素はフィールドとして扱われます。

フォームの入力

フォームの入力は常にフィールドと値のリストです。フォームの入力を追加するのは極めて簡単で、唯一問題となるのは、req.body (存在する場合) が JSON REST 呼び出しの結果ではなく、フォームの入力であることを確認することです。呼び出しの結果であるか、フォームの入力であるかは、mime タイプによって判別できます。

	// If relevant, treat query (GET) and body (POST) fields as fields
	for (var field in req.query)
		processInput(req.method, req.path, field, req.query[field]);
	if (req.headers["content-type"] === "application/x-www-form-urlencoded")
		for (var bodyField in req.body)
			processInput(req.method, req.path, bodyField, req.body[bodyField]);

JSON REST 呼び出し

JSON ではフィールド自体にフィールドを含めることができるため、扱うのが難しくなります。JSON 構造体をフラットなハッシュに変換するために、ここでは recursive flatten() 関数を使用します。

// Flatten a structure into fields and values
var flatten = function(name, data) {
	var retVal = {};
	
	if (typeof data === "object") {
		// Hash or list
		if (Array.isArray(data)) {
			// List
			for(var i=0; i<data.length; i++)
				retVal = Object.assign(retVal, flatten(name + "-" + i, data[i]));
		} else {
			// Hash
			for (var field in data)
				retVal = Object.assign(retVal, flatten(name + "-" + field, data[field]));			
		}
	} else {
		// This is a scalar value
		retVal[name] = data;
	}
	
	return retVal;
};

app.all("*", function …) から抜粋した以下のコード・スニペットが、flatten() を呼び出した後、すべてのフィールドを処理します。

	// Flatten and process JSON is received
	if (req.headers["content-type"] === "application/json") {
		var fields = flatten("body", req.body);
		for (var jsonField in fields)
			processInput(req.method, req.path, jsonField, fields[jsonField]);
	}

無効な入力に対処する

processInput() 関数はここまでのところ、add2Input() を呼び出しているだけです。けれども、有効な入力が何であるかを学習するだけでは何も保護することにはなりません。実際にセキュリティーで保護するためには、フィールドを調べて、その値を既知の項目に照らし合わせて確認する必要があります。

その役目を担うのが、checkField() 関数です。この関数も同じく極めて単純なもので、以下の 4 つのエラー条件の有無を確認し、それぞれに対して例外をスローします。

  1. 型が num であり、値が数値ではない
  2. 型が num であり、値が小さすぎる (最小値未満)
  3. 型が num であり、値が大きすぎる (最大値超)
  4. 型が mchoice であり、値が既知の値のいずれでもない

processInput() 関数は、入力テーブルに追加するか (add2Input())、またはフィールドの値をチェックするか (checkField()) を決定しなければなりません。サンプル・アプリケーション内で、この関数はフィールドが既知であるかどうかをチェックし、既知である場合はフィールドが 5 回以上確認されているかどうかをチェックします (この値は app.js ソース・コード・ファイルの先頭近くで変更できます)。

// Process input. Either add it to the table, or verify it is legitimate
var processInput = function(method, url, field, value) {
	var key = method + ":" + url + ":" + field;
	var tableEntry = inputValuesTable[key];
	
	// We haven't seen this field yet, add it
	if (typeof tableEntry === "undefined")
		add2Input(key, value);	
	else {
		// If we haven't seen it enough times to think we know this field,
		// add this value
		if (tableEntry.count < count4Known)
			add2Input(key, value);	
		else
		// If we think we know the legitimate values, check it
			checkField(key, value);
	}  // If the type isn't undefined, meaning we've seen the field at least once
};

最後に app.all("*", …) 呼び出し内で、checkField() からスローされた例外を処理します。今のところ、アプリケーションはエラーをユーザーに返すようになっていますが、デモンストレーションにはこれで十分です。実際の実装では、潜在的な攻撃者に知られないよう、表示する情報を大幅に減らし、入力違反をログに記録する必要があるはずです。

// Catch all HTTP requests
app.all("*", /* @callback */ function(req, res, next) {
	.
	.
	.	
	// Try processing the input fields. If any of them are invalid, catch the
	// error and send it to the user
	try {	
		// Treat path components as input fields (except the first one)
		var pathComponents = req.path.split("/");	
		for(var i=2; i<pathComponents.length; i++)
			processInput(req.method, "/" + pathComponents[1], i, pathComponents[i]);
		
		// If relevant, treat query (GET) and body (POST) fields as fields
		for (var field in req.query)
			processInput(req.method, req.path, field, req.query[field]);
		if (req.headers["content-type"] === "application/x-www-form-urlencoded")
			for (var bodyField in req.body)
				processInput(req.method, req.path, bodyField, req.body[bodyField]);

		// Flatten and process JSON is received
		if (req.headers["content-type"] === "application/json") {
			var fields = flatten("body", req.body);
			for (var jsonField in fields)
				processInput(req.method, req.path, jsonField, fields[jsonField]);
		}
	} catch (e) {
		res.send("" + e);
		return ;  // Make sure we return without calling next(), so the invalid request
		          // will not be processed
	}

		
	next();
});

プロトタイプから本番へ

この記事の中で提供しているサンプル・アプリケーションは、本番で使用するためではなく、手法を説明するプロトタイプとして作成されています (このアプリケーションを本番環境内で使用することはお勧めしません。使用するとしたら、その前に、以下で提案する変更を加えてください)。このアプリケーションを大幅に改善することになる極めて単純な変更は数多くあります。

パスの構成要素

より高度なアルゴリズムでは、パスのすべての構成要素を一連の固定値として扱い、テキスト・フィールドとして十分な値になったところで、その構成要素をテキスト・フィールドに変換します。例えば以下のパスの場合、アルゴリズムはこれらの値のそれぞれが固定 URL であると見なします。

  • /rest/strCompare/a/b
  • /rest/strCompare/a/c
  • /rest/strCompare/b/c

このように /rest/strCompare/<文字列>/<文字列> の形で複数の異なる URL があると、アルゴリズムは /rest/strCompare だけが実際の URL であり、他の 2 つの文字列は入力フィールドであると判断します。

数値範囲を拡大する

アプリケーションによっては、観測された最小値よりわずかに小さい数値、または観測された最大値よりわずかに大きい数値を許可することが得策となる場合があります。けれどもこの手法には、攻撃者が徐々に数値範囲を広げて、最終的に問題のある値を挿入する可能性があるという問題があります。

ストレージ: メモリーまたはデータベースのどちらにするか

一般に、本番環境内でのデータは、アプリケーションが再起動しても消去されないよう、データベースに保管されます。私が developerWorks の一連の記事でメモリー内のハッシュ・テーブルを使用している唯一の理由は、単純さにあります。ただし、データベースではなくメモリーを使用することを議論する余地がある場合もあります。例えばコードの変更などが理由でアプリケーションが再起動されると、メモリーは消去されます。その場合、名前や意味が変更されている可能性があるため、入力フィールドを再び学習することが理にかなっています。

その一方、メモリーが消去されるということは、アプリケーションが再起動してから最初の数回は、システムがまだ入力フィールドを再学習している最中であるため、すべての入力を正当なものとして受け入れることを意味します。メモリーを使用するかどうかは、セキュリティーと便宜 (アプリケーションが変更されたことを手作業で指定する必要がないこと) との間のトレード・オフです。

欠落しているものを確認する

現在のアルゴリズムは、既存のフィールドに正当な値が入力されているかどうかをチェックするだけです。けれども、アプリケーションで期待するフィールドをクライアントが送信しなかったとしたらどうなるでしょうか?app.all("*", function …) 呼び出しに変更を加えることで、指定されているすべてのフィールドを追跡して、いずれかのフィールドが欠落している場合はアラームを起動するという仕組みにすることができます。

許可すべきか、ログに記録すべきか、ブロックすべきか

現在のところ、このフロントエンドは入力フィールドを学習している間、すべてのアクセスを許可し、学習が完了してからは許可されない値をブロックするようになっています。デモンストレーションに使用するにはこれで十分ですが、実際のアプリケーション内では多数の誤検出 (何の攻撃も仕掛けられていないとしても攻撃者が検出されること) が発生するため、役に立たないアプリケーションになってしまいます。

それよりも、入力フィールドに関する情報が検証された合計回数を追跡し (学習された値を調整する必要があるかどうかを調べられるようにするため)、該当するフィールドに数千もの正当な値が渡されているという事実などに基づいて値が無効であることが明らかな場合にだけ、アクセスをブロックするほうが賢明です。

まとめ

この記事で紹介した手法は、テクノロジーを使用することによって、人間の問題 (プログラマーが問題に対処し忘れるという問題) にパッチを充てるという試みです。ほとんどの試みと同じく、この手法は完璧なものではなく、エラーの原因になりがちですが、それでもセキュリティー・ポスチャーの改善には役立ちます。特に、使用シナリオに応じて適切に調整すれば、その効果は一層大きくなるはずです。


ダウンロード可能なリソース


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=セキュリティ
ArticleID=1046513
ArticleTitle=セキュリティーに配慮した機械学習フロントエンドを作成する
publish-date=06012017