Skip to content

Commit 1f7e602

Browse files
Handle Improper Output For Unittests
The old testing code only checks the first character of the stdout, meaning that if a user succeeds in printing a 1 before the rest of the output, the testing code will consider that a pass and discard the rest. This change checks that the rest of the stdout is empty as expected. Signed-off-by: Hassan Abouelela <hassan@hassanamr.com>
1 parent 1f0c8b5 commit 1f7e602

3 files changed

Lines changed: 84 additions & 37 deletions

File tree

backend/authentication/user.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def display_name(self) -> str:
3737
def discord_mention(self) -> str:
3838
return f"<@{self.payload['id']}>"
3939

40+
@property
41+
def user_id(self) -> str:
42+
return str(self.payload["id"])
43+
4044
@property
4145
def decoded_token(self) -> dict[str, any]:
4246
return jwt.decode(self.token, SECRET_KEY, algorithms=["HS256"])

backend/routes/forms/submit.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from typing import Any, Optional
1111

1212
import httpx
13+
import pymongo.database
14+
import sentry_sdk
1315
from pydantic import ValidationError
1416
from pydantic.main import BaseModel
1517
from spectree import Response
@@ -23,7 +25,7 @@
2325
from backend.route import Route
2426
from backend.routes.auth.authorize import set_response_token
2527
from backend.routes.forms.discover import AUTH_FORM
26-
from backend.routes.forms.unittesting import execute_unittest
28+
from backend.routes.forms.unittesting import BypassDetectedError, execute_unittest
2729
from backend.validation import ErrorMessage, api
2830

2931
HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify"
@@ -193,7 +195,22 @@ async def submit(self, request: Request) -> JSONResponse:
193195

194196
# Run unittests if needed
195197
if any("unittests" in question.data for question in form.questions):
196-
unittest_results = await execute_unittest(response_obj, form)
198+
unittest_results, errors = await execute_unittest(response_obj, form)
199+
200+
if len(errors):
201+
username = getattr(request.user, "user_id", "Unknown")
202+
sentry_sdk.capture_exception(BypassDetectedError(
203+
f"Detected unittest bypass attempt on form {form.id} by {username}. "
204+
f"Submission has been written to reporting database ({response_obj.id})."
205+
))
206+
database: pymongo.database.Database = request.state.db
207+
await database.get_collection("violations").insert_one({
208+
"user": username,
209+
"bypasses": [error.args[0] for error in errors],
210+
"submission": response_obj.dict(by_alias=True),
211+
"timestamp": response_obj.timestamp,
212+
"id": response_obj.id,
213+
})
197214

198215
failures = []
199216
status_code = 422
@@ -210,6 +227,8 @@ async def submit(self, request: Request) -> JSONResponse:
210227
failure_names = ["Could not parse user code."]
211228
elif test.return_code == 6:
212229
failure_names = ["Could not load user code."]
230+
elif test.return_code == 10:
231+
failure_names = ["Bypass detected."]
213232
else:
214233
failure_names = ["Internal error."]
215234

backend/routes/forms/unittesting.py

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
TEST_TEMPLATE = file.read()
1414

1515

16+
class BypassDetectedError(Exception):
17+
"""Detected an attempt at bypassing the unittests."""
18+
19+
1620
UnittestResult = namedtuple(
1721
"UnittestResult", "question_id question_index return_code passed result"
1822
)
@@ -65,9 +69,12 @@ async def _post_eval(code: str) -> dict[str, str]:
6569
return response.json()
6670

6771

68-
async def execute_unittest(form_response: FormResponse, form: Form) -> list[UnittestResult]:
72+
async def execute_unittest(
73+
form_response: FormResponse, form: Form
74+
) -> tuple[list[UnittestResult], list[BypassDetectedError]]:
6975
"""Execute all the unittests in this form and return the results."""
7076
unittest_results = []
77+
errors = []
7178

7279
for index, question in enumerate(form.questions):
7380
if question.type == "code":
@@ -101,40 +108,57 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit
101108
code = code.replace("### UNIT CODE", unit_code)
102109

103110
try:
104-
response = await _post_eval(code)
105-
except HTTPStatusError:
106-
return_code = 99
107-
result = "Unable to contact code runner."
108-
else:
109-
return_code = int(response["returncode"])
110-
111-
# Parse the stdout if the tests ran successfully
112-
if return_code == 0:
113-
stdout = response["stdout"]
114-
passed = bool(int(stdout[0]))
115-
116-
# If the test failed, we have to populate the result string.
117-
if not passed:
118-
failed_tests = stdout[1:].strip().split(";")
119-
120-
# Redact failed hidden tests
121-
for i, failed_test in enumerate(failed_tests.copy()):
122-
if failed_test in hidden_tests:
123-
failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}"
124-
125-
result = ";".join(failed_tests)
126-
else:
127-
result = ""
128-
elif return_code in (5, 6, 99):
129-
result = response["stdout"]
130-
# Killed by NsJail
131-
elif return_code == 137:
132-
return_code = 7
133-
result = "Timed out or ran out of memory."
134-
# Another code has been returned by CPython because of another failure.
135-
else:
111+
try:
112+
response = await _post_eval(code)
113+
except HTTPStatusError:
136114
return_code = 99
137-
result = "Internal error."
115+
result = "Unable to contact code runner."
116+
else:
117+
return_code = int(response["returncode"])
118+
119+
# Parse the stdout if the tests ran successfully
120+
if return_code == 0:
121+
stdout = response["stdout"]
122+
try:
123+
passed = bool(int(stdout[0]))
124+
except ValueError:
125+
raise BypassDetectedError("Detected a bypass when reading result code.")
126+
127+
if passed and stdout.strip() != "1":
128+
# Most likely a bypass attempt
129+
# A 1 was written to stdout to indicate success,
130+
# followed by the actual output
131+
raise BypassDetectedError(
132+
"Detected improper value for stdout in unittest."
133+
)
134+
135+
# If the test failed, we have to populate the result string.
136+
elif not passed:
137+
failed_tests = stdout[1:].strip().split(";")
138+
139+
# Redact failed hidden tests
140+
for i, failed_test in enumerate(failed_tests.copy()):
141+
if failed_test in hidden_tests:
142+
failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}"
143+
144+
result = ";".join(failed_tests)
145+
else:
146+
result = ""
147+
elif return_code in (5, 6, 99):
148+
result = response["stdout"]
149+
# Killed by NsJail
150+
elif return_code == 137:
151+
return_code = 7
152+
result = "Timed out or ran out of memory."
153+
# Another code has been returned by CPython because of another failure.
154+
else:
155+
return_code = 99
156+
result = "Internal error."
157+
except BypassDetectedError as error:
158+
return_code = 10
159+
result = "Bypass attempt detected, aborting."
160+
errors.append(error)
161+
passed = False
138162

139163
unittest_results.append(UnittestResult(
140164
question_id=question.id,
@@ -144,4 +168,4 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit
144168
result=result
145169
))
146170

147-
return unittest_results
171+
return unittest_results, errors

0 commit comments

Comments
 (0)