はじめに
こんにちは。Shizen Connectの佐藤です。
私は現在エネルギーマネジメントシステムを支えるエッジ端末「Shizen Box」を開発しています。
一般的にエッジ端末(IoTデバイス)へのペネトレーションテストは、アプリケーション層だけではなく、カーネル、ブートローダー、ファームウェアまでが指摘対象になり得ると思います。
そのときに、自分たちでフルスタックに対応できる開発環境があるかが重要になるので、今回は環境構築で直面したrootfsのGit管理の失敗を交えながら、ペンテストに対応できる開発環境についてお話します。
昨年、Shizen Boxは第三者ペネトレーションテスト(以下、ペンテスト)を実施しました。
ペンテスト対応のための開発環境の構築には、以下の要件がありました。
- 第三者ペネトレーションテストで指摘される全レイヤの脆弱性に対応できること
- キッティングから運用まで、一貫して自分たちで責任を持てる環境を構築すること
- 属人性を排除してチームレビュー可能な環境とすること
結果として、私が考えていた理想的な構成よりも、実運用で最後まで自分たちが責任を持てる構成を優先しました。これにより、より実践的な開発環境を構築できました。
Shizen Boxの役割
Shizen Box は、エネルギーマネジメントシステムにおいて、太陽光発電や蓄電池などの複数のプロトコル機器との接続・連系制御を担うエッジ端末です。
クラウドと現場機器の橋渡し役として、ECHONET LiteやModbusなどの多様なプロトコルを介した確実な機器制御と安全性の両立が求められます。
Shizen Boxのアーキテクチャ
5つのレイヤ構成
Shizen Box は、典型的な IoT / エッジデバイスと同様に、複数レイヤで構成されています。
| レイヤ | 役割 | 管理の必要性 |
|---|---|---|
| application | 機器制御、クラウド連携サービス | 機能追加の頻度が最も高い |
| rootfs | ユーザー空間の実行環境 | パッケージ脆弱性対応、設定変更 |
| Linux kernel | ハードウェア制御、デバイスドライバ | カーネル脆弱性対応、ドライバ調整 |
| U-Boot | ブートローダー | 起動プロセス制御 |
| Module FW | WiFiハードウェアモジュールのファームウェア | ハードウェア連携の安定性確保 |
これらに対してペネトレーションテストや障害対応を見据えると、自分たちのサービスレイヤ(application)だけを触れる状態では不十分で、すべてのレイヤを把握・改善できる開発環境が必要でした。
本記事における「フルスタック」の定義
IoT/組み込み開発における「フルスタック」は、Webアプリケーション開発のフロントエンド〜バックエンドという意味ではなく、ハードウェア層からアプリケーション層までの全レイヤを指します。
本記事では、Module FW(物理層)からapplication(アプリケーション層)までの5層を「フルスタック」と呼んでいます。
特にペンテストでは、アプリケーション層だけでなく、カーネルやブートプロセスに関する指摘を受ける可能性があり、それらに迅速に対応するためにはフルスタックでの対応力が求められました。
開発環境の設計判断
理想として考えていた構成(Yocto / Poky)
一般的な組込みLinuxの開発において、理想的には Yocto Project / Poky を用いた構成を目指します。
なぜなら、Yoctoには以下の利点があるからです。
- レイヤとレシピによる依存関係管理
- BitBakeによる自動ビルド
- 誰がいつどの環境でビルドしても同一のrootfsを生成できる再現性
私自身これまでの経験から、今後の長期運用や将来のCVE(Common Vulnerabilities and Exposures:共通脆弱性識別子)対応を考えるとこの構成が理想でした。
Yocto Project
組み込みLinux向けのカスタムディストリビューションを構築するためのオープンソースプロジェクトです。Poky はその参照実装(リファレンスディストリビューション)で、実際の開発ではPokyをベースにカスタマイズするのが一般的です。ビルドシステムにはBitBakeを使用し、各ソフトウェアの取得・設定・ビルド手順は「レシピ」と呼ばれるファイルに記述します。
制約条件
しかし、理想的なYocto / Poky構成をそのまま採用することには、当時の開発において以下の制約がありました。
- 既存成果物としてrootfsとそのビルド環境のみが存在した
- ビルド専用の高性能マシンを前提にできない
- Yoctoの学習コストがチーム全体での即応性を妨げる可能性があった
結果として、完全自動化よりも障害発生時に自分たちで改善できる構成を優先することにしました。
採用した構成
Shizen Boxでは、以下の運用重視の構成を採用しています。
- 各コンポーネント(application, kernel, U-Boot等)を独立したGitHubリポジトリで管理
- kernel / U-Boot は rootfs リポジトリの submodule として参照
- application / module-fw は独立したリポジトリで管理し、ビルド後のバイナリを rootfs へインストール
- 組み合わせ(スナップショット)でビルド状態を確定
rootfs(親リポジトリ)
├── application (独立リポジトリ) → ビルド後バイナリをインストール
├── linux-kernel (submodule) → カーネルイメージを配置
├── u-boot (submodule) → ブートローダーを配置
└── Module FW (独立リポジトリ) → ビルド後バイナリをインストール
この構成により、以下の運用上の利点を得られました。
- 属人性を排除してチームレビューできる(コンポーネントごとにPull Request)
- 特定レイヤをブラックボックス化せずフルスタックで対応できる
- 障害時にどこまで戻すか(どのsubmoduleを巻き戻すか)即断できる
実装で直面した課題と解決
rootfsをGitで管理する問題
理想と現実のギャップが最も明確に問題として顕在化したのが、rootfsの扱いでした。
環境構築を開始した当初、実機へ展開するrootfsをそのままGitで構成管理しようとした結果、展開後の実機で以下の問題が発生しました。
setuidビット消失によるsudo不能
setuidビットは、実行ファイルを実行したユーザーではなく、そのファイルの所有者の権限で実行させるための特殊設定です。
例えば/usr/bin/sudoにはroot所有のsetuidビットが設定されており、一般ユーザーが実行してもroot権限で動作します。
正常な実機上でsetuidは以下で確認できます。
$ ls -l /usr/bin/sudo
-rwsr-xr-x 1 root root ... /usr/bin/sudo
↑ ここが通常はs
これに対してrootfsをGit管理すると以下のようにsetuidビットが消失してしまいます。
$ ls -l /usr/bin/sudo
-rwxr-xr-x 1 root root ... /usr/bin/sudo
↑ sではなくxになってしまう
この状態でsudo実行した場合、以下のように実行できません。
$ sudo id
sudo: /usr/bin/sudo must be owned by uid 0 and have the setuid bit set
この結果、Git上でパーミッションが壊れたrootfsを実機へ展開した際に管理者昇格が機能しなくなります。
なぜGitで壊れるか
Gitはプログラムのソースコードなど変更履歴を記録・追跡するための分散型バージョン管理システムです。
保持できるのは:
- 実行ビット
- ファイル内容
保持できないのは:
- 詳細パーミッション(600 / 644など)
- setuid / setgidビット
- uid / gid
- デバイスノード
- 特殊ファイル属性
このため、ターゲットのrootfsをまるごとGitで構成管理することはできないことが分かりました。
Gitの内部構造による制限
Gitの内部の仕組み(Treeオブジェクト)に関するドキュメントにおいては、ファイル(blob)に対して保持されるモードが厳格に限定されていることが説明されています。
- ファイルに指定されるモードは通常のUNIXのモードから取られていますが、UNIXのモードに比べてはるかに柔軟性が低く設定されています
- Gitにおいて、ファイル(blob)に対して有効なモードは以下の3つだけです
-
100644: 通常のファイル -
100755: 実行可能ファイル -
120000: シンボリックリンク
-
※これら以外に、ディレクトリの 040000 やサブモジュールの 160000 などのモードが使用されることもあります。
ドキュメントにある通り、Gitがファイルに対して記録できるモードはこの3種類のみに制限されているため、UNIXシステムで利用できるsetuidなどの細かい権限設定は保存されない仕組みになっています。
rootfsは「ファイル集合」であると同時に「属性集合」でもあるため、Gitでrootfsを完全再現するには不向きであることが分かりました。
方針転換:rootfsを成果物として扱わない
この経験から、方針を以下のように切り替えました。
- 実機に展開する rootfs 自体は Git 管理しない
- ベースとなる rootfs.tar.gz(ベンダー提供またはビルド済み)を展開
- その上に kernel / U-Boot / Module FW / application を重ねる
つまり、Git で管理するのは「rootfsの中身」ではなく、「rootfsを再現するための手順(スクリプト・設定ファイル)」としました。
これには以下のメリットがあります。
- 意図しないパーミッション崩壊を防げる
- リポジトリサイズ問題を回避できる
- 同一手順で再現可能な rootfs を構築できる
この方針転換で構築した結果、現時点では十分安定して運用できています。
rootfs再現の具体的な手順
実際のrootfs構築は、以下のような手順で行っています。
-
ベースrootfsの展開
# ベンダー提供またはビルド済みのrootfs.tar.gzを展開 tar xzf rootfs.tar.gz -C ./build/rootfs/ -
クロスアーキテクチャ環境の準備
x86ホスト上でARM用rootfsを操作するため、qemu-arm-static(ARM命令セットをx86上でエミュレートする静的バイナリ)を使用します。
事前に
qemu-user-staticパッケージをインストールします。このパッケージはbinfmt_miscの登録まで自動で行います。sudo apt install qemu-user-staticbinfmt_misc とは
Linuxカーネルの機能で、特定のバイナリ形式を認識して指定のインタープリタで透過実行させる仕組みです。ここではARM ELFバイナリをqemu-arm-static経由で実行するよう登録されます。chroot内でbashがopkgなどの子プロセスを呼び出す際にも、この設定があることで自動的にqemu経由で実行されます。# qemu-arm-staticを配置 cp /usr/bin/qemu-arm-static ./build/rootfs/usr/bin/ # rootfs構築用のfixupスクリプトを配置 cp scripts/fixup.sh ./build/rootfs/tmp/ -
chrootでrootfs内に入り、パッケージ展開を実行
# chrootしてARM環境として実行 sudo chroot ./build/rootfs /usr/bin/qemu-arm-static /bin/bash /tmp/fixup.sh -
fixupスクリプト内での処理
#!/bin/bash # fixup の例 # ipkパッケージ(opkg用のパッケージ形式)のインストール # opkgは組み込みLinux向けの軽量パッケージマネージャ opkg install --force-reinstall --force-overwrite /tmp/packages/*.ipk -
ホスト側でのドライバ展開とdepmod
chroot内では
uname -rがホストのカーネルバージョンを返すため、Module FWドライバの展開とdepmodはchroot外(ホスト側)から実行します。depmodの-bオプションでrootfsのパスを指定することで、実機に展開する前にホスト上でモジュール依存関係を解決できます。# rootfs内のカーネルバージョンを取得 KERNEL_VERSION=$(ls ./build/rootfs/lib/modules/ | head -1) # Module FWのドライバをrootfsへ展開 sudo cp -r module-fw/drivers/* ./build/rootfs/lib/modules/${KERNEL_VERSION}/ # ホスト側からrootfsに対してdepmodを実行 sudo depmod -a -b ./build/rootfs ${KERNEL_VERSION} -
後処理とクリーンアップ
# 作業用ファイルの削除 sudo rm ./build/rootfs/usr/bin/qemu-arm-static sudo rm ./build/rootfs/tmp/fixup.sh
以上の方法で、x86ホスト上でARM用rootfsを構築しながら、パーミッションやパッケージ管理を正しく維持しています。qemu-arm-staticを使うことで、x86ホスト上でARMバイナリをエミュレーション実行できるようになり、chrootした環境内でネイティブのパッケージマネージャ等を利用可能としています。
開発環境を支える2つのビルドパス
なぜ2系統のビルドパスが必要か
前述した5つのレイヤを管理する上で、開発環境には以下の2つの異なる目的があります。
- 運用アップデート:即応性・影響範囲を限定して素早く脆弱性改善や機能向上する(差分構成)
- キッティング:ブランクまたは破損状態から初期状態を作る(フル構成)
ペンテスト対応では、指摘された脆弱性に対して迅速に修正・検証・デプロイするサイクルが求められます。
そのため、全体を再構築するキッティング用のパスと、差分更新可能な運用アップデート用のパスを分けることで、目的に応じた最適な対応が可能になります。
2つのビルドパスの比較
| 観点 | パス①(運用アップデート) | パス②(キッティング) |
|---|---|---|
| 目的 | 差分更新・迅速対応 | 初期状態構築 |
| 出力形式 | ipkパッケージ群 | SDイメージ |
| 配布方法 | クラウド経由 | SDカード |
| 影響範囲 | 更新対象コンポーネント | 全レイヤ |
| 使用タイミング | 脆弱性修正・機能追加 | 初期キッティング・全体リカバリ |
データフロー概要
以下は、Shizen Box におけるキッティングと運用中のShizen Boxへのリモートアップデートのデータフロー概要です。
図:キッティング(左)と運用アップデート(右)の2系統のビルドパス
パス① 運用アップデート用バイナリの生成と配布
運用アップデートでは、application リポジトリ内で管理している Docker 環境を起点にビルドします。
- GitHub から各コンポーネントのリポジトリを取得
- Docker 上でクロスコンパイルを行い、リモートアップデート用のバイナリを生成
- 生成したバイナリをクラウドへ登録
- クラウド側(デプロイサーバー)の仕組みによりShizen Box へ自動的に配布・更新
ipkパッケージの生成方法
ipkはopkg(組み込みLinux向けの軽量パッケージマネージャ)用のパッケージ形式です。ビルド環境は用途によって使い分けています。
- applicationのビルド: Docker環境でクロスコンパイルし、ipkパッケージを生成
- 汎用パッケージのビルド: pokyのBitBakeを使用してipkパッケージを生成
# 汎用パッケージのビルド例
source poky/oe-init-build-env
bitbake <package-name>
# 生成されたipkファイルを取得
cp build/tmp/deploy/ipk/armv7a/*.ipk ./packages/
このように、Dockerによる迅速な開発と、pokyによる再現性の高いビルドの恩恵を組み合わせたハイブリッドな構成としています。
このパス①の経路では、以下を重視しています。
- 差分更新による影響範囲の限定
- 障害時の切り分け容易性(どのコンポーネントが原因かを特定しやすい)
- 運用中のShizen Boxへの脆弱性改善の迅速なデプロイ
パス② キッティング用 SD ブートイメージの生成
キッティング時には、運用時とは別のビルド環境を使用します。
- ビルド環境で、各リポジトリを submodule として取得
- application / kernel / U-Boot / Module FW を rootfs に展開
- パッケージ・設定を含めた SD ブート用イメージを生成
- 生成した SD イメージを SD カードへ書き込み
- Shizen Box に挿入して初期状態を構築
このパス②の構成により、以下を達成しています。
- 初期状態の信頼性(全レイヤを含む完全な状態)
- 運用中アップデートの可視性(何が変更されたか追跡可能)
- レイヤ横断での調査容易性(問題発生時に全レイヤを調査可能)
実施した第三者ペネトレーションテストにおいても、このフルスタック開発環境を前提に application / rootfs / kernel / u-boot を跨いだ対策と脆弱性改善が可能となりました。
おわりに
理想とする技術選定よりも、問題が浮上したときにチームで責任を持って対応できること。
Shizen Box の開発環境は、その問いに対する組み込みLinux開発における現実的な解の一つだと考えています。
Yoctoの完全自動化よりも運用時の即応性と可視性を優先した結果、submodule方式とrootfs再現手順の管理によって、ペンテスト対応に必要なフルスタック対応力を確保できました。
次回以降は、これらの環境を使ってのペネトレーションテスト対策と、指摘対応とCVE登録までの実施内容を書きたいと思っています。
運用重視の開発環境構築に取り組んでいる方の参考になればと思います。
