hnwの日記

PHPの中身をgdbで観察できるようなDockerイメージを作りました

CLI版のPHPgdb上で動かしつつ、内部的なデータ構造を覗き見ることができるようなDockerイメージを作ってDocker Hubにアップロードしました。Docker環境さえあればすぐに動かすことができます。


このイメージを動かせばCのコードを書かなくてもPHP内部のデータ構造を確認することができます。PHPの内部構造を詳しく知りたい、というような人は参考にしてみてください。

準備

Macの人はDocker for Macを用意しましょう。他のOS上でも同様にDockerをインストールしてください。また、イメージの圧縮時サイズが200MB程度ありますので、それなりのネットワーク環境で遊ぶことをオススメします。

起動

Docker 1.10以降ではseccompにより一部システムコールが制限されているため、コンテナ内でgdbによるデバッグができません。期待通りにgdbを動かすにはコンテナ起動時にコマンドラインオプション「--cap-add=SYS_PTRACE --security-opt seccomp=unconfined」を付ける必要があります。

$ docker run -v $(pwd):/work -w /work --rm -it --cap-add=SYS_PTRACE --security-opt seccomp=unconfined yhnw/php-debug:7.1 /bin/bash

遊び方

とりあえずzvalの中身を覗いてみましょう。


まず作業ディレクトリに移動して、適当なPHPプログラムを作ります。今回は中身を見てみたい変数を列挙してvar_dump()してみましょう。

$ mkdir -p work/php-debug
$ cd work/php-debug
$ vim var_dump.php


今回作成したvar_dump.phpは下記のようなファイルです。

<?php
$a = strtoupper("foo");
$b = [ $a, &$a, "FOO"];
$c = $a;
var_dump($b);
$a = 123;
$b[1000] = 1234.5;
var_dump($b);


このディレクトリでDockerイメージ「yhnw/php-debug:7.1」を実行してgdbを起動します。

$ docker run -v $(pwd):/work -w /work --rm -it --cap-add=SYS_PTRACE --security-opt seccomp=unconfined yhnw/php-debug:7.1 /bin/bash
root@536f406bded6:/work# gdb php
GNU gdb (Debian 7.7.1+dfsg-5) 7.7.1
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from php...done.


まずPHP関数var_dump()にブレークポイントを設置します。PHPの内部関数はCの世界では「zif_」というプレフィックスがつきますので、zif_var_dumpという関数名になります。

(gdb) b zif_var_dump
Breakpoint 1 at 0x8429a4: file /usr/src/php/ext/standard/var.c, line 205.


では先ほどのPHPプログラムを実行してみましょう。

