✦urchin_hat
URCHIN_HAT / TECHNICAL FIELD NOTES

TECH MEMOTECH MEMO

SRE、クラウド、AI、DevOps。
観測したこと、試したこと、考えたことを記録する技術アーカイブ。

ウニの帽子をかぶり、ノートに技術メモを書くキャラクター
OBSERVATION LOG 50 ENTRIES
# FIELD NOTES

TECH MEMO TOPICS

01 / RELIABILITY SRE &
RELIABILITY
02 / PLATFORM CLOUD &
PLATFORM
03 / AUTOMATION AI &
AUTOMATION
Dockerのroot問題と向き合って学んだ、最小特権のコンテナ設計 TECH MEMO / FIELD NOTE

Dockerのroot問題と向き合って学んだ、最小特権のコンテナ設計

前回のインジェクション検証から、権限設計へ進む理由

前回の記事「Dockerで学ぶSQL・コマンドインジェクションの仕組みと対策」では、利用者の入力値が意図せず命令として解釈される原因と、アプリケーション側の対策を整理しました。あわせて、万が一コードの修正漏れやすり抜けが生じた場合の多層防御として、コンテナの非root実行やファイルシステムの読み取り専用化を取り上げています。

今回は、そのコンテナにおける最小特権化の効果を、ローカルの実験環境で段階的に確かめます。

きっかけ:Security HubのECS警告から生じた疑問

今回コンテナの権限設計を深掘りしようと考えた直接のきっかけは、業務でAWS ECSのタスク定義を運用している際、AWS Security Hubから「[ECS.20] ECSコンテナは非rootユーザーとして実行する必要がある」という警告を受けたことでした。

コンテナイメージ側のDockerfileで USER を指定しているつもりでも、ECSタスク定義のコンテナ定義側で明示的に user を指定していないと、Security HubのECS.20では非準拠(FAILED)として検出されます。評価対象はタスク定義のコンテナ定義であり、Dockerfileの USER は評価されません。ECS.20の判定条件

この警告を見て、最初は「タスク定義にユーザーIDを追記すれば消える設定作業」くらいに受け止めていました。しかし、前回のインジェクション対策を振り返る中で、「そもそも非root化するだけで何が防げて、何が防げないのか」「Linuxのcapabilitiesやファイルシステムの読み取り専用化とどう組み合わさっているのか」を曖昧なままにしていることに気づきました。そこで、ローカル環境で権限を段階的に削り、挙動を確かめることにしました。

侵害されたプロセスが引き継ぐ権限を考える

前回のOSコマンドインジェクションの実験では、HTTPリクエストのパラメータを exec.Command("sh", "-c", cmd) へそのまま渡す脆弱なGo製コードを用意しました。このとき起動するシェルや外部コマンドは、Goアプリケーションを実行しているOSユーザーの権限を引き継ぎます。アプリケーションがコンテナ内でUID 0(root)として動作していれば、注入された悪意あるコマンドもコンテナ内のroot権限で実行されてしまいます。

もちろん、コンテナを非root化したからといって、入力処理の脆弱性そのものが修正されるわけではありません。シェルを介さずに引数を分離して渡す実装や、用途に応じた厳密な入力検証を行う根本対策が不可欠です。その上で、もしアプリケーションが侵害されたとしても、攻撃者が操作できるファイルや、特権を必要とする操作を制限するのが、今回の権限設計の狙いです。

例えば、アプリケーションのバイナリや設定ファイルをroot所有として配置し、実行ユーザーには読み取りと実行権限だけを与え、親ディレクトリにも書き込めないようにしておけば、侵入後のプログラム改ざんを防ぐ効果があります。一方で、そのプロセス自身が読み取れる環境変数や一時ファイル、書き込み権限を持つデータ領域には、侵入後もアクセスできてしまいます。

さらにSQLインジェクションを振り返ると、コンテナのOSユーザーとは別の層の権限設計も必要です。アプリケーションをコンテナ内で非rootで動かしても、データベース接続ユーザーに強力な管理者権限が与えられていれば、SQLを通じてデータベース内のテーブルが操作されてしまいます。

