Fix misuse of fork in sandbox causing crashes

This commit is contained in:
Bo Anderson 2024-08-28 03:45:26 +01:00
parent 45be393f95
commit 6a0db5035f
No known key found for this signature in database
6 changed files with 198 additions and 175 deletions

View File

@ -81,7 +81,6 @@ module Homebrew
exec_args << "--HEAD" if f.head? exec_args << "--HEAD" if f.head?
Utils.safe_fork do |error_pipe|
if Sandbox.available? if Sandbox.available?
sandbox = Sandbox.new sandbox = Sandbox.new
f.logs.mkpath f.logs.mkpath
@ -93,9 +92,10 @@ module Homebrew
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks") sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log") sandbox.allow_write_path(HOMEBREW_PREFIX/"var/log")
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run") sandbox.allow_write_path(HOMEBREW_PREFIX/"var/run")
sandbox.deny_all_network_except_pipe(error_pipe) unless f.class.network_access_allowed?(:test) sandbox.deny_all_network unless f.class.network_access_allowed?(:test)
sandbox.exec(*exec_args) sandbox.run(*exec_args)
else else
Utils.safe_fork do
exec(*exec_args) exec(*exec_args)
end end
end end

View File

@ -755,21 +755,14 @@ class BottleFormulaUnavailableError < RuntimeError
end end
end end
# Raised when a child process sends us an exception over its error pipe. # Raised when a `Utils.safe_fork` exits with a non-zero code.
class ChildProcessError < RuntimeError class ChildProcessError < RuntimeError
attr_reader :inner, :inner_class attr_reader :status
def initialize(inner) def initialize(status)
@inner = inner @status = status
@inner_class = Object.const_get inner["json_class"]
super <<~EOS super "Forked child process failed: #{status}"
An exception occurred within a child process:
#{inner_class}: #{inner["m"]}
EOS
# Clobber our real (but irrelevant) backtrace with that of the inner exception.
set_backtrace inner["b"]
end end
end end

View File

@ -931,7 +931,6 @@ on_request: installed_on_request?, options:)
formula.specified_path, formula.specified_path,
].concat(build_argv) ].concat(build_argv)
Utils.safe_fork do |error_pipe|
if Sandbox.available? if Sandbox.available?
sandbox = Sandbox.new sandbox = Sandbox.new
formula.logs.mkpath formula.logs.mkpath
@ -943,9 +942,10 @@ on_request: installed_on_request?, options:)
sandbox.allow_fossil sandbox.allow_fossil
sandbox.allow_write_xcode sandbox.allow_write_xcode
sandbox.allow_write_cellar(formula) sandbox.allow_write_cellar(formula)
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:build) sandbox.deny_all_network unless formula.network_access_allowed?(:build)
sandbox.exec(*args) sandbox.run(*args)
else else
Utils.safe_fork do
exec(*args) exec(*args)
end end
end end
@ -1161,7 +1161,6 @@ on_request: installed_on_request?, options:)
args << post_install_formula_path args << post_install_formula_path
Utils.safe_fork do |error_pipe|
if Sandbox.available? if Sandbox.available?
sandbox = Sandbox.new sandbox = Sandbox.new
formula.logs.mkpath formula.logs.mkpath
@ -1171,12 +1170,13 @@ on_request: installed_on_request?, options:)
sandbox.allow_write_xcode sandbox.allow_write_xcode
sandbox.deny_write_homebrew_repository sandbox.deny_write_homebrew_repository
sandbox.allow_write_cellar(formula) sandbox.allow_write_cellar(formula)
sandbox.deny_all_network_except_pipe(error_pipe) unless formula.network_access_allowed?(:postinstall) sandbox.deny_all_network unless formula.network_access_allowed?(:postinstall)
Keg::KEG_LINK_DIRECTORIES.each do |dir| Keg::KEG_LINK_DIRECTORIES.each do |dir|
sandbox.allow_write_path "#{HOMEBREW_PREFIX}/#{dir}" sandbox.allow_write_path "#{HOMEBREW_PREFIX}/#{dir}"
end end
sandbox.exec(*args) sandbox.run(*args)
else else
Utils.safe_fork do
exec(*args) exec(*args)
end end
end end

View File

