第178回 検査したELFの内容を固定して起動する:Pythonのmemfdとファイルシール

第177回では、ELF実行ファイルを開いてSHA-256を照合し、同じファイル記述子から起動しました。これで検査後のパス差し替えは避けられます。ただし、別の書き込み用ファイル記述子から元ファイルを変更できる場合は、検査後に内容が変わる余地が残ります。

今回は元ファイルを直接実行しません。内容を匿名のmemfdへコピーし、書き込み、伸長、縮小をファイルシールで禁止します。封印済みコピーを読み直してSHA-256を照合し、そのファイル記述子からELFを起動します。

固定できるのは主実行ファイルのバイト列です。動的リンカー、共有ライブラリ、設定ファイル、環境変数、カーネルまで同じ状態にする仕組みではありません。

元ファイルの変更と実行対象を切り離す

方式 パス差し替え 検査後の元ファイル変更 実行する内容
検査後にパスを開き直す 影響を受ける 影響を受ける 開き直した時点のファイル
開いた元ファイルを検査して実行する 避けられる 同じinodeへの書き込みが反映され得る 元ファイル
コピーを封印後に検査して実行する 避けられる 封印済みコピーには反映されない 封印後にSHA-256を照合したmemfd

os.memfd_create()は、Linux上で匿名ファイルを作り、そのファイル記述子を返します。MFD_ALLOW_SEALINGを指定すると、fcntl経由でファイルシールを追加できます。

元ファイルだけをハッシュ化してからコピーすると、実際に起動するコピーを確認したことになりません。この実装はコピー先を先に封印し、その後で内容を読み直します。

SHA-256は、起動する内容が事前に承認した内容と一致するかを調べる値です。実行直前に同じ配布先から期待値を作るだけでは、承認の根拠になりません。本番では、期待値を承認済み成果物や別に管理した記録から取得します。

今回の実行契約

項目 仕様
動作環境 Python 3.10以上、Linux 3.17以上、glibc 2.27以上。memfd、ファイルシール、ファイル記述子を受け取るos.execve()が利用できること
入力 絶対パスで指定する通常ファイル。末尾要素のシンボリックリンクは拒否する
対象形式 何らかの実行モードビットを持つ、4バイト以上128 MiB以下のファイル。先頭4バイトがELFマジックであることを確認するが、ELFヘッダ全体は解析しない。シェバンスクリプトは対象外
期待値 小文字16進数64文字のSHA-256
コピー 元ファイルを1 MiBずつ読み、同じバイト列をmemfdへ書く
補助的な変更検知 コピー前後のデバイス、inode、サイズ、更新時刻、状態変更時刻と、実際にコピーしたサイズを比較する
シール F_SEAL_WRITE、F_SEAL_GROW、F_SEAL_SHRINK、F_SEAL_SEALを設定し、取得し直して確認する
内容検査 封印済みmemfdを先頭から読み直し、サイズ、ELFマジック、SHA-256を確認する
引数 --以降で対象パスとその引数を受け取り、シェルを介さずに渡す
環境と入出力 環境変数と標準入力・標準出力・標準エラー出力を引き継ぐ
成功時 ラッパーを対象ELFへ置き換える。ラッパー独自の成功行は出力しない

メタデータの比較は、コピー中の変化を見つけるための補助検査です。それだけで元ファイルの完全なスナップショットを保証するものではありません。起動を許可する最終条件は、封印後のコピーが承認済みSHA-256と一致することです。

128 MiBはこのサンプルで決める上限です。memfdはメモリやスワップなどtmpfsの資源を使うため、無制限にはしません。元ファイルへの書き込みを権限で防げる場合は、第177回の方式の方がコピー用資源を必要とせず単純です。