権限を設定する場所制限する対象
コンテナのOSユーザー(UID/GID)プロセスによるファイルアクセスやOSコマンドの実行権限
DB接続ユーザーSQLを通じて操作できるテーブルやデータ更新権限
クラウドのIAMロール / サービスアカウントクラウドAPI経由で操作できるインフラリソース

もしrootのまま侵害されたら何が起きるのか

「コンテナをrootで動かすことのリスク」を具体的にイメージするために、もしWeb脆弱性(OSコマンドインジェクションやRCE)を突かれて侵入された場合、攻撃者に何ができてしまうのかを整理してみます。

コンテナ侵害時におけるroot実行と非root実行の影響範囲の対比

  1. 攻撃ツールの導入と足場固め コンテナ内がroot(UID 0)で、パッケージマネージャーや書き込み可能なシステム領域、外部への通信が利用できれば、攻撃者は apk add や apt-get install などでツールを追加できます。非rootでシステム領域への書き込み権限がなければ、通常のパッケージインストールや /usr/bin への配置を制限できます。ただし、非rootでも書き込み可能な /tmp やデータ領域へツールを持ち込み、実行できる場合があります。

  2. アプリケーション本体やシステム設定の改ざん root権限があり、対象のファイルシステムが書き込み可能であれば、アプリケーションファイルの置き換えや設定の改ざんが可能になります。さらに /etc/resolv.conf や /etc/hosts を書き換えて通信先を攻撃者のサーバーへ誘導したり、悪意あるルート証明書を追加してTLS通信を傍受したりするリスクも生じます。非rootで動かし、対象ファイルと親ディレクトリへの書き込みを許可しなければ、こうした改ざんを制限できます。

  3. 共有ボリュームやファイルシステム全体の破壊 rootで利用できる権限が多いほど、書き込み可能なルートファイルシステムや共有ボリュームで、削除・暗号化の被害が広がりやすくなります。ただし、実際に操作できる範囲はcapabilities、マウント設定、ファイルやディレクトリの権限にも依存します。非root化で書き込み先を絞っても、アプリケーション自身が書き込めるデータは破壊され得ます。

  4. コンテナ脱出(Container Escape)の足がかり Dockerのデフォルト設定では、UID 0に対して一部のLinuxケーパビリティ(CAP_CHOWN、CAP_FOWNER、CAP_SETUID など)が付与されています。ホストのLinuxカーネルやコンテナランタイムに脆弱性がある場合、こうした権限が攻撃に利用される可能性があります。また、ホストのDockerソケット(/var/run/docker.sock)をマウントし、侵害されたプロセスから操作できる状態にすると、Dockerデーモンを通じたホストへの攻撃につながります。非root化は利用できる権限を減らしますが、非rootでも悪用できる脆弱性はあり、ソケットへのアクセス可否もUIDだけでなくグループやパーミッションに依存します。Docker Engineのセキュリティ

非root化は、侵害後に攻撃者が操作できる範囲を狭める対策です。コンテナ脱出やツールの持ち込みをすべて防げるわけではないため、ほかの制限と組み合わせて考えます。

今回の実験では、インジェクション攻撃を再現するのではなく、コンテナ内で直接シェルプローブを実行して各ファイルの操作可否を検証しました。侵害されたプロセスに何をさせないかを具体的に考えるための結果として読み解きます。

「root」という言葉を3つの層に分解する

コンテナのセキュリティを考える際、「rootで動かしてはいけない」という指針をよく見かけます。しかし、この「root」という言葉は、3つの階層に分けて捉える視点が欠かせません。

  1. コンテナ内プロセスの実行UID: コンテナ内でプロセスがUID 0(root)として動いているか、UID 10001などの一般ユーザーとして動いているか。
  2. コンテナエンジンを実行するユーザー: DockerデーモンやPodmanが、ホストのroot権限で動いているか(rootful)、一般ユーザー権限で動いているか(rootless)。なお、Podmanは常駐デーモンを必要としません。
  3. ホストまたはVMのユーザー名前空間(User Namespaces): コンテナ内のUID 0が、外側の親名前空間でどのUIDに対応付けられているか(uid_map のマッピング関係)。

