hnwの日記

GAE/SE PHP 7.2環境は実用性が高そうだという話

筆者の周囲だけかもしれませんが、さいきんGoogle App Engine Standard Environment(以下GAE/SE)が再注目されつつあるように思います。今回筆者もgVisorベースのGAE/SE PHP 7.2環境に触ってみたので、その内容を紹介します。

GAE/SEとは

GAE/SEは元祖PaaSとも言えるような、Googleが提供するフルマネージド環境です。以前からJavaPython、Go、PHPの4言語の環境が提供されていましたが、Go以外の言語のバージョンアップは長いこと提供されておらず、Googleの本気度に疑問を持っていた人も多かったように思います(私もその一人でした)。

ところが最近になってNode.js 8、Java 8、Python 3.7、PHP 7.2と立て続けに新バージョンを提供してきており、Googleが水面下でGAE/SEに開発リソースを投入していたことが明らかになってきました。

この急ピッチの言語提供に一役買っているのが新サンドボックス実装であるgVisorです。私のざっくりの理解ではLinuxシステムコール類をユーザーランドで提供するようなもので、既に多くのOSSが修正なしで動くような対応状況になっているようです。このgVIsorはGoでフルスクラッチ実装されており、ソースコードも公開されています。

サンドボックスのGAE/SE PHP 5.5環境とPHP 7.2環境の違い

以前のGAE/SE PHP 5.5環境を使ったことがある人は、GAEは制約が強い環境だという印象を持っているかもしれません。実際、旧サンドボックス上で動作しているPHP 5.5環境には次のような制約がありました。

  • PHP本体にかなり大きなパッチが当たっている
    • SAPI名からして「Google AppEngine PHP Runtime SAPI」と普通ではない雰囲気
  • PHPから読めるディレクトリが非常に少ない
    • 自分のデプロイしたファイルくらいしか見えない
  • PHPから書き込めるディレクトリはおそらく無い
    • ファイルを作りたい場合GCSに書く必要がある
  • コマンド実行が許可されていない
    • そもそもプロセスの概念が存在する環境なのか疑問

特にファイル操作とコマンド実行の制約が大きく、既存のPHPアプリケーションがそのまま動くとは言いがたい状況でした。

一方、PHP 7.2環境は次のような状況です。

  • 普通のPHPが動いている
    • SAPIはphp-fpm
  • PHPから全ファイルが見える
  • /tmpがtmpfsになっており、PHPから書き込める
  • コマンド実行ができる
    • /usr/bin などに置いてあるバイナリが実行可能
    • Ubuntux86_64バイナリをデプロイすると実行可能
  • わりと普通のLinux環境っぽい
    • /proc/devがある(まだ少し足りていない雰囲気)

サンドボックス環境に比べれば圧倒的に普通の環境と言えそうです。PHP 5.5時代にGAEは厳しいなーと思った人がいたとしても、今回は当時とは全然違う印象になるのではないでしょうか。

PHP 7.2環境の各種コマンドの実行結果

いくつかのコマンドを試した中から面白かったものを紹介します。

ps aux

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.3  0.2  13488  5620 ?        Ssl  17:11   0:00 serve index.php
root        14  1.0  4.2 265716 88352 ?        Ss   17:11   0:00 php-fpm: master process (/tmp/serve-361831195/php-fpm.conf)
root        15  0.8  3.0 266060 63724 ?        S    17:11   0:00 php-fpm: pool app
root        16  2.4  1.1  30132 23492 ?        Sl   17:11   0:00 caddy -quiet -conf /tmp/serve-361831195/Caddyfile
root        38  0.4  0.1  12680  3240 ?        S    17:11   0:00 sh -c ps aux 2>&1
root        39  0.4  0.2  42440  5928 ?        R    17:11   0:00 ps aux

サンドボックス環境を知っていると「psコマンドがマトモに動く!」という当たり前のことに感動してしまいますね。

