😀

Webアプリの限界を超える方法(セキュリティ編)~ActiveXを葬る~

2023/01/06に公開

他の投稿はこちら

毎年、確定申告の時期になるとe-taxクソ、IE氏ねという呪詛怨念がタイムラインに流れてきます。
マイクロソフト自身ですらIE使わないでくれと泣き言を言っていますが
企業様におかれましては簡単にIEの利用を辞められない理由があると思います。

そうですActiveXですね。
既存のActiveX資産をリプレースするための代替手段がないと、IEから脱却することが出来ません。

そして、前回はActiveXの代替手段となりうる技術を紹介しました。

前回までのあらすじ

前回は、ブラウザからネイティブアプリにWebSocket(またはHTTP&CORS)で接続し、Webアプリとネイティブアプリを連携させることで、Webアプリ単体では実現できないような機能を実現する方法を紹介しました。
実はこの方法は、ブラウザから通信内容が丸見えなため、他のWebアプリに相乗りされたり、ユーザーに通信内容を改竄される可能性があるという、セキュリティ上の問題があります。
この問題について、解決方法を考えてみました。

A.ドメインの制限

ブラウザがネイティブアプリに接続する際のヘッダー情報をチェックし
特定のドメインからのみ接続できるようにします。
この制限によって、情報を抜き取ろうとする悪意のあるWebアプリや
相乗りしようとするWebアプリのアクセスを防ぐことができます。

#B.トークンによる識別

Spotify社の特許(特許第6018292号)として、ネイティブアプリが生成したトークンを通信に含め
正当な通信を識別する方法が提案されています。

C.通信の暗号化

しかし、上記A、Bいずれの方法でも、通信内容がユーザーから依然丸見えであり、解析、改竄を防ぐことが出来ません。
例えば、ネイティブアプリでICカードから会員情報を読み取ってWebアプリに送信する場合など
処理内容をユーザーから隠蔽したい場合は、上記の方法では十分と言えません。

この問題は、通信内容を暗号化することによって解決できます。
暗号化復号化を行う鍵は、Webアプリとネイティブアプリだけが持ち
通信を中継するブラウザには解読できないようにします。
(いわゆる終端間暗号化)

暗号化.png

厳密には、逆アセンブルやメモリスキャンを行い、ネイティブアプリ内の鍵を取り出すことも可能と思いますが
アプリケーションのプロテクトの範疇になると思うので触れません。

Webスキミング対策として

近年、コンテンツに不正なJavaScriptを埋め込むことによって、クレジットカード番号などの機密情報を抜き取る、Webスキミング(またはオンラインスキミング)と呼ばれるハッキング手法が流行しています。

しかし、Webスキミングの防衛手段というと、コンテンツの改ざんの早期発見など、スキミングそのものを防止できないソリューションが主流で、決定打に欠けるのが現状です。

当手法を用いれば、既に暗号化された情報がブラウザ上を流れるため、不正なJavaScriptによって機密情報を抜き取ることが出来ず、Webスキミングを根本的に防止することが出来ます。
(暗号鍵が漏洩した場合を除く。)

それでは、通信の暗号化の方法について、詳細を説明していきます。

共通鍵の交換方法

暗号化通信を行うにあたって、通信内容を暗号化復号化するための鍵を生成し、Webアプリとネイティブアプリで共有しなければなりません。
(以降この共有する鍵を共通鍵と言います。)
そのため、生成した共通鍵を一方から一方へ安全に渡す必要があります。
共通鍵を渡す方法は、要件に応じて、何パターンか考えられます。

  1. 公開鍵暗号を使用する。
    ネイティブアプリが公開鍵によって共通鍵を暗号化し、Webアプリが秘密鍵によって共通鍵を復号化する。

  2. 公開鍵暗号を使用する。
    Webアプリが公開鍵によって共通鍵を暗号化し、ネイティブアプリが秘密鍵によって共通鍵を復号化する。

  3. Webアプリとネイティブアプリに共通鍵を手動で設定する。

ここでは公開鍵暗号を使用(パターン1)を例にして、実装例を紹介していきます。

実装例