特にmacOSでDocker Desktopを利用する場合、Docker EngineはホストのmacOS上で直接動いているのではなく、macOSが管理するLinux仮想マシン(VM)の内部で動作しています。そのため、コンテナ内のプロセスがUID 0であったとしても、macOSホストそのもののroot権限を直接持っているわけではありません。

しかし、Linux仮想マシンの内部、あるいは本番環境のLinuxホスト上で見たとき、ユーザー名前空間によるUIDの分離がなく、コンテナ内のUID 0がホストのUID 0に対応している構成では、コンテナ脱出後にホスト側へ及ぶ影響も考慮する必要があります。UIDが同じでも、コンテナ内ではcapabilitiesやほかの名前空間による制限があるため、ホストのrootと同じ操作ができるわけではありません。コンテナ内の実行ユーザーを非rootにすることと、コンテナエンジン側の権限を縛ることは、それぞれ独立した多層防御の構成要素です。

Docker Desktopで権限を4段階に変えて実測する

今回の検証では、Alpine 3.22をベースにした共通イメージを作成し、実行時の引数だけを変えて4段階の比較を行いました。

実験環境とプローブの準備

検証用のDockerfileでは、UID/GID 10001の app ユーザーを作成し、調査用のシェルスクリプト probe を配置しています。

FROM alpine:3.22
RUN addgroup -g 10001 app && adduser -D -u 10001 -G app app
COPY scripts/probe.sh /usr/local/bin/probe
RUN chmod 755 /usr/local/bin/probe
USER app
CMD ["/usr/local/bin/probe"]

調査スクリプト probe.sh では、プロセスの実効権限(UID/GID、/proc/self/status のcapabilitiesやフラグ)を確認した上で、次の操作を試行します。

  • /etc/lab-probe への書き込み(ルートファイルシステムへの書き込み)
  • /tmp/lab-probe への書き込み(一時領域への書き込み)
  • /lab/root-only の読み取り(root所有かつパーミッション600のファイル)
  • /lab/root-only のパーミッション変更(chmod 644)
  • /lab/created-by-container への書き込み(マウントされたvolume領域)

実験スクリプト lab.sh では、各回の実行ごとに隔離されたDocker volumeを作成してコンテナの /lab にマウントし、事前にUID 0でパーミッション600の root-only ファイルを作成した上で、検証コンテナを起動する構成にしました。

4段階の実行条件と結果

検証は次の4段階で行いました。

  • 01-root: UID 0(--user 0:0)、デフォルトcapabilities
  • 02-user: UID 10001(--user 10001:10001)、デフォルトcapabilities
  • 03-root-no-caps: UID 0(--user 0:0)、capabilities全削除(--cap-drop=ALL)
  • 04-hardened: UID 10001(--user 10001:10001)、capabilities全削除、--security-opt=no-new-privileges、--read-only、/tmp へのtmpfsマウント、ネットワーク無効化(--network=none)、CPU・メモリ・PID制限

Mac上のDocker Desktop(Linux VM内)で実行した実測結果は次の通りです。

検証段階UIDCapEffNoNewPrivs/etc 書き込み/tmp 書き込みroot-only 読み取りchmod 644/lab 書き込み
01-root0a80425fb0成功成功成功成功成功
02-user1000100拒否成功拒否拒否成功
03-root-no-caps000成功成功成功成功成功
04-hardened1000101拒否 (Read-only)成功拒否拒否成功

すべての段階で、コンテナ内の uid_map と gid_map は 0 0 4294967295 であり、コンテナ内のUID 0はDocker DesktopのLinux VM内におけるUID 0に対応している状態が確認できました。

実測結果から分かった3つの事実

capabilities削減と非root化、read-only rootfsの防御範囲の比較

この実測結果から、コンテナのセキュリティ設定に関して重要なポイントが見えてきます。

1. capabilities全削除でも、所有者権限によるアクセスは残る

最も注目すべき結果は、段階03(UID 0、--cap-drop=ALL)の挙動です。

コンテナからすべてのLinux capabilitiesを削除したため、/proc/self/status の実効capability(CapEff)は 0000000000000000 になりました。しかし、root所有でパーミッション600が設定された root-only ファイルの読み取りも、chmod 644 によるパーミッション変更も成功しました。

