Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
8d18c80
Add Empty Spawn Instance Handler
gqvz Jan 17, 2026
4ddd5cb
Add redis cache backup and restore commands
gqvz Jan 17, 2026
6075e1e
Refactor Redis command execution to use environment variable for pass…
gqvz Jan 31, 2026
5b43dce
Enhance Redis backup command to conditionally include user and passwo…
gqvz Jan 31, 2026
0714508
Cleanup: add cache cleanup
kunrex Feb 3, 2026
3022211
Feat: Add redis set up to beast init and beast config
kunrex Feb 5, 2026
db539d0
Rename Cache function
kunrex Mar 28, 2026
57f8aad
Add graceful cache closure
kunrex Mar 28, 2026
047b97e
Add redis set up to config
kunrex Mar 28, 2026
4cb8eea
Add acl save and fix sprintf typos
kunrex Mar 28, 2026
5312624
Add cache comment and acl warning
kunrex Mar 28, 2026
78a9107
Add instanced challenges
kunrex Feb 3, 2026
00a5cb1
Init key utils
kunrex Mar 28, 2026
d2e3a99
Erros: Add better error messages
kunrex Feb 11, 2026
857b447
Fix: fix readme port mapping
kunrex Feb 18, 2026
ea1ed3a
Cleanup: Fix typos, clean comments, add constants for magic values.
kunrex Mar 6, 2026
ec8b944
Remove hosted address
kunrex Apr 2, 2026
eb7a6f0
Refactor instance manager
kunrex Apr 2, 2026
9555cca
Abstract functions to pkg
kunrex Apr 2, 2026
3dcf7c0
Add instanced info to challenge metadata
gqvz Apr 2, 2026
1b4b7c0
Fix compose deploy project name
kunrex Apr 2, 2026
d3e1c0a
Add dynamic port mapping to docker compose challenges
kunrex Apr 8, 2026
89b96b0
Resolve server host issue
kunrex Apr 9, 2026
28e2a11
refactor: implement distinct user challenge tracking with accurate so…
gqvz Apr 9, 2026
83481d0
refactor: optimize slice allocation
gqvz Apr 10, 2026
2d03aa1
feat: integrate hint penalties into leaderboard calculations
gqvz Apr 10, 2026
5f3effb
Fix port assign bug
kunrex Apr 13, 2026
8f5ee9a
Fix port bug suggestions
kunrex Apr 13, 2026
70b679b
Rework port management to avoid unnececarry race conditions
kunrex Apr 13, 2026
4ffd6e8
Add server deployed to api
kunrex Apr 13, 2026
584e4eb
Add order by to query top users
kunrex Apr 13, 2026
5ed55dd
Add error check to save flag submission
kunrex Apr 13, 2026
d54ff4f
Avoid default port mapping in compose
kunrex Apr 14, 2026
4be8f6a
Fix kill instance container compose project name
kunrex Apr 14, 2026
b28730c
Add User Hint migration
kunrex Apr 14, 2026
0f26f37
Refactor tag fetching from DB
kunrex Apr 14, 2026
2842db8
Update exaples
kunrex Apr 14, 2026
4cde860
Fix instance port assignment
kunrex Apr 14, 2026
9b2a826
Add resource limits to containers
kunrex Apr 14, 2026
4f266f8
Fix instance container ports
kunrex Apr 14, 2026
f8a43db
Unify localhost checks
kunrex Apr 14, 2026
32433a3
Fix container name bugs
kunrex Apr 15, 2026
6d12008
Kill instances for running challenges
kunrex Apr 16, 2026
9ce212a
chore: ignore local beast runtime files
v1bh475u Jul 7, 2026
117cb9d
fix: add atomic submission guards
v1bh475u Jul 7, 2026
362ca7c
fix: use atomic submission flow
v1bh475u Jul 7, 2026
97b5b46
fix: make instance cleanup event driven
v1bh475u Jul 7, 2026
33e662e
fix: reserve ports atomically
v1bh475u Jul 7, 2026
0fe5ee9
fix: centralize docker client creation
v1bh475u Jul 7, 2026
7c8d14a
test: cover submission race guards
v1bh475u Jul 7, 2026
70f3e58
test: cover Redis port and cleanup flows
v1bh475u Jul 7, 2026
e8d98a4
test: cover Docker runtime naming
v1bh475u Jul 7, 2026
c5069db
test: add backend submit race script
v1bh475u Jul 7, 2026
f87ce83
chore: ignore local codex files
v1bh475u Jul 7, 2026
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
26 changes: 24 additions & 2 deletions _examples/example.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ default_cpu_shares = 1024
default_memory_limit = 1024
default_pids_limit = 100

