hnwの日記

PHPのis_numeric関数は使うべきでないという話

本稿は私が前職の技術ブログで執筆した記事「そのis_numeric()は適切ですか?」を改題・再編集して掲載するものです。前職には許可を取ってあります*1

本稿ではPHPの関数is_numeric()の使いどころについて問題提起をしてみます。

is_numeric関数とは

さて、まずはis_numeric()のリファレンスマニュアルを見てみましょう。

bool is_numeric ( mixed $var )


指定した変数が数値であるかどうかを調べます。数値形式の文字列は以下の要素から なります。(オプションの)符号、任意の数の数字、(オプションの)小数部、 そして(オプションの)指数部。つまり、+0123.45e6 は数値として有効な値です。十六進表記(0xf4c3b00c など) や二進表記 (0b10100111001 など) は認められません。


http://php.net/manual/ja/function.is-numeric.php

なるほど、与えられた引数がnumeric(数字的)かを調べるような、名前の通りの関数なんですね、というのが初見での印象かと思います。

何が問題なのか?

マニュアルの記述を見ただけでこの関数の問題点に気付く人はあまりいないかもしれません。私がこの関数について問題だと思うのは、「numeric」と言われたときに想像するものが人によって違う点です。

具体的には、ユーザーの入力値が10進整数として正しいかチェックする意図でis_numeric()を利用しているPHPプログラムを見かけることがあります。しかし、マニュアルを読めばわかるように、この関数は以下の文字列を受け取ってもtrueを返してしまいます。

  • 1.23
  • .123
  • 1e2

これらの文字列を与えてもtrueになることを忘れていないですか、というのが今回の問題提起です。もちろん、is_numeric()によるチェックをすり抜けて先の処理に進んだところで、セキュリティホールになるようなコードは考えにくいと思います。しかし、これが原因のバグというのは十分考えられるのではないでしょうか。

下記はネット上で見つけたコードを少しアレンジしたものです。

<?php
function validate_date($date) {
    $dateArr = explode("-", $date);
    if (count($dateArr) == 3 &&
        is_numeric($dateArr[0]) && strlen($dateArr[0]) == 4 &&
        is_numeric($dateArr[1]) && strlen($dateArr[1]) == 2 &&
        is_numeric($dateArr[2]) && strlen($dateArr[2]) == 2) {
          return checkdate($dateArr[1], $dateArr[2], $dateArr[0]);
    }
    return false;
}

例えば、上記のコードでvalidate_date("12e3-12-31")はtrueになりますが、この値をそのままSQL文の日付型の値として使うとSQLエラーになります。似たような状況は十分あり得るのではないでしょうか。

is_numeric()の詳細な挙動

細かい挙動が気になる人のために、以下にPHP 7.2.4でis_numeric()がtrueを返す条件を書き出してみました。(バージョンごとに挙動が異なる部分もあるのですが、下記のように把握をしておけば大抵の人にとっては十分だろうと思います)

  • 整数
  • 浮動小数点数
  • 全体が下記のいずれかの正規表現にマッチする文字列
    • [\x20\t\x0a-\x0d]*[\+\-]?[0-9]+([\.][0-9]*)?([Ee][\+\-]?[0-9]+)?
    • [\x20\t\x0a-\x0d]*[\+\-]?[\.][0-9]+([Ee][\+\-]?[0-9]+)?

ほぼマニュアルの通りの挙動です。マニュアルに書いていないことは、先頭の空白文字列を読み飛ばしてくれることくらいでしょうか。「3.」も「.3」もtrueになるのに驚かれた方がいるかもしれませんが、PHPプログラム中に浮動小数点数を書く場合も同じように書けますので、それほど意外なことではありません。

まとめ

is_numeric()は引数が数値っぽいかを返す関数ですが、浮動小数点数形式の文字列であってもtrueを返します。知識としては知っている人が多いと思いますが、うっかり10進整数のチェックに使っていたりしないでしょうか。10進整数のチェックが目的であれば、正規表現^[0-9]+$などと記述するのが一番誤解が少ない書き方のような気がします。

is_numeric()のチェックの後で浮動小数点数にキャストするのが適した状況も考えられなくはありませんが、少なくとも私はそんなコードを書いたことがありませんね…。

