Skip to content

Commit 2b84b60

Browse files
committed
Better isolation for malicious python submissions
1 parent 38080d8 commit 2b84b60

7 files changed

Lines changed: 374 additions & 23 deletions

File tree

ecs-runner/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This image is the runtime container for marathon match scoring tasks launched by
1919
- The container entrypoint starts as `root`. Do not override the ECS task-definition `user` for this container; root is needed for trusted bootstrap work and for preparing runner-owned files before the child JVM starts.
2020
- The trusted parent runner performs network bootstrap work: fetch challenge config, download tester/submission artifacts, upload artifacts, and post the scoring callback.
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.
22-
- 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, then supervises the solution process group so tester timeouts can still terminate lower-privilege processes.
22+
- 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.
2424
- 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.
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.

ecs-runner/boilerplate/src/main/java/com/topcoder/marathon/MarathonTester.java

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public abstract class MarathonTester {
3535
private final List<BufferedWriter> solInputWriters = new ArrayList<BufferedWriter>();
3636
private static final int maxSolutionOutputLength = 10_000_000;
3737
private static final long processDrainTimeoutMillis = 250;
38+
private static final long processTimeoutDestroyMillis = 250;
3839
private static final long errorReaderDrainTimeoutMillis = 250;
3940
private BufferedWriter solOutputWriter;
4041
private BufferedWriter solErrorWriter;
@@ -110,17 +111,20 @@ protected final void startTime() {
110111
lastTimeoutThread = new Thread() {
111112
public void run() {
112113
try {
113-
boolean finished = process.waitFor(timeLimit - elapsedTime, TimeUnit.NANOSECONDS);
114+
long remainingTime = Math.max(1L, timeLimit - elapsedTime);
115+
boolean finished = process.waitFor(remainingTime, TimeUnit.NANOSECONDS);
114116
if (!finished) {
117+
boolean notifyTimeout = false;
115118
synchronized (timeLock) {
116119
if (lastStart > 0) elapsedTime += System.nanoTime() - lastStart;
117120
lastStart = 0;
118-
if (process != null) process.destroyForcibly();
119121
if (!timeout) {
120122
timeout = true;
121-
timeout();
123+
notifyTimeout = true;
122124
}
123125
}
126+
terminateTimedOutProcess();
127+
if (notifyTimeout) timeout();
124128
}
125129
} catch (Exception e) {
126130
}
@@ -186,6 +190,73 @@ public final double runTest() {
186190
return score;
187191
}
188192

193+
/**
194+
* Terminates the submitted solution process after its measured execution
195+
* time expires and closes runner-side streams so blocked tester reads unwind.
196+
*
197+
* <p>The ECS scorer command is wrapped by a small native supervisor. A
198+
* graceful destroy gives that wrapper a catchable signal it can relay to the
199+
* low-privilege scorer process group before this method escalates to a hard
200+
* process kill.
201+
*/
202+
private void terminateTimedOutProcess() {
203+
Process processToStop = process;
204+
if (processToStop != null) {
205+
processToStop.destroy();
206+
try {
207+
if (!processToStop.waitFor(processTimeoutDestroyMillis, TimeUnit.MILLISECONDS)) {
208+
processToStop.destroyForcibly();
209+
processToStop.waitFor(processDrainTimeoutMillis, TimeUnit.MILLISECONDS);
210+
}
211+
} catch (InterruptedException e) {
212+
Thread.currentThread().interrupt();
213+
processToStop.destroyForcibly();
214+
} catch (Exception e) {
215+
processToStop.destroyForcibly();
216+
}
217+
}
218+
219+
closeSolutionStreamsAfterTimeout();
220+
}
221+
222+
/**
223+
* Closes solution pipes after timeout termination.
224+
*
225+
* <p>Some failed process-tree terminations can leave descendant processes
226+
* holding the stdout pipe open. Closing the Java-side reader prevents
227+
* {@link #readLine()} from blocking forever after the timeout has already
228+
* been recorded.
229+
*/
230+
private void closeSolutionStreamsAfterTimeout() {
231+
for (BufferedWriter out : solInputWriters) {
232+
try {
233+
out.close();
234+
} catch (Exception e) {
235+
}
236+
}
237+
if (solOutputReader != null) {
238+
try {
239+
solOutputReader.close();
240+
} catch (Exception e) {
241+
}
242+
}
243+
if (solOutputWriter != null) {
244+
try {
245+
solOutputWriter.close();
246+
} catch (Exception e) {
247+
}
248+
}
249+
if (solErrorReader != null) {
250+
solErrorReader.closeAndWait(errorReaderDrainTimeoutMillis);
251+
solutionError = solErrorReader.getOutput();
252+
} else if (solErrorWriter != null) {
253+
try {
254+
solErrorWriter.close();
255+
} catch (Exception e) {
256+
}
257+
}
258+
}
259+
189260
protected final void writeLine(int v) throws Exception {
190261
writeLine(String.valueOf(v));
191262
}

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

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <fcntl.h>
66
#include <grp.h>
77
#include <linux/audit.h>
8+
#include <linux/capability.h>
89
#include <linux/filter.h>
910
#include <linux/landlock.h>
1011
#include <linux/seccomp.h>
@@ -104,6 +105,10 @@
104105
#define __NR_landlock_restrict_self 446
105106
#endif
106107

108+
#ifndef CAP_KILL
109+
#define CAP_KILL 5
110+
#endif
111+
107112
#if MM_SUPERVISE_CHILD
108113
static volatile sig_atomic_t supervised_child_pid = -1;
109114
#endif
@@ -659,14 +664,31 @@ static int enter_isolated_execution(void) {
659664
* because its real UID is still the runner UID.
660665
*/
661666
#if MM_SUPERVISE_CHILD
667+
static int signal_child_process_group(pid_t child_pid, int signo) {
668+
if (kill(-child_pid, signo) == 0 || errno == ESRCH) {
669+
return 0;
670+
}
671+
672+
if (kill(child_pid, signo) == 0 || errno == ESRCH) {
673+
return 0;
674+
}
675+
676+
return errno;
677+
}
678+
662679
static void forward_signal_to_child(int signo) {
663680
pid_t child_pid = (pid_t) supervised_child_pid;
664681
if (child_pid > 0) {
665-
if (kill(-child_pid, signo) != 0) {
666-
if (kill(child_pid, signo) != 0 && errno == EPERM) {
667-
_exit(128 + signo);
682+
int signal_error = signal_child_process_group(child_pid, signo);
683+
if (signo == SIGTERM) {
684+
int kill_error = signal_child_process_group(child_pid, SIGKILL);
685+
if (signal_error == 0) {
686+
signal_error = kill_error;
668687
}
669688
}
689+
if (signal_error == EPERM) {
690+
_exit(128 + signo);
691+
}
670692
}
671693
}
672694

@@ -702,18 +724,26 @@ static int wait_status_to_exit_code(int status) {
702724

703725
/**
704726
* Drops the supervising wrapper back to the real user that invoked the setuid
705-
* scorer helper. The already-forked child keeps the temporary root privilege it
706-
* needs to switch to the configured isolated UID before exec.
727+
* scorer helper while retaining only CAP_KILL. The already-forked child keeps
728+
* the temporary root privilege it needs to switch to the configured isolated
729+
* UID before exec.
707730
*/
708731
static int drop_supervisor_to_invoker(void) {
709732
#if MM_DROP_SUPERVISOR_PRIVS
710733
uid_t uid = getuid();
711734
gid_t gid = getgid();
735+
struct __user_cap_header_struct cap_header;
736+
struct __user_cap_data_struct cap_data[_LINUX_CAPABILITY_U32S_3];
712737

713738
if (uid == 0 && gid == 0) {
714739
return 0;
715740
}
716741

742+
if (prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0) != 0) {
743+
perror("supervisor prctl(PR_SET_KEEPCAPS)");
744+
return 1;
745+
}
746+
717747
if (setgroups(0, NULL) != 0) {
718748
perror("supervisor setgroups");
719749
return 1;
@@ -728,6 +758,22 @@ static int drop_supervisor_to_invoker(void) {
728758
perror("supervisor setuid");
729759
return 1;
730760
}
761+
762+
memset(&cap_header, 0, sizeof(cap_header));
763+
memset(&cap_data, 0, sizeof(cap_data));
764+
cap_header.version = _LINUX_CAPABILITY_VERSION_3;
765+
cap_header.pid = 0;
766+
cap_data[CAP_KILL / 32].effective = 1U << (CAP_KILL % 32);
767+
cap_data[CAP_KILL / 32].permitted = 1U << (CAP_KILL % 32);
768+
if (syscall(SYS_capset, &cap_header, &cap_data) != 0) {
769+
perror("supervisor capset(CAP_KILL)");
770+
return 1;
771+
}
772+
773+
if (prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0) != 0) {
774+
perror("supervisor prctl(PR_SET_KEEPCAPS clear)");
775+
return 1;
776+
}
731777
#endif
732778
return 0;
733779
}

scripts/fixtures/Blocks/marathon-match-test.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@
468468
"fileName": "submission-issue-45-ignore-sigterm.zip",
469469
"language": "cpp",
470470
"issue": 45,
471-
"validation": "Regression submission ignores SIGTERM and spins so per-test timeout must use destroyForcibly/SIGKILL."
471+
"validation": "Regression submission ignores SIGTERM and spins so per-test timeout must still terminate the scorer process group."
472472
}
473473
},
474474
{

src/api/marathon-match-config/marathon-match-config.service.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
BadRequestException,
33
ConflictException,
4+
HttpException,
45
NotFoundException,
56
} from '@nestjs/common';
67
import {
@@ -528,6 +529,93 @@ describe('MarathonMatchConfigService', () => {
528529
);
529530
});
530531

532+
it('preserves Review API validation upload authorization failures', async () => {
533+
const {
534+
service,
535+
ecsService,
536+
httpService,
537+
m2mService,
538+
prisma,
539+
prismaErrorService,
540+
} = createService();
541+
542+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
543+
id: 'config-1',
544+
challengeId: '30000123',
545+
active: true,
546+
submissionApiUrl: 'https://submissions.example.com/v6',
547+
testerId: 'tester-1',
548+
taskDefinitionName: 'mm-runner',
549+
taskDefinitionVersion: '7',
550+
tester: {
551+
compilationStatus: CompilationStatus.SUCCESS,
552+
},
553+
phaseConfigs: [
554+
{
555+
id: 'phase-provisional',
556+
configType: PhaseConfigType.PROVISIONAL,
557+
phaseId: 'provisional-phase',
558+
startSeed: BigInt(100),
559+
numberOfTests: 50,
560+
},
561+
],
562+
});
563+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
564+
httpService.post.mockReturnValue(
565+
throwError(() => ({
566+
isAxiosError: true,
567+
message: 'Request failed with status code 403',
568+
response: {
569+
status: 403,
570+
data: {
571+
message: 'Insufficient permissions',
572+
code: 'FORBIDDEN',
573+
},
574+
},
575+
})),
576+
);
577+
578+
let thrown: unknown;
579+
try {
580+
await service.uploadTestSubmission(
581+
'30000123',
582+
{
583+
configType: PhaseConfigType.PROVISIONAL,
584+
},
585+
{
586+
buffer: Buffer.from('zip'),
587+
mimetype: 'application/zip',
588+
originalname: 'solution.zip',
589+
size: 3,
590+
} as Express.Multer.File,
591+
{
592+
isMachine: false,
593+
userId: '40051399',
594+
} as never,
595+
);
596+
} catch (error) {
597+
thrown = error;
598+
}
599+
600+
expect(thrown).toBeInstanceOf(HttpException);
601+
const exception = thrown as HttpException;
602+
expect(exception.getStatus()).toBe(403);
603+
expect(exception.getResponse()).toEqual({
604+
message:
605+
'Review API rejected the validation submission upload with 403 Forbidden. Confirm the Marathon Match M2M credentials are authorized for create:submission in Review API. Upstream message: Insufficient permissions',
606+
code: 'VALIDATION_SUBMISSION_UPLOAD_REJECTED',
607+
details: {
608+
challengeId: '30000123',
609+
memberId: '40051399',
610+
upstreamCode: 'FORBIDDEN',
611+
upstreamMessage: 'Insufficient permissions',
612+
upstreamStatusCode: 403,
613+
},
614+
});
615+
expect(prismaErrorService.handleError).not.toHaveBeenCalled();
616+
expect(ecsService.launchScorerTask).not.toHaveBeenCalled();
617+
});
618+
531619
it('rejects validation submissions when the tester is not compiled', async () => {
532620
const { service, ecsService, httpService, prisma } = createService();
533621

0 commit comments

Comments
 (0)