0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【小型装置のWeb UI・第4回】PHPのexecから装置を動かす前に ― C製コマンドとの境界を安全に作る

0
Last updated at Posted at 2026-08-08

exec()へ渡す文字列は、組み立て終わった時点で勝負がついています

第1回では、2行LCDしかない小型装置へWeb UIを足すときの役割分担を扱いました。第2回では応答の順序を、第3回では要求値と装置が返した値の分け方を扱いました。ここまではすべて、その画面が「装置の今」をどう見せるかという話です。

今回はその間にある境界そのものです。ブラウザから来たHTTP要求を、PHPがどうやって「許可済みの装置操作」へ翻訳するか。言い換えると、exec()の引数を組み立てるまでに何を済ませておくか、という話です。

この行が実行された時点で、安全かどうかはもう決まっています。

exec($command . ' 2>&1', $output, $exit_code);

だから見るべきはexec()ではなく、$commandがどう作られたかです。

この記事で分かること

  • 操作名をホワイトリストで受けるとはどういう形か
  • 数値をPHPとCの両方で検証するのは、なぜ冗長ではないのか
  • 実行対象のバイナリを固定する方法
  • 終了コードと標準出力をどう扱うか
  • タイムアウトが無いとどうなるか。そして「HTTPが失敗した」と「装置が動かなかった」は別物であること
  • Webサーバの実行ユーザーとデバイス権限の考え方
  • 都度起動方式と常駐デーモン方式の分かれ目
  • このサンプルが製品ではない理由

小型装置WebUI_④PHP_exec境界の7つの関門_icon.png

関門1: 操作名はホワイトリストで受けます

まず、ブラウザから来た文字列がそのまま動詞になってはいけません。公開サンプルのAPIはこう書いています。

$method = strtoupper($_SERVER['REQUEST_METHOD']);
$action = request_value('action', 'status', $method);

if (($action === 'status' && $method !== 'GET')
    || ($action !== 'status' && $method !== 'POST')) {
    header('Allow: ' . ($action === 'status' ? 'GET' : 'POST'));
    fail_response(405, 'method not allowed for action');
}

$command = escapeshellarg($binary);

if ($action === 'status') {
    $command .= ' status';
} elseif ($action === 'start') {
    $command .= ' start';
} elseif ($action === 'stop') {
    $command .= ' stop';
} elseif ($action === 'set_level') {
    // 後述
} else {
    fail_response(400, 'unknown action');
}

素朴に見えますが、この形には意味があります。$actionの値はどこにも連結されていません。連結されるのは' status'のようなこのファイルに書いてあるリテラルだけです。$actionは「どのリテラルを選ぶか」の分岐条件にしか使われていません。

もうひとつ、状態取得のstatusはGET、状態を変えるstartstopset_levelはPOSTに限定しました。HTTPメソッドだけで認証やCSRF対策になるわけではありません。それでも、URLを開いただけ、リンクを先読みしただけ、といった読み取り操作の形で装置状態が変わる入口は残さないほうがよいと判断しました。GETとPOSTの両方から同じ値を拾う書き方も避け、要求されたメソッド側だけを読みます。

配列で引くやり方も同じ効果になります。

$table = array('status' => 'status', 'start' => 'start', 'stop' => 'stop');
if (!isset($table[$action])) {
    fail_response(400, 'unknown action');
}
$command .= ' ' . $table[$action];

どちらでも構いませんが、避けたいのは次のような書き方です。

// これは書かない
$command .= ' ' . escapeshellarg($action);

エスケープしているので一見安全に見えます。実際シェルの解釈は防げます。しかし防げていないのは、device_ctlが将来増やすサブコマンドすべてが、その瞬間からWeb経由で呼べるようになることです。保守用のdump-registersfactory-resetを足した翌日に、外から呼べる操作が2つ増えている。エスケープはシェルの問題を解きますが、権限の問題は解きません。

HTTPのset_level(アンダースコア)とCLIのset-level(ハイフン)が違うのは誤記ではありません。PHPがHTTPの語彙を装置操作の語彙へ翻訳している、という境界の存在をそのまま形にしています。片方の名前を変えるときにもう片方が自動で追従しないのは、むしろ望ましい性質です。