じゃあctype_digit()を使えばいいんじゃないか?と考える人がいるかもしれませんが、これはこれで罠があるので使わない方がいいというのが私の考えです(参考:「ctype_digit関数の罠」)。

*1:「いっすかー」「いいよー」くらいのノリです

PHP 7.2.0からDateTimeでミリ秒表示するときの丸め処理が変わった話

エイプリルフールなので(?)、PHPの日付処理の細かい挙動がひっそり変わった話の解説をします。

ちなみに本稿はSlackグループ「PHPユーザーズ」の#randomチャンネルでの議論をまとめ直したものです。議論のきっかけを下さったmsngさん、tadsanさん、do_akiさんはじめとする皆様ありがとうございました。

PHP 7.0から日付のフォーマット文字列にミリ秒を意味する「v」が追加された

PHP 7.0.0から、DateTime::format()でミリ秒指定ができるようになっています。

v ミリ秒 (PHP 7.0.0 で追加) Same note applies as for u. 例: 654


http://php.net/manual/ja/function.date.php

date関数と違ってDateTimeオブジェクトはマイクロ秒の処理を行うので、これをミリ秒単位に丸めるような場合にvが有用というわけです。たとえば次のように利用できます。

<?php
var_dump((new \DateTime('2018-04-01 00:11:22.123456'))
         ->format('v')); // string(3) "123"

PHP 7.2から「v」の挙動が変わった

ところで、このvの挙動がPHP 7.1と7.2とで変わりました。7.1までは1ミリ秒以下を四捨五入していたのが、7.2からは切り捨てになっています。

<?php
var_dump((new \DateTime('2018-04-01 00:11:22.345678'))
         ->format('v')); // PHP 7.1.x: "346" / PHP 7.2.x: "345"

仕様変更が必要だった理由

仕様変更の理由を想像すると、単純に四捨五入すると困る状況があるから、ということだと思います。たとえば次のように現在時刻をミリ秒まで表示するプログラムを考えてみます。

<?php
var_dump((new \DateTime())->format('Y-m-d H:i:s.v'));

DateTimeのコンストラクタは無引数の場合現在時刻を返します。たとえば次のような値を返しているのであれば、四捨五入でも切り捨てでも全く問題はありません。

string(23) "2018-04-01 00:11:22.346"

しかし、現在時刻の秒未満が仮に999.9ミリ秒だった場合、四捨五入すると次のような結果になってしまいます。

string(24) "2018-04-01 00:11:22.1000"

この文字列全体を日付関数でparseするような場合、これでは23秒ではなく22.1秒として解釈されてしまうでしょう。つまり、vで四捨五入するのは仕様として良くないということになります。切り捨てであればこのような問題は起こらず、丸め後の値は必ず3桁になります。

一般に、何かの数を丸める場合に良かれと思って四捨五入にしてしまうことがある気がします。ところが、今回は四捨五入では問題になるという面白い事例でした。

参考URL

セキュリティの話題に丸腰で踏み込んでくる人を見た

Qiita上で「ゲームでよくされるチート手法とその対策 〜アプリケーションハッキング編〜」という記事がいいね数を集めているようですが、全セクションにツッコミどころがあるような印象です。私はセキュリティ本職というわけではありませんが、素人の私から見てもひどいと思ったところだけ個別にツッコミを入れてみます。


念のため補足しておくと、誰であろうと情報発信すること自体は大変良いことです。ただ、誤りを含んだ文章がウッカリ注目されてしまうとそれを信じてしまう人も出てくるので、大人げないと思いつつツッコミを入れる次第です。

デコンパイル(逆コンパイル)

2.の詳しい解説として、C/C++で記述されたコードをコンパイルすると機械語に変換されます。これを逆コンパイルしても、逆アセンブラまでにしかなりません。そのため、この状態ではソースコードの中身を解析するのは(人間では)非常に困難なため、ネイティブコードで書いた処理というのはデコンパイルへの対策となります。


