Skip to content

Commit 80b8556

Browse files
committed
Make the sandbox actually use a restricted environment
1 parent eb9d9d0 commit 80b8556

File tree

5 files changed

+163
-21
lines changed

5 files changed

+163
-21
lines changed

setup/worker_server_info.py.template

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ server_info = {{
33
"mail_name": "AI Contest",
44
"mail_password": "",
55
"root_path": "{contest_root}",
6+
"repo_path": "{repo_dir}",
67
"maps_path": "{map_dir}",
78
"submissions_path": "{compiled_dir}",
89
"api_base_url": "{api_url}",

setup/worker_setup.py

+5
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def setup_contest_files(opts):
130130
with open(si_filename, 'r') as si_file:
131131
si_template = si_file.read()
132132
si_contents = si_template.format(contest_root=contest_root,
133+
repo_dir=opts.local_repo,
133134
map_dir=map_dir, compiled_dir=compiled_dir,
134135
api_url=opts.api_url, api_key=opts.api_key)
135136
with CD(worker_dir):
@@ -173,6 +174,8 @@ def create_jail_group(options):
173174
""" Create user group for jail users and set limits on it """
174175
if not file_contains("/etc/group", "^jailusers"):
175176
run_cmd("groupadd jailusers")
177+
run_cmd("groupadd jailkeeper")
178+
run_cmd("usermod -a -G jailkeeper %s" % (options.username,))
176179
limits_conf = "/etc/security/limits.conf"
177180
if not file_contains(limits_conf, "@jailusers"):
178181
# limit jailuser processes to:
@@ -205,6 +208,8 @@ def create_jail_user(username):
205208
home_dir = os.path.join(jail_dir, "home/home/jailuser")
206209
os.makedirs(home_dir)
207210
run_cmd("chown %s:jailusers %s" % (username, home_dir))
211+
run_cmd("chown :jailkeeper %s" % (jail_dir,))
212+
run_cmd("chmod g=rwx %s" % (jail_dir,))
208213
fs_line = "unionfs-fuse#%s=rw:%s=ro:%s=ro %s fuse cow,allow_other 0 0" % (
209214
os.path.join(jail_dir, "scratch"),
210215
os.path.join(jail_dir, "home"),

worker/engine.py

+1
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ def run_game(game, botcmds, options, gameid=0):
208208
for bot in bots:
209209
if bot.is_alive:
210210
bot.kill()
211+
bot.release()
211212

212213
# close all the open files
213214
if stream_log:

worker/jail_own.c

+5-2
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ int own_dir_tree(const char *base_dir, uid_t owner, gid_t group) {
4444
fprintf(stderr, "Could not change owner on '%s'\n", child_path);
4545
error = 1;
4646
} else {
47-
printf("Changed owner on: %s\n", child_path);
47+
// Uncomment for verbose output on every file ownership change
48+
// printf("Changed owner on: %s\n", child_path);
4849
}
4950
if (entry->d_type == DT_DIR) {
5051
error = own_dir_tree(child_path, owner, group) ? 1 : error;
@@ -107,8 +108,10 @@ int main(int argc, char *argv[]) {
107108
int status = 0;
108109
printf("Changing ownership to uid %d, gid %d\n", owner_uid, owner_gid);
109110
if (chown(base_path, owner_uid, owner_gid)) {
110-
printf("Could not change owner on %s\n", base_path);
111+
fprintf(stderr, "Could not change owner on %s\n", base_path);
111112
status = EXIT_CHOWN_FAIL;
113+
} else {
114+
printf("Changed owner on: %s\n", base_path);
112115
}
113116
if (own_dir_tree(base_path, owner_uid, owner_gid)) {
114117
status = EXIT_CHOWN_FAIL;

worker/sandbox.py

+151-19
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,99 @@
99
from Queue import Queue, Empty
1010
from threading import Thread
1111

12+
try:
13+
from server_info import server_info
14+
_SECURE_DEFAULT = True
15+
except ImportError:
16+
_SECURE_DEFAULT = False
17+
18+
class JailError(StandardError):
19+
pass
20+
21+
class _Jail(object):
22+
def __init__(self):
23+
self.locked = False
24+
jail_base = "/srv/chroot"
25+
all_jails = os.listdir(jail_base)
26+
all_jails = [j for j in all_jails if j.startswith("jailuser")]
27+
for jail in all_jails:
28+
lock_dir = os.path.join(jail_base, jail, "locked")
29+
try:
30+
os.mkdir(lock_dir)
31+
except OSError:
32+
# if the directory could not be created, that should mean the
33+
# jail is already locked and in use
34+
continue
35+
with open(os.path.join(lock_dir, "lock.pid"), "w") as pid_file:
36+
pid_file.write(str(os.getpid()))
37+
self.locked = True
38+
self.name = jail
39+
break
40+
else:
41+
raise JailError("Could not find an unlocked jail")
42+
self.jchown = os.path.join(server_info["root_path"],
43+
server_info["repo_path"], "worker/jail_own")
44+
self.base_dir = os.path.join(jail_base, jail)
45+
self.number = int(jail[len("jailuser"):])
46+
self.chroot_cmd = "sudo -u {0} schroot -u {0} -c {0} -d {1} ".format(
47+
self.name, "/home/jailuser")
48+
49+
def __del__(self):
50+
if self.locked:
51+
raise JailError("Jail object for %s freed without being released"
52+
% (self.name))
53+
54+
def release(self):
55+
if not self.locked:
56+
raise JailError("Attempt to release jail that is already unlocked")
57+
lock_dir = os.path.join(self.base_dir, "locked")
58+
pid_filename = os.path.join(lock_dir, "lock.pid")
59+
with open(pid_filename, 'r') as pid_file:
60+
lock_pid = int(pid_file.read())
61+
if lock_pid != os.getpid():
62+
# if we ever get here something has gone seriously wrong
63+
# most likely the jail locking mechanism has failed
64+
raise JailError("Jail released by different pid, name %s, lock_pid %d, release_pid %d"
65+
% (self.name, lock_pid, os.getpid()))
66+
os.unlink(pid_filename)
67+
os.rmdir(lock_dir)
68+
self.locked = False
69+
70+
def prepare_with(self, command_dir):
71+
if os.system("%s c %d" % (self.jchown, self.number)) != 0:
72+
raise JailError("Error returned from jail_own c %d in prepare"
73+
% (self.number,))
74+
scratch_dir = os.path.join(self.base_dir, "scratch")
75+
if os.system("rm -rf %s" % (scratch_dir,)) != 0:
76+
raise JailError("Could not remove old scratch area from jail %d"
77+
% (self.number,))
78+
home_dir = os.path.join(scratch_dir, "home/jailuser")
79+
os.makedirs(home_dir)
80+
if os.system("cp -r %s %s" % (command_dir, home_dir)) != 0:
81+
raise JailError("Error copying working directory '%s' to jail %d"
82+
% (command_dir, self.number))
83+
if os.system("%s j %d" % (self.jchown, self.number)) != 0:
84+
raise JailError("Error returned from jail_own j %d in prepare"
85+
% (self.number,))
86+
87+
def signal(self, signal):
88+
if not self.locked:
89+
raise JailError("Attempt to send %s to unlocked jail" % (signal,))
90+
if os.system("sudo -u {0} killall -{1} -u {0}".format(
91+
self.name, signal)) != 0:
92+
raise JailError("Error returned from jail kill for %s"
93+
% (self.name,))
94+
95+
def kill(self):
96+
self.signal("KILL")
97+
98+
def pause(self):
99+
self.signal("STOP")
100+
101+
def resume(self):
102+
self.signal("CONT")
103+
104+
12105
def _monitor_input_channel(sandbox):
13106
while sandbox.is_alive:
14107
try:
@@ -31,7 +124,8 @@ class Sandbox:
31124
32125
"""
33126

34-
def __init__(self, working_directory, shell_command, stderr=None):
127+
def __init__(self, working_directory, shell_command, stderr=None,
128+
secure=_SECURE_DEFAULT):
35129
"""Initialize a new sandbox and invoke the given shell command inside.
36130
37131
working_directory: the directory in which the shell command should
@@ -40,15 +134,26 @@ def __init__(self, working_directory, shell_command, stderr=None):
40134
shell command is executed.
41135
shell_command: the shell command to launch inside the sandbox.
42136
stderr: where the bot's stderr output should be written out to
137+
defaults to keeping the current stderr for the child process
138+
secure: really use a jail or just run the command directly
139+
defaults to True when a server_info module is found, False
140+
otherwise
43141
44142
"""
45-
shell_command = shell_command.replace('\\','/')
46143
self.is_alive = False
47144
self.command_process = None
48145
self.stdout_queue = Queue()
49-
self.stderr_queue = Queue()
50146

51-
self.command_process = subprocess.Popen(shlex.split(shell_command),
147+
if secure:
148+
self.jail = _Jail()
149+
self.jail.prepare_with(working_directory)
150+
shell_command = self.jail.chroot_cmd + shell_command
151+
working_directory = None
152+
else:
153+
self.jail = None
154+
155+
shell_command = shlex.split(shell_command.replace('\\','/'))
156+
self.command_process = subprocess.Popen(shell_command,
52157
stdin=subprocess.PIPE,
53158
stdout=subprocess.PIPE,
54159
stderr=stderr,
@@ -57,41 +162,62 @@ def __init__(self, working_directory, shell_command, stderr=None):
57162
stdout_monitor = Thread(target=_monitor_input_channel, args=(self,))
58163
stdout_monitor.start()
59164

165+
def release(self):
166+
"""Release the sandbox for further use
167+
168+
If running in a jail unlocks and releases the jail for reuse by others.
169+
Must be called exactly once after Sandbox.kill has been called.
170+
171+
"""
172+
if self.is_alive:
173+
raise JailError("Jail released while still alive")
174+
if self.jail:
175+
self.jail.release()
176+
60177
def kill(self):
61-
""" Shuts down the sandbox.
178+
"""Shuts down the sandbox.
62179
63180
Shuts down the sandbox, cleaning up any spawned processes, threads, and
64181
other resources. The shell command running inside the sandbox may be
65182
suddenly terminated.
66183
67184
"""
68185
if self.is_alive:
69-
try:
70-
self.command_process.kill()
71-
self.command_process.wait()
72-
except OSError:
73-
pass
186+
if self.jail:
187+
self.jail.kill()
188+
else:
189+
try:
190+
self.command_process.kill()
191+
self.command_process.wait()
192+
except OSError:
193+
pass
74194
self.is_alive = False
75195

76196
def pause(self):
77197
"""Pause the process by sending a SIGSTOP to the child
78198
79199
This method is a no-op on Windows
80200
"""
81-
try:
82-
self.command_process.send_signal(signal.SIGSTOP)
83-
except (ValueError, AttributeError):
84-
pass
201+
if self.jail:
202+
self.jail.pause()
203+
else:
204+
try:
205+
self.command_process.send_signal(signal.SIGSTOP)
206+
except (ValueError, AttributeError):
207+
pass
85208

86209
def resume(self):
87210
"""Resume the process by sending a SIGCONT to the child
88211
89212
This method is a no-op on Windows
90213
"""
91-
try:
92-
self.command_process.send_signal(signal.SIGCONT)
93-
except (ValueError, AttributeError):
94-
pass
214+
if self.jail:
215+
self.jail.resume()
216+
else:
217+
try:
218+
self.command_process.send_signal(signal.SIGCONT)
219+
except (ValueError, AttributeError):
220+
pass
95221

96222
def write(self, str):
97223
"""Write str to stdin of the process being run"""
@@ -146,12 +272,18 @@ def main():
146272
parser.add_option("-r", "--receive-delay", action="store",
147273
dest="resp_delay", type="float", default=0.01,
148274
help="Time in seconds to sleep before checking for a response line")
275+
parser.add_option("-j", "--jail", action="store_true", dest="secure",
276+
default=_SECURE_DEFAULT,
277+
help="Run in a secure jail")
278+
parser.add_option("-o", "--open", action="store_false", dest="secure",
279+
help="Run without using a secure jail")
149280
options, args = parser.parse_args()
150281
if len(args) == 0:
151282
parser.error("Must include a command to run.\
152283
\nRun with --help for more information.")
153284

154-
sandbox = Sandbox(options.working_dir, " ".join(args))
285+
sandbox = Sandbox(options.working_dir, " ".join(args),
286+
secure=options.secure)
155287
for line in options.send_lines:
156288
if not sandbox.write_line(line):
157289
print >> sys.stderr, "Could not send line '%s'" % (line,)

0 commit comments

Comments
 (0)