Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8001230
Unify localhost checks
kunrex Apr 14, 2026
d2034fd
Feat: Add util functions for executing commands
kunrex Feb 7, 2026
b0d8f33
Feat: Add check.sh validation for instance challenges
kunrex Feb 7, 2026
5983874
Feat: Add check route with validation
kunrex Feb 7, 2026
7014b8b
Fix: fix unnecessary change
kunrex Feb 7, 2026
9019f96
Checks: Check if port 22 is exposed on all containers and refactor ut…
kunrex Feb 7, 2026
2aff015
Add: Add chmod to challenge setup
kunrex Feb 11, 2026
3208969
Fix: fix check path bugs and confirm test
kunrex Feb 11, 2026
6ac0a5b
Fix: fix bugs, update typos, add temp utils
kunrex Feb 18, 2026
de3c87f
Allow SSH into containers
kunrex Mar 18, 2026
8ca25ff
Remove submit file
kunrex Apr 13, 2026
966ad58
Fix post rebase errors
kunrex Apr 13, 2026
d355d5b
Bootstrap Dockerize Beast
sukhman-sukh Mar 22, 2026
cd7e517
Fix after rebase
kunrex Apr 14, 2026
bd3ffe4
Fix ssh key bug
kunrex Apr 14, 2026
cb0ac9e
Fix default port check
kunrex Apr 14, 2026
15e5f27
Add dynamic scoring
kunrex Apr 14, 2026
caea4ad
Add api locking
kunrex Apr 14, 2026
7631f90
Edit check route failure message, add check route checks
kunrex Apr 15, 2026
0013eea
Reintroduce regression
kunrex Apr 15, 2026
8a7d7c2
Remove ssh-agent check
kunrex Apr 15, 2026
51e383b
Fix port assignment in compose and update examples
kunrex Apr 16, 2026
de43392
Remove logs
kunrex Apr 16, 2026
8552dc3
Fix redis-instance rebase fallout
v1bh475u Jul 7, 2026
5eb9e17
feat: add hydra-net provisioning scripts
v1bh475u Jul 8, 2026
77b9cbd
feat: run hydra-net setup during provisioning
v1bh475u Jul 8, 2026
864616c
feat: validate hydra compose ssh contract
v1bh475u Jul 8, 2026
695205b
feat: run compose checks in internal checker
v1bh475u Jul 8, 2026
ae188ae
docs: document hydra compose ssh contract
v1bh475u Jul 8, 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
53 changes: 53 additions & 0 deletions Dockerfile.app
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
FROM golang:1.23 AS builder

RUN apt-get update && apt-get install -y \
make git gcc util-linux \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN cp scripts/docker-enter /usr/bin/docker-enter && \
cp scripts/docker_enter /usr/bin/docker_enter && \
chmod u+s /usr/bin/docker_enter && \

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

The image sets the setuid bit on /usr/bin/docker_enter. If this binary is ever executable by a non-root user inside the container, it becomes a straightforward privilege-escalation path. If setuid is not strictly required, drop it; otherwise document why it’s needed and consider restricting permissions/ownership to reduce the attack surface.

Suggested change
chmod u+s /usr/bin/docker_enter && \

Copilot uses AI. Check for mistakes.
gcc -o /usr/bin/importenv scripts/importenv.c

RUN make build

FROM ubuntu:22.04

