まずは相談する

PHP 5.3.3 に PHP2Shell の修正を自分で移植した話。2年前の自分が残したビルドツリーに助けられた

夕方にPHPの情報を知って、その夜は大変な夜になる気がした。 保守を持っている企業さんは少ないが、すでに過去作ったサイトの管理者さんや、Web制作会社さんからどんどん増える問合せ。

この日は別件でコテンパンにやられていたので、暴食モード。バーガーキングのお出ましだ。ワッパーを食べてる間にも問い合わせは増え、そうこうしてる間に作業予定が30分単位で積み上がる。

ただまだ余裕はある。のそりと作業にはいる。

作業を進めて終わるかどうか、お客さんから連絡が来たのは深夜1:29だった。相当切羽詰まっている。このクライアントはプロジェクトや作業単位でスポットで対応しており保守は結んでいなかった。

「至急、PHP へのアクセスを遮断する緊急メンテにしてほしい。うちの PHP は古すぎてパッチが当たるかわからないけど・・・」

ハイレゾ音源を扱う EC-CUBE 2系のサイトで、PHP は 5.3.3。サポートが切れて何年も経っている。

結果から書くと、公式の修正を 5.3 向けに書き直して本番でビルドし、差し替えるところまでやった。ふさいだのは RCE の2件だけで、延命は延命だ。その過程で、2年前の自分にずいぶん助けられた。

何が起きていたのか

PHP2Shell と呼ばれている脆弱性は、CVE-2026-109618 と CVE-2026-109619 の2件。どちらも CVSS v4.0 で 9.2 で、ログインなしでサーバー上のコードを実行されるたぐいのものだ。

109618 はメモリ不足(OOM)のエラーで処理を抜けるときの問題。PHP は OOM になると longjmp で処理を飛ばす。すると準備の途中だったデータが、後片付けの段階で正しいものとして扱われてしまう。それがリモートコード実行につながる。

109619 は解放済みのメモリを指す状態が、リクエストをまたいで残る問題。同じワーカーが次のリクエストを処理するときに、それを使ってしまう。

10月20日に修正前の情報がリークし、21日に公式の修正版(8.2.35 / 8.3.36 / 8.4.27 / 8.5.12)が出た。悪用の試みも報告されていた。

問題は 8.1 以下だ。EOL なので公式のリリースはない。PHP Group は 7.4 / 8.0 / 8.1 向けに「最小限の対策」をソースパッチとして公開したが、5.x 向けには何も出ていない。 ※EOLとは「End of Life(エンド・オブ・ライフ)」の略、保守サポートやセキュリティアップデートの提供の完全停止を意味する。

公式の暫定策としては memory_limit を -1 にする、post_max_size を小さくする、session.upload_progress を無効にする、などが案内されていた。いずれも「完全な対策ではない」と明記されている。

暫定策が、このサイトでは使えない

まずはデザインを保ったままメンテナンス画面に切り替えた。勿論PHPでリクエストを受け取るのは意味が無いので、静的HTMLに302リダイレクトだ。

遮断はこれで済む。そのうえで、ホスティング会社の対応を待たずに自分で直せないかを調べ始めた。

環境は CentOS 6、Apache 2.2 の prefork + mod_php、PHP 5.3.3。暫定策を当てはめてみると、どれも効かないことがすぐわかった。

このサイトは扱うファイルが大きい。memory_limit は 15GB、post_max_size は 10GB に設定してあり、物理メモリも 15GB だ。

memory_limit=-1 にしても実質何も変わらない。post_max_size は商売の都合で下げられない。session.upload_progress は後述するが、そもそも 5.3 に存在しない。

ここで腹が決まった。正直滅茶苦茶怖いし大変そうだけど、自分でphp5.x版のパッチから修正部分を移植してこの環境(5.3.3用)のパッチを手動で作って当てるしかない。

前任者は誰だ

PHP の素性を調べるところから始めた。なぜなら、万が一通常の5.3.3バージョンではなく何らかの改変ビルドだった場合に、そのままパッチを当てると最悪今の環境が崩壊するからだ。