コピー、封印、照合、起動を順番に行う

  1. O_NOFOLLOWとO_NONBLOCKを付けて元ファイルを開き、通常ファイル、サイズ、実行モードを確認する
  2. MFD_CLOEXEC | MFD_ALLOW_SEALINGで匿名ファイルを作る
  3. 元ファイルの全バイトを匿名ファイルへコピーし、コピー前後のメタデータを比較する
  4. 匿名ファイルを所有者の読み取り・実行だけにし、内容とサイズを変えられないシールを設定する
  5. シールを取得し直した後、封印済みコピーのサイズ、ELFマジック、SHA-256を確認する
  6. os.execve()へ封印済みファイル記述子と引数配列を渡す

O_NONBLOCKは、通常ファイルでは動作を変えません。通常ファイルではないFIFOなどを誤って指定したときに、形式を確認する前のopen()で待ち続けることを避けます。

MFD_CLOEXECにより、起動が成功すると実行用ファイル記述子は新しいプログラムへ残りません。シェバンスクリプトでは、インタープリターが閉じられた記述子を参照できず失敗する場合があるため、この実装はELFだけを受け付けます。

実装する

次をsealed_exec.pyとして保存します。外部ライブラリは使いません。

import argparse
import errno
import fcntl
import hashlib
import hmac
import json
import os
import re
import stat
import sys

MAX_BYTES = 128 * 1024 * 1024
CHUNK_SIZE = 1024 * 1024


class VerificationError(Exception):
    pass


class UnsupportedError(Exception):
    pass


def sha256_arg(value):
    if re.fullmatch(r"[0-9a-f]{64}", value) is None:
        raise argparse.ArgumentTypeError(
            "--sha256 must be 64 lowercase hexadecimal characters"
        )
    return value


def parse_args():
    parser = argparse.ArgumentParser(
        description="Copy an ELF into a sealed memfd, verify it, and execute it."
    )
    parser.add_argument("--sha256", required=True, type=sha256_arg)
    parser.add_argument("program", help="absolute path to an ELF executable")
    parser.add_argument("program_args", nargs=argparse.REMAINDER)
    args = parser.parse_args()
    if not os.path.isabs(args.program):
        parser.error("program must be an absolute path")
    return args


def check_features():
    required = [
        (os, "memfd_create"),
        (os, "MFD_CLOEXEC"),
        (os, "MFD_ALLOW_SEALING"),
        (os, "O_CLOEXEC"),
        (os, "O_NOFOLLOW"),
        (os, "O_NONBLOCK"),
        (fcntl, "F_ADD_SEALS"),
        (fcntl, "F_GET_SEALS"),
        (fcntl, "F_SEAL_SHRINK"),
        (fcntl, "F_SEAL_GROW"),
        (fcntl, "F_SEAL_WRITE"),
        (fcntl, "F_SEAL_SEAL"),
    ]
    missing = [name for module, name in required if not hasattr(module, name)]
    if os.execve not in os.supports_fd:
        missing.append("fd-based os.execve")
    if missing:
        raise UnsupportedError(
            "required Linux feature is unavailable: " + ",".join(missing)
        )


def required_seals():
    return (
        fcntl.F_SEAL_SHRINK
        | fcntl.F_SEAL_GROW
        | fcntl.F_SEAL_WRITE
        | fcntl.F_SEAL_SEAL
    )


def source_state(st):
    return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)


def write_all(fd, data):
    view = memoryview(data)
    while view:
        written = os.write(fd, view)
        if written == 0:
            raise OSError(errno.EIO, "zero-byte write to memfd")
        view = view[written:]


def hash_sealed_memfd(fd):
    os.lseek(fd, 0, os.SEEK_SET)
    digest = hashlib.sha256()
    prefix = bytearray()
    total = 0
    while True:
        block = os.read(fd, CHUNK_SIZE)
        if not block:
            break
        digest.update(block)
        total += len(block)
        if len(prefix) < 4:
            prefix.extend(block[: 4 - len(prefix)])
    return digest.hexdigest(), bytes(prefix), total