関門2: 数値は型で受けて、範囲まで見ます

set_levelだけは外から値が来ます。

$raw_level = request_value('value', '', $method);
$level = filter_var($raw_level, FILTER_VALIDATE_INT, array(
    'options' => array('min_range' => 0, 'max_range' => 100)
));
if ($level === false) {
    fail_response(400, 'value must be an integer from 0 to 100');
}
$command .= ' set-level ' . (string)$level;

ポイントは、連結しているのが$raw_levelではなく$levelだという点です。$levelfilter_varを通ったintなので、この時点で「0以上100以下の整数」以外ではあり得ません。文字列として連結し直していますが、中身は数字だけです。

is_numeric()ではなくFILTER_VALIDATE_INTを使っているのは、is_numeric()"1e2"" 12""0x1A"(PHPのバージョンによる)を通してしまうためです。装置へ送る値の妥当性を、文字列の見た目で判断しないほうが安全です。

C側でも同じ検証をします

device_ctlは、PHPが検証済みの値を渡してくると分かっていても、自分でもう一度確かめます。

static int parse_level(const char *text, int *value) {
  char *end = NULL;
  long parsed;

  errno = 0;
  parsed = strtol(text, &end, 10);
  if (errno != 0 || end == text || *end != '\0' || parsed < 0 || parsed > 100) {
    return -1;
  }
  *value = (int)parsed;
  return 0;
}

これは冗長でしょうか。冗長ではありません。理由は2つあります。

ひとつは、device_ctlがPHPからしか呼ばれないという保証がないことです。保守の人はシェルから直に叩きますし、起動スクリプトから呼ぶこともあります。装置を動かすプログラムが、呼び出し元の行儀を前提にして良いことはありません。

もうひとつは、検証の意味が層ごとに違うことです。PHPの検証は「不正な要求を400で返す」ためのもので、返すのはHTTPのステータスコードです。Cの検証は「装置へ範囲外の値を書かない」ためのもので、返すのは終了コードです。同じ0〜100を見ていても、守っている対象が違います。

関門3: 実行するバイナリを固定します

$binary = realpath(__DIR__ . '/../../bin/device_ctl');
if ($binary === false || !is_executable($binary)) {
    fail_response(503, 'device_ctl is not built or executable');
}
$command = escapeshellarg($binary);

device_ctlとだけ書いてPATHに任せると、実行されるものがWebサーバの環境変数に依存します。realpath()で絶対パスへ解決し、is_executable()で存在と実行権を確かめ、escapeshellarg()で包む。少なくともパスに空白が含まれていても、1つの引数として渡せます。日本語を含むパスは、実機のロケールを含めて確認してください。

realpath()falseを返したときに500ではなく503を返しているのは、これが「要求が悪い」のではなく「装置側の準備ができていない」状態だからです。make setupをしていないだけのことが多く、呼び出し側が要求を作り直しても直りません。

関門4: 終了コードと標準出力を分けて考えます

device_ctlは終了コードを使い分けています。0が成功、2が引数不正、3が状態ファイルを開けない、4が読み込み失敗、5が書き込み失敗です。PHP側はまず0かどうかだけを見ます。

exec($command . ' 2>&1', $output, $exit_code);

if ($exit_code !== 0) {
    error_log('device_ctl failed: ' . implode("\n", $output));
    fail_response(500, 'device operation failed');
}

$json = implode("\n", $output);
$decoded = json_decode($json, true);
if (!is_array($decoded) || !isset($decoded['ok'])) {
    fail_response(502, 'device_ctl returned an invalid response');
}

echo $json;

ここには意図的な選択が2つと、割り切りが1つあります。

意図的な選択その1は、終了コードが0でもJSONとして読み直していることです。json_decode()が失敗したら502を返します。502は「上流のサーバが不正な応答を返した」という意味なので、Cプログラムを上流と見なすこの構図に合っています。0で終わったのに壊れたJSONが出てくるのは、device_ctl側のバグか、標準出力に何かが混ざったときです。どちらもブラウザへそのまま流すべきではありません。

