ChromeExtension + Serverless でフィッシングサイトをブロックするツールを作ってみたお話

More than 1 year has passed since last update.

はじめに

今日(2015/12/16)から冬休みという方も多いのではないのでしょうか?

冬休みの間 serverless を触りたい!ひまだひまだ!!!!
という方は シズオカアプリコンテスト に参加してみては
いかがでしょうか?

参加申し込みをするだけで $50分のクーポン が貰えますよ!

今回作ったアプリケーションもこのシズオカアプリコンテストの出品作品です。

なお、リポジトリは以下で公開しています。(PRお待ちしております)
https://github.com/kengos/is_it_phishing

フィッシングサイトをブロックしたい

じぶん銀行 では 12/21 にこんな注意喚起をしています。

不審なメールのリンク先には絶対にアクセスしないようご注意ください。
万が一、不審なメールのリンク先にアクセスしてしまった場合、偽サイトでパスワードや確認番号表の数字の入力を求められても絶対に入力しないでください。
http://www.jibunbank.co.jp/announcement/2015/1221_01.html

そんなこと言われても、自分自身の注意だけだと限界がありますよね。

どうせなら機械的にブロックできる仕組みを用意したいなと考えました。

どうやってブロックするのか?

(日本を対象とした)フィッシングサイトをブロックするには以下の方法が良さそうだなと考えました。

  • whois の登録情報中に CN, TWなどが含まれている
  • whois の登録情報の メールアドレスが フリーのメールアドレスだ
  • google に インデックスされていない(違法サイトなので当然)
  • 中国の無料のアクセス解析ツールが仕込まれている(51.la)
  • 文章中の日本語が怪しい

今回は「文章中の日本語が怪しい」以外を組み込んだアプリを作ってみることにしました。
(めんどくさい + サンプル探すのが大変)

また、DNS汚染などでの被害もあるようなので、
別環境から正引きしたIPアドレスとクライアントがアクセスしているIPアドレスが同一かどうかの
チェックも含めてみることにしました。

こんなイメージとなります

qiita_1.png

技術的なお話

ChromeExtensionの仕組み

qiita_2.png

content/main.js

クライアントがHTMLを読み込むと以下が実行され, background/main.js に対してメッセージが送信されます。

content/main.js
chrome.runtime.sendMessage({
  location: window.location,
  // scripts には HTML中のscriptタグ, iframeタグの src属性の中身が入っている
  scripts: scripts.concat(iframes)
});

background/main.js

background/main.js は content/main.jsが送ってくるメッセージを以下のように待ち受けています

メッセージを受信すると
1. ScoreBoard というクラスに対して送信されてきた requestオブジェクトを渡します
2. ScoreBoard の結果を計算します(非同期)
3. ScoreBoard が完了した場合に chrome.tabs.sendMessage で送信してきたタブ(sender.tab.id)に対して結果を返します

background/main.js
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
  var board = new ScoreBoard(request);
  board.calculate();
  var timer = setInterval(function() {
    if(board.complete()) {
      clearInterval(timer);
      chrome.tabs.sendMessage(sender.tab.id, {request: request, sender: sender, score: board.getScore(), messages: board.getMessages()});
    }
  }, 100);
});

クライアント側のHTML書き換え

content/main.js は以下でbackground/main.js が送信してくるメッセージを待ち受けています

content/main.js
chrome.runtime.onMessage.addListener(function(response, sender, sendResponse) {
  console.log(response);
  if (response.score < 80) {
    return;
  }

  replaceDocument();
  // Document書き換え
  var ulTag = document.querySelector('#phishing-injection-messages');

  response.messages.forEach(function(message) {
    var liTag = document.createElement('li');
    liTag.innerHTML = message;
    ulTag.appendChild(liTag);
  });
});

response は以下の形式のデータが入っています。(これは実際のフィッシングサイトで得られた結果です)

{
  score: 80,
  messages: [
    "js.users.51.la の javascript/iframe が呼び出されています。",
    "Googleの検索エンジンに登録されていません。"
  ]
}

scoreが 80以上の場合はイカの画像のように messagesの中身とともに画面に表示します。
(既存のHTMLは全て非表示にしています)

スクリーンショット 2015-12-26 21.30.24.png

script, iframe の src=xxxx に対して行う処理

この処理では 該当ページ中に含まれている script src=xxxx, iframe src=xxxx のすべての値を
クライアント側から送信してもらい、それらをブラックリスト方式で判定しています。

実装箇所: background/hostname_validator.js

受け取った src/iframe 中の src の配列が hostnames です