技術用語の使い方が正確でない点は見なかったフリをするとして、C/C++で記述すれば安全だというのは幻想です。攻撃者の多くは高機能な逆アセンブラ(おそらくIDA Pro)を所有しており、昔に比べると格段に処理を追いやすい環境が整っています。基本的にはネイティブバイナリからでもコードの内容は把握可能だと考えるべきでしょう。


そもそもこのセクションの前半で紹介されている「iOS Reverse Engineeringの操作手順」もARMのアセンブリをIDA Pro/Hopperで解析する話題になっており、ネイティブコードなら安全という主張とは反対の内容です。ご自身で紹介している記事なのに読んでいないのかしら?と思ってしまいますね。


話を戻すと、クライアント側バイナリの挙動は全て解析される前提でシステム全体を設計すべきです。つまり、解析されて困る内容はサーバ側で実装するのが原則論になるでしょう。


絶対に秘密にしたいロジックをユーザーに配布するバイナリに入れたい場合は、自前での対策はあきらめてアンチデバッギング機能・アンチ逆アセンブル機能を持ったセキュリティ製品を購入した方が良いでしょう。実際の現場ではバレてもクリティカルじゃないけど簡単にバレるのはイヤという程度の状況だったりするため、自前実装で頑張っているところもあれば外部ソリューションに頼っているところもあるといった印象です。

乱数調整

なお、乱数を生成する際、現在時刻を基にして、擬似乱数を生成することが多いので、特定のタイミング(時刻)を狙うとうまくいく、といった都市伝説みたいな論調はあながち間違っているわけではありません。

間違いでしょう。明示的ににひどい実装をしない限りそんなことにはなりません。


引用部からすると乱数シードとして現在時刻を利用している言語・環境が多い、という風に読み取れますが、モバイルアプリの文脈でそんな環境はないはずです。現在のOSは各種割り込みのタイミングから十分なエントロピー(乱雑さ)を蓄積し、それを利用して乱数を生成しているため、十分にセキュアだと言えます(マシンの外部から乱数列を推測することは原則不可能です)。また、各言語の乱数生成の実装はOSに依存しているはずで、これらもセキュアだと言えます。


ネット上には自前実装の乱数生成器が転がっていたりして、そうしたものは時刻を乱数シードに使っているかもしれませんが、それらはセキュリティ知識の無い人が作成した脆弱な実装です。言うまでもありませんがコピペして使うなどは言語道断です。


(上記段落は指摘があったので修正します)OSや言語によっては時刻を乱数seedに流用しており「暗号に使うには」セキュアとは呼べない実装もあるようですが、そうだとしてもマシン外部から乱数列を推測できるような実装がありふれているとは言えないように思います。コメントでも書きましたが、元記事の筆者が紹介している記事はゲームボーイでの実装で本体起動後の何秒後かを狙うことで抽選結果をコントロールできるような事例のようです。つまり、攻撃者がマシンの内部状態に介入できる状態であるわけです。モバイルゲームのサーバサイド環境とかけはなれた事例を元に、そんな攻撃がありうるという主張は非論理的であるように感じます。


もちろん、重要な抽選はサーバ側で行うべきという主張自体は正しいと思います。また、サーバ側で実装するにしても乱数シードのエントロピー確保について熟慮すること、および採用する疑似乱数の性質を把握することの2点は必須と言えるでしょう。


ちなみに、乱数シードのエントロピー不足でサーバ上の乱数生成器が攻撃対象になりうるという事例は私が以前会社の技術ブログ記事「PHPのセッションIDは暗号論的に弱い乱数生成器を使っており、セッションハイジャックの危険性がある」で紹介しました。読んで頂ければわかりますが、この手の攻撃は普通の条件ではまず成立しません。ご参考まで。

余談

