Skip to content
Open
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
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
73 changes: 36 additions & 37 deletions _examples/instanced-compose/README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,52 @@
# 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 (users SSH into it; it also serves the web app here). 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
└─────────────────────────┘ └────────────┘
HTTP: INSTANCE_PORT → 80 (dynamic on host)
SSH: INSTANCE_SSH_PORT → 22 (dynamic on host)
└──────────────────────────────────────────────────
```

## 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 = "INSTANCE_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 (here, SSH on the `ssh` service). HTTP is still published via `INSTANCE_PORT` → 80; both variables must appear in `docker-compose.yml` ports so Beast allocates a host port for each.

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"
- "${INSTANCE_PORT}:80"
- "${INSTANCE_SSH_PORT}:22"
```

## Challenge Details
## Challenge details

This is a SQL injection challenge:

Expand All @@ -51,39 +57,32 @@ 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 INSTANCE_PORT=8080
export INSTANCE_SSH_PORT=2222
docker compose up -d --build
```

The web app is at `http://localhost:8080`. SSH matches `default_port_var`: `ssh -p 2222 beast@localhost` (adjust user/host as in your setup).

## 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 **`INSTANCE_SSH_PORT`** (SSH), matching `default_port_var`. HTTP is bound separately via `INSTANCE_PORT` (another host port in the same compose up).

```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 = "INSTANCE_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
7 changes: 5 additions & 2 deletions _examples/instanced-compose/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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"
- "${INSTANCE_SSH_PORT}:22"
environment:
- DB_HOST=db
- DB_USER=challenge
Expand All @@ -15,7 +18,7 @@ services:
depends_on:
- db
restart: unless-stopped

db:
image: mysql:5.7
environment:
Expand Down
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