<img src="https://qiita-image-store.s3.amazonaws.com/0/16162/dfa88f4e-f60c-08a2-eb99-1ac0c34b78a3.png" width=50%>

システムの全体図です。

  1. ネイティブアプリが共通鍵を発行
  2. 発行した共通鍵を公開鍵で暗号化
  3. ネイティブアプリが、暗号化済み共通鍵をウェブブラウザに送信
  4. ウェブブラウザが、暗号化済み共通鍵をWebアプリに送信
  5. Webアプリが暗号化済み共通鍵を秘密鍵で復号化

という流れで共通鍵の交換を行います。
コード例を交えて解説していきます。
ちなみに今回のコードはWebアプリをPHP、ネイティブアプリをC#(WinForm)、通信を中継するブラウザ上のHTMLコンテンツをJavaScriptで作成しています。

1. 共通鍵を発行, 2. 公開鍵で暗号化

bridge.js

var ws;
var is_encrypted = false;

function connect_secure_server() {
    var support = "MozWebSocket" in window ? 'MozWebSocket' : ("WebSocket" in window ? 'WebSocket' : null);

    if (support == null) {
        return;
    }
    if( ws != null){
        ws.close();
    }

    ws = new window[support]('wss://local.sample.com/');
			
    //受信処理
    ws.onmessage = function (evt) {
        if( is_encrypted ){
            var encrypted = evt.data;

            output_log("暗号化済みメッセージをネイティブアプリから受信:" + encrypted);

            $.post("decrypt.php", {"data": evt.data}, function(decrypted){

                output_decrypt_log("Webアプリによる復号化結果:" + decrypted);

            });
        }
        else{

            //ネイティブアプリから暗号化済み共通鍵受け取り

            var json = evt.data;

            var data = JSON.parse(json);

            if( data["command"] == "get_common_key_result"){

                //暗号化済み共通鍵をWebアプリに送信

                common_key_result(data["message"]);

            }
        }
    };
    //接続時処理
    ws.onopen = function () {
        console.log('* Connection open');
    };
    //切断時処理
    ws.onclose = function () {
        console.log('* Connection closed');
    }
}

function common_key_result(message){
    $.get("check_common_key.php", {"data": message}, function(result){
        output_decrypt_log(result);
        is_encrypted = true;
    });
}

function output_system_log(log){
    console.log(log);
    $("#_log").append("<li class='system'>" + log + "</li>");
}
function output_log(log){
    console.log(log);
    $("#_log").append("<li class='encrypt'>" + log + "</li>");
}
function output_decrypt_log(log){
    console.log(log);
    $("#_log").append("<li class='decrypt'>" + log + "</li>");
}

$(document).ready(function(){
	
    connect_secure_server();

});

上記は、ブラウザで実行されるJavaScriptです。
図中では、Webアプリとネイティブアプリを中継するHTMLコンテンツとなります。
初期表示時に、ネイティブアプリに向けてウェブソケット接続を行っています。

bridge.js
    ws = new window[support]('wss://local.sample.com/');

local.sample.comはローカルホスト(IPv4:127.0.0.1)が設定されたドメインの例です。
ネイティブアプリにネームベース証明書を設定することによって、SSL通信を可能にしています。
(例えば、「*.sample.com」でネームベース証明書を取得すれば、「local.sample.com」でも同じ証明書が使用できます。)

次に、接続先のネイティブアプリのコードです。

native.cs

        static void HandleServerNewSessionConnected(SuperWebSocket.WebSocketSession session)
        {
            frm.Invoke((MethodInvoker)delegate () {

                frm.add_log(session.SessionID, "接続");

                //共通鍵取得

                var session_data = new SessionData();

                session_data.session = session;
                session_data.common_key = System.Web.Security.Membership.GeneratePassword(8, 0);
                session_data.recived_data_ary = new Dictionary<string, MessageData>();

                //共通鍵暗号化

                var PublicKey = File.ReadAllText("public_key.pem");
                var publicParameters = CreatePublicKeyParameters(PublicKey);
                var cipherText = RsaEncrypt(publicParameters, session_data.common_key);

                //電文作成送信

                MessageData send = new MessageData();

                send.command = "get_common_key_result";
                send.message = cipherText;

                string send_str = JsonConvert.SerializeObject(send);

                session.Send(send_str);

                frm.session_data_map.Add(session.SessionID, session_data);

                frm.add_log(session.SessionID, "共通鍵送信");

            });
            
        }