(gdb) run var_dump.php
Starting program: /usr/local/bin/php var_dump.php
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, zif_var_dump (execute_data=0x7ffff2214140, return_value=0x7fffffffb0b0) at /usr/src/php/ext/standard/var.c:205
205		if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {
(gdb) n
209		for (i = 0; i < argc; i++) {


実行するとブレークポイントの設定によりPHPプログラム5行目のvar_dump()で停止します。次に「n」をタイプしてzend_parse_parameters()を実行しています。これによりvar_dump()のパラメータがargsに代入されます。


さて、ではvar_dump()の第一引数$bの中身を見てみましょう。

(gdb) printzv &args[0]
[0x7ffff2214190] (refcount=2) array:     Packed(3)[0x7ffff2258300]: {
      [0] 0 => [0x7ffff225fa08] (refcount=3) string: FOO
      [1] 1 => [0x7ffff225fa28] (refcount=2) reference: [0x7ffff2202120] (refcount=3) string: FOO

      [2] 2 => [0x7ffff225fa48] (refcount=0) string: FOO
}


情報量が多いので全部は説明しませんが、$bは3要素の配列であり、ハッシュテーブルを持たない真の配列(Packed)であることがわかります。また、$b[0]の文字列"FOO"の参照カウントは3であることがわかります。これは$b[0]と、$cと、$aの実体($aおよび$b[1]が共有している)の3つから参照されているためです。$b[1]の参照カウントが2なのは$aと$b[1]が同じ実体を参照しているという意味です。


$b[2]の参照カウントは0ですが、これは文字列リテラルを代入していることが原因です。文字列リテラルインターン化文字列になるため、参照カウントおよびGCの対象外なのです(参考:「PHPのインターン化文字列とは何か - hnwの日記」)。


では、次のブレークポイントまでプログラムを再開してみましょう。

(gdb) c
Continuing.
array(3) {
  [0]=>
  string(3) "FOO"
  [1]=>
  &string(3) "FOO"
  [2]=>
  string(3) "FOO"
}

Breakpoint 1, zif_var_dump (execute_data=0x7ffff2214140, return_value=0x7fffffffb0b0) at /usr/src/php/ext/standard/var.c:205
205		if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {


プログラム5行目のvar_dump()の結果が表示され、8行目のvar_dump()で再び停止しました。改めて$bの中身を見てみましょう。

(gdb) n
209		for (i = 0; i < argc; i++) {
(gdb) printzv &args[0]
[0x7ffff2214190] (refcount=2) array:     Hash(4)[0x7ffff2258300]: {
      [0] 0 => [0x7ffff225fb60] (refcount=2) string: FOO
      [1] 1 => [0x7ffff225fb80] (refcount=2) reference: [0x7ffff2202120] long: 123

      [2] 2 => [0x7ffff225fba0] (refcount=0) string: FOO
      [3] 1000 => [0x7ffff225fbc0] double: 1234.500000
}


$b[1000]に値を代入したことで$bが4要素の連想配列(Hash)に変わったことがわかります。PHPは添字が全部数字であっても中身がスカスカになってしまうような場合は内部構造として連想配列を採用します。各要素のzvalのアドレスも書き換わっていることから、データ構造の変更に伴い要素のメモリコピーが発生していることもわかります。仕組み上当然のことですが、PHP 7において当初配列として利用していたものを途中から連想配列として利用するのは非効率なのです。


また、$a[0]の参照カウントが1減って2になっていることもわかります。これは$aの実体が123に書き換わり、参照先が変わったためです。


ちなみにprintzvというのはPHPに標準添付の.gdbinitで定義されている関数で、zvalの中身を見るのに非常に便利です。もしgdbの標準コマンドで$b[0]の中身を見るとすれば、次のような手順が必要になります。

(gdb) p args[0].u1.v.type
$1 = 7 '\a'
(gdb) p args[0].value.arr.arData[0]->val.u1.v.type
$2 = 6 '\006'
(gdb) p (char *)args[0].value.arr.arData[0]->val->value.str.val
$3 = 0x7ffff22649d8 "FOO"
(gdb) p args[0].value.arr.arData[0]->val->value.str->gc->refcount
$4 = 2


まず$bが配列であること(type=7)を確認し、次に$b[0]が文字列であること(type=6)を確認してその値にアクセスしています。zvalでは共用体を多用しているので仕方ないのですが、ちょっと面倒すぎますよね。

シンボルテーブルを確認する

シンボルテーブルを確認する方法も紹介しておきます。グローバルなシンボルテーブル(つまりグローバル変数)は次のように確認できます。

(gdb) print_ht &executor_globals.symbol_table
Hash(10)[0x1368530]: {
  [0] _GET => [0x7ffff2259100] (refcount=2) array:
  [1] _POST => [0x7ffff2259120] (refcount=2) array:
  [2] _COOKIE => [0x7ffff2259140] (refcount=2) array:
  [3] _FILES => [0x7ffff2259160] (refcount=2) array:
  [4] argv => [0x7ffff2259180] (refcount=2) array:
  [5] argc => [0x7ffff22591a0] long: 1
  [6] _SERVER => [0x7ffff22591c0] (refcount=2) array:
  [7] a => [0x7ffff22591e0] indirect: [0x7ffff2214080] (refcount=2) reference: [0x7ffff2202120] long: 123


  [8] b => [0x7ffff2259200] indirect: [0x7ffff2214090] (refcount=2) array:

  [9] c => [0x7ffff2259220] indirect: [0x7ffff22140a0] (refcount=2) string: FOO

}


そのスコープでのシンボルテーブル(=ローカル変数)は次のようにすれば見えることもあります(PHP 5.2までは常にローカル変数に対応するシンボルテーブルが作られていたのですが、PHP 5.3以降は必要なときだけ作られるようです)。

(gdb) print_ht execute_data.symbol_table

Dockerイメージについて

ちなみに今回作ったDockerイメージはオフィシャルのphpイメージのリポジトリをforkして作りました。Travis CIを利用しており、masterにコミットするとCIでDocker Hubへのデプロイまで行うようにしてありますので、参考にしてみてください。


ちなみに5.6と7.0のイメージもデプロイ済みです。

まとめ

Cもgdbも良くわからなくても、PHPプログラム中のvar_dump()のタイミングで止める方法と「printzv」だけで結構遊べるのではないでしょうか。もう少し深く知りたい場合はgdbのマニュアルおよびPHPソースコードのZend/以下を調べてみてください。

2016年をふりかえる

年末なので今年何をしたか列挙していきます。

書いた

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


Qiitaでは62本の記事を書きました。今年はPHP以外の内容を多めにしたつもりだったんですが、人気が出るのはPHPの記事が多いですね。


また、会社ブログにも2本記事を書きました。うち1本の翻訳記事が人気を集めました。

参加した

勉強会・カンファレンス発表を3件行ったのを含め、計5回勉強会に参加しました。


AVTOKYOは本当に面白かったです。来年は全然行ったことがない集まりに積極的に行きたいですね。


PHP勉強会は去年に引き続きの主催でした。約1年ぶりの開催でしたが、来年はペースを上げて年2回開催したいと思っています。

意識的にインプットを増やした

今年は過去触ったことがない技術も積極的に触っていこう、という気持ちで色々取り組んでみました。


つまみ食い程度で終わってしまいアウトプットまで至らないものも多かったので、来年はもう少し深掘りする題材を見つけたいです。

個人プロダクトのリリース・メンテナンス

今年5月にVisual Studio Codeのエクステンション「Auto-open Markdown Preview」を作ってMarketplaceに公開しました。


思ったより多くの人が使ってくれているようでissueもボチボチ来ているんですが、現状で自分の用途は完全に満たしているので、機能追加にはあまり気乗りしないのが悩みどころです。


また、継続してメンテナンスしているPHP拡張モジュールのphp-timecopについて2回マイナーバージョンアップを行いました(PHP7対応、マイクロ秒対応)。

OSSへのcontribution

今年はZshにバグレポを送る、ということをしてみました。この内容はZsh 5.3に取り込まれています。


また、PHP本体へのバグレポを2本、pull requestを1本出しました。


php-buildに3件Pull Requestを送ったりもしました。


他にもPHP-doc ML(PHP日本語マニュアルについて議論するML)に修正提案を5件投げました。しばらくML配信が止まっていたんですが、無事に復活してよかったです!

まとめ

今年は別の技術領域に入門して継続的に取り組んでいこうと思っていたのですが、残念ながら実現できませんでした。一方で、PHPについては目鼻が利くせいか、ちょっと調べるとそこそこ面白い話題にたどり着けたような気がします。他の技術領域で同じようなレベル感になりたいものです。


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

第七回闇PHP勉強会でrealpathキャッシュとデプロイの話をしました

昨日12月11日に第七回闇PHP勉強会を開催いたしました。私を含め発表者6人ということで、とても盛り上がった勉強会になりました。発表者の皆さま、またご参加いただいた皆さま、本当にありがとうございました。また会場提供いただいたピクシブ株式会社さまにも大変お世話になりました。


以下が私の発表資料です。



PHPアプリケーションをsymlink切り替えでデプロイしているとrealpathキャッシュ絡みで何かしらトラブルがありますよね、というくくりで複数のトピックを紹介するような内容でした。タイトルの通り、一番話したかったのはmod_phpphp-fpmとでOPcacheの挙動が変わる話だったんですが、かなり入り組んだ内容だったのでうまく伝わらなかったかもしれません。


質問タイムに、@edvakfさんから面白いエピソードを聞くことができました。Pixivではこの手の問題に一通りハマった結果、現在では「realpath_cache_size=0」での運用に落ち着いており、性能面でも特に問題は出ていないそうです。なぜ性能面で問題が出ないのか、その場では答えられなかったのですが、OPcacheによるopcodeキャッシュがrealpath cacheの前段のキャッシュのように働いているのが理由かもしれません。多段キャッシュ構成で前段キャッシュの方が高性能であれば、後段キャッシュって何の意味もないですからね…。


また、発表資料ではblue-green deploymentが最強ではないかという話を紹介しましたが、Pixivではデプロイ完了までの時間を重視しているためsymlink切り替えの方が良い、という話も聞けました。環境によって何を重視するかは異なるはずで、選択肢は人それぞれということだと思います。


他にも、rsyncデプロイやFTPデプロイもまだまだ現役ですよ!という話も多数聞くことができました。だから120秒間の新旧*.php混在問題は黙っていてほしい、なんて話もあったりなかったり(笑)。


懇親会もピクシブの皆さまにお世話になりまして、勉強会会場で引き続きの開催となりました。その場の空気感を維持したまま議論が深められる上に離脱率も減るので、同じ会場での懇親会はメリットが大きいなあ、と感じました。おかげさまで色々な方とお話しすることができました。


次回こそは半年後くらいに開催したいと考えておりますので、引き続きよろしくお願いいたします!

PHPカンファレンス2016でOpenWrtについてLT発表をしました

11月3日に開催されたPHPカンファレンス2016でLT発表してきました。以下が発表資料です。



OpenWrtの名前を知ってもらって、その後うっかり触る人が出てきたらいいなあ、と考えて発表してみました。PHPとほとんど関係ない内容でしたが、それなりに面白がって頂けたのかな?と思っています。興味を持った方は「OpenWrt」で検索すれば大体のことは日本語で見つかると思います。また、下記URLで自分がLEDE(OpenWrtのfork)をインストールしたときの手順を紹介しています。

OpenWrtの近況

OpenWrtはルータ向けのLinuxディストリビューションですよ、という程度の紹介をプレゼン中で行いましたが、改めて昨今のOpenWrtを取り巻く状況について書いてみます。


まず、今年の10月にOpenWrt Summit 2016がドイツで開催されました。去年に引き続き2回目の開催のようで、ここに来てプロジェクトとしての勢いが加速しているような印象です。


このOpenWrt Summitをスポンサードしているのがprpl Foundationという組織です。この組織の正体はイマイチわかりませんが、QualcommBroadcomといったMIPS系SoCに関わる大手企業が一定の金を出していそうな雰囲気で、OpenWrt絡みのプロジェクトに出資するような活動もしています(参照:「OpenWrt/LEDE Project Funding Support from prpl」(PDF))。OSSプロジェクトでお財布の心配が無くなることは良いニュースではないでしょうか。


一方で、OpenWrtからLEDEというforkができて大半の開発者が両方にコミットしているような状況でもあり、この人たち本当に大丈夫なのかしら?と思ったりもします。


そんなわけで、OpenWrtは超有望ってほどでもないけど一定期待されているOSSプロジェクトという評価が妥当なのかな、と個人的には考えています。

OpenWrtのバイナリパッケージをTravis CI上でビルド&デプロイ

発表では全く触れませんでしたが、OpenWrtのバイナリパッケージのMakefileGitHub上で管理して、git pushすると勝手にTravis CI上でビルドが走ってデプロイまで行われる仕組みを作りました。ビルド環境はDockerイメージとして構築したものをDocker Hubにアップロードしています。



仕組みの詳細については下記リポジトリの.travis.ymlやTravis CIのログなどをご確認ください。


そもそも、自分専用のパッケージを作るだけであれば手元のマシンでビルドした方が断然楽です。ただ、皆が手元でビルドしているとノウハウの共有が行われにくいので、このように全手順をネット上に公開できる仕組みは価値があると考えています。まだまだ荒削りだと思いますが、参考にして頂ければ嬉しいです。

他のセッションについての感想など

今年のPHPカンファレンスも刺激をたくさんもらった集まりでした。特にt_wadaさんの発表「PHP7で堅牢なコードを書く - 例外処理、表明プログラミング、契約による設計」はスライドもトークも素晴らしいものでした。今回は平行トラック数が多くて見られなかった発表も多かったのですが、興味深いものばかりで、ジャンルとしても多岐に渡っているように感じました。スライドも動画も公開されつつあると思いますので、後で確認したいと思います。


また、懇親会や2次会で多くの方々と技術の話題から雑談まで色々とお話しさせて頂きました。話そうと思っていたのに結局話せなかった方も何人かいらっしゃいましたが、次回こそよろしくお願いいたします。


最後になりますが、発表者の皆様、スタッフの皆様、本当におつかれさまでした。来年も期待しています!

PHPでは正規表現コンパイル結果のキャッシュが暗黙に行われている

筆者がPHPをさわり始めたころ、「PerlのコレはPHPではどうやるんだろう?」と思うことが頻繁にありました。一部の疑問については解説を見つけたり自分でソースコードを読んだりして解決したものの、考えるのをやめてしまったものもあります。その一つが正規表現コンパイル結果の保存に関するもので、最近まで完全に忘れていました。

正規表現コンパイルというのは与えられた正規表現を解釈して実行しやすいデータ構造に変換する作業のことを指します。具体的にはDFA(決定性有限オートマトン)を構成するか、正規表現エンジン内部で用いられるVM命令列に変換するかといった処理になります。これらは複雑な処理ですので、性能の観点で言えば同じ正規表現に対するコンパイル処理はできるだけ繰り返したくありません。

Perlの場合、/foobar/ のようなスタティックな正規表現コンパイルは1回しか行われません。一方で、正規表現中に変数が使われている場合は毎回内容が変わる可能性があるため、毎回コンパイルが走ります。毎回のコンパイルを防ぐためのoフラグというものがあるなど、このあたりの仕組みについてはラクダ本でもページ数を割いて説明されており、多くのPerlプログラマ正規表現コンパイルがいつ走るかを意識しながらコードを書いているはずです。

一方、PHPでは正規表現コンパイルに関する話題自体をほとんど聞いたことがないように思います。PHPで同じ正規表現処理が何度も実行される場合に、正規表現コンパイルが1回しか行われないのか、毎回行われているのか、この疑問に答えられるPHPプログラマはごく少数ではないでしょうか。

本稿ではPHP正規表現コンパイルとそのキャッシュの仕組みについて紹介します。

PHPでの正規表現処理

PHP正規表現処理の関数を2系統持っており、それぞれ下記の拡張モジュールで提供されています。

  • PCRE
  • mbstring

PCRE拡張で採用されているPCREはPerl正規表現を提供するライブラリで、他のOSSでの採用事例も多く見られます。PHPでは本家PCREのバージョンアップにマメに追従しており、PHP 7.0.12ではPCRE 8.38が同梱されています。

一方、mbstringは日本では定番のマルチバイト処理の拡張モジュールで、正規表現関数も提供しています。mbstringで利用している正規表現ライブラリはRuby 1.9系でも採用されていた鬼車で、PHPに同梱されているのは鬼車 5.9.6です。いまのところ鬼車6.x系への追従や鬼雲に切り替えるなどという話は聞いたことがありません。正直なところ、UTF-8全盛の昨今であればPCREだけで十分な気もします。

ちなみにPHP 5.xまでは更に別のPOSIX正規表現ライブラリも持っていたのですが、7.0からは削除されています。

PCREとコンパイル結果のキャッシュ

まずPCREの方から紹介します。PCRE拡張では正規表現コンパイル結果のキャッシュがPHPのプロセス内で最大4096個までキャッシュされる仕組みになっています。このキャッシュはプロセス内で永続化されているため、正規表現コンパイル結果はリクエストをまたいで共有されます(ただし、プロセスをまたいでの共有はできません)。

最初の疑問について言えば、同じ正規表現が与えられた場合には最初の1回しか正規表現コンパイルは走らないし、もしかすると以前のリクエストで作られたキャッシュにヒットすれば1回も正規表現コンパイルを行わない可能性さえあるというわけです。

実は、このことはPHPマニュアルにも書いてあります。

この拡張モジュールでは、コンパイルした正規表現のためにスレッド単位のグローバルキャッシュ (最大 4096) を管理しています。

http://php.net/manual/ja/intro.pcre.php

このキャッシュの効果は簡単な実験で確認できます。次のようなプログラムを実行してみましょう。

<?php
$num_regex = 4096;
$start = microtime(true);
for ($i = 0; $i < 100; $i++) {
    for ($j = 0; $j < $num_regex; $j++) {
        preg_match("/([a-z]{1,10}){1,10}$j/", "foo");
    }
}
var_dump(microtime(true)-$start);

これは$num_regex種類の異なる正規表現マッチを繰り返し実行するだけのコードで、私の手元で実行したところ0.45秒程度でした。ところが、$num_regexを1増やして4097にしてみると実行時間が18秒となり、劇的に時間がかかるようになってしまいました。正規表現のキャッシュサイズが4096であるため、それ以上の種類数にしてしまうと毎回キャッシュが追い出されてしまって都度正規表現コンパイルが走るので非常に遅くなるというわけです。

このキャッシュ処理の詳細はPHPソースコードext/pcre/php_pcre.cのpcre_get_compiled_regex_cache関数で記述されています。

mbstring(鬼車)とコンパイル結果のキャッシュ

mbstringの正規表現コンパイル結果もキャッシュされていますが、こちらは同一リクエスト内のみで使い回され、リクエスト間で共有されることはありません。mbstringではコンパイル結果のキャッシュ個数に上限はなく、異なる正規表現コンパイルするたびにメモリを消費していきます。

また、同じ正規表現で内部的なフラグだけが異なっているような場合は最新1件しかキャッシュされません。つまり、mb_eregとmb_eregiとで同じ$patternを与えたような場合、前に実行した方のコンパイル結果は上書きされてしまい、後で実行した方のキャッシュしか残りません。

このキャッシュ処理はPHPソースコードext/mbstring/php_mbregex.cphp_mbregex_compile_pattern関数で行われています。

キャッシュに関する注意点

これらのキャッシュは正規表現を理解しているわけではなく、正規表現パターン文字列をキーにしてコンパイル結果を連想配列に格納しているだけです。明らかに同じ内容の正規表現であってもパターン文字列が異なっていればキャッシュは使われず、再度コンパイルが行われます。

たとえば下記のようなコードを書いた場合も、それぞれ個別にコンパイルされてキャッシュエントリを2個消費してしまいます。

<?php
$foo = "foo"
preg_match("/foo/", $foo);
preg_match("~foo~", $foo);

仕組みを考えれば仕方ないかもしれませんが、少し残念ですね。

まとめ

PHPプログラムを書いたらマイナス21億行目あたりでエラーが出た

(2016/10/5 20:40 追記)誤解を招いている部分がありそうなので文末に補足を追記しました。巨大なプログラムを食わせただけでPHPが死ぬわけではありません。

毎度おなじみ、意図的に重箱の隅をつついてみたよって話です。あるPHPプログラムを実行したら次のようなエラーに遭遇しました。

$ php over-2g-lines.php
int(0)
PHP Fatal error:  Uncaught Error: Call to undefined function var____dump() in /Users/hnw/over-2g-lines.php:2150000004
Stack trace:
#0 {main}
  thrown in /Users/hnw/over-2g-lines.php on line -2144967292

21億5千万4行目で致命的エラーが発生したよ!という表示のあとでスタックトレースが表示されているんですが、スタックトレースの方ではマイナス21.4億行目あたりでエラーが出ていることになっています。行数がマイナスというのは不思議ですね。

種明かしするまでもないと思いますが、原因は32bit整数のオーバーフローです。PHPVM命令を管理するzend_op構造体のlinenoメンバは32bit符号無し整数で管理されているため、PHPは43億行目あたりまでしか正確にプログラムの行数をカウントできません。

さらに、printfの修飾子として%dなどとsignedの指定をしている場所があると上のように21.5億あたりでオーバーフローして負数になってしまいます。これはもちろんバグですが、修正したところで誰得な気がします。気が向いたらバグレポを出そうかなと思いつつ、今日はブログ記事にして寝ることにします。

ちなみに上記結果はPHP 7.0.9のものですが、PHP 5系(5.3.0以降)でも大差ないエラー表示になります。

再現方法

手元でも再現したい方のために、実験に使ったPHPプログラムをgistに上げておきました。次のようにすれば元のプログラムを手元に復元できます。

$ curl 'https://gist.githubusercontent.com/hnw/128439edf806daadbdf548b730d67627/raw/over-2g-lines.php.bz2.base64' | base64 -D | bzip2 -cd > over-2g-lines.php

gistに上がっているファイルは2KBほどですが、展開後のPHPファイルは約2GBになりますので、テキストエディタなどで開くと確実に大惨事です。ご注意ください。

ちなみにPHPファイルの内容は以下の通りです。

<?php

/* (21億5千万行の空行) */

$x=0;
var_dump($x);
var____dump($x);

補足というか蛇足というか

21.5億行を超えるPHPプログラムを動かすとPHPがエラーを返すように読み取った方がいるかもしれませんが、そうではありません。巨大なプログラムの後ろの方でわざとエラーを出してみた、というのが上の実験内容です。実際、21億5千万3行目のvar_dump($x)の結果は正しく出力されています(最初の実行結果を参照ください)。21.5億行目から43億行目の間あたりでエラーを起こした場合にエラー表示箇所の行数表示がマイナスになるバグがあるみたい、というのがお伝えしたかった内容でした。

そもそも、古代のBASIC以外のプログラミング言語にとって行数というのは重要なパラメータではなく、エラー発生時に発生箇所をわかりやすく表示するための補助情報に過ぎません。これはPHPにおいても同じで、どれだけ行数の多いプログラムを与えてもそれだけで死ぬことはありません。

もっとも、マトモな処理が21億行書かれたプログラムを与えるとプログラムのparseだけで大量のCPU時間とメモリを消費してしまい、別の理由で死ぬ可能性が高いと思います。これは他の言語でも同じでしょう。

続・世界最小のRSA鍵ペアは何bitか

前回の記事「世界最小のRSA鍵ペアは何bitか」でp=3, q=5(つまりn=15)の場合のRSA鍵ペアを紹介しましたが、kazuhookuさんからこんなブックマークコメントを頂きました。

面白い。n=4(あるいは2)はダメなのかな

もっと小さいnを採用できないのか?という指摘かと思います。前回記事では普段のRSA暗号のノリで「p,qは異なる奇素数」という前提を置いていましたが、既に非常識なくらい短い鍵長の話をしている中で常識にとらわれるのは無意味というものでしょう。

本稿では15未満のnでRSA暗号らしきものが構成できるのかどうかを探ります。

n=1の場合

RSA暗号の平文mに対して m^(e*d) = m (mod n)が成り立つ最小のnを考えると、n=p=q=e=d=1が見つかります。これは1bit RSA鍵ということになりますので、もし認められるなら世界最小なのは間違いありません。

n=1のRSA暗号というのは、平文も暗号文も0しか無い世界ということになります。文字が一種類しか無い世界での暗号とは何なのか?という哲学的な問いはいったん忘れて、まずはこのような鍵に対してopensslコマンドが動作するのかを調べることにしましょう。

$ cat /tmp/public-key-n1.pem
-----BEGIN PUBLIC KEY-----
MBowDQYJKoZIhvcNAQEBBQADCQAwBgIBAQIBAQ==
-----END PUBLIC KEY-----
$ openssl asn1parse -strparse 17 < /tmp/public-key-n1.pem
    0:d=0  hl=2 l=   6 cons: SEQUENCE
    2:d=1  hl=2 l=   1 prim: INTEGER           :01
    5:d=1  hl=2 l=   1 prim: INTEGER           :01
$ perl -e 'print "\x00"' | openssl rsautl -raw -encrypt -pubin -inkey /tmp/public-key-n1.pem | base64
RSA operation error
140735149846608:error:04068065:rsa routines:RSA_EAY_PUBLIC_ENCRYPT:bad e value:rsa_eay.c:169:

暗号化しようとすると「bad e value」と怒られてしまいました。OpenSSLのソースコードを確認したところ、n<=eだとエラーになるようです。これが必須のチェックだとは思いませんが、変な鍵なのは間違いないでしょう。

n=2の場合

次はn=2,e=1が候補になります。同様にOpenSSLで動作確認してみましょう。

$ cat /tmp/public-key-n2.pem
-----BEGIN PUBLIC KEY-----
MBowDQYJKoZIhvcNAQEBBQADCQAwBgIBAgIBAQ==
-----END PUBLIC KEY-----
$ perl -e 'print "\x01"' | openssl rsautl -raw -encrypt -pubin -inkey /tmp/public-key-n2.pem | base64
RSA operation error
140735149846608:error:0306E06C:bignum routines:BN_mod_inverse:no inverse:bn_gcd.c:525:

今度は「no inverse」というエラーで怒られました。どうやら多倍長演算の前準備としてmod nで0x10000000000000000のモジュラ逆数を計算する処理が走るようで、nが偶数だと必ず死ぬようです。OpenSSLのバグといえばバグだと思いますが、仕方がないというものでしょう。

n=3の場合

n=3,e=1の場合はどうでしょうか。

$ cat /tmp/public-key-n3.pem
-----BEGIN PUBLIC KEY-----
MBowDQYJKoZIhvcNAQEBBQADCQAwBgIBAwIBAQ==
-----END PUBLIC KEY-----
$ perl -e 'print "\x02"' | openssl rsautl -raw -encrypt -pubin -inkey /tmp/public-key-n3.pem | base64
Ag==
$ cat /tmp/private-key-n3.pem
-----BEGIN RSA PRIVATE KEY-----
MBsCAQACAQMCAQECAQECAQMCAQECAQECAQECAQE=
-----END RSA PRIVATE KEY-----
$ echo "Ag==" | base64 -D | openssl rsautl -raw -decrypt -inkey /tmp/private-key-n3.pem | od -tx1 -Ax
0000000    02
0000001

何もトラブルなく動いてしまいました。なんと2bit鍵というわけです。

とはいえ、個人的にはこれをRSA暗号だと言うのには抵抗があります。e=1のときは平文と暗号文が完全に一致するので、そもそも暗号になっていません。

また、RSA暗号ではサイズの大きい合成数nの素因数分解が困難であるということが暗号強度の根拠になっています。しかし、nが素数だとこの前提が崩れてしまい、誰でも秘密鍵を復元できてしまいます。また、公開鍵に含まれるnが素数かどうかはミラー・ラビン素数判定法で高速に判定できるので、攻撃者は弱い鍵を容易に探すことができます。

逆に言うと、nが素数だったりe=1であったりする非常に弱い鍵ペアであってもOpenSSLで正常動作するというのは意外な結果かもしません。

まとめ

  • OpenSSLはnが偶数のRSA鍵を想定しておらず、エラー終了する
  • OpenSSLで動作する最小のRSA鍵ペア(らしきもの)はn=3のときである
  • OpenSSLは弱いRSA鍵に対して寛容
    • e=1やnが素数の場合でも正常動作する
    • これが問題となる場合はアプリケーション側での対処が必要