def prepare_sealed_elf(program, expected_sha256):
    source_fd = None
    memfd = None
    try:
        source_fd = os.open(
            program,
            os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK,
        )
        before = os.fstat(source_fd)
        if not stat.S_ISREG(before.st_mode):
            raise VerificationError("source is not a regular file")
        if before.st_size < 4:
            raise VerificationError("source is too small to be an ELF file")
        if before.st_size > MAX_BYTES:
            raise VerificationError("source exceeds the 128 MiB limit")
        if before.st_mode & 0o111 == 0:
            raise VerificationError("source has no execute mode bit")

        memfd = os.memfd_create(
            "verified-elf",
            os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING,
        )
        copied_size = 0

        while True:
            block = os.read(source_fd, CHUNK_SIZE)
            if not block:
                break
            copied_size += len(block)
            if copied_size > MAX_BYTES:
                raise VerificationError("source grew beyond the 128 MiB limit")
            write_all(memfd, block)

        after = os.fstat(source_fd)
        if (
            source_state(before) != source_state(after)
            or copied_size != before.st_size
        ):
            raise VerificationError("source metadata changed while copying")

        os.fchmod(memfd, 0o500)
        seals = required_seals()
        fcntl.fcntl(memfd, fcntl.F_ADD_SEALS, seals)
        actual_seals = fcntl.fcntl(memfd, fcntl.F_GET_SEALS)
        if actual_seals & seals != seals:
            raise VerificationError("required file seals were not applied")

        sealed_size = os.fstat(memfd).st_size
        actual_sha256, prefix, hashed_size = hash_sealed_memfd(memfd)
        if sealed_size != copied_size or hashed_size != sealed_size:
            raise VerificationError("sealed copy size mismatch")
        if prefix != b"\x7fELF":
            raise VerificationError("sealed copy does not start with the ELF magic")
        if not hmac.compare_digest(actual_sha256, expected_sha256):
            raise VerificationError("SHA-256 mismatch")

        os.lseek(memfd, 0, os.SEEK_SET)
        result = (memfd, actual_sha256, sealed_size)
        memfd = None
        return result
    finally:
        if source_fd is not None:
            os.close(source_fd)
        if memfd is not None:
            os.close(memfd)


def emit_error(kind, message, exit_code):
    payload = {"status": "error", "kind": kind, "message": message}
    print(
        json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
        file=sys.stderr,
        flush=True,
    )
    return exit_code


def main():
    args = parse_args()
    try:
        check_features()
        memfd, _actual_sha256, _size = prepare_sealed_elf(
            args.program, args.sha256
        )
    except UnsupportedError as exc:
        return emit_error("unsupported", str(exc), 69)
    except VerificationError as exc:
        return emit_error("verification_failed", str(exc), 65)
    except (OSError, ValueError) as exc:
        return emit_error("prepare_failed", str(exc), 126)

    try:
        argv = [args.program, *args.program_args]
        os.execve(memfd, argv, os.environ.copy())
    except (OSError, ValueError) as exc:
        return emit_error("exec_failed", str(exc), 126)
    finally:
        os.close(memfd)


if __name__ == "__main__":
    raise SystemExit(main())

write_all()は、os.write()が入力全体を書き切るとは仮定せず、残りがなくなるまで書き込みます。hash_sealed_memfd()は、シール設定後のコピーを先頭から読み、実行対象そのもののハッシュとELFマジックを確認します。

F_SEAL_WRITEは通常の書き込みと新しい共有書き込みマッピングを拒否し、F_SEAL_GROWとF_SEAL_SHRINKはサイズ変更を拒否します。F_SEAL_SEALは、後からシールを追加できない状態にします。既存の共有書き込みマッピングがあり、F_SEAL_WRITEを設定できない場合は、OSエラーとして起動前に停止します。

通常実行を確認する

次は機能確認用です。/usr/bin/printfがシンボリックリンクの場合もあるため、最終実体を取得してから使います。