PID 16番のCaddyはGo言語で書かれたWebサーバ実装で、php-fpmの前段で動いているようです。

lsof -p 16

lsofコマンドでCaddyがオープンしているファイルを確認してみました。

COMMAND PID USER   FD      TYPE DEVICE     SIZE NODE NAME
caddy    16 root  cwd   unknown                      /proc/16/cwd (readlink: No such file or directory)
caddy    16 root  rtd   unknown                      /proc/16/root (readlink: No such file or directory)
caddy    16 root  txt       REG   0,14 20099720  200 /usr/local/sbin/caddy
caddy    16 root    0u      CHR    0,0             5 /dev/null
caddy    16 root    1u     FIFO   0,12             2 host
caddy    16 root    2u     FIFO   0,12             3 host
caddy    16 root    3u     sock    0,5             5 can't identify protocol
caddy    16 root    4u     0000    0,2        0   14 anon_inode
caddy    16 root    5u     sock    0,5            24 can't identify protocol
caddy    16 root    6u     sock    0,6            23 can't identify protocol

ファイルディスクリプタ3番5番6番TCPだと思うんですが、「can't identify protocol」となっています。/proc以下の未実装部分の影響でしょう。

ちなみにlsofコマンドは標準イメージ内に無かったのでUbuntu環境から持ってきたバイナリファイルをデプロイしましたが、普通に動きました。

ifconfig

eth0      Link encap:AMPR NET/ROM  HWaddr
          inet addr:192.168.1.1  Mask:255.255.255.255
          UP RUNNING  MTU:0  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

eth2      Link encap:AMPR NET/ROM  HWaddr
          inet addr:169.254.8.1  Mask:255.255.255.255
          inet6 addr: fe80::c001/128 Scope:Global
          UP RUNNING  MTU:0  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

lo        Link encap:AMPR NET/ROM  HWaddr
          inet addr:127.0.0.1  Mask:255.255.255.255
          inet6 addr: ::1/128 Scope:Global
          UP RUNNING  MTU:0  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

ifconfigコマンドもUbuntuから持ってきたものです。送受信パケット数がゼロなのは、現時点では未実装ということだと思います。

一部コマンドは動かない

体感値としては9割以上のコマンドがそれっぽい結果を返してくれるのですが、コマンドによっては異常終了するものもあります。

たとえばdfコマンドは未実装システムコールを呼んでいるのか「Function not implemented」というエラーが出て終了してしまいます。また、netstatを試すとサービス全体が刺さってしまい、インスタンスを再起動する羽目になりました。gVisor自体がまだ不安定なのかもしれません。

まとめ

そんなわけでGAE/SE PHP 7.2環境で色々試してみて、PHP 5.5環境よりは断然制約が少ない環境のように感じました。

これだけ普通のLinuxっぽければ言語本体の移植が早いのも不思議は無いしアプリケーションの動作もほぼ問題なしだよね、という気持ちになります*1。逆に、以前のサンドボックスがマゾすぎたとも言えそうですね…。

gVisorの開発は続いていくはずですので、その意味でも今後ますます期待できる環境だと言えそうです。

*1:たとえば既にWordPressは動いているようです

Travis CIのcron jobsを使ってGitHubに定期的にcommitする方法

みなさん、Travis CI使ってますか?Trais CIはクラウドCIサービスの1つで、GitHub上で公開しているOSSを自動テストする目的であれば定番中の定番といっていいサービスです。

ところで、さいきん私の公開しているプロジェクト「hnw/wsoui」で以下のことを実現したいと考えました。

  • ネット上のデータを加工してGoの連想配列の形で提供したい
  • 自動更新したい
    • 情報ソースは不定期に更新される
    • そこそこ最新のデータを反映していてほしい

これを実現する方法は何種類か思いつきますが、今回Travis CI のcron jobsを使って1週間に1回、更新があったときだけgit commitするような仕組みを作りましたので、これを紹介します。