RegExpを使用して、testをしているだけです。

ブラックリストに含まれていた場合は該当の hostname を this.errorHostsに格納しています

this.buildResult() で スコア と クライアント側に表示するメッセージを生成しています

validate: function(hostnames) {
  var reg = new RegExp(HostnameValidator.ERROR_HOSTS.join('|'));
  this.errorHosts = [].filter.call((hostnames || []), function(hostname) {
    return reg.test(hostname);
  });
  this.buildResult();
  return this.errorHosts.length == 0;
}

whois 処理

AWS API Gatewayとの通信が発生する部分です

ChromeExtension側の処理

実装箇所: background/whois_validator.js

Ajaxを利用して APIGatawayと通信をしています。
APIGataway側からのレスポンスを元に クライアント側に送信するスコア, メッセージを生成します

whois_validator.js
// hostname = window.location.hostname
lookup: function(hostname) {
  var self = this;
  $.ajax({
    type: 'GET',
    url: WhoisValidator.API_END_POINT,
    data: {'hostname': hostname},
    dataType: 'json'
  }).done(function(json) {
    self.setCache(hostname, json);
    self.response = json;
  }).fail(function(json) {
    self.response = null;
  }).always(function() {
    self.buildResult();
  });
}

AWS APIGateway, AWS lambda 側の処理

APIGateway/lambda: dns/whois

ビジネスロジック: dns/lib/whois.js

外部ライブラリとして node-whois, change-case を利用しています

event.hostname/dns/whois?hostname=xxxxxxxxの部分が設定されてきますので、
その値を whois.lookup で検索しています

この結果は通常のコンソールで行う whois と同様の結果が返ってきますので、適当に整形してjson形式でレスポンスを返します

レスポンスを返す際には return cb(null, {}) とすれば良いらしく、
{}の箇所に返したい任意のjson文字列をいれるだけでした

module.exports.respond = function(event, cb) {
  if(event.hostname === '') {
    return cb(new Error("hostname is required"), null);
  }

  whois.lookup(event.hostname, function(err, whoisText) {
    if (err) {
      return cb(err, null);
    }
    var response = {};

    if(event.hostname.match(/\.jp$/)) {
      response['type'] = 0;
      response['text'] = whoisText;
    } else {
      var lines = whoisText.split(/\r\n|\r|\n/).filter(function(line, index) {
        return String(line).trim().match(': ');
      });
      lines.forEach(function(line) {
        var parts = line.split(": ");
        var key = changeCase.camelCase(parts[0]);
        response[key] = parts[1];
      });
    }

    return cb(null, response);
  });

};

DNS正引き 処理

ChromeExtension側の処理

実装箇所: background/ip_validator.js

Ajaxを利用して APIGatawayと通信をしています。
APIGataway側からのレスポンスを元に クライアント側に送信するスコア, メッセージを生成します

ip_validator.js
// hostname = window.location.hostname
lookup: function(hostname) {
  var self = this;
  $.ajax({
    type: 'GET',
    url: IpValidator.API_END_POINT,
    data: {'hostname': hostname},
    dataType: 'json'
  }).done(function(json) {
    self.setCache(hostname, json);
    self.response = json;
  }).fail(function(json) {
    self.response = null;
  }).always(function() {
    self.buildResult(hostname);
  });
}

APIGateway, lambda側の処理

APIGatewayの処理実装箇所: whois/ip

lambda(ビジネスロジック): dns/lib/ip.js

nodeの標準機能で hostname => ipaddress変換ができるので、以下のようになります

ip.js
var dns = require('dns');

module.exports.respond = function(event, cb) {
  if(event.hostname === '') {
    return cb(new Error("hostname is required"), null);
  }

  dns.resolve(event.hostname, function (err, addresses) {
    if (err) {
      return cb(err, null);
    }

    return cb(null, addresses);
  });
};

呼び出すと以下のようなレスポンスが返ってきます

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 18
Connection: keep-alive
Date: Sun, 27 Dec 2015 03:52:56 GMT
x-amzn-RequestId: 506bf61b-ac4d-11e5-95e3-63eadd8da545
X-Cache: Miss from cloudfront
Via: 1.1 61d0a31bb18a85e2e5a9c2cc54beb3e5.cloudfront.net (CloudFront)
X-Amz-Cf-Id: BKLP2gP0ls54Y8Rcw7xZ3mMu216AfErzrxc7_v-0XUYC9PZJkvqqiw==

["216.58.220.195"]