意図的な選択その2は、$decodedを捨てて$jsonをそのままechoしていることです。デコードは検証のためだけに行い、出力は元の文字列を使います。再エンコードすると、PHPのJSONエンコーダの癖(数値の型、スラッシュのエスケープ、キーの順序)が入り込みます。装置が返したものを、途中で書き換えずに届けたい。

割り切り2>&1です。標準エラー出力を標準出力へ混ぜています。成功時にCがうっかり標準エラーへ何か書くと、それがJSONの前後へ混入して502になります。第2引数の$outputは行の配列なので、切り分けたければproc_open()でパイプを分けるのが正攻法です。

失敗時の詳細をHTTP応答へそのまま載せるのも避けました。perror()の出力には、実機のパス、デバイス名、権限状態が含まれることがあります。詳細はWebサーバ側のログへ残し、ブラウザには固定文言だけを返します。

if ($exit_code !== 0) {
    error_log('device_ctl failed: ' . implode("\n", $output));
    fail_response(500, 'device operation failed');
}

教材としても、利用者へ見せる情報と保守者がログで見る情報は分けておく方が、実機へ移すときに境界を崩さずに済みます。

関門5: タイムアウトが無いことを、正直に見ます

このサンプルの一番弱いところです。exec()は子プロセスが終わるまで待ちます。上限はありません。

device_ctl/tmpの状態ファイルを触るだけなら、まず止まりません。しかし実機ではこの先にSPIやI2Cの転送、デバイスの応答待ちがあります。デバイスが応答を返さなければ、device_ctlはそこで止まり、PHPプロセスも止まり、php-cgiのワーカーが1つ塞がります。要求が続けば全部塞がって、Web UIそのものが応答しなくなります。装置が1つ黙っただけで、装置の様子を見るための画面まで見えなくなるわけです。

第2回でブラウザ側にtimeout: 2000を入れましたが、あれはブラウザが待つのをやめるだけで、サーバ側のプロセスには何も起きません。ブラウザから見えなくなったプロセスが裏で生き続けます。

対処は2通りあります。

外側から縛るなら、coreutilsのtimeoutコマンドで包みます。

$timeout = '/usr/bin/timeout'; // 配置は実機で確認して固定する
$command = escapeshellarg($timeout) . ' 5 ' . escapeshellarg($binary) . ' status';

GNU coreutilsのtimeoutが打ち切ると、通常は終了コード124が返るため、PHP側で「装置が時間内に応答しなかった」として504へ変換できます。ただし、BusyBoxではアプレットの配置、オプション、終了コードがビルド構成や版によって異なります。実機では絶対パスと--helpだけでなく、実際に打ち切ったときの終了コードも確かめてください。

内側から縛るなら、proc_open()でパイプを開き、stream_select()で待ち時間の上限を持たせて、超えたらproc_terminate()します。コードは増えますが、標準出力と標準エラーを分けられるので関門4の割り切りも同時に解けます。

そして、どちらにしても残る問題があります。

打ち切ったあと、装置がどうなっているかは分かりません

これはこの回で一番言いたいことです。

タイムアウトで打ち切ったとき、あるいはHTTPが500を返したとき、ブラウザ側で起きているのは「結果を受け取れなかった」ことだけです。装置が操作されたかどうかは、別の話です。状況は3つに分かれます。

状況 装置 ブラウザから区別できるか
未適用 操作は届いていない。値は変わっていない できない
適用済みだが応答だけ届かなかった 操作は完了している。値は変わっている できない
どちらか分からない これだけが言える

ブラウザが名乗ってよいのは3つ目だけです。 「操作に失敗しました」と書くと1つ目を主張したことになりますが、その根拠はどこにもありません。だから第3回のサンプルでは、失敗時の表示をこうしています。

操作結果を確認できませんでした。装置状態を再取得します。

そして.always()refreshStatus(true)を呼び、装置へ聞き直します。ここで返ってきた値が唯一の事実です。自分が送った値でも、送れなかったという事実でもありません。

再取得もできないときは、第2回の鮮度表示(2回連続失敗でstale)へ落ちます。値は消さず、信用度だけを下げる。装置が止まっているのか通信が切れているのかを読者が読み分けられるように、取得時刻を常に出しておきます。