Travis CI のcron jobs

Travis CIでは、gitリポジトリに新たなcommitがpushされるたびにテストスクリプトが実行されます。これにより、万一バグがあった場合でも早期に気付くことができたり、どのcommitでエンバグしたか調べる材料になったりするわけです。

また、commitごとのテスト実行とは別に、定期的にテストを行うような機能「Cron Jobs」も提供されています。これはTravis CIのコンソール画面「Settings」「Cron Jobs」から設定できます。ビルド間隔は日次、週次、月次のいずれか一つを選択できます。

本来cron jobsは依存ライブラリのバージョンアップなどで以前通っていたテストが失敗するようになっていないかを定期的にチェックする機能です。今回のようにcron jobsのときだけ別の処理を行うというのは変則的な使い方ではありますが、色々と便利な応用が考えられるのではないでしょうか。

通常のテストと自動更新との共存

さて、Travis CI上でジョブを定期実行できることはわかりました。テストスクリプト内で環境変数を見ればcron jobsかどうか判定できますから、「定期実行されたときだけ自動更新を実行する」という今回の目標は十分実現できそうです。

とはいえ、テストスクリプトにテスト以外の処理を追記してしまうと保守性が下がってしまうかもしれません。もう少し綺麗に実現する方法はないのでしょうか。

2018/6時点ではβ機能ですが、Travis CIにはBuild Stageという仕組みがあり、「cron jobsのときだけ実行」をシンプルに実現することができます。具体的には、.travis.ymlを次のように記述できます。

jobs:
  include:
    - stage: 'Test'
      script:
        - go test -v .
    - stage: 'Update Check'
      if: type = cron
      script:
        - [cron jobsのときに実行したいコマンド1] ...
        - [cron jobsのときに実行したいコマンド2] ...

このように記述すると普段はテストだけを実行し、cron jobsのときだけテスト成功後に「Update Check」処理が走ります。小さなことですが、別の処理を別の場所に記述できた方が安心ですよね。


git commitするために

さて、ここまでの知識でTravis CI上でcurlでデータを取ってきて変換スクリプトを動かすところまではできそうです。あとは生成されたファイルをgit commitするだけですが、これが意外と面倒です。

まずは対象リポジトリのデプロイキーを用意します。これはSSH鍵ペアを新規作成した上で、公開鍵をGitHubリポジトリページから「Settings」「Deploy keys」で設定します。

$ ssh-keygen -t ed25519 -f /tmp/id_ed25519
$ cat /tmp/id_ed25519.pub
ssh-ed25519 ******************************************************************** hnw@example.com

今回のようにデプロイキーで外部からpushするような場合は「Allow write access」は必須になりますので注意してください。

次に、先ほど生成した秘密鍵を暗号化してリポジトリにアップロードします。まずtravisコマンドをインストールしてそれを利用します。

$ gem install travis
(snip)
$ travis encrypt-file /tmp/id_ed25519
Detected repository as hnw/wsoui, is this correct? |yes|
encrypting /tmp/id_ed25519 for hnw/wsoui
storing result as id_ed25519.enc
storing secure env variables for decryption

Please add the following to your build script (before_install stage in your .travis.yml, for instance):

    openssl aes-256-cbc -K $encrypted_****_key -iv $encrypted_****_iv -in id_ed25519.enc -out /tmp/id_ed25519 -d

Pro Tip: You can add it automatically by running with --add.

Make sure to add id_ed25519.enc to the git repository.
Make sure not to add /tmp/id_ed25519 to the git repository.
Commit all changes to your .travis.yml.
$ git add id_ed25519.enc

暗号化した秘密鍵Travis CI上で復号して$HOME/.ssh/以下にコピーして使います。設定ファイル.travis.ymlは最終的に次のようになりました。

language: go

go:
  - "1.10"