調べが進むと分かったのはRPM 版の PHP も入っていたが、Apache が実際に読んでいたのはソースからビルドされた libphp5.so ということだった。rpm -V で RPM のファイルが書き換わっていることを確認し、md5 を取るとビルドツリー内のものと一致した。

rpm -V php                 # RPM 由来のファイルが書き換わっていないか
md5sum <モジュールのパス>/libphp5.so <ビルドツリー>/libs/libphp5.so

調べていくと、ソースビルドの跡がいろいろ出てきた。/usr/local/src/php-5.3.3 のビルドツリー。configure だけして使わなかった /root/php-5.3.3。

curl のディレクトリに転がっていた PHP の tarball。RedHat の spec ファイルから写したような configure オプション。

日付は2024年...おかしいぞ?PHP5.3のEOLは2014年8月のはず

ここで推理する。10年前のPHP5.3.3本体を10年越しにビルドし直した...誰が何のために?

configure に --with-curl=/usr/local があり、curl 7.29 にリンクされている。CentOS 6 標準の curl は TLS 1.2 を話せない。決済や外部 API のために TLS 1.2 対応の curl を入れて、PHP を組み直したのだろう。ログに不審な点はない、筋は通る。

……

……

……で、思い出した。2024年といえばこの辺りにTLSの1.0と1.1がサポート切れ。この時期決済系で少しざわついて作業が立て込んだ時期だ。

この企業さん、常時保守の契約は結んでいなかったが、触った覚えがある。

そう、2024年11月に TLS 1.2 対応をやったのは自分だった。

前任者は自分だった。笑うしかないが、ここから話が速くなる。

修正を読んで、5.3 向けに書き直す

読んだのは2つ。公式が 8.1 向けに出した最小限の対策と、有志が GitHub で公開している 5.6 向けのバックポートだ。

5.6 向けのパッチをそのまま 5.3 に当てると、10 ハンク中 7 つが失敗した。ただ失敗の理由はコードの形が違うだけで、やるべきことは同じだった。なので 5.3 向けに書き直した。変更は 4 ファイル、約 70 行。

Zend/zend_alloc.c と .h では、OOM のときに zend_bailout(longjmp)で抜けるのをやめた。代わりに stderr に Fatal error を出して、_exit(1) で終了する。

109618 の本体はここだ。発想は「飛ばずにその場で死ぬ」というシンプルなもので、だから古い版にも移植できた。

main/rfc1867.c では、multipart の各パートで Content-Disposition と Content-Type 以外のヘッダを保持しないようにした。大量のヘッダでメモリを食わせる手口をつぶすためだ。

main/main.c では、リクエストの終了時に PG(http_globals) を NULL に戻す。109619 のほうの手当てになる。

パッチそのものは、この記事では配布していない。どのファイルの何を変えたかは上のとおりだ。読める人なら、公式のパッチと 5.6 向けのバックポートを見れば同じものが書ける。

意外だったのは、5.3 がいちばん楽だったこと。session.upload_progress は 5.4 で追加された機能で、5.3 には存在しない。109619 の確認済みの攻撃経路がもともとないのだ。

5.4 以降で必要になる「upload_progress を強制オフにする」手当ては要らなかった。古いことが、ここだけは味方した。

本番に入れる前に、手元の Ubuntu(gcc 13)で 5.3.29 をビルドして試した。古い PHP を今のツールチェーンでビルドするのは一仕事だった。autoconf 2.59 を自前でビルドし、パーサは bison 3.8 で手動生成する必要があった。本番でこれをやるのは避けたい、と思いながら進めた。

2年前のビルドツリーで、make 一発

本番ではこの手間が要らなかった。2年前に片付けずに残しておいたビルドツリーと config.status が、そのまま使えたからだ。

cd /usr/local/src/php-5.3.3
patch -p1 < php2shell-5.3.patch   # configure はやり直さない
make

configure をやり直さず、パッチを当てて make するだけ。「前回とまったく同じ設定で、修正だけ入ったバイナリ」が数分でできた。