上記はネイティブアプリの、ウェブソケット接続確立時のコードです。
HandleServerNewSessionConnectedは、ブラウザから新たな接続が行われた時に実行されます。
フォーム部のメンバ変数にSessionDataというデータクラスの連想配列を用意し、接続ごとにデータを管理しています。

native.cs
                session_data.common_key = System.Web.Security.Membership.GeneratePassword(8, 0);

の部分で任意の8文字のパスワードを生成し、共通鍵としています。

次に、

native.cs
                var PublicKey = File.ReadAllText("public_key.pem");
                var publicParameters = CreatePublicKeyParameters(PublicKey);
                var cipherText = RsaEncrypt(publicParameters, session_data.common_key);

の部分で共通鍵の暗号化を行っています。
まず、「public_key.pem」というファイル名の公開鍵をローカルのファイルから読み込みます。
読み込んだ内容からRSAParametersオブジェクトを作成し、RSA暗号化を行って共通鍵を暗号化します。

CreatePublicKeyParametersの内容は以下になります。

native.cs

        static RSAParameters CreatePublicKeyParameters(string publicKey)
        {
            using (var reader = new StringReader(publicKey))
            {
                var pem = new Org.BouncyCastle.Utilities.IO.Pem.PemReader(reader).ReadPemObject();

                using (var stream = new MemoryStream(pem.Content, false))
                {
                    var parameters = PublicKeyFactory.CreateKey(pem.Content) as RsaKeyParameters;

                    return new RSAParameters
                    {
                        Exponent = parameters?.Exponent?.ToByteArrayUnsigned(),
                        Modulus = parameters?.Modulus?.ToByteArrayUnsigned(),
                    };
                }
            }
        }

渡された公開鍵の文字列をライブラリで読み込み、RSAParametersオブジェクトを作成して返却しています。
RsaEncryptの内容は以下になります。

native.cs
        static string RsaEncrypt(RSAParameters parameters, string plainText)
        {
            using (var rsa = new RSACryptoServiceProvider(1024))
            {
                rsa.ImportParameters(parameters);
                var plainBytes = Encoding.UTF8.GetBytes(plainText);
                var cipherBytes = rsa.Encrypt(plainBytes, false);

                return Convert.ToBase64String(cipherBytes);
            }
        }

RSAParametersオブジェクトで共通鍵文字列を暗号化し、さらにBASE64文字列に変換して返却しています。
これで暗号化済み共通鍵の文字列が作成できました。

3. ネイティブアプリが、暗号化済み共通鍵をウェブブラウザに送信

native.cs
                MessageData send = new MessageData();

                send.command = "get_common_key_result";
                send.message = cipherText;

                string send_str = JsonConvert.SerializeObject(send);

                session.Send(send_str);

                frm.session_data_map.Add(session.SessionID, session_data);

暗号化済み共通鍵をウェブブラウザに向けて送信します。
送信後、ウェブソケット接続のIDとデータを紐付けます。

4. ウェブブラウザが、暗号化済み共通鍵をWebアプリに送信

bridge.js

            //ネイティブアプリから暗号化済み共通鍵受け取り

            var json = evt.data;

            var data = JSON.parse(json);

            if( data["command"] == "get_common_key_result"){

                //暗号化済み共通鍵をWebアプリに送信

                common_key_result(data["message"]);

            }
bridge.js

function common_key_result(message){
    $.get("check_common_key.php", {"data": message}, function(result){
        output_decrypt_log(result);
        is_encrypted = true;
    });
}

ウェブブラウザで受信した暗号化済み共通鍵を、Webアプリに送信しています。

5. Webアプリが暗号化済み共通鍵を秘密鍵で復号化

ckeck_common_key.php
<?php

session_start();

$data = base64_decode($_GET["data"]);

$private_key = openssl_pkey_get_private(file_get_contents("private_key.pem"));

