Skip to content

Commit 8caa5d1

Browse files
committed
Stability fixes and a tweak for a Python running bug found
1 parent 2b84b60 commit 8caa5d1

8 files changed

Lines changed: 324 additions & 117 deletions

File tree

ECS_RUNNER_LIFETIME.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Inside the task, the trusted Java parent runner:
9898
1. Fetches the challenge config, tester JAR, and tester metadata from `marathon-match-api-v6`.
9999
2. Downloads the submission from the configured Submission API URL.
100100
3. Posts an initial progress callback for `PROVISIONAL` and `SYSTEM` runs.
101-
4. Starts the tester in an isolated child JVM. Submitted solution commands run as the separate `scorer` user with scrubbed environment, restricted filesystem access, and no outbound INET/INET6 socket creation.
101+
4. Starts the tester in an isolated child JVM. Submitted solution commands run as the separate `scorer` user with scrubbed environment, restricted filesystem access, and no outbound INET/INET6 socket creation. For standard generic-runner seeds, the child invokes `mm-scorer-isolate --cleanup-scorer-state` before and after each test case so scorer-owned files in fixed writable roots are reset between seeds.
102102
5. Uploads public/private artifacts back through Submission API.
103103
6. Posts the final scoring callback to `POST /v6/marathon-match/internal/scoring-results`.
104104
7. Exits with code `0` only after the final callback succeeds.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ The ECS task still needs trusted outbound access to fetch challenge config, down
157157
- The trusted parent runner holds `ACCESS_TOKEN`, performs network calls, and never loads untrusted submission code directly.
158158
- The parent launches a separate child JVM through `mm-runner-isolate` with a scrubbed environment, so submission processes do not inherit the bearer token or other runner env vars.
159159
- Generic submitted solution commands run through `mm-scorer-isolate` as the separate non-root `scorer` user. Downloaded tester JARs and serialized scorer config are kept runner-owned mode `0400`, so submitted code cannot read or modify them from `/tmp`.
160+
- Standard generic-runner seed execution asks `mm-scorer-isolate` to reset scorer-owned writable state before and after each test case. The cleanup helper only scans fixed writable roots such as `/tmp`, `/var/tmp`, `/dev/shm`, and the scorer home, and only removes entries owned by the scorer UID.
160161
- Generic submitted solution commands also run under a filesystem allowlist that permits runtime/toolchain reads and scorer temp writes but does not permit reading infrastructure paths such as `/etc/hostname`, `/etc/resolv.conf`, `/proc/self/cgroup`, `/proc/self/mounts`, or proc network tables.
161162
- Native wrappers block `io_uring` and creation of non-`AF_UNIX` sockets for submitted solution processes and their fork/exec children, so submissions cannot open live outbound network connections.
162163
- Standard Topcoder Marathon testers run through the generic runner flow, which creates the callback score payload from trusted runner code. Custom tester `runTester(...)` result maps remain supported for advanced cases.

ecs-runner/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ This image is the runtime container for marathon match scoring tasks launched by
2121
- The tester executes in a separate child JVM launched through `mm-runner-isolate` as uid/gid `10001` (`runner`) with a scrubbed environment, so `ACCESS_TOKEN` and other runner env vars are not inherited by untrusted code.
2222
- Generic submitted solution commands execute through the setuid-root `mm-scorer-isolate` bridge as uid/gid `10002` (`scorer`). The bridge drops its supervisor back to the invoking `runner` uid after it forks the solution child, retaining only `CAP_KILL` so tester timeouts can still terminate the lower-privilege solution process group.
2323
- Downloaded tester JARs are mode `0400` runner-owned files. The serialized scorer config starts as a mode `0400` runner-owned handoff file and the child JVM deletes it immediately after loading it, before tester or submitted solution code executes.
24-
- Generic Marathon seed execution resets scorer-owned writable state, including `/tmp`, before and after every test case, so files written by one seed are not visible to later seeds in the same submission run.
24+
- Generic Marathon seed execution resets scorer-owned writable state before and after every test case, so files written by one seed are not visible to later seeds in the same submission run. The Java child invokes the setuid `mm-scorer-isolate --cleanup-scorer-state` mode, which scans only fixed writable roots such as `/tmp`, `/var/tmp`, `/dev/shm`, and the scorer home, and removes only entries owned by uid `10002`.
2525
- Generic submitted solution commands run under a Landlock filesystem allowlist. Runtime and toolchain files are readable, `/tmp` and the scorer home are writable, and infrastructure-revealing paths such as `/etc/hostname`, `/etc/resolv.conf`, `/proc/self/cgroup`, `/proc/self/mounts`, and proc network tables are not readable by submitted code.
2626
- Artifact previews and artifact zip uploads include only non-symlink regular files from the runner artifact directories. Submitted symlinks are ignored instead of being dereferenced by the trusted parent runner.
2727
- Submitted solution processes and their fork/exec children cannot use `io_uring` and can create only `AF_UNIX` sockets. These restrictions are kernel seccomp filters inherited across fork/exec, so clearing `LD_PRELOAD` in a spawned child process does not restore INET or INET6 socket access. Outbound network access from the submission itself is therefore blocked even though the parent runner still has the trusted egress it needs.