@ -5,12 +5,19 @@ require "erb"
require "io/console" require "io/console"
require "pty" require "pty"
require "tempfile" require "tempfile"
require "utils/fork"
# Helper class for running a sub-process inside of a sandboxed environment. # Helper class for running a sub-process inside of a sandboxed environment.
class Sandbox class Sandbox
SANDBOX_EXEC = "/usr/bin/sandbox-exec" SANDBOX_EXEC = "/usr/bin/sandbox-exec"
private_constant :SANDBOX_EXEC private_constant :SANDBOX_EXEC
# This is defined in the macOS SDK but Ruby unfortunately does not expose it.
# This value can be found by compiling a C program that prints TIOCSCTTY.
# The value is different on Linux but that's not a problem as we only support macOS in this file.
TIOCSCTTY = 0x20007461
private_constant :TIOCSCTTY
sig { returns(T::Boolean) } sig { returns(T::Boolean) }
def self.available? def self.available?
false false
@ -125,15 +132,12 @@ class Sandbox
add_rule allow: false, operation: "network*" add_rule allow: false, operation: "network*"
end end
sig { params(path: T.any(String, Pathname)).void }
def deny_all_network_except_pipe(path)
deny_all_network
allow_network path:, type: :literal
end
sig { params(args: T.any(String, Pathname)).void } sig { params(args: T.any(String, Pathname)).void }
def exec(*args) def run(*args)
seatbelt = Tempfile.new(["homebrew", ".sb"], HOMEBREW_TEMP) Dir.mktmpdir("homebrew-sandbox", HOMEBREW_TEMP) do |tmpdir|
allow_network path: File.join(tmpdir, "socket"), type: :literal # Make sure we have access to the error pipe.
seatbelt = File.new(File.join(tmpdir, "homebrew.sb"), "wx")
seatbelt.write(@profile.dump) seatbelt.write(@profile.dump)
seatbelt.close seatbelt.close
@start = T.let(Time.now, T.nilable(Time)) @start = T.let(Time.now, T.nilable(Time))
@ -141,11 +145,11 @@ class Sandbox
begin begin
command = [SANDBOX_EXEC, "-f", seatbelt.path, *args] command = [SANDBOX_EXEC, "-f", seatbelt.path, *args]
# Start sandbox in a pseudoterminal to prevent access of the parent terminal. # Start sandbox in a pseudoterminal to prevent access of the parent terminal.
PTY.spawn(*command) do |r, w, pid| PTY.open do |controller, worker|
# Set the PTY's window size to match the parent terminal. # Set the PTY's window size to match the parent terminal.
# Some formula tests are sensitive to the terminal size and fail if this is not set. # Some formula tests are sensitive to the terminal size and fail if this is not set.
winch = proc do |_sig| winch = proc do |_sig|
w.winsize = if $stdout.tty? controller.winsize = if $stdout.tty?
# We can only use IO#winsize if the IO object is a TTY. # We can only use IO#winsize if the IO object is a TTY.
$stdout.winsize $stdout.winsize
else else
@ -164,16 +168,34 @@ class Sandbox
winch.call(nil) winch.call(nil)
stdin_thread = Thread.new do stdin_thread = Thread.new do
IO.copy_stream($stdin, w) IO.copy_stream($stdin, controller)
rescue Errno::EIO rescue Errno::EIO
# stdin is unavailable - move on. # stdin is unavailable - move on.
end end
r.each_char { |c| print(c) } stdout_thread = Thread.new do
controller.each_char { |c| print(c) }
end
Process.wait(pid) Utils.safe_fork(directory: tmpdir, yield_parent: true) do |error_pipe|
if error_pipe
# Child side
Process.setsid
controller.close
worker.ioctl(TIOCSCTTY, 0) # Make this the controlling terminal.
File.open("/dev/tty", Fcntl::O_WRONLY).close # Workaround for https://developer.apple.com/forums/thread/663632
worker.close_on_exec = true
exec(*command, in: worker, out: worker, err: worker) # And map everything to the PTY.
else
# Parent side
worker.close
end
end
rescue ChildProcessError => e
raise ErrorDuringExecution.new(command, status: e.status)
ensure ensure
stdin_thread&.kill stdin_thread&.kill
stdout_thread&.kill
trap(:TTIN, old_ttin) trap(:TTIN, old_ttin)
trap(:WINCH, old_winch) trap(:WINCH, old_winch)
end end
@ -194,12 +216,10 @@ class Sandbox
write_to_pty.call write_to_pty.call
end end
end end
raise ErrorDuringExecution.new(command, status: $CHILD_STATUS) unless $CHILD_STATUS.success?
rescue rescue
@failed = true @failed = true
raise raise
ensure ensure
seatbelt.unlink
sleep 0.1 # wait for a bit to let syslog catch up the latest events. sleep 0.1 # wait for a bit to let syslog catch up the latest events.
syslog_args = [ syslog_args = [
"-F", "$((Time)(local)) $(Sender)[$(PID)]: $(Message)", "-F", "$((Time)(local)) $(Sender)[$(PID)]: $(Message)",
@ -231,6 +251,7 @@ class Sandbox
end end
end end
end end
end
# @api private # @api private
sig { params(path: T.any(String, Pathname), type: Symbol).returns(String) } sig { params(path: T.any(String, Pathname), type: Symbol).returns(String) }

View File

@ -16,7 +16,7 @@ RSpec.describe Sandbox, :needs_macos do
specify "#allow_write" do specify "#allow_write" do
sandbox.allow_write path: file sandbox.allow_write path: file
sandbox.exec "touch", file sandbox.run "touch", file
expect(file).to exist expect(file).to exist
end end
@ -65,10 +65,10 @@ RSpec.describe Sandbox, :needs_macos do
end end
end end
describe "#exec" do describe "#run" do
it "fails when writing to file not specified with ##allow_write" do it "fails when writing to file not specified with ##allow_write" do
expect do expect do
sandbox.exec "touch", file sandbox.run "touch", file
end.to raise_error(ErrorDuringExecution) end.to raise_error(ErrorDuringExecution)
expect(file).not_to exist expect(file).not_to exist
@ -80,7 +80,7 @@ RSpec.describe Sandbox, :needs_macos do
allow(Utils).to receive(:popen_read).and_call_original allow(Utils).to receive(:popen_read).and_call_original
allow(Utils).to receive(:popen_read).with("syslog", any_args).and_return("foo") allow(Utils).to receive(:popen_read).with("syslog", any_args).and_return("foo")
expect { sandbox.exec "false" } expect { sandbox.run "false" }
.to raise_error(ErrorDuringExecution) .to raise_error(ErrorDuringExecution)
.and output(/foo/).to_stdout .and output(/foo/).to_stdout
end end
@ -96,7 +96,7 @@ RSpec.describe Sandbox, :needs_macos do
allow(Utils).to receive(:popen_read).and_call_original allow(Utils).to receive(:popen_read).and_call_original
allow(Utils).to receive(:popen_read).with("syslog", any_args).and_return(with_bogus_error) allow(Utils).to receive(:popen_read).with("syslog", any_args).and_return(with_bogus_error)
expect { sandbox.exec "false" } expect { sandbox.run "false" }
.to raise_error(ErrorDuringExecution) .to raise_error(ErrorDuringExecution)
.and output(a_string_matching(/foo/).and(matching(/bar/).and(not_matching(/Python/)))).to_stdout .and output(a_string_matching(/foo/).and(matching(/bar/).and(not_matching(/Python/)))).to_stdout
end end
@ -104,28 +104,28 @@ RSpec.describe Sandbox, :needs_macos do
describe "#disallow chmod on some directory" do describe "#disallow chmod on some directory" do
it "formula does a chmod to opt" do it "formula does a chmod to opt" do
expect { sandbox.exec "chmod", "ug-w", HOMEBREW_PREFIX }.to raise_error(ErrorDuringExecution) expect { sandbox.run "chmod", "ug-w", HOMEBREW_PREFIX }.to raise_error(ErrorDuringExecution)
end end
it "allows chmod on a path allowed to write" do it "allows chmod on a path allowed to write" do
mktmpdir do |path| mktmpdir do |path|
FileUtils.touch path/"foo" FileUtils.touch path/"foo"
sandbox.allow_write_path(path) sandbox.allow_write_path(path)
expect { sandbox.exec "chmod", "ug-w", path/"foo" }.not_to raise_error(ErrorDuringExecution) expect { sandbox.run "chmod", "ug-w", path/"foo" }.not_to raise_error(ErrorDuringExecution)
end end
end end
end end
describe "#disallow chmod SUID or SGID on some directory" do describe "#disallow chmod SUID or SGID on some directory" do
it "formula does a chmod 4000 to opt" do it "formula does a chmod 4000 to opt" do
expect { sandbox.exec "chmod", "4000", HOMEBREW_PREFIX }.to raise_error(ErrorDuringExecution) expect { sandbox.run "chmod", "4000", HOMEBREW_PREFIX }.to raise_error(ErrorDuringExecution)
end end
it "allows chmod 4000 on a path allowed to write" do it "allows chmod 4000 on a path allowed to write" do
mktmpdir do |path| mktmpdir do |path|
FileUtils.touch path/"foo" FileUtils.touch path/"foo"
sandbox.allow_write_path(path) sandbox.allow_write_path(path)
expect { sandbox.exec "chmod", "4000", path/"foo" }.not_to raise_error(ErrorDuringExecution) expect { sandbox.run "chmod", "4000", path/"foo" }.not_to raise_error(ErrorDuringExecution)
end end
end end
end end

View File

@ -6,33 +6,37 @@ require "socket"
module Utils module Utils
def self.rewrite_child_error(child_error) def self.rewrite_child_error(child_error)
error = if child_error.inner["cmd"] && inner_class = Object.const_get(child_error["json_class"])
child_error.inner_class == ErrorDuringExecution error = if child_error["cmd"] && inner_class == ErrorDuringExecution
ErrorDuringExecution.new(child_error.inner["cmd"], ErrorDuringExecution.new(child_error["cmd"],
status: child_error.inner["status"], status: child_error["status"],
output: child_error.inner["output"]) output: child_error["output"])
elsif child_error.inner["cmd"] && elsif child_error["cmd"] && inner_class == BuildError
child_error.inner_class == BuildError
# We fill `BuildError#formula` and `BuildError#options` in later, # We fill `BuildError#formula` and `BuildError#options` in later,
# when we rescue this in `FormulaInstaller#build`. # when we rescue this in `FormulaInstaller#build`.
BuildError.new(nil, child_error.inner["cmd"], BuildError.new(nil, child_error["cmd"], child_error["args"], child_error["env"])
child_error.inner["args"], child_error.inner["env"]) elsif inner_class == Interrupt
elsif child_error.inner_class == Interrupt
Interrupt.new Interrupt.new
else else
# Everything other error in the child just becomes a RuntimeError. # Everything other error in the child just becomes a RuntimeError.
RuntimeError.new(child_error.message) RuntimeError.new <<~EOS
An exception occurred within a child process:
#{inner_class}: #{child_error["m"]}
EOS
end end
error.set_backtrace child_error.backtrace error.set_backtrace child_error["b"]
error error
end end
def self.safe_fork # When using this function, remember to call `exec` as soon as reasonably possible.
# This function does not protect against the pitfalls of what you can do pre-exec in a fork.
# See `man fork` for more information.
def self.safe_fork(directory: nil, yield_parent: false)
require "json/add/exception" require "json/add/exception"
Dir.mktmpdir("homebrew", HOMEBREW_TEMP) do |tmpdir| block = proc do |tmpdir|
UNIXServer.open("#{tmpdir}/socket") do |server| UNIXServer.open("#{tmpdir}/socket") do |server|
read, write = IO.pipe read, write = IO.pipe
@ -78,6 +82,8 @@ module Utils
pid = T.must(pid) pid = T.must(pid)
begin begin
yield(nil) if yield_parent
begin begin
socket = server.accept_nonblock socket = server.accept_nonblock
rescue Errno::EAGAIN, Errno::EWOULDBLOCK, Errno::ECONNABORTED, Errno::EPROTO, Errno::EINTR rescue Errno::EAGAIN, Errno::EWOULDBLOCK, Errno::ECONNABORTED, Errno::EPROTO, Errno::EINTR
@ -100,14 +106,17 @@ module Utils
if data.present? if data.present?
error_hash = JSON.parse(T.must(data.lines.first)) error_hash = JSON.parse(T.must(data.lines.first))
raise rewrite_child_error(error_hash)
e = ChildProcessError.new(error_hash)
raise rewrite_child_error(e)
end end
raise "Forked child process failed: #{$CHILD_STATUS}" unless $CHILD_STATUS.success? raise ChildProcessError, $CHILD_STATUS unless $CHILD_STATUS.success?
end end
end end
if directory
block.call(directory)
else
Dir.mktmpdir("homebrew-fork", HOMEBREW_TEMP, &block)
end
end end
end end