if(openssl_private_decrypt($data, $decrypted, $private_key)){
    echo "common_key recieved";
    $_SESSION["common_key"] = $decrypted;
}
else{
    echo "failed";
}

Webアプリでは受信した暗号化済み共通鍵を、秘密鍵を用いて復号化しています。
復号化した共通鍵は、セッション変数に保持しています。

これで、Webアプリとネイティブアプリが共通鍵を共有することが出来たので
以降の通信を暗号化することができます。

通信例(ネイティブアプリ→Webアプリ)

native.cs

                        MessageData send = new MessageData();

                        send.command = "encrypt_test";
                        send.message = "test";

                        string send_str = JsonConvert.SerializeObject(send);

                        var enclypted = AesEncrypt(send_str, session_data.common_key);

                        session.Send(enclypted);

ネイティブアプリで送信メッセージを作成し、共通鍵を用いてAES暗号化し、送信します。

AES暗号化処理はこちら

native.cs

        static string AesEncrypt(string src, string password)
        {
            string ret = "";
            int len;
            byte[] buffer = new byte[4096];

            using (MemoryStream src_fs = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(src)))
            {
                using (MemoryStream outfs = new MemoryStream())
                {
                    using (AesManaged aes = new AesManaged())
                    {
                        aes.BlockSize = 128;              // BlockSize = 16bytes
                        aes.KeySize = 128;                // KeySize = 16bytes
                        aes.Mode = CipherMode.CBC;        // CBC mode
                        aes.Padding = PaddingMode.PKCS7;    // Padding mode is "PKCS7".

                        // パスワード文字列が大きい場合は、切り詰め、16バイトに満たない場合は0で埋めます
                        byte[] bufferKey = new byte[16];
                        byte[] bufferPassword = Encoding.UTF8.GetBytes(password);
                        for (var i = 0; i < bufferKey.Length; i++)
                        {
                            if (i < bufferPassword.Length)
                            {
                                bufferKey[i] = bufferPassword[i];
                            }
                            else
                            {
                                bufferKey[i] = 0;
                            }
                        }
                        aes.Key = bufferKey;

                        // IV ( Initilization Vector ) は、AesManagedにつくらせる
                        aes.GenerateIV();

                        //Encryption interface.
                        ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);

                        using (CryptoStream cse = new CryptoStream(outfs, encryptor, CryptoStreamMode.Write))
                        {
                            outfs.Write(aes.IV, 0, 16); // 次にIVもファイルに埋め込む

                            while ((len = src_fs.Read(buffer, 0, 4096)) > 0)
                            {
                                cse.Write(buffer, 0, len);
                            }
                        }
                    }

                    var ary = outfs.ToArray();

                    ret = Convert.ToBase64String(ary);
                }
            }

            return ret;
        }
bridge.js
        if( is_encrypted ){
            var encrypted = evt.data;

            output_log("暗号化済みメッセージをネイティブアプリから受信:" + encrypted);

            $.post("decrypt.php", {"data": evt.data}, function(decrypted){

                output_decrypt_log("Webアプリによる復号化結果:" + decrypted);

            });
        }

ネイティブアプリから暗号化済メッセージを受け取ったブラウザは
メッセージに処理を加えずにWebアプリに転送します。
例では、続くレスポンスで復号化結果を確認しています。

decrypt.php
<?php

session_start();

$str = $_POST["data"];
$key = $_SESSION["common_key"];

$str = base64_decode($str);
$iv = substr($str, 0, 16);
$encrypt = substr($str, 16);

$decrypt = openssl_decrypt($encrypt,'aes-128-cbc',$key,OPENSSL_RAW_DATA, $iv);

echo $decrypt;

Webアプリは共通鍵を用いてAES復号化を行います。

という内容の

特許を取得しました。(特許第6451963号)
業務で当技術の利用を検討されている方は、
<img height="22px" alt="mail.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/16162/cb64c3d4-0acc-31d2-a598-362f90e9f71d.jpeg">
まで、お気軽にご相談下さい。

※当投稿は技術の解説が主目的であり、ガイドライン「宣伝や販売を主目的とした記事は投稿しない」には抵触しないことを、予め運営様に確認済みです。

Discussion