「HTTP失敗=装置は動いていない」と書いた画面は、動いてしまった場合に嘘をつきます。しかも嘘をついたことに気づけません。操作が届いたかどうかを知る方法は、装置へ聞き直すこと以外にありません。

冪等性のある操作(set-level 42は何度送っても結果が同じ)なら、再送で解決できます。冪等でない操作(「カウンタを1増やす」「ログを1件追加する」)を作るときは、要求側でIDを振って装置側で重複を弾く仕組みが要ります。このサンプルの操作はすべて冪等なので、そこまでは踏み込んでいません。

実行ユーザーとデバイス権限

lighttpdとphp-cgiは、たいていwww-dataのような専用ユーザーで動きます。そのユーザーが/dev/spidev0.0/dev/i2c-1を開ける必要があります。

やり方はいくつかありますが、選び方の順序ははっきりしています。

まず、device_ctlにsetuid rootを付けるのは避けます。Web経由で到達できるバイナリにroot権限を持たせると、そのバイナリの引数解釈のバグがそのままroot権限の穴になります。関門1〜3をどれだけ丁寧に作っても、setuidを1つ付けた時点で守るものの重さが変わります。

現実的なのは、デバイスファイルのグループを専用グループにして、そのグループへWebサーバのユーザーを入れることです。udevのルールでGROUP="device", MODE="0660"のように指定します。root権限を与えるより範囲を限定できますが、そのグループへ割り当てたデバイスにはWebサーバの実行プロセスから到達できます。専用グループへ含める対象も最小限にします。

もう一段固めるなら、デバイスを触るのを常駐デーモンだけにして、WebサーバとはUNIXドメインソケットで話します。デバイス権限を持つのはデーモンだけ、Webサーバはソケットへの接続権だけ、という分け方です。次の節の話につながります。

排他は、すべての操作が必ず通る場所に置きます

第3回でも触れましたが、ここで整理しておきます。公開サンプルの排他はC側にあります。

