Appearance
Lab Scenarios
The portal serves four scenarios. Students see them as Scenario 1 to 4; the backend knows them by their internal IDs 01, 06, 09 and 11, which are used for routing, provisioning and scoring (displayNumber vs id in cyberrange/portal/src/hooks/useScenarios.ts).
Catalog
| Scenario | Internal ID | Type | Difficulty | Lab time limit | MITRE (portal) | Scored on | Tasks | Points |
|---|---|---|---|---|---|---|---|---|
| 1. Network Reconnaissance & Exploitation | 01 | Offensive | Easy | 30 min | T1046 | Kali | 4 auto + 1 flag | 275 |
| 2. SQL Injection & Reflected XSS | 06 | Offensive | Medium | 45 min | T1190 | Browser (DVWA proxy), Kali fallback | 4 auto + 1 flag | 350 |
| 3. SIEM Alert Triage | 09 | Defensive | Medium | 45 min | T1595 | meta | unscored prep + 3 auto | 225 |
| 4. Vulnerability Hardening | 11 | Defensive | Hard | 60 min | T1548 | meta | 3 auto | 200 |
- The time limit is real: the reaper ends the lab when it runs out. It comes from
SCENARIO_TTL_MINUTESincyberrange/src/provisioning/config.pyand matches the card duration from the difficulty level incomponents/scenarios/DifficultyBadge.tsx(test_limits_match_portal_catalog_durationsfails if they drift). An unknown scenario getsDEFAULT_TTL_MINUTES(60). - "Flag" tasks score on a correct flag typed into the lab page; "auto" tasks score from evidence in the lab (see below). A Scenario 0 orientation page (
/scenario/orientation) explains the lab layout and scoring before Scenario 1; it starts no lab and has no score. - Every scenario runs in the same three-machine lab (Kali, meta, DVWA). What changes is the guide, which machine is scored and which checks run.
- The catalog order is the recommended order: attack (1, 2), then defend (3, 4). Scenario 4 fixes the weakness Scenario 1 exploits.
Where a scenario is defined
There is no single scenario file. A scenario is the combination of these pieces, all keyed by the internal ID:
| Piece | File | What it holds |
|---|---|---|
| Catalog entry | cyberrange/portal/src/hooks/useScenarios.ts | Name, type, difficulty, MITRE tag, milestone names, descriptions and points, guide file |
| Student guide | cyberrange/portal/public/scenarios/scenario_0N_*.md | Walkthrough; the lab shows only the current task's ### Task N section, the PDF export has the whole guide |
| Lab surface and cues | labSurface, prepTask, cue, cueVariant in useScenarios.ts | Which right-hand pane the lab shows (terminal, dvwa or siem) and the per-task side cues |
| Prerequisites and hints | cyberrange/portal/src/lib/scenarioPrereqs.ts, lib/scenarioHints.ts | The "Before you start" box on the scenario page and the per-task progressive hints (keyed by internal ID; kept in step with the guide by hand) |
| Scoring target | SCENARIO_TARGETS in cyberrange/src/provisioning/ssh_verifier.py | Which container the check runs in (kali or meta) |
| Milestone checks | check_scenario_<id>() in cyberrange/src/provisioning/scoring_checks.sh | The PASS/FAIL logic for each milestone |
| Poller milestone count | SCENARIO_MILESTONE_COUNTS in score_poller.py | How many milestones the automatic poller checks (flag tasks are not polled) |
| Flag-only tasks | PURE_FLAG_MILESTONES in hybrid_scoring.py | Tasks that score on a correct flag alone: (1, 5) and (6, 5) |
| Time limit | SCENARIO_TTL_MINUTES in config.py | Minutes before the reaper ends the lab |
| Browser signals (Scenario 2 only) | cyberrange/portal/src/lib/dvwaScoreSignals.ts | Response patterns that prove each DVWA milestone |
| Rubrics | schema/v5.sql and v10.sql seeds, and DEFAULT_RUBRICS in rubrics.py | Criteria, points, MITRE technique and NIST phase per milestone |
| SIEM detection evidence | DETECTION_RULES in wazuh_rule_map.py | Optional Wazuh rule that adds detection evidence to a check |
| Per-student flags | prepare_scenario_flags() and plant_scenario_flags() in flag_planting.py | Where each milestone's flag is hidden in the lab; Scenario 1's Task 5 flag is planted on Kali and a failure there fails the provision |
| Failure diagnostics | REASON_CODES_BY_SCENARIO in scoring_diagnostics.py, the DIAG line in scoring_checks.sh | Instructor-only reason codes for a failed check (see below) |
Adding a scenario
A new scenario needs an entry in every row above that applies, a golden-image change if it needs new software, and tests. test_sec01_rbac_matrix.py is not affected because scenarios do not add routes.
Scenario lifecycle
| Stage | What happens | Source |
|---|---|---|
| Definition | The pieces in the table above ship with the code release | see above |
| Provisioning | Admission checks, then the background build; the lab row stores scenario_id | Provisioning & Teardown |
| Interaction | Two-pane lab page: the exercise panel (task outline, goals, score status, walkthrough) on the left; the terminal tabs, embedded DVWA pane or terminal plus SIEM modal on the right | Lab Experience |
| Scoring | Behavioral checks run in the scored container; results are rows in milestone_verification | below |
| Completion | The portal marks the scenario complete when every task has a PASS; points are summed once per passed milestone (earned_points() in pods_router.py) | pods_router.py, useScenarioMilestones.ts |
| Teardown | Containers and network are deleted; scores are not | Provisioning & Teardown |
Scoring workflow
Scoring paths
| Path | Who triggers it | What decides PASS | Writes | Status |
|---|---|---|---|---|
| Automatic poller | API background task every SCORE_POLL_INTERVAL_SECONDS (3) | scoring_checks.sh in the scored container | a row per check, including FAIL | Implemented |
| Manual Check | Student, from the exercise panel | same script, run immediately | a row per check; a definitive PASS or FAIL also records the one attempt in manual_check_attempts | Implemented, one per task: after a failed check the task is locked to instructor review |
| Browser evidence | Portal DVWA proxy (Scenario 2 only) | response patterns in dvwaScoreSignals.ts | one PASS row per milestone | Implemented |
| Flag-only task | FlagSubmission in the exercise panel → POST /progress/{scenario}/flag | a correct per-student flag alone (PURE_FLAG_MILESTONES: Scenario 1 Task 5, Scenario 2 Task 5) | one PASS row marked pure_flag_capture; a wrong flag scores nothing and opens no case | Implemented |
| Hybrid flag (other milestones) | POST /progress/{scenario}/flag | flag and lab state must agree; disagreement escalates a staff-only SCORING_CONFLICT | PASS row when both agree | API only: the portal shows the flag box only on flag-only tasks |
| Instructor approval | Instructor or admin resolving a milestone-scoped case (conflict, written report or manual review) as APPROVED | human judgment | PASS row marked instructor_approved_conflict or instructor_approved_review (deduplicated); changing the decision to REJECTED or RETRY deletes that instructor-granted row again | Implemented |
| Written report or manual review | Student ("Ask an instructor", or automatically after a failed Manual Check), graded by staff | human judgment | review status, 0 to 100 score and feedback; approval credits the milestone as above | Implemented |
Sources: score_poller.py, scoring.py, ssh_verifier.py, scoring_checks.sh, hybrid_scoring.py, pods_router.py (browser_score, resolve_student_review, verify_milestone_route, manual_check_locked_ids), schema/v10.sql, cyberrange/portal/src/components/scenario/FlagSubmission.tsx, cyberrange/portal/src/app/lab/dvwa/[[...path]]/route.ts.
Properties worth knowing
- Checks read evidence, not intent. Most checks look for a matching command in shell history (flushed after every command by
enable_history_flush.sh), a file with the right content, or live system state. A typed command counts only where the check also confirms something real (for example a working route to the lab subnet for Scenario 1, or the HTTP response code for Scenario 4). - Checks are tied to the student's own lab. Scenario 1 derives the lab subnet from the container's own
eth0address, so scanning another lab's subnet does not count. - Placeholders do not score. Scenario 2's Kali fallback checks for Milestones 1 and 3 ignore history lines containing angle-bracket placeholders or
echo/printf(history_has_real_line()); Scenario 3 rejects uneditedPASTE…skeleton text, literalHH:MMand the guide's fictional example, and its Task 1 and Task 2 timestamps must match a real invalid-user SSH event in meta'sauth.log(±5 minutes). - An automatic PASS is permanent. Once a milestone has a PASS row the poller stops checking it, and later changes in the lab cannot remove it. The only PASS rows that can be taken back are instructor-granted ones, when the instructor changes the decision. Students can erase their own rows for a scenario with the reset (
DELETE /progress/{scenario}), which is offered once every task is complete; the reset also clears their used Manual Check attempts for that scenario. - The Manual Check lock is per student, not per lab. It is keyed by student, scenario and milestone, so it survives a page refresh, a new lab and a new sign-in. An infrastructure
ERRORorUNKNOWNdoes not use up the attempt. - Growth. Every automatic check of an unpassed milestone writes one
milestone_verificationrow and oneaudit_logrow, andGET /pods/{id}/milestonesreturns the full history. See Findings. - Inputs are shape-checked. Since pull request 277 the flag submission body and the portal-to-API browser scoring bodies reject unknown fields, and the browser bodies also bound
student_id(same pattern as the student-facing models), the IDs and the label length before any handler runs (FlagSubmissionRequestinmodels.py;BrowserScoreIn,BrowserDiagnosticIninpods_router.py).
Scoring diagnostics (instructor only)
Pull requests 242, 260, 261, 262 and 267 added a way for staff to see why a check failed without changing how it scores. Students still see only the generic result.
- For Scenarios 1, 3 and 4 (
check_scenario_1,_9,_11) the check prints one line,DIAG reason=<CODE> location=<LOC> copies=<N>, before its PASS/FAIL token. The verifier still reads only the last line, and PASS is printed only when the reason isNONE. For Scenario 2 the portal DVWA proxy sends a near-miss code instead (browserNearMiss()indvwaScoreSignals.ts). - Codes come from a fixed list per scenario (
REASON_CODES_BY_SCENARIO), for exampleCOMMAND_NOT_FOUND,SESSION_NOT_LIVE(Scenario 1),AGENT_MISMATCH,TIMESTAMP_NOT_MATCHED(Scenario 3),DEFAULT_PASSWORD_PRESENT,OLD_CREDS_ACCEPTED(Scenario 4) andSECURITY_NOT_LOW,UNION_NO_HASH(Scenario 2). Anything outside the list is dropped; no file contents, commands or payloads are stored. - Rows go to the
scoring_diagnosticstable (schema/v14.sql): one row per student, lab and milestone, updated on repeat failures and replaced when the milestone passes, so polling does not grow it. The score poller purges rows older than the retention period (default 14 days) at most hourly. - It is off by default and switched on per scorer ID with
SCORING_DIAGNOSTICS_SCENARIOS(for example9or1,6,9,11;S3_DIAGNOSTICS_ENABLED=1still turns on9), withSCORING_DIAGNOSTICS_RETENTION_DAYSfor retention (cyberrange/env/oci-12gib.env.example). Recording is best effort and never blocks a score. - Staff read them with
GET /instructor/students/{student_id}/diagnostics(instructor or admin only), shown on the instructor's student page (components/instructor/ScoringDiagnostics.tsx).
Sources: scoring_diagnostics.py, scoring.py, ssh_verifier.py (verify_milestone_detailed), score_poller.py, pods_router.py, scoring_checks.sh.
Safety model shared by all scenarios
- Offensive tools run only inside the student's own lab network; labs are not peered with each other (see Containers & Networks).
- Vulnerable services (Tomcat Manager with a default credential, DVWA at security level Low) exist only in lab containers created from golden images and are destroyed with the lab.
- DVWA is reachable only through the session-gated portal proxy, which allows only the pages the scenario needs.
- Students have
sudoinside their own unprivileged containers, never on the host. - Labs expire when their scenario's time limit runs out (30 to 60 minutes).