ecs-runner/scripts/mm-net-isolate.c

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,221 @@ static void close_extra_fds(void) {
177177
}
178178
}
179179

180+
/**
181+
* Checks whether a directory entry is "." or "..".
182+
*
183+
* name is the entry name returned by readdir. Returns 1 for the current or
184+
* parent directory pseudo-entry and 0 for all other names.
185+
*/
186+
static int is_current_or_parent_directory(const char *name) {
187+
return strcmp(name, ".") == 0 || strcmp(name, "..") == 0;
188+
}
189+
190+
/**
191+
* Builds a child path under a parent directory.
192+
*
193+
* buffer receives the joined path, buffer_size is its capacity, parent is the
194+
* directory being scanned, and name is a direct child entry. Returns 0 when the
195+
* joined path fits and 1 when it would be truncated.
196+
*/
197+
static int join_child_path(
198+
char *buffer,
199+
size_t buffer_size,
200+
const char *parent,
201+
const char *name
202+
) {
203+
int written = snprintf(buffer, buffer_size, "%s/%s", parent, name);
204+
if (written < 0 || (size_t) written >= buffer_size) {
205+
fprintf(stderr, "cleanup path too long under %s\n", parent);
206+
return 1;
207+
}
208+
return 0;
209+
}
210+
211+
/**
212+
* Removes one untrusted path tree without following symlinks.
213+
*
214+
* The cleanup entry point is used by the Java runner between test cases. It
215+
* runs with the scorer helper's setuid-root privilege, but it only removes
216+
* entries that were already selected as owned by the scorer UID. path is the
217+
* selected top-level entry. Returns 0 when the tree was removed or already
218+
* disappeared and 1 when any entry could not be removed.
219+
*/
220+
static int remove_tree_no_follow(const char *path) {
221+
struct stat stat_buffer;
222+
if (lstat(path, &stat_buffer) != 0) {
223+
if (errno == ENOENT || errno == ENOTDIR) {
224+
return 0;
225+
}
226+
fprintf(stderr, "lstat(%s): %s\n", path, strerror(errno));
227+
return 1;
228+
}
229+
230+
if (S_ISDIR(stat_buffer.st_mode)) {
231+
DIR *dir = opendir(path);
232+
struct dirent *entry;
233+
int failed = 0;
234+
235+
if (dir == NULL) {
236+
if (errno == ENOENT || errno == ENOTDIR) {
237+
return 0;
238+
}
239+
fprintf(stderr, "opendir(%s): %s\n", path, strerror(errno));
240+
return 1;
241+
}
242+
243+
while ((entry = readdir(dir)) != NULL) {
244+
char child_path[PATH_MAX];
245+
if (is_current_or_parent_directory(entry->d_name)) {
246+
continue;
247+
}
248+
if (
249+
join_child_path(
250+
child_path,
251+
sizeof(child_path),
252+
path,
253+
entry->d_name
254+
) != 0
255+
) {
256+
failed = 1;
257+
continue;
258+
}
259+
if (remove_tree_no_follow(child_path) != 0) {
260+
failed = 1;
261+
}
262+
}
263+
264+
if (closedir(dir) != 0) {
265+
fprintf(stderr, "closedir(%s): %s\n", path, strerror(errno));
266+
failed = 1;
267+
}
268+
if (rmdir(path) != 0 && errno != ENOENT && errno != ENOTDIR) {
269+
fprintf(stderr, "rmdir(%s): %s\n", path, strerror(errno));
270+
failed = 1;
271+
}
272+
return failed;
273+
}
274+
275+
if (unlink(path) != 0 && errno != ENOENT && errno != ENOTDIR) {
276+
fprintf(stderr, "unlink(%s): %s\n", path, strerror(errno));
277+
return 1;
278+
}
279+
return 0;
280+
}
281+
282+
/**
283+
* Deletes scorer-owned top-level entries under one writable directory.
284+
*
285+
* directory is a fixed cleanup root compiled into the helper. deleted_entries
286+
* receives the number of selected top-level entries successfully removed.
287+
* Returns 0 when the directory was scanned cleanly and 1 when any selected
288+
* entry could not be inspected or removed. Missing directories are ignored.
289+
*/
290+
static int cleanup_scorer_owned_entries_under(
291+
const char *directory,
292+
int *deleted_entries
293+
) {
294+
DIR *dir = opendir(directory);
295+
struct dirent *entry;
296+
int failed = 0;
297+
298+
if (dir == NULL) {
299+
if (errno == ENOENT || errno == ENOTDIR) {
300+
return 0;
301+
}
302+
fprintf(stderr, "opendir(%s): %s\n", directory, strerror(errno));
303+
return 1;
304+
}
305+
306+
while ((entry = readdir(dir)) != NULL) {
307+
char entry_path[PATH_MAX];
308+
struct stat stat_buffer;
309+
310+
if (is_current_or_parent_directory(entry->d_name)) {
311+
continue;
312+
}
313+
if (
314+
join_child_path(
315+
entry_path,
316+
sizeof(entry_path),
317+
directory,
318+
entry->d_name
319+
) != 0
320+
) {
321+
failed = 1;
322+
continue;
323+
}
324+
325+
if (lstat(entry_path, &stat_buffer) != 0) {
326+
if (errno == ENOENT || errno == ENOTDIR) {
327+
continue;
328+
}
329+
fprintf(stderr, "lstat(%s): %s\n", entry_path, strerror(errno));
330+
failed = 1;
331+
continue;
332+
}
333+
334+
if (stat_buffer.st_uid != (uid_t) MM_ISOLATED_UID) {
335+
continue;
336+
}
337+
338+
if (remove_tree_no_follow(entry_path) == 0) {
339+
*deleted_entries += 1;
340+
} else {
341+
failed = 1;
342+
}
343+
}
344+
345+
if (closedir(dir) != 0) {
346+
fprintf(stderr, "closedir(%s): %s\n", directory, strerror(errno));
347+
failed = 1;
348+
}
349+
return failed;
350+
}
351+
352+
/**
353+
* Deletes scorer-owned writable state from fixed, compiled-in directories.
354+
*
355+
* This mode intentionally accepts no path arguments. It is called by the Java
356+
* runner before and after each seed to prevent scorer-owned filesystem state
357+
* from carrying across tests. Returns 0 when cleanup completed and 1 when any
358+
* cleanup root or selected scorer-owned entry could not be processed.
359+
*/
360+
static int cleanup_scorer_writable_state(void) {
361+
static const char *const cleanup_directories[] = {
362+
"/tmp",
363+
"/var/tmp",
364+
"/dev/shm",
365+
MM_ISOLATED_HOME
366+
};
367+
size_t index;
368+
int deleted_entries = 0;
369+
int failed = 0;
370+
371+
if (geteuid() != 0) {
372+
fprintf(stderr, "cleanup requires setuid-root scorer helper\n");
373+
return 1;
374+
}
375+
376+
for (
377+
index = 0;
378+
index < sizeof(cleanup_directories) / sizeof(cleanup_directories[0]);
379+
index++
380+
) {
381+
if (
382+
cleanup_scorer_owned_entries_under(
383+
cleanup_directories[index],
384+
&deleted_entries
385+
) != 0
386+
) {
387+
failed = 1;
388+
}
389+
}
390+
391+
printf("%d\n", deleted_entries);
392+
return failed;
393+
}
394+
180395
#if MM_INSTALL_SOCKET_FILTER
181396
static int install_socket_filter(void) {
182397
struct sock_filter filter[] = {
@@ -849,6 +1064,10 @@ int main(int argc, char **argv) {
8491064
}
8501065

8511066
close_extra_fds();
1067+
if (argc == 2 && strcmp(argv[1], "--cleanup-scorer-state") == 0) {
1068+
return cleanup_scorer_writable_state();
1069+
}
1070+
8521071
sanitize_environment();
8531072

8541073
#if MM_SUPERVISE_CHILD

0 commit comments

Comments
 (0)