jobs:
  include:
    - stage: 'Test'
      script:
        - go test -v .
    - stage: 'Update Check'
      if: type = cron
      script:
        - git checkout master
        - curl "https://code.wireshark.org/****" > /tmp/manuf
        - scripts/oui-convert.pl /tmp/manuf > ouidata.go
        - go test -v .
        - openssl aes-256-cbc -K $encrypted_****_key -iv $encrypted_****_iv -in id_ed25519.enc -out $HOME/.ssh/id_ed25519 -d
        - chmod 600 $HOME/.ssh/id_ed25519
        - scripts/push-if-updated.sh ouidata.go

ここで注意すべきことですが、Travis CIのテスト対象は特定commitでありGitでいうところの「detached HEAD」と呼ばれる状態になっているため、そのままでは元のリポジトリにpushできません。そのため、まずmasterブランチに切り替えるという処理を行っています。ちょっとしたTIPSですが、私は少々ハマりました。

Travis CI上で自動更新を行うメリット

上記設定により、Travis CI上で自プロジェクトの自動更新ができるようになりました。しかし、cronで実行するだけであれば、VPSを利用することもできます。わざわざ手間をかけてTravis CIで実行するメリットは何でしょうか。

個人的には、GitHub+Travis CI環境では実行に必要な全ファイルが公開されていること、また実行ログが残ることがメリットだと考えています。

今回は自動生成したソースコードを自動commitしているわけですから、おかしな内容がcommitされてしまう可能性もゼロではありません。そんな場合でも、必要ファイルとログが公開されていれば、赤の他人であっても原因をつきとめて修正してPull Requestを送ることが可能です。

こんな過疎プロジェクトで誰もPull Request送らないでしょ、というツッコミもあるとは思いますが、可能性って大事だと思うんですよね。

まとめ

  • Travis CIのcron jobsを使うと定期実行ジョブをソースコードつき・ログつきで公開できる
    • インターネット上で更新され続けている何かを変換してgit commitするような用途には非常に良いのでは?たとえば日本の祝日カレンダーライブラリなど。

日本語のパスワードジェネレータを作ってみた

Webサービスを使っていると、たまに「秘密の質問」の設定を求められることがあります。

こういう場合、個人的にはランダム文字列を登録したいと思うのですが、次のようにマルチバイト文字しか登録できないことが多い気がします。

普通のパスワードジェネレータではマルチバイト文字のパスワード生成ができないので、このような用途には使えません。そこで、ランダムなマルチバイト文字列を生成するサービス「 秘密の答えジェネレータ」を作ってみました。

自分でも実用しており、既に5サービスに設定しましたが、非常に便利だと感じます。

本稿ではこのサービスの技術面の詳細について紹介します。

「秘密の答えジェネレータ」の構成要素

「秘密の答えジェネレータ」はHTML+JavaScriptだけで実現されており、GitHub Pagesでホストしています。また、独自ドメインDNSおよびSSL化はCloudFlareで行っています。

このような構成にすると月額コスト0円で運用できる点、また万一GitHub Pagesが使えなくなったようなときに移転先がいくらでも見つかる*1点が良いですね。

ブラウザ上でパスワード生成することの是非

パスワード類は自分だけが知っているのが大前提ですから、リモートサーバ上で生成された文字列はパスワードとしては不適切です。その意味で、パスワードジェネレータをサーバサイド実装するのは良いアイデアとは言えません。

前述したとおり、本サービスはHTML+JavaScriptだけで実現されています。言い換えると、ランダム文字列の生成処理は全てローカルマシンのWebブラウザ上で実行されます。つまり、技術的には安全と言えるはずです。

ただ、利用者が一見してサーバサイド実装かクライアントサイド実装かわからない、というのは問題かもしれません。

乱数生成について

JavaScript上で乱数生成する場合Math.random()を使う事例が多いかと思いますが、この関数は多くのブラウザ上で暗号論的擬似乱数生成器(CSPRNG)として実装されておらず、特にパスワード生成には向かないようです*2