# Port range for localhost deployments (format: START:END)
local_host_port_range = "30000:40000"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have this field at all?


Comment thread
v1bh475u marked this conversation as resolved.
Outdated
# List of ip addresses of all the servers where challenge could be deployed for
# balanced load accross servers.
[available_servers]
Expand All @@ -44,6 +47,9 @@ username = "user1"
# Path to private SSH key for interacting with the server.
ssh_key_path = "/path/to/your/private/key1"

# Port range for this server (format: START:END)
port_range = "30000:40000"

# Status of remote server to be used
# If it is set to false then that remote server will not be used
active = false
Expand All @@ -53,10 +59,13 @@ active = false
host = "localhost"

# Username to be used for ssh connection (Leave empty for localhost)
username = "user1"
username = ""

# Path to private SSH key for interacting with the server. (Leave empty for localhost)
ssh_key_path = "/path/to/your/private/key1"
ssh_key_path = ""

# Port range for this server (format: START:END) - uses local_host_port_range if empty
port_range = ""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the need for local_host_port_range if we have this field for each available server.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't localhost treated separately? it's not necessarily added to the server list and is used as a default if no servers have been added. I had brought up treating localhost as a server with dagger (along with I think a few minor fixes), but that's work for another pr.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My issue with this is that because we are treating localhost and other servers separately in case of deployment, we are always having a nasty entangled if-else case spread everywhere making the code kinda messy.


# Status of remote server to be used
active = true
Expand Down Expand Up @@ -113,6 +122,19 @@ host = "localhost"
port = "5432"
sslmode = "prefer"

[redis_config]
host = "localhost"
port = "6379"
password = ""
user = ""

[instance_config]
# Port Range for localhost. per-server this is configured via `port-range`
local_host_port_range = '10000:11000'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default_expiration = 300
max_extension = 600
max_instances_per_user = 3

Comment on lines +128 to +132

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [instance_config] section documents local_host_port_range, but the code only supports per-server available_servers.<name>.port_range and does not read local_host_port_range. This mismatch means copying the example config can leave localhost/servers without a usable port range, breaking instance/compose deployment. Align the example and code: either remove this key from docs or add support + fallback logic in config validation.

Copilot uses AI. Check for mistakes.
# The following fields are required only while hosting a competition on beast
# This section contains information about the competition to be hosted
# Structure of the sections with the acceptable fields are:
Expand Down
12 changes: 12 additions & 0 deletions _examples/instanced-compose/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM php:7.4-apache

# Install MySQL extension
RUN docker-php-ext-install mysqli pdo pdo_mysql

# Copy challenge files
COPY challenge/ /var/www/html/

# Set permissions
RUN chown -R www-data:www-data /var/www/html

EXPOSE 80
89 changes: 89 additions & 0 deletions _examples/instanced-compose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Instanced Docker Compose Challenge Example

This is an example of an **instanced challenge using Docker Compose** - a multi-container challenge where each user gets their own isolated environment with a web server and database.

## Architecture

```
┌──────────────────────────────────────────┐
│ User's Instanced Environment │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ PHP/Apache │ ───▶ │ MySQL │ │
│ │ (web) │ │ (db) │ │
│ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ Port: 31234 (dynamically assigned) │
└──────────────────────────────────────────┘
```

## Key Configuration

In `beast.toml`:

```toml
[challenge.metadata]
instanced = true
instance_expiration = 600 # 10 minutes

[challenge.env]
docker_compose = "docker-compose.yml"
default_port = 8080
```

In `docker-compose.yml`, use the `INSTANCE_PORT` environment variable:

```yaml
services:
web:
ports:
- "${INSTANCE_PORT:-8080}:80"
```

## Challenge Details

This is a SQL injection challenge:

1. The login form is vulnerable to SQL injection
2. Bypass authentication to login as admin
3. The flag is stored in the `secrets` table

### Solution

```
Username: admin' OR '1'='1' --
Password: anything
```

Or use UNION-based injection to extract data directly.

## Testing Locally

```bash
# Build and run locally (for testing)
cd _examples/instanced-compose
docker-compose up -d

# Access at http://localhost:8080
```

## Usage via Beast API

```bash
# Spawn your instance
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/instances/instanced-compose/spawn

# Response:
# {
# "instance_id": "abc123def456",
# "challenge_name": "instanced-compose",
# "hosted_address": "localhost",
# "port": 31234,
# "expires_at": "2024-01-15T10:40:00Z",
# "ttl_seconds": 600
# }

# Access your instance
open http://localhost:31234
```
33 changes: 33 additions & 0 deletions _examples/instanced-compose/beast.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[author]
name = "beast-admin"
email = "admin@beast.local"
ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ"