configure をやり直さないことには、もうひとつ意味があった。前回のビルドで入れた curl 7.29 / OpenSSL 1.0.1e へのリンク、つまり TLS 1.2 対応を壊さずに済む。ここを巻き戻したら決済が止まる。

構築情報のメモも残していたはずだ。でもいちばん役に立ったのは、メモではなく「捨てずに残っていたビルド環境そのもの」だった。

ソースからビルドしたら、ビルドツリーは消さない。何年後かの自分が拾う。

本番に入れて、確認する

差し替えの手順はこうした。

  1. 稼働中の libphp5.so を .pre-php2shell.日付 という名前でコピーして残す。すぐ戻せるように
  2. 新しい .so を別名で置き、mv で入れ替える。このやり方にした理由は「やらかし」の節に書く
  3. Apache を graceful で再起動する
cp <ビルドツリー>/libs/libphp5.so <モジュールのパス>/libphp5.so.new
mv <モジュールのパス>/libphp5.so.new <モジュールのパス>/libphp5.so
apachectl graceful

稼働中の .so を cp で直接上書きしてはいけない。理由は後で書く。

確認したのは4つ。

やらかし

隠さず書く。4つある。

ひとつめ。稼働中の libphp5.so を cp で上書きしてから graceful したら、Apache の親プロセスがセグフォで落ちた。肝を冷やした。

サイトが約1分止まった。心臓も1分止まったかもしれない。(メンテ画面だから一応大丈夫だけど...)

ロード中の .so を同じ inode のまま書き換えたのが原因で、service httpd start で即復旧した。別名でコピーしてから mv する。2回目はこの手順でやり、graceful は正常に通った。上に載せた手順は、この失敗のあとの形だ。

ふたつめ。phpinfo の Build Date を新しくしたくて touch したファイルを間違えた。main/info.c ではなく ext/standard/info.c が正しい。

みっつめ。確認用に叩いた curl http://localhost/ が 500 を返して焦った。原因は CMS 側だった。ホスト名の不一致を検知してリダイレクトし、その直後に trigger_error で処理を止める作りになっている。Host ヘッダを付けたら 200 が返った。

パッチのせいだと思い込んで数分損した。

よっつめ。自分で入れたメンテナンス転送が httpd.conf と .htaccess の両方にあって、.htaccess だけ外しても転送され続けた。夜中に入れたものは、入れた場所を忘れる。

挙動が変わるところ

このパッチにはトレードオフがある。memory_limit を超えたとき、register_shutdown_function とデストラクタが走らなくなる。エラーは PHP の error_log ではなく stderr、つまり Apache の error_log に出る。

一方で prefork なので、落ちるのはそのリクエストを処理していたワーカー1つだけ。ほかのリクエストには影響しない。このサイトでは受け入れられる変化だった。

延命は延命

今どき PHP を手でビルドする人はいない。でも EOL の環境を抱えていると、PHP Group ですら「ソースパッチは置くので自分でビルドして」というスタンスになる。5.x に至っては、そのパッチすらない。

だから自分で書いた。修正の中身は longjmp をやめて即座に終了させるという単純な発想で、だから 5.3 にも移植できた。2年前の自分が残したビルドツリーがあったから、本番では make 一発で済んだ。

とはいえ、ふさいだのは RCE 2件だけだ。5.3.3 には他にも穴がいくらでも残っている。これは延命であって、直したわけではない。

私はそのことをお客さんに隠さず伝えて、サーバー移行の話をちゃんとする。緊急対応をやった人間が「これで大丈夫です」と言ってはいけない。そこが保守屋の線引きだと思っている。

ビルドツリーは消さない。延命は延命と言う。今回持ち帰ったのは、この2つだ。

フリーランスとはまさに傭兵。あらゆる戦場を生き抜くフルスタックエンジニアを目指して日々研鑽を続けます。

こんなエンジニアと仕事がしたい。相談をしたいと思ったらお問合せフォームから連絡頂けると嬉しいです。(宣伝)

まずは相談する