はじめに
表題の通り、AWS Lambda上で動くRubyコードの実行機能を作ろうとしましたが、実現ができなかったため備忘録として投稿します
設計、実装にあたってはClaudeの助けを借りて実現することができました。
なぜ作ろうと思ったのか
私が制作している個人開発サービス アルゴ算額では、提出された解答コードの正誤判定のため、Paiza.ioのAPIを利用させていただいているのですが、隔離されたコード実行環境を自作することで置き換えられないかと考え作成しようと思いました。
なぜLambdaを選んだのか
- RubyにはFile,Process.kill,Systemなどのコードが存在し、アプリケーションサーバーで動かすと機密情報の漏洩やサービス停止のリスクがある
- 呼び出しごとに独立した実行環境が用意されるため、常時稼働するサーバーに比べて他プロセスへの影響を局所化しやすい
- 使った分だけの従量課金のため、採点リクエストが少ない個人開発サービスでもコストを抑えられる
- 同時多発する採点リクエストに対しても、サーバーの台数管理を意識せずスケールできる
- メモリ・タイムアウトといった実行環境の上限をプラットフォーム側の設定として明示的にコントロールできる
つまり、「信頼できない第三者のコードを動かす」という要件と、「個人開発で運用コストを抑えたい」という要件の両方に噛み合う選択肢としてLambdaを選びました。
Rubyコードをサンドボックス化する難しさ
Lambda上で動かすとはいえ、渡すのは第三者が投稿した任意のRubyコードです。素朴にevalすれば、AWSの認証情報を盗まれたりリソースを食い潰されたりする恐れがあります。
そこでまず考えたのが「Ruby側で危険なメソッドを塞ぐ」というアプローチでしたが、これは成立しませんでした。
-
$SAFEはRuby 3.0で「特別な意味を持たない通常のグローバル変数」に降格 -
taintはRuby 3.2で完全に削除 -
Kernel#systemやFileをundef_methodで潰しても、default gemのfiddleを使えばFiddle::Handle.new(nil)からlibcの関数(execve、connect、ptraceなど)を直接呼び出せてしまう -
const_getや__send__、TracePoint、ObjectSpace、RubyVM::InstructionSequenceといった経路からも、潰したはずのメソッドや定数に復元的にアクセスできる
上記の理由により、Rubyの言語機能だけで信頼境界を作ることはできませんでした
そのため有効な境界はOSのプロセス境界だけ、という前提に立って設計し直すことにしました
設計方針
「Rubyレベルで塞ぐ」ことを諦めた代わりに、systemやProcess.spawn、fiddleはすべて実行できてよいという前提にしました
その上で、
- ハンドラプロセス(信頼できるコード)とユーザーコード(信頼できないコード)をOSプロセスとして分離する
- CPU時間・メモリ・プロセス数・ファイルサイズ・ファイルディスクリプタ数・出力サイズのすべてに上限を設ける
- 上限を超えたら例外的な後始末を待たず、即座にSIGKILLで強制終了する
という方針で実装しました。
実装内容
ハンドラのコード全文はこちら
require "fiddle"
require "fileutils"
require "json"
require "rbconfig"
require "securerandom"
module CodeRunner
CPU_SECONDS = [ 5, 6 ].freeze
ADDRESS_SPACE_BYTES = 768 * 1024 * 1024
# 1 ファイルあたり。/tmp(512MB)を 1 ファイルで埋められないようにする。
FILE_SIZE_BYTES = 8 * 1024 * 1024
# Lambda 全体の fd 上限 1,024 を子 1 つで食わせない。
OPEN_FILES = 64
# core dump による /tmp 圧迫を防ぐ。
CORE_SIZE = 0
# RLIMIT_STACK は指定しない。下げると正当な深い再帰が SystemStackError になり採点結果が変わる。
# RLIMIT_NPROC は per-UID かつ Linux ではスレッドも計上されるため、
# Init 時に自 UID のタスク数を数えて余裕分を足す(fork bomb 対策)。
NPROC_HEADROOM = 8
# /proc が読めない環境(macOS 等)向けのフォールバック。
NPROC_FALLBACK = 64
# --- 入出力の制限 ---
# パイプから読み取る際のバイト上限。ここで打ち切らないとハンドラ側のメモリを食い潰す。
OUTPUT_LIMIT_BYTES = 64 * 1024
# レスポンスに載せる文字数の上限。
#
# answer_results.output の Rails 側バリデーション(65,535 文字)を必ず下回らせる。
# バイトで切っただけだと
# - ASCII 64KiB = 65,536 文字ちょうどで上限を 1 文字超える
# - 不正バイトの scrub で 1 バイトが U+FFFD(3 バイト)に膨らむ
# のどちらでも保存に失敗し、CorrectnessCheckJob の retry で同じコードが 3 回実行される。
RESPONSE_MAX_CHARS = 60_000
SOURCE_MAX_LENGTH = 65_535
STDIN_MAX_LENGTH = 65_535
DEFAULT_TIMEOUT_MS = 5_000
MAX_TIMEOUT_MS = 5_000
# SIGKILL 後に残りの出力を読み切りゾンビを回収するための猶予。
# これを超えたら諦めて戻る(Lambda 関数タイムアウト 10s に食い込ませない)。
KILL_GRACE_SECONDS = 1.0
# cleanup でゾンビ回収に使う予算。ゾンビも RLIMIT_NPROC を消費するので取りこぼしたくないが、
# ここで粘りすぎると関数タイムアウトに食い込む。
REAP_TIMEOUT_SECONDS = 0.2
POLL_INTERVAL_SECONDS = 0.05
READ_CHUNK_BYTES = 64 * 1024
ENTRYPOINT_FILENAME = "main.rb"
STDIN_FILENAME = "stdin.txt"
WORKDIR_PREFIX = "run-"
# ユーザーコードに渡す環境変数はこれだけ(unsetenv_others: true で他は全て消える)。
# PATH に ruby の実体(Lambda では /var/lang/bin)を含めていないので、
# インタプリタは必ず RbConfig.ruby の絶対パスで起動すること。
CHILD_PATH = "/usr/bin:/bin"
CHILD_LANG = "C.UTF-8"
CHILD_TZ = "Asia/Tokyo"
TMP_ROOT = ENV.fetch("CODE_RUNNER_TMP_ROOT", "/tmp")
# TMP_ROOT の全削除は破壊的なので、Lambda 実行環境か、TMP_ROOT が明示指定されている
# ときだけ行う。この条件が無いと、開発機で誤って handler.rb を読み込んだだけで
# 本物の /tmp が消える。
# (AWS_LAMBDA_FUNCTION_NAME は本番の予約環境変数、LAMBDA_TASK_ROOT は RIE でも入る)
TMP_ROOT_WIPABLE = ENV.key?("AWS_LAMBDA_FUNCTION_NAME") ||
ENV.key?("LAMBDA_TASK_ROOT") ||
ENV.key?("CODE_RUNNER_TMP_ROOT")
# /proc 経由のプロセス観測。Linux 以外では機能しない。
module ProcessTable
PROC_ROOT = "/proc"
# /proc/<pid>/stat の starttime は 22 番目のフィールド。comm(2 番目)を読み飛ばした
# 配列では 3 番目のフィールドが先頭に来るので index は 22 - 3 = 19。
STARTTIME_INDEX = 19
module_function
def available?
File.directory?(PROC_ROOT)
end
def own_uid_pids
return [] unless available?
Dir.children(PROC_ROOT).filter_map do |entry|
next unless entry.match?(/\A\d+\z/)
pid = entry.to_i
pid if owned_by_me?(pid)
end
rescue SystemCallError
# cleanup の ensure から呼ばれるため、ここで例外を上げると本来伝搬すべき例外を潰す。
[]
end
def owned_by_me?(pid)
File.stat(File.join(PROC_ROOT, pid.to_s)).uid == Process.uid
rescue SystemCallError
false
end
# RLIMIT_NPROC はプロセスではなくタスク(スレッド)を数えるので、
# ここでも /proc/<pid>/task を数える。
def own_uid_task_count
own_uid_pids.sum { |pid| task_count(pid) }
end
def task_count(pid)
Dir.children(File.join(PROC_ROOT, pid.to_s, "task")).size
rescue SystemCallError
0
end
# comm には空白や括弧が入りうるので、最後の閉じ括弧の後ろから分割する。
def start_ticks(pid)
stat = File.read(File.join(PROC_ROOT, pid.to_s, "stat"))
close_paren = stat.rindex(")")
return nil unless close_paren
stat[(close_paren + 2)..].to_s.split[STARTTIME_INDEX]&.to_i
rescue SystemCallError
nil
end
end
PR_SET_DUMPABLE = 4
def self.protect_from_ptrace
prctl = Fiddle::Function.new(
Fiddle::Handle.new(nil)["prctl"],
[ Fiddle::TYPE_INT ] * 5,
Fiddle::TYPE_INT
)
prctl.call(PR_SET_DUMPABLE, 0, 0, 0, 0).zero?
rescue StandardError
# prctl を持たない環境(macOS 等)では諦める。本番は常に Linux。
false
end
def self.wipe_tmp_root
return unless TMP_ROOT_WIPABLE
return unless File.directory?(TMP_ROOT)
Dir.children(TMP_ROOT).each { |entry| FileUtils.rm_rf(File.join(TMP_ROOT, entry)) }
rescue SystemCallError
nil
end
wipe_tmp_root
BASELINE_PIDS = ProcessTable.own_uid_pids.freeze
# 自分自身の /proc が読めないことは現実には起きないが、万一 nil になった場合は 0 に倒す。
# sweep 側の条件は `ticks < HANDLER_START_TICKS` なので、0 なら誰も見逃さない(fail-close)。
HANDLER_START_TICKS = ProcessTable.start_ticks(Process.pid) || 0
NPROC_LIMIT = begin
count = ProcessTable.own_uid_task_count
count.positive? ? count + NPROC_HEADROOM : NPROC_FALLBACK
end
# ベースラインを採り終えてから保護をかける(順序を入れ替えても実測上は同じだが、
# /proc の見え方に依存する処理を保護前に済ませておく方が安全側)。
PTRACE_PROTECTED = protect_from_ptrace
unless PTRACE_PROTECTED
warn "[code_runner] prctl(PR_SET_DUMPABLE, 0) に失敗しました。" \
"ptrace 保護と /proc 経由の情報漏洩対策が無効な状態で動作します。"
end
# 上限に達した後も読み捨てを続けるバッファ。
# 読むのを止めるとパイプが満杯になって子がブロックし、kill 処理とデッドロックする。
class CappedBuffer
def initialize(limit_bytes:, max_chars:)
@limit_bytes = limit_bytes
@max_chars = max_chars
@bytes = "".b
@truncated = false
@text = nil
end
def <<(chunk)
remaining = @limit_bytes - @bytes.bytesize
if remaining <= 0
@truncated = true
elsif chunk.bytesize > remaining
@bytes << chunk.byteslice(0, remaining)
@truncated = true
else
@bytes << chunk
end
self
end
# 読み切ったあとに 1 度だけ呼ぶ。text / truncated? はこれ以降に参照する。
#
# PostgreSQL の text 型に保存できる文字列にするため、この順序で処理する。
# 1. 不正な UTF-8 を scrub する(1 バイトが U+FFFD = 3 バイトに膨らむ)
# 2. NUL を落とす。valid_encoding? は true を返し scrub でも消えないが、
# PostgreSQL は符号位置 0 を保持できない
# 3. 最後に文字数で締める。1 と 2 でバイト数も文字数も変わるので、締めは必ず最後
def finalize
text = @bytes.dup.force_encoding(Encoding::UTF_8)
text = text.scrub unless text.valid_encoding?
text = text.tr("\u0000", "\uFFFD") if text.include?("\u0000")
if text.length > @max_chars
text = text[0, @max_chars]
@truncated = true
end
@text = text
self
end
attr_reader :text
def truncated?
@truncated
end
end
# 1 回の invoke。
class Sandbox
def call(event)
started_at = monotonic_now
raise ArgumentError, "event must be a Hash" unless event.is_a?(Hash)
# 前回の invoke がハンドラごと殺されて cleanup を完走できていない場合に備え、
# 開始時にも掃除する。これがないと残骸が今回のユーザーコードと同時に動き、
# /tmp に残った main.rb や stdin.txt を読まれる。
sweep_stray_processes
CodeRunner.wipe_tmp_root
source = fetch_source(event)
stdin_text = fetch_stdin(event)
timeout_seconds = fetch_timeout_seconds(event)
@workdir = prepare_workdir(source, stdin_text)
result = execute(timeout_seconds)
build_response(result, event, started_at)
ensure
cleanup
end
private
def fetch_source(event)
source = event["source"]
raise ArgumentError, "source must be a String" unless source.is_a?(String)
raise ArgumentError, "source is too long" if source.length > SOURCE_MAX_LENGTH
source
end
def fetch_stdin(event)
stdin_text = event["stdin"] || ""
raise ArgumentError, "stdin must be a String" unless stdin_text.is_a?(String)
raise ArgumentError, "stdin is too long" if stdin_text.length > STDIN_MAX_LENGTH
stdin_text
end
def fetch_timeout_seconds(event)
requested = event["timeout_ms"]
return DEFAULT_TIMEOUT_MS / 1000.0 if requested.nil?
raise ArgumentError, "timeout_ms must be numeric" unless requested.is_a?(Numeric)
requested = requested.to_i
requested = DEFAULT_TIMEOUT_MS unless requested.positive?
[ requested, MAX_TIMEOUT_MS ].min / 1000.0
end
def prepare_workdir(source, stdin_text)
path = File.join(TMP_ROOT, "#{WORKDIR_PREFIX}#{SecureRandom.uuid}")
# mkdir_p と違い、既に存在すれば失敗する。UUID なので衝突は事故だけだが、
# 他人が先回りして作ったディレクトリを使うことがないようにしておく。
Dir.mkdir(path, 0o700)
write_exclusively(File.join(path, ENTRYPOINT_FILENAME), source)
write_exclusively(File.join(path, STDIN_FILENAME), stdin_text)
path
end
# O_EXCL | O_NOFOLLOW で開く。残留プロセスがいた場合に main.rb をシンボリックリンクへ
# 差し替えて採点を乗っ取る、という経路を塞ぐ。
def write_exclusively(path, content)
flags = File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW | File::BINARY
File.open(path, flags, 0o600) { |file| file.write(content) }
end
def execute(timeout_seconds)
ios = []
begin
out_reader, out_writer = IO.pipe
ios.push(out_reader, out_writer)
err_reader, err_writer = IO.pipe
ios.push(err_reader, err_writer)
begin
@pgid = spawn_child(out_writer, err_writer)
ensure
# 親側の書き込み端を閉じないと子の終了後も EOF が来ない。
close_io(out_writer)
close_io(err_writer)
end
pump(out_reader, err_reader, timeout_seconds)
ensure
# 2 本目の IO.pipe が失敗した場合でも、開いた分は必ず閉じる。
ios.each { |io| close_io(io) }
end
end
def spawn_child(out_writer, err_writer)
Process.spawn(
child_env,
RbConfig.ruby,
"-E", "UTF-8:UTF-8",
# -w は付けない。"assigned but unused variable" が stderr に出て採点に影響する。
"--disable-did_you_mean",
"--disable-error_highlight",
ENTRYPOINT_FILENAME,
unsetenv_others: true,
# プロセスグループ宛 SIGKILL でツリーを一括終了するため。
pgroup: true,
chdir: @workdir,
# fd 3 以降の継承を防ぎ、ハンドラが握るソケット等を子に渡さない。
close_others: true,
umask: 0o077,
# stdin はパイプではなくファイル。パイプにすると子が読まない大入力で
# 親の write がブロックしデッドロックする。
in: File.join(@workdir, STDIN_FILENAME),
out: out_writer,
err: err_writer,
**rlimits
)
end
def child_env
{
"LANG" => CHILD_LANG,
"TZ" => CHILD_TZ,
"HOME" => @workdir,
"PATH" => CHILD_PATH
}
end
def rlimits
{
rlimit_cpu: CPU_SECONDS,
rlimit_as: ADDRESS_SPACE_BYTES,
rlimit_nproc: NPROC_LIMIT,
rlimit_fsize: FILE_SIZE_BYTES,
rlimit_nofile: OPEN_FILES,
rlimit_core: CORE_SIZE
}
end
# 出力の読み取りと壁時計デッドラインの監視を 1 つのループで行う。
#
# Ruby の Timeout を子プロセスの内部に仕込む方式は rescue Exception / ensure で
# 握り潰せるため無効。ここで親がモノトニック時計を見て SIGKILL する。
# SIGTERM は trap("TERM") {} で無視されるので一切送らない。
def pump(out_reader, err_reader, timeout_seconds)
buffers = { out_reader => new_buffer, err_reader => new_buffer }
open_readers = buffers.keys.dup
deadline = monotonic_now + timeout_seconds
hard_deadline = nil
status = nil
timed_out = false
loop do
now = monotonic_now
if !timed_out && now >= deadline
timed_out = true
hard_deadline = now + KILL_GRACE_SECONDS
terminate_children
end
break if hard_deadline && now >= hard_deadline
read_ready(open_readers, buffers)
status ||= reap
break if status && open_readers.empty?
# 子は終わったが孫がパイプを握っている場合、EOF は来ない。
# デッドラインまで待ち、超えたら kill してから読み切る。
sleep(POLL_INTERVAL_SECONDS) if open_readers.empty? && status.nil?
end
status ||= reap
{
stdout: buffers[out_reader].finalize,
stderr: buffers[err_reader].finalize,
status: status,
timed_out: timed_out
}
end
def new_buffer
CappedBuffer.new(limit_bytes: OUTPUT_LIMIT_BYTES, max_chars: RESPONSE_MAX_CHARS)
end
def read_ready(open_readers, buffers)
return if open_readers.empty?
ready = IO.select(open_readers, nil, nil, POLL_INTERVAL_SECONDS)
return unless ready
ready.first.each do |io|
buffers[io] << io.read_nonblock(READ_CHUNK_BYTES)
rescue IO::WaitReadable
next
rescue EOFError, IOError, SystemCallError
open_readers.delete(io)
end
end
def reap
_, status = Process.waitpid2(@pgid, Process::WNOHANG)
status
rescue Errno::ECHILD
# 既に回収済み。exit status は取得できない。
nil
end
def terminate_children
kill_process_group
sweep_stray_processes
end
def kill_process_group
return unless @pgid
Process.kill("KILL", -@pgid)
rescue Errno::ESRCH, Errno::EPERM
nil
end
# Process.setsid でプロセスグループから抜けた孫や、前回の invoke で取りこぼした
# 残骸を回収する最終手段。
#
# 「Init 時のベースラインに無い PID」だけを条件にすると、Lambda 以外の環境
# (CI ランナー等)で無関係なプロセスを巻き込みうる。ハンドラ自身の starttime 以降に
# 生まれたものという条件を重ね、対象をこの実行環境が生んだ子孫に限定している。
def sweep_stray_processes
return unless ProcessTable.available?
(ProcessTable.own_uid_pids - BASELINE_PIDS).each do |pid|
next if pid == Process.pid
ticks = ProcessTable.start_ticks(pid)
# starttime が読めなかったものは見逃さず殺す(fail-close)。
# ベースライン外と分かっている以上、取りこぼす方が危険。
next if ticks && ticks < HANDLER_START_TICKS
begin
Process.kill("KILL", pid)
rescue Errno::ESRCH, Errno::EPERM
next
end
end
end
def build_response(result, event, started_at)
status = result[:status]
exited_normally = status && !status.signaled?
{
"stdout" => result[:stdout].text,
"stderr" => result[:stderr].text,
"exit_status" => exited_normally ? status.exitstatus : nil,
"signal" => status&.signaled? ? Signal.signame(status.termsig) : nil,
"timed_out" => result[:timed_out],
"truncated" => {
"stdout" => result[:stdout].truncated?,
"stderr" => result[:stderr].truncated?
},
"duration_ms" => ((monotonic_now - started_at) * 1000).round,
"request_token" => event["request_token"]
}
end
# invoke ごとに必ず実行する後始末。
# Lambda は完了しなかったバックグラウンドプロセスを実行環境の再利用時に再開させるため、
# プロセスとファイルの両方を残さない。
def cleanup
kill_process_group
sweep_stray_processes
reap_remaining
CodeRunner.wipe_tmp_root
end
# ゾンビも RLIMIT_NPROC を消費する。SIGKILL 直後は終了しきっていないことがあるので、
# 短い予算のあいだ回収を試みる。
def reap_remaining
return unless @pgid
deadline = monotonic_now + REAP_TIMEOUT_SECONDS
loop do
return if Process.waitpid(@pgid, Process::WNOHANG)
return if monotonic_now >= deadline
sleep(POLL_INTERVAL_SECONDS / 5)
end
rescue Errno::ECHILD
nil
end
def close_io(io)
io.close if io && !io.closed?
end
def monotonic_now
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
end
# Lambda ランタイムの呼び出し規約。context はこのハンドラでは使わないが引数として必須。
def lambda_handler(event:, context:)
CodeRunner::Sandbox.new.call(event)
end
プロセス構成
Lambda 実行環境
└─ ruby(Lambda ruby3.4 ランタイム) … ハンドラ = 信頼コード
└─ [fork+exec] ruby main.rb … 信頼できないユーザーコード
stdin ← workdir/stdin.txt(ファイル)
stdout → pipe(64KiB キャップ、超過後も読み捨て継続)
stderr → pipe(同上)
処理の流れ
処理は2段階に分かれます
Initフェーズ(実行環境が起動したときに1度だけ)
Lambdaは呼び出しのたびに新しい実行環境を作るとは限らず、前回の環境を使い回すことがあります(ウォームスタート)。そのため起動時に一度だけ、/tmpを空にし、今動いているプロセス一覧をBASELINE_PIDSとして記録し、protect_from_ptraceでハンドラ自身を保護します。
invokeフェーズ(リクエストのたびに)
lambda_handler → Sandbox#call が以下を順に実行
-
sweep_stray_processes/wipe_tmp_rootで前回の残骸を掃除 -
fetch_sourceなどで入力値を検証 -
prepare_workdirでコードをmain.rbとして書き込む -
spawn_childで子プロセスとして実行 -
pumpで出力を読みつつ制限時間を監視し、超えたらSIGKILL -
build_responseでレスポンスを組み立てる -
ensureのcleanupで子プロセス・残留プロセス・/tmpを必ず後始末
考えられる攻撃例と対策
| 攻撃例 | 対策 |
|---|---|
while true; end(無限ループ) |
rlimit_cpu: [5, 6] でCPU時間をハード制限、カーネルが強制終了 |
| 巨大配列の確保(メモリ爆弾) |
rlimit_as: 768MiB で上限、超過は子だけNoMemoryError
|
loop { fork }(fork爆弾) |
rlimit_nproc でプロセス数に上限 |
trap("TERM") { } |
SIGTERMは送らない。捕まえられないSIGKILLのみ使用 |
大量のputs
|
CappedBufferが64KiBで打ち切り、以降は読み捨て |
Process.setsidでグループ離脱 |
sweep_stray_processesが基準線外のプロセスを個別に強制終了 |
main.rbをシンボリックリンクに差替え |
O_EXCL | O_NOFOLLOWで書き込み自体を失敗させる |
親(ハンドラ)をkill
|
直接は防げないが、次回invoke開始時の掃除で被害を1回に限定 |
| Lambda Runtime APIへの直接アクセス | 防げない。そもそも正解をLambdaに渡さない設計で回避 |
上7つはテストで防げることを確認済み
下2つはコードだけでは防ぎきれず、被害範囲の限定や設計で回避
まとめ
- Rubyに権限モデルは無く、「危険なメソッドを塞ぐ」対策は
fiddleで迂回されるため無効 - 有効な境界はOSのプロセスだけだったので、ハンドラとユーザーコードをプロセスで分離し、rlimitとSIGKILLで封じ込めた
- 典型的な攻撃パターンはDocker上のテストで防げることを確認した
ただしこれは手元のDocker環境での検証結果に過ぎず、実際にLambdaへデプロイするとDockerでは見えなかった問題が見つかりました。続きをデプロイ編として近日中に投稿します。