cb(new Error('hostname is requires'), null)が実行されると次のようなメッセージになります

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 213
Connection: keep-alive
Date: Sun, 27 Dec 2015 03:54:16 GMT
x-amzn-RequestId: 7fc898b0-ac4d-11e5-b91c-ab7dacc15141
X-Cache: Miss from cloudfront
Via: 1.1 65fe4947494affb6443dd314059f54c8.cloudfront.net (CloudFront)
X-Amz-Cf-Id: a-4so43Jp1j9iIuDoSUma1mScWIvgGDWKEGgwh4v6Fn03NtFoDLk-w==

{"errorMessage":"hostname is required","errorType":"Error","stackTrace":["Object.module.exports.respond (/var/task/modules/dns/lib/ip.js:5:15)","module.exports.handler (/var/task/modules/dns/ip/handler.js:19:7)"]}

Googleへの検索リクエスト

lambda側で処理を書こうかなとも思ったのですが、リクエストIPを分散したほうがよさ気だったので
クライアント側からリクエスト送信をしています

background/google_validator.js

(Deprecatedな)GoogleSearchApiを利用しています

検索クエリを site:hostname とすることで インデックスに登録されているかを調べています

google_validator.js#lookup
// hostname = window.location.hostname
lookup: function(hostname) {
  var self = this;
  $.ajax({
    type: 'GET',
    url: 'http://ajax.googleapis.com/ajax/services/search/web?v=1.0&q=site:' + hostname,
    dataType : "json"
  }).done(function(response){
    self.setCache(hostname, response);
    self.response = response;
  }).fail(function(response){
    self.response = null;
  }).always(function() {
    self.buildResult();
  });
}

AWS serverless(APIGateway/Lambda) について

Servelessのデプロイ

serveless function deploy: Lambdaのデプロイを行います
serveless endpoint deploy: APIGatewayのデプロイを行います

deployするfunction, endopointを選択し Deployを選択するとDeployできます

なお、-aオプションですべてのデプロイができ -s [stage名]で指定したstageのfunction/endpointのデプロイが出来ます

(今回はdevelopment stageのみしか用意していないので聞かれていません)

s.gif

AWSの料金は?

開発時点ではこんな感じのリクエスト数でした

aws_1.png

serverlessを使った際にAWS上で確認するコンソールは?

  • Cloud Formation: serverlessの構成管理
  • AWS Lambda: APIGatewayから呼び出されるビジネスロジック郡
  • API Gateway: 外部とのインターフェース
  • S3: 実態ファイル
  • CloudWatch: AWS Lambdaのログなどはここから
  • Billing
  • IAM

S3

serverless.[region].[app名]/Serverless/[app名]/[stage] 以下に配置されます

aws_2.png

lambdas以下にはLambdaのFunctionがRevisionごとに配置されます

aws_s3.png

AWS Lambda

Functionごとに以下のように配置されます

aws_3.png

各Functionの中身はRevision管理され以下のようになります

aws_4.png

AWS APIGateway

aws_6.png

CloudWatch

aws_5.png

以下は cb(new Error('hostname is required'), null) の際のログです

aws_7.png

serverless でハマったこと

リクエストパラメータのもらい方

各々のAPIは次のように受け取ることにしました。

whois: /dns/whois?hostname=xxxx
DNS正引き: /dns/ip?hostname=xxxx

hostname=xxxx の設定をどのようにすればいいのかにハマりました。

結論的には以下の設定を入れてdeployするだけでした。

s-function.json
"requestParameters": {
  "integration.request.querystring.integrationQueryParam": "method.request.querystring.hostname"
},
"requestTemplates": {
  "application/json": "{\"hostname\":\"$input.params('hostname')\"}"
},
event.json
{
  "whois": {
    "hostname": "$input.params('hostname')"
  }
}

最後に(Help me!)

◯ whois について .comなどはうまく取れるのですが取れないものも多く困っています
予算的な都合で公開API等は使いたくないのでなにかいい方法を知っている方教えて下さい。
◯文書内の怪しい日本語チェックをしてみたいです。詳しい人(ry

かなり雑な作りですが、以下からダウンロードできます($5払いました...)

https://chrome.google.com/webstore/detail/%E3%83%95%E3%82%A3%E3%83%83%E3%82%B7%E3%83%B3%E3%82%B0%E3%82%B5%E3%82%A4%E3%83%88%E3%82%92%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF%E3%81%99%E3%82%8B%E3%83%84%E3%83%BC%E3%83%AB%E4%BB%AE/mhkdeobnhdhcgeklilgplkadcblehdja?hl=ja