if (flock(fd, LOCK_EX) != 0) {

PHP側ではありません。理由は、装置を操作する経路がWebだけではないからです。LCDの物理ボタンから呼ばれることもあれば、保守の人がシェルから叩くこともあります。PHPに鍵を置くと、PHPを通らない経路が全部素通りします。

鍵は、すべての操作が必ず通る一番内側へ置きます。 このサンプルではそれがdevice_ctlでした。実機で常駐デーモン方式にするなら、鍵はデーモンの中へ移ります。どちらにしても「Webの都合で決めた場所」ではありません。

小型装置WebUI_⑤都度起動と常駐デーモン_icon.png

都度起動と常駐デーモンの分かれ目

このサンプルは都度起動です。要求が来るたびにdevice_ctlをfork/execします。

都度起動の利点は、状態を持たないことです。プロセスが終われば後片付けは要りません。メモリリークは1回の実行で終わり、異常終了しても次の要求は新しいプロセスで始まります。監視も再起動も不要です。小さな装置で、操作の頻度が低く、初期化が軽いなら、これで十分足ります。

不利になるのは次の場合です。

初期化が重いとき。 キャリブレーション値の読み出し、FPGAのコンフィグレーション、大きなテーブルの構築。1回の操作のためにこれをやり直すのは無駄です。

デバイスを開きっぱなしにしたいとき。 デバイスによっては、オープンのたびにリセットがかかったり、初期化シーケンスを要求したりします。開閉そのものが装置の状態を動かすなら、開き続ける主体が要ります。

装置側に状態を持つとき。 転送の途中経過、リトライの回数、直前の値。プロセスが消えると消えてしまうものを持つなら、消えない場所が要ります。

呼び出し頻度が上がったとき。 第2回のポーリング周期の話とつながります。1秒ごとにfork/execと動的リンクと初期化を繰り返すのは、装置のCPUに対して安い買い物ではありません。周期を詰めたくなったら、まず都度起動のままで良いかを疑ってください。

一方、常駐デーモンにすると必ず増えるものがあります。プロセスの監視と再起動(BusyBoxなら/etc/inittabrespawn)、IPCの設計(ソケットのパス、権限、メッセージの形式)、そしてデーモン自身のリークや状態破損への備えです。都度起動でOSに任せていた後片付けを、自分で書くことになります。

私の見方はこうです。まず都度起動で作り、周期を詰めたくなったときか、デバイスを開き続けたくなったときに移す。 最初から常駐にすると、動かないときの切り分け先が増えます。ただし移行のしやすさだけは最初から確保しておきます。具体的には、device_ctlの中で「引数を解釈する部分」と「装置を触る部分」を関数として分けておくことです。デーモン化するときに、前者をIPCの受け口へ差し替えれば済みます。

製品にするなら、ここから先が要ります

公開サンプルのAPIには、ファイルの先頭にこう書いてあります。

/*
 * 教材用APIです。認証・TLS・CSRF対策は含みません。
 * 信頼できるローカル環境以外へ公開しないでください。
 */

このシリーズの範囲は「社内LANの中の装置を、同じLANのPCから操作する」ところまでです。製品として出すなら、少なくとも次が要ります。

  • 認証。 誰が操作したのかが分からないと、装置の状態が変わった理由を後から追えません。
  • TLS。 平文のHTTPは、同じLANの中でも覗けます。組み込み機器では証明書の配布と更新が現実的な難所になります。
  • CSRF対策。 操作系のPOSTが対象です。トークンをフォームへ埋めてサーバで照合します。
  • レート制限。 都度起動方式は、要求の連打がそのままプロセス生成の連打になります。
  • 監査ログ。 いつ、どこから、どの操作が来て、装置がどう応じたか。装置の不具合報告を受け取ったときに、最初に見る場所です。

このどれもが、この記事で扱った関門1〜5とは独立に必要です。入力を丁寧に検証していても、誰でも叩ける状態なら守れているのは装置の壊れ方だけです。

確認した範囲

この記事のコードは、公開サンプルembedded-web-control-demoの実物です。

確認日は2026-08-06です。環境はLinux(Ubuntu 24.04ベース)、PHP 8.4.21の内蔵サーバ、gcc 13.3.0です。C側は-std=c11 -Wall -Wextra -Wpedantic -O2で警告0、make teststatusset-levelstartstopの順に期待どおりのJSONを返すことを確認しています。
次は未確認です。lighttpd+php-cgi構成、組み込み実機(BusyBoxルートファイルシステム)、timeoutコマンドやproc_open()によるタイムアウト付き実行、udevのグループ設定、そして常駐デーモン方式の実装。この記事のうちタイムアウトと権限と常駐化の節は、サンプルへまだ入っていない設計の話です。

まとめ

  • 入力を許可済みの操作へ絞り込めるかは、$commandを組み立て終わった時点でほぼ決まります。見るべきはexec()の行だけではなく、その手前です。
  • 操作名はホワイトリストで受けます。エスケープはシェルの問題を解きますが、「呼べる操作が増える」問題は解きません。
  • 数値はPHPとCの両方で検証します。同じ範囲を見ていても、守っている対象が違うので冗長ではありません。
  • 実行対象はrealpath()で固定します。PATHに任せません。
  • 終了コードが0でも、返ってきたJSONは読み直します。壊れていたら502です。
  • タイムアウトを持たないexec()は、装置が黙ったときにWeb UIごと巻き込みます。
  • HTTPの失敗を、装置操作の不成立と言い換えないでください。 ブラウザが言えるのは「確認できなかった」だけです。事実は装置へ聞き直して得ます。
  • 排他は、すべての操作が必ず通る一番内側へ置きます。Webの都合で場所を決めません。
  • 都度起動で始めて、周期かデバイスの都合で必要になったら常駐へ移します。移せるように関数を分けておきます。

境界を安全にするというのは、危ない関数を避けることではありません。外から来た値が、どこで「許可済みの操作」へ変わるのかを、1か所に集めて言い切れる状態にすることです。

次回は、その境界の向こう側へ入ります。device_ctlの中で、アプリケーション層、ファームウェア層、HAL層、デバイスドライバがどう分かれているか。そして、その分け方を守った場合と崩した場合で、ひとつの変更がどこまで波及するかを比べます。

関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?