Linuxでは、通常、プロセスの実効UIDがファイルの所有者UIDと一致していれば、CAP_FOWNER がなくても chmod を実行できます。一方、読み取りや書き込みには、それぞれの権限ビットが必要です。今回の読み取りが成功したのは、UID 0がファイルの所有者であり、モード600で所有者に読み取り権限が与えられていたためです。chmodの仕様、openの仕様

つまり、--cap-drop=ALL を指定したとしても、プロセスがUID 0で動いている限り、UID 0が所有するファイルに対する操作権限は失われません。「capabilitiesをすべて落としたから安全」と判断するのではなく、root所有のファイルへの操作を制限するには、非root化とファイルパーミッションの設計も必要だと分かりました。

2. 非root化とパーミッションによって、ファイルへのアクセスを制限できる

段階02(UID 10001)では、root所有の /lab/root-only に対する読み取りや chmod は Permission denied で確実に拒否されました。また、ルートファイルシステム直下の /etc へのファイル作成も拒否されています。

一方で、一般ユーザーの書き込みが許可された /tmp や、検証用にパーミッションを開けた /lab volumeへの書き込みは成功しています。アプリケーションに必要な保存先へのアクセスを維持しながら、システム領域や他ユーザーのファイルへの不正アクセスを制限できました。

3. rootfsの読み取り専用化と個別マウントの役割分担

段階02と段階04では、どちらも /etc への書き込みが失敗しています。しかし、その失敗理由は異なります。

段階02のエラーはパーミッション不足(Permission denied)によるものでした。一方、段階04では --read-only フラグを指定しているため、ファイルシステムの読み取り専用エラー(Read-only file system)によって拒否されました。

仮にファイルパーミッションの設定ミスによって書き込み権限が緩んでいたとしても、コンテナのルートファイルシステム全体が読み取り専用になっていれば、その読み取り専用領域への書き込みは拒否されます。別途書き込み可能な状態でマウントした領域は、この制限の対象外です。

さらに段階04では、ルートファイルシステムを読み取り専用にしつつ、--tmpfs /tmp:rw,noexec,nosuid,size=16m とvolumeマウント(/lab)を組み合わせています。その結果、/etc への書き込みを阻止しながら、一時ファイルの生成やデータ保存に必要なディレクトリだけを書き込み可能にできました。

非rootで動くコンテナ設計:Dockerfileのベストプラクティス

コンテナを非rootで安全に動かすためには、実行時の引数だけでなく、Dockerfileを作成するビルド段階からの設計が必要です。

既存のDockerfileの末尾に USER 10001 を追記しただけでは、起動スクリプトがroot権限を前提にしていたり、アプリケーションが必要なログディレクトリに書き込めず起動エラーになったりすることがあります。

ビルド時と実行時の権限を分離する

Dockerfileの設計では、ビルド時に必要な準備と、実行時に必要な権限を明確に分けるのが基本です。

FROM alpine:3.22

# 1. ビルド時:root権限でユーザー作成とディレクトリ準備を行う
RUN addgroup -g 10001 app \
    && adduser -D -u 10001 -G app app \
    && mkdir -p /var/lib/app \
    && chown app:app /var/lib/app

# 2. アプリケーション本体はroot所有のまま配置する(改ざん防止)
COPY ./app /opt/app
RUN chmod 755 /opt/app/server

# 3. 実行時:一般ユーザーへ切り替える
USER 10001:10001
WORKDIR /opt/app
CMD ["./server"]

この構成には2つのポイントがあります。

ひとつは、アプリケーションの実行バイナリ(/opt/app/server)をroot所有とし、app ユーザーには読み取り・実行だけを許可している点です。親ディレクトリの /opt/app にも app ユーザーの書き込みを許可しなければ、侵害されたプロセスによるバイナリの上書きや置き換えを制限できます。コピー元のディレクトリにグループやその他のユーザーの書き込み権限が付いていないことも確認します。

もうひとつは、書き込みが必要なデータ保存先(/var/lib/app)だけを事前に作成し、app ユーザーに所有権を渡している点です。アプリケーションが必要とする書き込み領域だけを明示的に絞り込みます。

Dockerfileと実行時設定の組み合わせ