[challenge.metadata]
name = "instanced-compose"
flag = "FLAG{c0mp0s3_1nst4nc3s_r0ck!}"
type = "web"
description = "A web challenge with database backend. Each user gets their own isolated environment!"
points = 200
difficulty = "medium"
tags = ["web", "sql", "instanced"]
maxAttemptLimit = 100
instanced = true
instance_expiration = 12

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instance_expiration value is set to 12 seconds, which is extremely short for a challenge instance. This seems like it's intended for testing purposes. For actual use, this should be a more reasonable duration (e.g., 600 seconds as mentioned in the README). Consider updating this to match the recommended value or adding a comment indicating this is a test value.

Suggested change
instance_expiration = 12
instance_expiration = 600

Copilot uses AI. Check for mistakes.

[[challenge.metadata.hints]]
text = "Check for SQL injection vulnerabilities"
points = 30

[[challenge.metadata.hints]]
text = "The admin password might be in the database..."
points = 50

[challenge.env]
docker_compose = "docker-compose.yml"
default_port = 8080

[resource]
cpu_shares = 1024
memory_limit = 536870912
pids_limit = 100
158 changes: 158 additions & 0 deletions _examples/instanced-compose/challenge/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html>
<head>
<title>Secret Vault - Login</title>
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
color: #fff;
}
.container {
background: rgba(255, 255, 255, 0.1);
padding: 40px;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
width: 350px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #00d9ff;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #aaa;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 16px;
box-sizing: border-box;
}
input[type="submit"] {
width: 100%;
padding: 14px;
border: none;
border-radius: 8px;
background: #00d9ff;
color: #1a1a2e;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
input[type="submit"]:hover {
background: #00b8d4;
}
.error {
background: rgba(255, 0, 0, 0.2);
padding: 10px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
}
.success {
background: rgba(0, 255, 0, 0.2);
padding: 10px;
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
}
.hint {
text-align: center;
margin-top: 20px;
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Secret Vault</h1>

<?php
$error = '';
$success = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$host = getenv('DB_HOST') ?: 'db';
$user = getenv('DB_USER') ?: 'challenge';
$pass = getenv('DB_PASS') ?: 'challengepass';
$dbname = getenv('DB_NAME') ?: 'ctf';

$conn = new mysqli($host, $user, $pass, $dbname);

if ($conn->connect_error) {
$error = "Connection failed. Please try again.";
} else {
$username = $_POST['username'];
$password = $_POST['password'];

// VULNERABLE: SQL Injection!
$query = "SELECT * FROM users WHERE username='$username' AND password='$password'";
$result = $conn->query($query);

if ($result && $result->num_rows > 0) {
$row = $result->fetch_assoc();

if ($row['role'] === 'admin') {
// Admin login - show secrets
$secrets_query = "SELECT * FROM secrets";
$secrets_result = $conn->query($secrets_query);

$success = "Welcome Admin! Here are your secrets:<br><br>";
while ($secret = $secrets_result->fetch_assoc()) {
$success .= "<b>" . htmlspecialchars($secret['secret_name']) . ":</b> " .
htmlspecialchars($secret['secret_value']) . "<br>";
}
} else {
$success = "Welcome, " . htmlspecialchars($row['username']) . "! You're logged in as a regular user.";
}
} else {
$error = "Invalid username or password!";
}

$conn->close();
}
}
?>

<?php if ($error): ?>
<div class="error"><?php echo $error; ?></div>
<?php endif; ?>

<?php if ($success): ?>
<div class="success"><?php echo $success; ?></div>
<?php else: ?>
<form method="POST">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required placeholder="Enter username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required placeholder="Enter password">
</div>
<input type="submit" value="Login">
</form>
<?php endif; ?>

<p class="hint">Hint: Try logging in as admin to see the secrets!</p>
</div>
</body>
</html>
29 changes: 29 additions & 0 deletions _examples/instanced-compose/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
version: '3.8'

services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
# Use INSTANCE_PORT env var if available, otherwise default to 8080
- "${INSTANCE_PORT:-8080}:80"
environment:
- DB_HOST=db
- DB_USER=challenge
- DB_PASS=challengepass
- DB_NAME=ctf
depends_on:
- db
restart: unless-stopped

db:
image: mysql:5.7
environment:
- MYSQL_ROOT_PASSWORD=rootpass
- MYSQL_DATABASE=ctf
- MYSQL_USER=challenge
- MYSQL_PASSWORD=challengepass
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
restart: unless-stopped
Loading