一方、多くのブラウザ上で動くCSPRNGとしてwindow.crypto.getRandomValues()という関数があります。私の手元で試したところ、ChromeSafari、Edge、Firefoxの全てで動作しました。PCブラウザだけでなく、スマートフォン上のブラウザでも実装されているようです。

Operaだけはwindow.crypto.getRandomValues()が実装されていないようですが、OperaではMath.random()がCSPRNGになっているとのことです*3。つまり、window.crypto.getRandomValues()が存在しなかったときだけMath.random()にフォールバックすればブラウザ上でもセキュアに乱数生成ができると言えそうです*4

ちなみに、本実装ではwindow.crypto.getRandomValues()から取り出したUInt32の値の剰余を取ることでN文字から1文字を取り出す処理を実装しているのですが、偏りが出てしまうので厳密には良くありません。具体的には、ひらがなのランダム文字列を生成する際に「ぁ」が「ん」より0.0000000233%ほど出現しやすくなっています。将来的に修正するかもしれませんが、現時点ではそのような問題があると認識した上でご利用ください。

ES6実装について

私は普段JavaScriptをほとんど書かないのですが、そろそろ生のES6で書いても許される空気を感じたため、今回のサービスでは実験的に大半の処理をES6で書いてみました。

そのためIE11で動かなくなってしまいましたが、それ以外であればPC・スマートフォンとも大抵のブラウザで動作します。

私が使った機能はarrow functionとclassくらいではあるのですが、トランスパイラなしでES6を書ける時代はすぐそこかな、という印象を持ちました。

秘密の質問は常に悪なのか?

さて、今回秘密の質問に対して嘘の回答をすることをオススメしているわけですが、そもそも秘密の質問というやり方自体にセキュリティ上の問題があるのでしょうか?

個人的には秘密の質問が常に悪いとは思いません。重要な操作の際のみ要求するような、第二パスワード的な位置づけであれば十分意味があるように思います。一方で、秘密の質問に正解するだけでパスワード変更ができるようなサービス設計は非常に問題があると感じます。

また、秘密の質問が複数のサイトで重複しやすい点(母親の旧姓や好きな食べ物など)、また平文で記録されることが多い点、この2つはかなり致命的な問題であると感じます。つまり、あるサービスでユーザ情報の漏洩が起きた場合に、他サービスの秘密の質問の答えまでバレてしまうリスクが高いわけです。

上記のような理由から、秘密の質問の答えは毎回異なるランダム文字列にすべきだと私は考えています。

生成した文字列をどう保管するか

このサービスで生成した文字列をサービス登録後に破棄してしまうことはオススメしません。破棄するのでなく、パスワードリマインダーのメモ欄などに記録しておいた方が後々面倒が無いように思います。

秘密の質問の答えを平文で保存しておくのが不安な場合は、正しい回答と自動生成文字列を組み合わせて使うこともできます。たとえば「母親の旧姓は?」に対して「高橋ゃたぞゅもぽねん」などと入力し、後ろの8文字だけを平文で記録しておけば多少は安心度が増すかもしれません。

参考URL

まとめ

  • 「秘密の質問」向けのパスワードジェネレータを作りました
    • 既存のパスワード生成ツールではマルチバイト文字に対応していないため
  • 半分シャレのつもりでしたが、案外実用的です
    • 入力内容を他人に見られた場合のショルダーハック対策にもなるように思います(↓入力例)

*1:パッと思いついた中ではGAEかNetlifyが良さそうです

*2:Secure random values (in Node.js)

*3:cryptanalysis - Are there any Javascript CSPRNGs? - Cryptography Stack Exchange

*4:IE10以前ではセキュアじゃないと思いますが、今回はそもそも動作しないので無視しています

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の「関数プロトタイプ」と同じ使い方です。

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

プロトタイプを確認する

では、実際に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ソースコードを読むときに少しだけ役立つかも知れません。

*1:他にはClosure::fromCallable()でも使われています