Dockerfileに記述する USER 命令は、コンテナ起動時の既定ユーザーを宣言するものです。今回の実験で --user 0:0 を指定して上書きしたように、実行時の指定によってコンテナの動作は変わります。

そのため、Dockerfile単体で完結させるのではなく、コンテナオーケストレーションやインフラ側の設定と組み合わせることが欠かせません。

クラウドのセキュリティ基準との対応(AWS ECS / Google Cloud)

主要なパブリッククラウドでは、セキュリティ監査ツールやポスチャ管理機能によってコンテナの実行権限が評価されます。今回の検証内容は、AWSとGoogle Cloudのセキュリティ基準と関係しています。ただし、ローカルでの実測は、クラウド上での監査への準拠を確認したものではありません。

AWS Security Hub(ECSコントロール)

AWS Security Hubでは、ECSタスク定義の設定に対して次の項目がチェック対象となります。AWS Security HubのECSコントロール一覧

  • [ECS.20] ECSコンテナは非rootユーザーとして実行する必要がある 最新のアクティブなタスク定義のコンテナ定義を評価し、Linuxコンテナの user が未設定、または既定のrootユーザーに設定されている場合に非準拠となります。Dockerfileの USER は評価対象ではないため、タスク定義側でも非rootユーザーを明示します。ECS.20の判定条件
  • [ECS.4] ECSコンテナは特権コンテナとして実行しない コンテナ定義の privileged が false であることを確認します。特権コンテナはホストのすべてのデバイスやcapabilitiesへのアクセスを許可してしまうため、原則として無効化します。
  • [ECS.5] ECSコンテナのルートファイルシステムは読み取り専用にする コンテナ定義の readonlyRootFilesystem が true であることを確認します。

Google Cloud(GKE Security PostureとPodのセキュリティ基準)

GKEでも、ワークロードの設定監査やPodのセキュリティ基準を通じて、実行権限を確認・制限できます。

  • GKE Security Posture ダッシュボード ワークロードの設定監査を有効にすると、root実行に関わる設定上のリスクを確認できます。公式資料では、runAsNonRoot: false のワークロードに対して、Pod仕様の変更を推奨する例が示されています。GKE Security Posture ダッシュボードの概要
  • Pod Security Admission(PSA)の Restricted プロファイル Kubernetes標準のセキュリティ基準であるPod Security Standardsにおいて、最も厳格な Restricted プロファイルを適用(pod-security.kubernetes.io/enforce=restricted)すると、非root実行を要求しない設定や、特権昇格を許可する設定など、基準に違反するPodの作成が拒否されます。これはPodの設定に対する検査であり、イメージ内の実行ユーザーを調べるものではありません。runAsNonRoot: true が指定されていても、イメージのユーザーがrootであれば起動時に失敗します。Pod Security Standards
  • CIS Google Kubernetes Engine (GKE) Benchmark GKEのセキュリティ設計ではCISベンチマークも参照されます。項目番号や適用対象は版によって異なるため、利用する版とクラスタの構成に合わせて確認します。GKEのセキュリティ要塞化ガイド

no-new-privileges による権限昇格の阻止

段階04で指定した --security-opt=no-new-privileges は、Linuxの no_new_privs 属性を、prctl の PR_SET_NO_NEW_PRIVS 操作で有効にするオプションです。

この属性が有効なプロセス(/proc/self/status の NoNewPrivs: 1)では、execve によるプログラムの実行時に、setuid・setgidビットやファイルcapabilitiesを通じて新しい権限を獲得できなくなります。ただし、すでに持っている権限を削除するものではなく、カーネル脆弱性などによる権限昇格をすべて防ぐものでもありません。Linuxのno_new_privsの仕様

scratchイメージによる最小化と非root化の組み合わせ

コンテナイメージを軽量化し、攻撃対象領域を削る手法として、ベースイメージに scratch を採用する構成がよく使われます。

Goなどで静的リンクされたシングルバイナリを作成し、FROM scratch に配置すると、イメージ内にOSのパッケージやシェル(sh、bash)、curl や wget といったツール類は一切含まれません。

FROM golang:1.24 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM scratch
COPY --from=builder /app/server /server
USER 10001:10001
ENTRYPOINT ["/server"]