本当はDBのトランザクションとロックに関してツッコミを入れようと思ったんですが、長編になりそうだったのでその他のところだけ記事にしました。もちろん私より適任の方が記事を書いてくださってもいいんですよ?(チラッ

PHPメソッドのprototypeとは何か

なんとなくPHPマニュアルを眺めていたところ、リフレクション機能に下記のようなメソッドを見つけました。

ReflectionMethod::getPrototype — メソッドのプロトタイプを (存在すれば) 取得する


http://php.net/manual/ja/reflectionmethod.getprototype.php


特定のメソッドについて、「プロトタイプ」の情報を返してくれるもののようです。しかし、この説明だけでは何の値が返ってくるのか想像がつきませんよね。本稿ではこのメソッドについて調べてみます。

「プロトタイプ」の意味

そもそもPHPでプロトタイプとは何を意味するのでしょう?PHPの文脈では耳慣れない単語のような気がします。


私も全くわからなかったのでPHPのCソースコードを眺めてみたところ、プロトタイプとは関数の型宣言の意味だとわかりました。Cの「関数プロトタイプ」と同じ使い方です。


この型宣言はインターフェースと継承の実現で利用されています((他にはClosure::fromCallable()でも使われています))。インターフェースを実装した場合、実装したメソッドはインターフェースと同じ個数の引数が必要で、全て同じ型でないといけません。これはまさに関数の型チェックそのものです。継承でメソッドをオーバーライドした場合も同様で、親メソッドの型と矛盾しないかどうかのチェックが走ります。

プロトタイプを確認する

では、実際にReflectionMethod::getPrototype()の動作を確認していきましょう。次のようなコードを動かしてみます。

<?php

interface Foo
{
    public function func1(int $x);
}

abstract class Bar implements Foo {
    public function func1(int $x) {
    }
    abstract public function func2(int $x, double $y);
    private function func3() {
    }
}

class Baz extends Bar {
    public function func1(int $x) {
    }
    public function func2(int $x, double $y) {
    }
    protected function func3() {
    }
}

class Baaz extends Baz {
    public function func2(int $x, double $y) {
    }
    public function func3() {
    }
}

$cl = new ReflectionClass(new Baaz());
$methods = $cl->getMethods();
foreach ($methods as $mt) {
    $proto = $mt->getPrototype();
    printf("method=%s::%s(), prototype=%s::%s() \n",
           $mt->getDeclaringClass()->getName(), $mt->getName(),
           $proto->getDeclaringClass()->getName(), $proto->getName());
}


これを実行すると次のような結果になります。

method=Baaz::func2(), prototype=Bar::func2()
method=Baaz::func3(), prototype=Baz::func3()
method=Baz::func1(), prototype=Foo::func1()


これはBaazクラスの全メソッドについて、それぞれのプロトタイプを表示したものです。


Baaz::func2のプロトタイプは抽象メソッドのBar::func2です。型チェックをするだけなら親のメソッドであるBaz::func2がプロトタイプになっていても良い気がしますが、どうやら親子関係として一番上位で定義されたものがプロトタイプになるようです。


Baaz::func3のプロトタイプは親のprotectedメソッドであるBar::func3です。親の親には同名のprivateメソッドが定義されていますが、これは子にもエクスポートされないので単に無視されています。



func1のプロトタイプはインターフェースであるFoo::func1となります。これもやはり最上位で定義されたものがプロトタイプになっています。

まとめ

PHPメソッドのプロトタイプとは型宣言のことであり、クラスやインターフェースの親子関係において最上位で定義されたメソッドが実体となります。これは主に子メソッドの型チェックに利用されます。


普段のPHPプログラミングでは全く役に立たない知識だと思いますが、PHPのCソースコードを読むときに少しだけ役立つかも知れません。

2017年をふりかえる

年末恒例の振り返り記事です。過去記事はこちら。

書いた

2017年ははてなダイアリーに本記事を含め18本の記事を書きました。人気があったのは下記の記事です。


Qiitaでは65本の記事を書きました。人気があったのは次の記事です。


例年と比べるとPHP以外の話題が多めで、どちらかと言えばインフラの人っぽいラインナップですね…。PHPの記事は去年よりむしろ多いくらいだったはずなんですが、イマイチ人気が出ませんでした。

参加した

勉強会発表・カンファレンス発表を計4件行ったのを含め、計11回技術系イベントに参加しました。


こうしてみると件数だけは多いのですが、会社の採用目的のイベントが多かったりして思ったより活動できていないなーという印象です。


そもそも今年は闇PHP勉強会を開催できていません。今年の9月頃からずっと開催したいと考えていたのですが、バタバタしていて身動きが取れない状態が続いてしまいました。気を取り直して来年前半のどこかで開催したいと思っています。

10年ぶりにWindows環境を手に入れた

今年の2月にThinkpad X260を入手して普段使いの環境をWindowsにしようと思ったんですが、馴染めずに12インチMacBookに戻りました。理由は複数あるんですが、一番我慢できなかったのはフォントのカッコ悪さでした。


結果として、Thinkpadリモートデスクトップ機として運用しています。家の外からでもSSH越しにアクセスできるようにしたので、どのマシンからでもWindowsに触れるようになりました。個人的には仮想環境でWindowsを持ち歩くより便利だと感じます。


また、Bash on Windows・MSYS2・Chocolateyなどを試して、最近のWindows環境の常識に触れられたのも良かったと思っています。

Google Cloud Platform (GCP) に触ってみた


今年の3月の無料枠拡大のタイミングから各GCPサービスを試しています。人気が出た記事も何本かはGCP関連のものでした。記事にしていない内容もありますが、下記のようなサービスに触れてみて一定の肌感覚が掴めたように思います。


それにしてもGoogleさん無料で遊べる範囲広すぎでは?と心配になるくらいですね。ありがたいことです。

PHP拡張php-timecopをPECLに登録した

PHPカンファレンス2017のLT「PHP拡張をPECLに登録してわかったこと」でも紹介した通り、今年の7月にphp-timecopをPECLに登録できました(PECL :: Package :: timecop)。結果としてRedHatディストリビューションの標準リポジトリに登録されるなど、利用されている方の利便性向上にも繋がっており、当初想像していたよりも大事件だと感じています。


Windows版のDLLが配布できるようになった点、またWindows上でのCIができるようになった点も個人的に長年の課題だったので嬉しい変化だと言えます。

電子工作の取り組みを継続

今年も電子工作・IoTまわりのキャッチアップは続けています。LoRaWANやWi-SUNのデバイスを触ってみたり、Amazon Dashボタンで遊んだり、シリアル通信インターフェース経由で温度センサーを操作するハードウェアを作ったりして個人的には引き出しが広がったように思います。

OSSへのcontribution

今年はMIPS32環境でGoプログラムを動かすのが自分の中のブームで、MIPS32バイナリをビルドするのに必要だったPull Requestを3本投げました。


また、php-buildには17件のPull Requestを出して16本mergeされました。自分でも頑張りすぎな気がします。


また、PHP本体へのバグレポを2本投げました。


他にもPHP-doc ML(PHP日本語マニュアルについて議論するML)に修正提案1件を含め3回投稿しました。

まとめ

今年はイマイチ何もできていないような気がしていましたが、まとめてみるとそれなりに活動できていることがわかりました。とはいえ自分の中での消化不良感は嘘ではないので、来年のモチベーションに変えていきたいと思います。


というわけで、来年もがんばるぞー

PHP 7のforeachを&つきで回すと配列の消費メモリが倍増する話

2015年12月にPHP 7.0.0がリリースされてから2年と少し経ってしまいました。今月頭には無事PHP 7.2.0がリリースされたわけですが、皆様の参加プロジェクトのPHP 7導入は進んでいるでしょうか?まだPHP 5系なんだよね、という方もPHP 7が高速だという噂くらいは聞いているかと思います。


さて、そのPHP 7.0では内部のデータ構造を大幅に変更し、PHP 5系に対する後方互換性を確保しつつ大きな性能改善を果たしたわけですが、PHP 7で相対的に不利になった機能があるのをご存じでしょうか?答えは参照です。参照を使うと通常の変数よりメモリを消費しますし、読み書きも若干遅くなります。特にforeachを&つきで回すような場合にその影響は顕著になります。


この話題は一部の人には当然の内容かと思いますが、あまり知られていないように感じたので、本稿で詳細を紹介します。

foreachの文法おさらい

まずはPHPの文法をおさらいしておきましょう。PHPのforeach文は配列をループさせる記法で、for文よりシンプルかつ高速なのでよく使われる構文です。

<?php
$sum = 0;
$arr = range(1,100);
foreach ($arr as $v) {
    $sum += $v;
}
echo $sum; // 5050


このように、foreachでは配列中の要素をループ中で取り出して利用することができます。それだけでなく、&つきで参照として取り出すことで、ループ変数を使って配列値を書き換えることもできます。

<?php
$sum = 0;
$arr = range(1,100);
foreach ($arr as &$v) {
    $v *= 2;
}
echo array_reduce($arr, function ($sum, $v) { return $sum+$v; }); // 10100


foreachを&つきで回すのは人によって好き嫌いがあるとは思いますが、なにげなく使っている人も多い機能ではないでしょうか。

foreachループ前後のメモリ使用量を観察してみる

さて、foreachループを回しただけでメモリ消費量が増えるなんてことが起こるのかどうか確認してみましょう。まずは&なしのforeachループで確認します。利用したPHPのバージョンは7.2.0です。

<?php
$array_size = 1024*1024;
$array = range(1, $array_size);
$sum = 0;
$start_memory = memory_get_usage(true);
$start_time = microtime(true);
foreach ($array as $v) {
    $sum += $v;
}
$end_time = microtime(true);
$end_memory = memory_get_usage(true);
printf("array_size = %d, %f sec, %+d bytes\n",
       $array_size, $end_time-$start_time, $end_memory-$start_memory);


上記コードでは要素数1Mの配列を生成し、foreachでループさせたときの実行時間を測定し、さらにループ開始前と終了後とのメモリ消費量の比較を行っています。これを実行すると次のような結果を得ます。

array_size = 1048576, 0.022955 sec, +0 bytes


foreach前後でメモリ消費量は変わっていません。まあ当然ですよね。では、上のプログラムのforeach文の$v&$vと書き換えて実行してみましょう。すると次のような結果になります。

array_size = 1048576, 0.052640 sec, +25165824 bytes


なんということでしょう!foreachを&つきでループするとメモリ消費量が25MBほど増えてしまいました。配列1要素あたりピッタリ24バイトの増加ということになります。PHP 7の配列は1要素あたり約32バイトを消費しますので、+75%程度のメモリ量の増加になっています*1。また、実行時間も倍以上になっています。


念のため補足しておくと、このループでは配列値を読み出しているだけで、特に配列操作をしているわけではありません。配列値を参照で受けとっただけでメモリ消費量が変わるのです。

zvalと参照の関係

上で見た不思議な現象を説明するにはPHPの内部構造を知る必要があります。PHP 7の変数の実体はCソースコードレベルではzvalと呼ばれる16bytesの構造体になります。このzvalのうち先頭8バイトが変数の値に対応します。また、残りの8バイトに型の情報やその他の情報が格納されます。たとえば$a = 123;とした場合はメモリ上で次のように格納されます。



この変数の値を別の変数に代入する場合は単にzvalの16バイトをコピーします。$b = $a;であれば次のようになります。



一方、$b = &$a;のように参照として代入する場合はzend_referenceという24バイトのデータ構造が作られ、参照を共有する変数はzend_referenceへのポインタを持つようになります。



つまり、参照として代入すると元の$aの構造ごと変わってしまうのです。foreachを&つきでループしたときに1要素あたり24バイト増加した理由も同じで、配列の全要素を$vに参照で渡すときにzend_referenceが作られたためです。読み出しているだけなのにメモリ使用量が増えるのは不思議に思えるかもしれませんが、参照の性質上仕方のないことです*2

補足:参照は常に悪いのか

上記の内容だけを見て既存ソースコードの参照を全部取り除こう、と考えた人がいるかもしれませんが、それは早計かもしれません。


本稿で議論しているのは1変数あたり24バイトの増加です。そんな程度の差を気にするくらいなら、コードの可読性を上げたり、テストカバレッジを上げたり、新機能を実装したりする方が有意義かもしれません。巨大な配列の場合には一定の影響を及ぼす可能性はありますが、それでも処理全体のパフォーマンスに与える影響はさほど大きくないように思います。


そもそもPHP 7の参照は「相対的に」不利になっただけであって、PHP 5と比べればなお互角または若干高速なくらいです。メモリ消費量の観点でも、全要素が参照になっているPHP 7の配列の方がPHP 5の通常の配列より断然コンパクトだったりします。性能の観点で見直すというのであれば、実コードでベンチマークテストを行ってからにすべきでしょう。


また、性能以外の理由で参照の善し悪しを整理する観点もあるかもしれません。これは本稿の範疇外ですが、下記ページなどはうまく論点がまとめられているように思います。

まとめ

PHP 7から参照で変数代入するとメモリ消費量が増えること、foreach文で&を使うとその影響が全要素に及ぶことを紹介しました。この結果だけをもって参照の善し悪しを議論するのは早計だと思いますが、こうした部分を入り口にPHPの内部構造に興味を持つのは良いことだと思います。


また、PHP 5の頃は配列の全要素の値を書き換えるような場合に&つきのforeachを使うのが最速でしたが、PHP 7では新たな配列を作るのとほとんど差がなくなりました。速度が理由で&をつけている人がもしいれば、PHP 7でのベンチマークテストをオススメします。


本稿を書き終わるあたりで似た内容を「PHP7はなぜ速いのか(zval編)」で書いていることに気づきましたが、少し切り口が違うので良いことにします。

*1:本稿タイトルで倍増と言っているのは25%ほど誇張です

*2:例示したコードを救うためだけの最適化は不可能ではありませんが、実装するメリットは薄そうです

Mackerelで家庭内ネットストーカーシステムを作ってみた

本エントリはMackerel Advent Calendar 2017の23日目の記事です。


自宅の無線LANの利用状況をMackerelで監視するようにしたところ、予想以上にキモい仕組みができました。たとえば、家族の誰か(正確には誰かのスマートフォン)が外出するとSlackに通知を飛ばすことができます。



同じことをしている人は多くないと思うので、その知見を紹介します。

システム概要

まずは我が家のネットワーク構成を紹介します。



インターネットに接続しているブロードバンドルータがあり、無線経由でスマートフォンやPCがぶら下がっているような、ごく普通のネットワーク構成です。唯一変わっている点は、ブロードバンドルータ上でLinuxおよびMackerelエージェントが動いていることでしょう。


このルータの詳細は本稿では省きますが*1、ザックリ言うとRaspberry Pi 3を無線LANアクセスポイント兼ブロードバンドルータとして運用している環境が一番イメージしやすいと思います。

iwコマンドの出力をMackerelで監視する

Linux無線LANアクセスポイントとして動かす場合、無線LANの利用状況がiwコマンドで確認できます。下記は私のブロードバンドルータ上での出力です*2

# iw dev wlan1 station dump
Station 00:00:5e:f0:07:87 (on wlan1)
        inactive time:  1600 ms
        rx bytes:       6216368
        rx packets:     50296
        tx bytes:       4006367
        tx packets:     30159
        tx retries:     1159
        tx failed:      2
        rx drop misc:   17
        signal:         -50 [-49, -57] dBm
        signal avg:     -50 [-49, -56] dBm
        tx bitrate:     243.0 MBit/s MCS 14 40MHz
        rx bitrate:     216.0 MBit/s MCS 13 40MHz
        expected throughput:    53.557Mbps
        authorized:     yes
        authenticated:  yes
        associated:     yes
        preamble:       long
        WMM/WME:        yes
        MFP:            no
        TDLS peer:      no
        DTIM period:    2
        beacon interval:100
        short slot time:yes
        connected time: 195122 seconds


このように、iw dev station dumpで現時点でアクティブなWi-Fiクライアントのネットワーク利用状況が取得できます。同じネットワークに5台のクライアントがぶら下がっている状態であれば同様の内容が5台分それぞれ表示されます。


今回、上記の情報を記録するMackerelプラグイン hnw/mackerel-plugin-iw を作成してみました。


以下、mackerel-plugin-iwによって得られる情報を紹介します。

Wi-Fiセッションの生死がわかる

iwコマンドでは接続中の端末のMACアドレスごとの通信状況が表示されます。つまり、MACアドレスごとのエントリの有無を1分に1回記録することで、どの端末が現在ネットワークに参加しているかがわかります。


これをグラフ化することで、自宅でいつPCを開いていたかがわかるようになりました。PCを開いている時間は常にWi-Fiセッションが維持されるので、セッションが切断されたということはPCをスリープさせたということになります。


また、特定のスマートフォンを持った人が外出したかどうかの判断にも利用できます。というのも、大半のスマートフォン端末は画面オフ時もWi-Fiセッションが切れません*3。つまり、スマートフォンWi-Fiセッションが切れたときは外出したと判断できるのです。


次のグラフは筆者がMacBookを閉じ、iPhoneを持って外出したときのものです。両者のWi-Fiセッションが同時間帯に切れていることから、9時頃に家を出たことがわかります。



ちなみに、mackerel-plugin-iwでは利便性向上のためにMACアドレスの前3桁からわかるベンダー名をラベルの先頭に追加しています*4。こうしておけばMACアドレスだけ表示するより端末の判別が容易ですし、万一見慣れない端末がネットワークに参加していればすぐに気づくことができます。

端末ごとのネットワーク転送量がわかる

iwコマンドでは送受信した累積バイト数がわかるので、これを1分ごとに記録することでネットワーク転送量をグラフ化することができます。これにより、各端末でどんな操作をしているか想像することができます。


たとえばYouTubeで動画を見ている場合には転送量のグラフは横ばいになりますが、Webページを閲覧している場合には凸凹が多いグラフになります。


他にもアプリケーションによっては特徴的なグラフになることがあります。下記のグラフは私がNintendo Switchスプラトゥーン2ナワバリバトルを6回遊んだときのものです。ナワバリバトルは同じ部屋にプレイヤーが集まるのを待ち、8人揃ったら3分間の通信対戦を行うまでが1セットになっています。実際、グラフを見ると約3分間一定量の通信を行って1分ほど通信が落ち着くという繰り返しになっているのがわかります。


電波強度から端末と親機の距離が推定できる

無線LANの電波強度は-30dBから-100dBといった値で表されます。この数字は小さくなればなるほど電波強度が弱く、親機と子機の距離が離れている可能性が高いという意味になります。


電波は人体やその他でも遮蔽されるのでそこまで正確な位置検出はできませんが、Wi-Fiルータと同じ部屋にあるか別の部屋なのか、くらいは判断ができそうです。


下記のグラフは私がWi-Fi親機のある部屋でスマートフォンを操作していて、電池残量が減ってきたので隣室でUSB充電をはじめたときのものです。両者は直線距離ではそれほど変わらないのですが、隣室まで壁を挟んでいるせいか約-40dB*5から約-60dBと電波強度が大きく減衰していることがわかります。


仕組みのキモさと対策

これまで紹介してきた情報は、同じ無線LANにぶら下がっている全端末についてグラフ化されています。自分の端末の情報を自分が把握できるだけであれば何の問題もないのですが、同じ無線LANネットワークを利用している家族の情報も取れてしまうわけですから、非常にキモい状態だといえるでしょう。本稿の最初で例示したように、家族の誰かが外出するたびに管理者に通知が飛ぶような設定もできるわけで、家族間であってもプライバシー侵害のような問題になりかねないような気もします。


一方で、このような仕組みを作れるのは我々自身が情報をまき散らしているからこそです。これらの情報が家族内でさえ知られて困るのであれば、ネットワークを分離したり常時4G回線を使ったりといった対策が必要になるでしょう。

まとめ

iwコマンドの出力をMackerelで監視することで、端末ごとの詳細なネットワーク利用状況の可視化が実現できました。また、状態変化に対してアラートを流せるようになりました。


この仕組みは自宅で利用していると非常にキモいのですが、たとえば会社所有スマートフォン端末の管理・棚卸しに使うなど、実用的な利用方法もありそうだと考えていますので、もう少しブラッシュアップしていきたいと考えています。

*1:詳細に興味がある方は「BHR-4GRVにLEDEをインストールしたメモ」をご覧下さい。

*2:MACアドレスは加工してあります

*3:一部のAndroid端末にはスリープ中にWi-Fiセッションを維持させない設定があるようですが、全iOS端末および大半のAndroid端末ではスリープ中もWi-Fiセッションが維持されるようです。また、両OSとも省電力モードのときはWi-Fiセッションの接続・切断を繰り返すようです

*4:hnw/wsoui というライブラリを自作しました

*5:現在のMackerelの仕様ではマイナスの値をグラフ化できないため正負反転して記録しています