RUN apt-get update && apt-get install -y ca-certificates curl gnupg lsb-release \
&& rm -rf /var/lib/apt/lists/*

RUN install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
chmod a+r /etc/apt/keyrings/docker.gpg && \
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list && \
apt-get update && \
apt-get install -y \
redis-tools \
postgresql-client \
docker-ce-cli \
docker-compose-plugin \
docker-buildx-plugin && \
apt-get clean && rm -rf /var/lib/apt/lists/*

COPY --from=builder /go/bin/beast /usr/local/bin/beast

COPY --from=builder /usr/bin/docker-enter /usr/local/bin/docker-enter
COPY --from=builder /usr/bin/docker_enter /usr/local/bin/docker_enter
COPY --from=builder /usr/bin/importenv /usr/local/bin/importenv

COPY setup.sh /usr/local/bin/setup.sh
RUN chmod +x /usr/local/bin/setup.sh

EXPOSE 5005

ENTRYPOINT ["setup.sh"]

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

ENTRYPOINT ["setup.sh"] relies on setup.sh being in PATH. Since the script is copied to /usr/local/bin/setup.sh, it’s safer/more explicit to use the absolute path in ENTRYPOINT to avoid failures if PATH is modified.

Suggested change
ENTRYPOINT ["setup.sh"]
ENTRYPOINT ["/usr/local/bin/setup.sh"]

Copilot uses AI. Check for mistakes.
63 changes: 63 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,66 @@ installenv:
@./scripts/installenv.sh

.PHONY: build format test check_format tools docs installenv

# ── Docker Compose Targets ────────────────────────────────────────────────────
# Usage: make up NAME=myctf
#
# NAME (required, no spaces) — used to create a .<NAME> folder on the host which
# is mounted as /root/.beast inside the beast container. This keeps each
# deployment isolated and named.
#
# Prerequisites:
# - config.toml must exist alongside this Makefile.
# - In config.toml set psql_config.host = "postgres" (the compose service name).
#
Comment on lines +92 to +95

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

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

The Makefile comments instruct setting psql_config.host = "postgres" as a docker-compose service name, but the added docker-compose.yml only defines the "beast" service (no "postgres"). Either add the dependent services to docker-compose.yml or update these prerequisites to match the actual deployment model.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +95

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

The Docker Compose helper comments instruct setting psql_config.host = "postgres", but the included docker-compose.yml in this PR only defines the beast service (no postgres service / compose network alias). This prerequisite is likely incorrect/misleading; either add the dependent services to compose or update the docs to reflect that Postgres/Redis are expected to be reachable on the host network.

Copilot uses AI. Check for mistakes.
# Targets:
# make up NAME=<name> — set up .<name>/, copy config, start services
# make down NAME=<name> — stop and remove services
# make logs NAME=<name> — tail beast service logs

check-name:
@if [ -z "$(NAME)" ]; then \
echo "Error: NAME is required. Usage: make up NAME=myctf"; \
exit 1; \
fi
@if echo "$(NAME)" | grep -q "[[:space:]]"; then \
echo "Error: NAME must not contain spaces"; \
exit 1; \
fi

check-config:
@if [ ! -f "config.toml" ]; then \
echo "Error: config.toml not found in current directory."; \
echo "Place your config.toml here (see _examples/example.config.toml)."; \
echo "Ensure psql_config.host = \"postgres\" for the compose network."; \
exit 1; \
fi

BEAST_DIR = $(HOME)/.$(NAME)

setup-beast-dir: check-name check-config
@echo "[*] Setting up $(BEAST_DIR)..."
@mkdir -p $(BEAST_DIR)/assets/logo
@mkdir -p $(BEAST_DIR)/assets/mailTemplates
@mkdir -p $(BEAST_DIR)/remote
@mkdir -p $(BEAST_DIR)/uploads
@mkdir -p $(BEAST_DIR)/secrets
@mkdir -p $(BEAST_DIR)/scripts
@mkdir -p $(BEAST_DIR)/staging
@mkdir -p $(BEAST_DIR)/cache
@mkdir -p $(BEAST_DIR)/logs
@cp config.toml $(BEAST_DIR)/config.toml
@echo "[*] $(BEAST_DIR) ready (mounted as /root/.beast in container)"

up: setup-beast-dir
@echo "[*] Starting beast services (project: $(NAME))..."
@BEAST_DIR=$(BEAST_DIR) docker compose --project-name $(NAME) up -d --build
@echo "[*] Beast API running at http://localhost:5005"

down: check-name
@docker compose --project-name $(NAME) down

logs: check-name
@docker compose --project-name $(NAME) logs -f beast

.PHONY: check-name check-config setup-beast-dir up down logs
15 changes: 10 additions & 5 deletions _examples/instanced-compose/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
FROM php:7.4-apache

# Install MySQL extension
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server \
&& rm -rf /var/lib/apt/lists/*

RUN groupadd -g 1337 beast-grp \
&& useradd -u 1337 -g 1337 -ms /bin/bash beast

RUN docker-php-ext-install mysqli pdo pdo_mysql

# Copy challenge files
COPY challenge/ /var/www/html/
COPY check.sh /challenge/check.sh

# Set permissions
RUN chown -R www-data:www-data /var/www/html
RUN chown -R www-data:www-data /var/www/html \
&& chmod +x /challenge/check.sh

EXPOSE 80
EXPOSE 80 22
85 changes: 48 additions & 37 deletions _examples/instanced-compose/README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,65 @@
# 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.
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

Beast treats the compose **service** named `ssh` as the primary instance container. The project name prefixes actual container names (for example `beast-instance-...-ssh-1`), so authors must keep the service key as `ssh`, not the literal container name.

```
┌──────────────────────────────────────────┐
│ User's Instanced Environment │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ PHP/Apache │ ───▶ │ MySQL │ │
│ │ (web) │ │ (db) │ │
│ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ Port: 31234 (dynamically assigned) │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ User's instanced environment │
│ ┌─────────────────────────┐ ┌────────────┐ │
│ │ ssh (PHP/Apache + SSH) │ ───▶ │ db │ │
│ └─────────────────────────┘ └────────────┘ │
│ │ │
│ ▼ │
│ SSH: SSH_PORT -> 22 through hydra-net │
└──────────────────────────────────────────────────┘
```

## Key Configuration
## 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
default_port_var = "SSH_PORT"
```

In `docker-compose.yml`, use the `INSTANCE_PORT` environment variable:
See `beast.toml` in this directory for full metadata (e.g. `instance_expiration`).

`default_port_var` selects which env-backed **host** port Beast treats as the primary instance port in API responses. For hydra-net challenges this must be the SSH mapping on the `ssh` service.

In `docker-compose.yml`, every published port must use an environment placeholder (no `${VAR:-default}` syntax). Example:

```yaml
services:
web:
ssh:
ports:
- "${INSTANCE_PORT:-8080}:80"
- "${SSH_PORT}:22"
networks:
- exposed
- internal

db:
networks:
- internal

networks:
exposed:
external: true
name: hydra-net
internal:
driver: bridge
internal: true
```

## Challenge Details
## Challenge details

This is a SQL injection challenge:

Expand All @@ -51,39 +70,31 @@ This is a SQL injection challenge:
### Solution

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

Or use UNION-based injection to extract data directly.

## Testing Locally
## Testing locally

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

# Access at http://localhost:8080
export SSH_PORT=2222
docker compose up -d --build
```

SSH matches `default_port_var`: `ssh -p 2222 beast@localhost` (adjust user/host as in your setup). The database is reachable only through the internal compose network.

## 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
The returned `port` is the allocated host port for **`SSH_PORT`** (SSH), matching `default_port_var`.

```bash
ssh -p <port from response> beast@<hosted_address>
```
2 changes: 1 addition & 1 deletion _examples/instanced-compose/beast.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ points = 50

[challenge.env]
docker_compose = "docker-compose.yml"
default_port_var = "INSTANCE_PORT"
default_port_var = "SSH_PORT"

[resource]
cpu_shares = 1024
Expand Down
3 changes: 3 additions & 0 deletions _examples/instanced-compose/check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
# Placeholder for Beast instanced challenges (dynamic flag / checks live here in real challenges).
exit 0
26 changes: 23 additions & 3 deletions _examples/instanced-compose/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
version: '3.8'

# Primary instance service must be named `ssh` (core.SSH_CONTAINER_COMPOSE).

services:
web:
ssh:
build:
context: .
dockerfile: Dockerfile
ports:
- "${INSTANCE_PORT}:80"
- "${SSH_PORT}:22"
volumes:
- challenge:/challenge
environment:
- DB_HOST=db
- DB_USER=challenge
- DB_PASS=challengepass
- DB_NAME=ctf
networks:
- exposed
- internal
depends_on:
- db
restart: unless-stopped

db:
image: mysql:5.7
environment:
Expand All @@ -25,4 +32,17 @@ services:
- MYSQL_PASSWORD=challengepass
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- internal
restart: unless-stopped

networks:
exposed:
external: true
name: hydra-net
internal:
driver: bridge
internal: true

volumes:
challenge:
17 changes: 14 additions & 3 deletions api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"errors"
"golang.org/x/crypto/ssh"
"log"
"net/http"
"strings"
Expand Down Expand Up @@ -215,10 +216,10 @@ func register(c *gin.Context) {
email = strings.TrimSpace(strings.ToLower(email))
sshKey = strings.TrimSpace(sshKey)

if username == "" || password == "" || email == "" {
if username == "" || password == "" || email == "" || sshKey == "" {

c.JSON(http.StatusBadRequest, HTTPPlainResp{
Message: "Username, password and email can not be empty",
Message: "Username, password, email, and sshKey can not be empty",
})
return
}
Expand All @@ -230,6 +231,15 @@ func register(c *gin.Context) {
return
}

key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(sshKey))
if err != nil {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
Error: "SSH Key is not valid",
})
}

sshKey = string(ssh.MarshalAuthorizedKey(key))

Comment on lines +234 to +242

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

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

After returning a 400 for an invalid SSH key, the handler continues execution and calls ssh.MarshalAuthorizedKey(key) even though key is nil. Add a return after the JSON response, and only parse/normalize sshKey when the form field is non-empty (it’s documented as optional).

Suggested change
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(sshKey))
if err != nil {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
Error: "SSH Key is not valid",
})
}
sshKey = string(ssh.MarshalAuthorizedKey(key))
if sshKey != "" {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(sshKey))
if err != nil {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
Error: "SSH Key is not valid",
})
return
}
sshKey = string(ssh.MarshalAuthorizedKey(key))
}

Copilot uses AI. Check for mistakes.
userEntry := database.User{
Name: name,
AuthModel: auth.CreateModel(username, password, core.USER_ROLES["contestant"]),
Expand Down Expand Up @@ -268,7 +278,8 @@ func register(c *gin.Context) {
}
}
}
err := database.CreateUserEntry(&userEntry)

err = database.CreateUserEntry(&userEntry)

if err != nil {
c.JSON(http.StatusNotAcceptable, HTTPErrorResp{
Expand Down
Loading