By now you should have enough tools in your toolbox to sandbox all your future code. However there’s a reason why we sandbox code. Projects grow high and large until it becomes impossible to ensure their code is… bug-free. A little bug in just one of the dozens of modules within a project shouldn’t equal to a fully compromised system when the bug is exploited by a hacker. Damage should be contained. Security policies implemented by OS tools external to your code can only work at a program (or user) level. The program will still be fully compromised and the hacker will have access to all data and credentials the program has access to.
When your program deals with data that can be processed independently, there’s an opportunity to implement a safer approach. If you can run multiple instances of your program as different users on your OS, you can use existing security solutions in your project. However if the concept of allocating defined portions of the data to fixed users doesn’t work for your project, you may need something more complex or custom-tailored. When the relationships between the users are blurry and your project demands policies that are more dynamic, you may need sandboxes.
As a rule of thumb, every shell should have sandboxes. Shell are programs that act as the membrane that sits between the human operator and some virtual world. Tablets, smartphones, and laptops display graphical shells to interact with programs, windows, and files. Servers employ textual shells. Likewise web browsers act as the shells to the www world.
I wouldn’t be surprised if Firefox and Chrome were the only software employing discretionary privilege dropping that you know. They are shells after all so it matters to them. More than that, they’re very well funded projects. Sandboxing used to be very expensive (especially outside FreeBSD). However these shouldn’t be the only software out there with builtin sandboxing support. Take Telegram, for instance. The right media parsing bug could mean a hacker having access to all my chat history. What time does my son leave school? What people do I trust my credit card info with? When will I go in a trip and leave my house unattended? These are just a few examples of the damage that might be done due to the lack of sandboxes in Telegram. Not only Telegram, but every instant messenger should be employing sandboxes. Media parsing should always be performed in dedicated sandboxes.
The first step into this direction is a realistic approach to real-world engineering: let’s not rewrite all code from scratch. Deal? The tricks you learned earlier will still be useful, but from now on I’ll share tricks to work on existing real-world code. Capsicum users refer to the ability to run unmodified code within sandboxes as oblivious sandboxing. Techniques for oblivious sandboxing most often than not have nothing to do with discretionary privilege dropping and can’t solve the problems we were mentioning just a second ago. However it’s possible to combine approaches from both worlds in the same project so it’s important to study the techniques for oblivious sandboxing too.
The one place we need to look at to implement oblivious sandboxing is actually
pretty obvious: the ambient authority functions. In fact, that’s what projects
such as Super Capsicumizer
9000 do. They inject a dynamic library into a process using LD_PRELOAD to
interpose ambient authority calls. This technique is actually yesterday news and
projects such as fakeroot have been using it for decades.
Super Capsicumizer 9000 is actually a small experiment hacked together by a very very small team. The experiment succeeded into opening old software built on top of complex libraries with a long history of changes. This is very promising. It’s a sign that maybe a single programmer working alone to interpose just a few functions for ambient authority access will have success in running legacy code.
Programmers almost never do syscalls directly, and instead rely on libc to do
the syscalls on their behalf. That’s why this approach works so well. All you
have to do is to write a definition for the function from libc you want to
interpose. If you’re linking against the dynamic libc, your function will be
loaded first and used instead. If you’re linking against the static libc,
chances are that the libc symbol is actually a weak symbol so it’ll be dropped
once the static linker see your definition. Emilua has been using this approach
to support dynamic and static executables on Linux and FreeBSD and so far
getaddrinfo was the only ambient authority function whose symbol lacked the
attribute for weak symbols (please comment on the linked bug reports if you plan
to build your own sandboxes using the same techniques or using Emilua):
The next step is choosing which functions to interpose. Functions from FreeBSD’s
libcasper are good first candidates. However for some reason libcasper doesn’t
interpose the functions it intends to replace so you’ll need to change names and
parameters accordingly. libcasper functions (e.g. cap_getaddrinfo) always take
an extra parameter. Another good source of inspiration to decide which functions
to interpose is the library used by Super Capsicumizer 9000: libpreopen. Most of
the time, you’ll only need to interpose a few functions even for complex
projects.
Chromium renderers need very little authority. They need access to fontconfig to find fonts on the system and to open those font files.
Emilua 0.11 abstracts all these details into the module libc_service. The
example below shows how we use this module to override the behavior of open to
return a rogue file descriptor when the subprocess try to open
/dev/null. Actual sandboxing setup (i.e. privilege dropping within the new
subprocess) is omitted for brevity. The example also shows how to prefill the
code cache for the new subprocess so it won’t query the filesystem to fetch the
Lua code to execute.
local libc_service = require 'libc_service'
local stream = require 'stream'
local pipe = require 'pipe'
local fs = require 'filesystem'
local master, slave = libc_service.new()
slave.open = [[
local real_open, path, flag, mode = ...
local res, errno, fd = real_open(path, flag, mode)
if fd then
return fd
else
return res, errno
end
]]
local source_tree_cache = {}
source_tree_cache['a.lua'] = [[
local stream = require 'stream'
local file = require 'file'
local fs = require 'filesystem'
local f = file.stream.new()
f:open(fs.path.new('/dev/null'), {'read_only'})
f = stream.scanner.new{ stream = f }
print(f:get_line())
]]
spawn_vm{
module = fs.path.new('/a.lua'),
subprocess = {
source_tree_cache = source_tree_cache,
libc_service = slave,
stdout = 'share',
stderr = 'share',
}
}
spawn(function() pcall(function()
while true do
master:receive()
if master.function_ ~= 'open' then
master:use_slave_credentials()
goto continue
end
local p, f, m = master:arguments()
if p ~= fs.path.new('/dev/null') then
master:use_slave_credentials()
goto continue
end
local pi, po = pipe.pair()
pi = pi:release()
spawn(function()
stream.write_all(po, '/dev/null contents\n')
po:close()
end):detach()
master:send_with_fds(-2, {pi})
::continue::
end
end) end):detach()
Emilua uses UNIX sockets behind the scenes for communication between both processes. This approach allows one to implement fully dynamic security policies. For instance, if you’re trying use Telegram’s tdlib to implement your own Telegram client, you could have the following rules for your secutiry policy:
-
Only resolve name queries to
pluto.web.telegram.org. -
Only allow connect requests to the IP addresses we resolved in previous steps.
The little Lua script we send to be executed in the sandboxed side means we can
apply some simple call fixups at the call site to further broaden the use cases
we can tackle. For instance, when sandboxed code try to open a GUI connecting to
/tmp/.X11-unix/X0, we can send a new file descriptor to an unrelated display
server and replace the socket from the original request with the new one using
dup2 from the Lua script at the call site. In fact, we can do that:
local libc_service = require 'libc_service'
local stream = require 'stream'
local system = require 'system'
local pipe = require 'pipe'
local unix = require 'unix'
local fs = require 'filesystem'
local preload_libc_path
do
local pi, po = pipe.pair()
po = po:release()
pi = stream.scanner.new{ stream = pi }
system.spawn{
program = 'pkg-config',
arguments = {'pkg-config', '--variable=libpath', 'emilua_preload_libc'},
environment = system.environment,
stdout = po,
}
po:close()
preload_libc_path = tostring(pi:get_line())
end
local xephyrconnep
for i = 1, 20 do
local pi, po = pipe.pair()
po = po:release()
pi = stream.scanner.new{ stream = pi }
local xephyr = system.spawn{
program = 'Xephyr',
arguments = { 'Xephyr', ':' .. i, '-displayfd', '3' },
environment = system.environment,
extra_fds = {
[3] = po,
},
}
po:close()
if pcall(function()
local nr = tostring(pi:get_line())
xephyrconnep = '/tmp/.X11-unix/X' .. nr
return true
end) then
break
end
end
if not xephyrconnep then
print('Failed to start Xephyr')
system.exit(1)
end
local master, slave = libc_service.new()
slave.connect_unix = [[
local real_connect, fd, path = ...
local res, errno, fd2 = real_connect(fd, path)
if fd2 then
C.dup2(fd2, fd)
C.close(fd2)
end
return res, errno
]]
local guiappenv = system.environment
guiappenv.DISPLAY = ':0'
guiappenv.LD_PRELOAD = preload_libc_path
guiappenv.EMILUA_LIBC_SERVICE_FD = '3'
local guiapp = system.spawn{
program = 'xterm',
arguments = { 'xterm' },
environment = guiappenv,
stdout = 'share',
stderr = 'share',
extra_fds = {
[3] = slave,
},
}
scope_cleanup_push(function() guiapp:wait() end)
spawn(function() pcall(function()
while true do
master:receive()
if
master.function_ == 'connect_unix' and
(
master:arguments() == fs.path.new('\0/tmp/.X11-unix/X0') or
master:arguments() == fs.path.new('/tmp/.X11-unix/X0')
)
then
local xephyrconn = unix.stream.dial(xephyrconnep)
master:send_with_fds(0, {xephyrconn:release()})
else
master:use_slave_credentials()
end
end
end) end):detach()
This example also shows that Emilua can make use of LD_PRELOAD to perform libc
interposition on existing programs such as xterm.
Another interesting approach that might prove useful to your projects is to use
kcmp in your security policies. This technique would allow you to increase
your policies granularities even further by implementing different subpolicies
for each file descriptor.
By the way, now we can interpose openat() on Linux to make it work as in
FreeBSD’s capability mode (but — as usual — don’t forget to forbid the actual
syscall):
local libc_service = require 'libc_service'
local master, slave = libc_service.new()
slave.openat = [[
local real_openat, dirfd, path, flags, mode, resolve = ...
local res, errno, fd = real_openat(dirfd, path, flags, mode, resolve)
if fd then
return fd
else
return res, errno
end
]]
local worker = spawn_vm{
module = 'module4',
subprocess = {
libc_service = slave,
},
}
pcall(function()
while true do
master:receive()
if master.function_ == 'openat' then
local path, flags, mode = master:arguments()
flags[#flags + 1] = 'resolve_beneath'
local dirfd = master:descriptors()
local ok, res = pcall(function()
return dirfd:openat(path, flags, mode)
end)
if ok then
master:send_with_fds(-1, {res})
else
master:send(-1, res)
end
else
master:use_slave_credentials()
end
end
end)
These techniques are already probably more than what you need, but I have few more tricks up in my sleeve to share, so let’s move on.