scratchと非rootの役割の違い

ここで注意が必要なのは、「scratchイメージを使うこと」と「非rootで動かすこと」は別の対策である という点です。

対策手法制限する対象
scratchベースの最小化不要な依存ライブラリ、シェル、攻撃に悪用されやすいツールの排除
非root実行(UID 10001)ファイルへのアクセスや、特権を必要とする操作
ルートファイルシステムの読み取り専用化読み取り専用領域へのファイルの書き込み・作成の禁止

scratchイメージを採用しても、Dockerfileで USER を指定しなければ、コンテナ内プロセスはデフォルトでUID 0(root)として起動してしまいます。scratchには /etc/passwd や adduser コマンドが存在しないため、USER 10001:10001 のように数値UID/GIDを直接指定する形をとります。

シェルが存在しなければ、前回の検証のような sh -c を介したOSコマンドインジェクションはコマンドの起動自体に失敗します。しかし、SQLインジェクションやアプリケーションロジックの脆弱性、環境変数に置かれた秘密情報の奪取、外部ネットワークへの不正通信などは、シェルがなくても発生し得ます。軽量化と権限制限は、必ずセットで進める必要があります。

侵害後に「どう壊れるか」まで設計する

インジェクション対策からコンテナの権限設計までを俯瞰すると、セキュリティ対策の本質は「侵入を未然に防ぐこと」だけにとどまりません。「もし侵入されたとき、被害をどこで食い止めるか(Blast Radiusの最小化)」 を設計することにあります。

最小特権コンテナとクラウドインフラを組み合わせた多層防御モデル

アプリケーションにどれだけ非rootやread-onlyを設定しても、そのプロセスが読み取れるAPIキーは侵害後も取得され得ます。また、アプリケーションに読み書きが許可されたデータ領域は、侵害されたプロセスからも操作できてしまいます。

想定される被害最小特権コンテナとインフラ設計による防御
アプリケーション本体や設定の改ざんroot所有とファイル・親ディレクトリの書き込み権限の制限、非root実行、read-only rootfs
アプリケーションデータの破壊書き込み先volumeの限定、定期バックアップと復旧確認
秘密情報の漏洩環境変数の削減、短命な認証情報(IAMロール)の活用、失効・更新手順
クラウドリソースへの攻撃拡大IAMロールの最小権限、用途別の権限分離
外部への不正通信・C2サーバー接続Egressネットワーク制限(通信先のホワイトリスト化)

非root化によってシステム領域を守りつつ、アプリが持つ認証情報を短命にし、通信先を絞り込む。それぞれの層でできることと残るリスクを把握し、多層防御を組み立てる姿勢が重要です。

本番環境へ持ち込むときに詰まりやすいポイント

コンテナの非root化や読み取り専用化を実際の業務アプリケーションに適用する際、開発環境や本番環境で直面しやすい典型的なトラブルをまとめました。

1. 書き込み先ディレクトリがroot所有のままになっている

よくある問題は、アプリケーションが出力するログ、一時キャッシュ、ファイルアップロード先、PIDファイルなどの保存先がroot所有のまま残っているケースです。

対策として、アプリケーションが使用する書き込みパスを洗い出し、Dockerfileでそのディレクトリを作成して実行ユーザーに所有権を付与しておきます。また、ログについてはファイルへの書き込みを避け、コンテナの標準出力(stdout/stderr)へ流す設計にすることで、ファイルパーミッションの問題を回避しやすくなります。

2. マウントによってイメージ内のディレクトリが隠れる

Dockerfileのビルド時に chown で所有権を設定していても、実行時にホストのディレクトリをbind mountしたり、既存のvolumeをマウントしたりすると、イメージ内のディレクトリがマウントによって隠れる点に注意が必要です。

マウントされた領域のUID/GIDやパーミッションが実行ユーザーと合っていないと、コンテナ内の一般ユーザーから書き込めず Permission denied になります。マウント先のパーミッションを実行ユーザーのUID/GIDに合わせて調整するか、初期化用コンテナ(init container)などで事前に権限を整える構成を検討します。

3. 起動スクリプトがroot権限を前提にしている