PROGRAM="$(readlink -f /usr/bin/printf)"
EXPECTED_SHA256="$(sha256sum "$PROGRAM" | awk '{print $1}')"
python3 sealed_exec.py --sha256 "$EXPECTED_SHA256" -- \
  "$PROGRAM" '%s\n' 'sealed execution'

標準出力は次の1行です。

sealed execution

この例で実行直前にハッシュを求めるのは、コピー、シール、起動が動くことを確認するためです。本番の期待値を同じ方法で毎回作ると、すでに差し替えられたファイルまで承認してしまいます。

ハッシュを64個の0へ変えると、対象ELFは起動されません。

python3 sealed_exec.py \
  --sha256 0000000000000000000000000000000000000000000000000000000000000000 \
  -- "$PROGRAM" '%s\n' 'must not run'
echo "$?"

標準エラー出力と終了コードは次のとおりです。

{"status":"error","kind":"verification_failed","message":"SHA-256 mismatch"}
65

入出力と終了コードを運用側で扱う

結果 動作
引数エラー argparseが説明を標準エラー出力へ出し、終了コード2
形式、サイズ、変更検知、ハッシュの不一致 1行JSONを標準エラー出力へ出し、終了コード65
シール追加後の取得結果に必要なシールがない 1行JSONを標準エラー出力へ出し、終了コード65
必要機能がない kind=unsupportedの1行JSONを出し、終了コード69
オープン、memfd作成、シール設定のシステムコールが失敗する kind=prepare_failedの1行JSONを標準エラー出力へ出し、終了コード126
起動が失敗する kind=exec_failedの1行JSONを標準エラー出力へ出し、終了コード126
起動成功 現在のプロセスがELFへ置き換わり、以後の出力と終了状態はそのプログラムのものになる

ラッパーは標準入力を読みません。対象プログラムは同じ標準入力・標準出力・標準エラー出力を使います。環境変数もそのまま渡すため、秘密値や不要な設定を絞る場合は第176回の許可リスト方式を組み合わせます。また、close-on-execが設定されていない別のファイル記述子を一括で閉じる処理は行っていません。

この方法で固定されないもの

  • 動的依存物:ELFインタープリター、共有ライブラリ、リンカー設定、ロケールや名前解決用ファイルは実行時に別途参照される
  • 期待値の出所:SHA-256の一致は内容の同一性を調べるだけで、発行元を証明する電子署名ではない
  • 権限境界:実行モードの有無は確認するが、元ファイルのACLやマウントのnoexecと同じ認可判定を再現するものではない
  • 実行ファイルの自己位置:/proc/self/exeは元のパスではなくmemfdを指すため、実行ファイル隣の資源を自己位置から探すプログラムは動作が変わり得る
  • ファイル属性:setuid、setgid、ファイルケーパビリティ、拡張属性、セキュリティラベルはコピーしない。権限昇格を必要とする実行ファイルには使わない
  • プロセスへの介入:同じ利用者や管理者によるデバッガ接続、実行後のメモリ改変、カーネル侵害は防がない
  • 副作用:対象プログラムのファイル更新、通信、通知、外部API呼び出しを制限も取り消しもしない

Linux 6.3以降では、実行可能なmemfdを制限するvm.memfd_noexecポリシーがあります。詳しい挙動はLinuxカーネルの文書で確認できます。

本実装はMFD_EXECを指定しません。ポリシー値が1の環境では、実行不可かつ実行ビットを変更できないmemfdが作られ、fchmod()が失敗します。値が2の環境では、memfd_create()自体が拒否されます。どちらも終了コード126で停止します。スクリプトを動かすためにOSの防御設定を緩めず、その環境で承認された配布・実行方式を使います。

最初に機能確認用ELFで、正常実行とハッシュ不一致時の停止を確かめます。その後、SHA-256の承認元、許可する対象パス、最大サイズ、環境変数、動的依存物を運用手順に明記してから業務処理へ組み込みます。