コンテナ起動時のエントリーポイントスクリプトで、起動直前に chown を実行したり、パッケージを追加インストールしたり、/etc 配下に動的な設定ファイルを生成する構成も見受けられます。

これらの処理がroot権限やroot所有の領域への書き込みを前提としている場合、非rootでの起動に失敗します。ビルド時にできる処理はすべてDockerfileの RUN ステップに移し、動的な設定ファイルが必要な場合は /tmp などの書き込み可能領域に配置するようアプリケーション側の設定を変更します。

4. 特権ポート(1024未満)のバインドに失敗する

Webサーバーをポート80や443で直接リッスンさせようとすると、Linuxの特権ポート制限により一般ユーザーではバインドに失敗することがあります。ただし、制限の境界はネットワーク名前空間の net.ipv4.ip_unprivileged_port_start に依存し、値が0なら非rootでも80や443を使用できます。CAP_NET_BIND_SERVICE の有無も関係するため、UIDだけでは判断できません。Linuxの特権ポート設定

コンテナ内では8080や8443などの非特権ポート(1024以上)でリッスンさせ、コンテナオーケストレーターやロードバランサー、リバースプロキシの側で80や443へポートマッピングを行う構成が一般的です。

5. クラウド基盤の仕様差(Cloud RunやKubernetes)

ローカルのDockerとクラウド基盤では、設定方法や制約が異なります。

Kubernetes(EKSやGKE)では、Podや各コンテナの securityContext で実行権限を定義します。runAsNonRoot: true と runAsUser: 10001 はPod側またはコンテナ側に指定できます。readOnlyRootFilesystem: true と allowPrivilegeEscalation: false は、各コンテナの securityContext に指定します。KubernetesのsecurityContextドキュメント

Cloud Runでは、実行環境の世代によって隔離方式が異なります。第1世代はgVisor、第2世代はmicroVMを使用します。Cloud Runの実行環境

Cloud Runのコンテナの書き込み可能なファイルシステムはインメモリで、書き込んだデータはインスタンスのメモリを消費します。Dockerの --read-only フラグと同等の厳格な書き込み禁止設定がそのまま提供されるわけではありません。Cloud Runのコンテナランタイム仕様

プラットフォームごとの制約と隔離モデルを確認した上で、設定を適用する必要があります。

今回の検証範囲と今後の課題

今回の検証は、macOS上のDocker Desktop(Linux VM内)で実施した実測データに基づいています。

検証できた範囲

  • AlpineコンテナにおけるUID 0とUID 10001のファイルアクセス権限の違い
  • Linux capabilitiesを全削除(--cap-drop=ALL)しても、所有者権限に基づくファイル読み取りやパーミッション変更(chmod)は残る事実
  • --read-only フラグによるルートファイルシステムの書き込み遮断と、tmpfsおよびvolumeによる書き込み領域の分離
  • /proc/self/status における NoNewPrivs: 1 設定フラグの反映確認

今回は未実測・対象外とした点

  • setuid バイナリを用いた権限昇格の阻止実演や、カーネル脆弱性によるコンテナ脱出耐性の実証
  • ネットワーク遮断(--network=none)やリソース制限(メモリ・CPU・PIDs limit)における負荷試験
  • Podman rootless環境における uid_map や podman unshare の実測(Linuxホスト上での検証)
  • 実際のGo製Webアプリケーションを用いた、実トラフィック下での非root・read-only運用テスト

これらは今後の追加検証課題として、別記事などで深掘りしていく予定です。

おわりに

今回、capabilitiesを全削除しても、UID 0にはroot所有のファイルに対する所有者としての権限が残ることを確認できました。非root化とread-onlyを組み合わせると、システム領域への書き込みを制限しながら、必要な保存先は書き込み可能にできます。

Security Hubの警告を見たときはユーザーIDの追記だけを考えていましたが、実測を通じて、それぞれの設定が制限する操作を整理できました。実際のアプリケーションへ適用する際は、必要な書き込み先や認証情報へのアクセスも確認し、侵害された場合の被害の拡大を抑える設計につなげたいです。

@urchin_hat
WRITTEN BY@urchin_hat

とある領域のSaaSサービスのSREとして、プロダクトの信頼性と事業成長を支えています。

← BACK TO MEMO