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
29 changes: 29 additions & 0 deletions _examples/simple-ssh/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
FROM ubuntu:16.04

# Install SSH server and other required packages
RUN apt-get update && apt-get install -y openssh-server sudo vim \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

# Configure SSH server
RUN mkdir /var/run/sshd
RUN echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config
RUN echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config

# Create a CTF user with a default password
RUN useradd -m -s /bin/bash ctf-user \
&& echo "ctf-user:ctf-password" | chpasswd \
&& echo "ctf-user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ctf-user

# Add the challenge flag in a hidden location
RUN echo "FLAG{SSH_CHALLENGE_FLAG}" > /home/ctf-user/.hidden_flag \
&& chmod 600 /home/ctf-user/.hidden_flag

# Add a welcome message
RUN echo "Welcome to the SSH Challenge! Find the hidden flag." > /etc/motd

# Expose SSH port
EXPOSE 22

# Start SSH service
CMD ["/usr/sbin/sshd", "-D"]
19 changes: 19 additions & 0 deletions _examples/simple-ssh/beast.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[author]
name = "ph03nix"
email = "author@example.com"
ssh_key = "ssh-ed25519 AAAAC3NzaC1lZD"

[challenge.metadata]
name = "simple-ssh"
flag = "FLAG{SSH_CHALLENGE_FLAG}"
type = "ssh"
points = 150
description = "SSH into the server and find the flag. Here are the creds: ctf-user:ctf-password"

[[challenge.metadata.hints]]
text = "Check for hidden files in the home directory"
points = 10

[challenge.env]
docker_context = "Dockerfile"
port_mappings = ["14442:22"]
36 changes: 34 additions & 2 deletions core/config/challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,9 @@ func (config *ChallengeMetadata) ValidateRequiredFields() (error, bool) {
// # Exists for flexibility reasons try to use existing base iamges wherever possible.
// base_image = ""
//
// # Docker file name for specific type challenge - `docker`.
// # Docker file name for specific type challenge - `docker` or `ssh`.
// # Helps to build flexible images for specific user-custom challenges
// docket_context = ""
// docker_context = ""
//
// # Environment variables that can be used in the application code.
// [[var]]
Expand Down Expand Up @@ -390,6 +390,23 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st
return fmt.Errorf("max ports allowed for challenge : %d given : %d", core.MAX_PORT_PER_CHALL, len(config.Ports))
}

// For SSH challenges, ensure there's an explicit host->container mapping to SSH port.
// If user didn't specify a mapping like "host:22", map the first port in `Ports`
// to container SSH port so downstream code doesn't need the challenge type.
if challType == core.SSH_CHALLENGE_TYPE_NAME {
sshMapped := false
for _, pm := range config.PortMappings {
_, cp, err := utils.ParsePortMapping(pm)
if err == nil && cp == core.SSH_PORT {
sshMapped = true
break
}
}
if !sshMapped && len(config.Ports) > 0 {
config.PortMappings = append(config.PortMappings, fmt.Sprintf("%d:%d", config.Ports[0], core.SSH_PORT))
}
}
Comment on lines +393 to +408

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

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

In SSH challenges, this logic appends a new port_mappings entry but leaves the original Ports entry intact. That causes GetPortMappings() to also add a hostPort:hostPort mapping for the same host port, which can lead to duplicate host port bindings (e.g., 14442:22 and 14442:14442) and deployment failure. Consider removing the selected host port from config.Ports (or otherwise excluding it from GetPortMappings) when you synthesize the SSH mapping, and ensure the max-port validation still reflects the final mapping set.

Copilot uses AI. Check for mistakes.

portMappings, err := config.GetPortMappings()
if err != nil {
return fmt.Errorf("error while parsing port mapping: %s", err)
Expand Down Expand Up @@ -495,6 +512,21 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st
return fmt.Errorf("web Root directory does not exist")
}
}
} else if challType == core.SSH_CHALLENGE_TYPE_NAME {
// Challenge type is SSH which requires docker_context
if config.DockerCtx == "" {
return fmt.Errorf("docker_context can not be empty for SSH challenges")
} else if config.DockerCtx != "" {
if filepath.IsAbs(config.DockerCtx) {
return fmt.Errorf("docker_context path should be relative to challenge directory root")
} else if err := utils.ValidateFileExists(filepath.Join(challdir, config.DockerCtx)); err != nil {
return fmt.Errorf("docker_context file does not exist")
}
}

if !checkIfPortExistInMapping(portMappings, core.SSH_PORT) {

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.

Maybe the port mapping being appended GetPortMappings could be handled here.

log.Warnf("No SSH port (22) found in port mappings for SSH challenge, it might not be accessible via SSH")
}
}

for _, script := range config.SetupScripts {
Expand Down
5 changes: 3 additions & 2 deletions core/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const ( //chall types
SERVICE_CHALLENGE_TYPE_NAME string = "service"
WEB_CHALLENGE_TYPE_NAME string = "web"
BARE_CHALLENGE_TYPE_NAME string = "bare"
SSH_CHALLENGE_TYPE_NAME string = "ssh"
)

const ( // chall actions
Expand Down Expand Up @@ -97,7 +98,7 @@ const ( // default config
ITERATIONS int = 65536
HASH_LENGTH int = 32
TIMEPERIOD int64 = 6 * 60 * 60
SSH_PORT int = 22
SSH_PORT uint32 = 22
)

const ( // roles
Expand Down Expand Up @@ -133,7 +134,7 @@ var USER_ROLES = map[string]string{
}

// Available challenge types
var AVAILABLE_CHALLENGE_TYPES = []string{STATIC_CHALLENGE_TYPE_NAME, SERVICE_CHALLENGE_TYPE_NAME, BARE_CHALLENGE_TYPE_NAME, WEB_CHALLENGE_TYPE_NAME}
var AVAILABLE_CHALLENGE_TYPES = []string{STATIC_CHALLENGE_TYPE_NAME, SERVICE_CHALLENGE_TYPE_NAME, BARE_CHALLENGE_TYPE_NAME, WEB_CHALLENGE_TYPE_NAME, SSH_CHALLENGE_TYPE_NAME}

var DockerBaseImageForWebChall = map[string]map[string]map[string]string{
"php": {
Expand Down
20 changes: 17 additions & 3 deletions docs/ChallTypes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Any service whether it is a binary file, or a shell script, which needs to be instantiated on every connection can be easily hosted using `service` type challenge. **Xinetd** is for hosting these type of challenges inside a docker container.

###Primary Requirements
### Primary Requirements

```toml
# Relative path to binary or script which needs to be executed when the specified
Expand All @@ -21,7 +21,7 @@ Web challenges are hosted using the corresponding images from Dockerhub. Current
* Python : Django and Flask
* Php

###Primary Requirements
### Primary Requirements

```toml
# Relative directory corresponding to root of the challenge where the root
Expand Down Expand Up @@ -57,10 +57,24 @@ entrypoint = ""

Authors might have tested the challenges in a isolated docker environment and might not want to port the challenge to one of these types. So they can use `docker` type challenge in which you can provide your own docker context file and ports.

###Primary Requirements
### Primary Requirements

```toml
# Docker file name for specific type challenge - `docker`.
# Helps to build flexible images for specific user-custom challenges
docket_context = ""

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

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

In the Docker challenge config snippet the key is shown as docket_context, but the actual config field is docker_context. This typo will mislead users and cause config validation failures if copy/pasted.

Suggested change
docket_context = ""
docker_context = ""

Copilot uses AI. Check for mistakes.
```

## SSH Challenge

SSH challenges allow participants to connect directly to a container via SSH. This challenge type requires a dockerfile to be provided, which will be run with the necessary port mappings to allow SSH access. If the 22 port has been explicitly mapped to a host port using `port_mappings` then it will be mapped that host port. Otherwise, if the `ports` list has been used, then the first port of that list, will be mapped to the container's 22 port.

### Primary Requirements

```toml
# Docker file name for the SSH challenge
docker_context = ""

# Port to be exposed for SSH access (typically 22)
port_mappings = ["14442:22"]
```
32 changes: 32 additions & 0 deletions docs/SampleChallenges.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,35 @@ The type of challenge consist of the following format - `web:php:<PHP Version>:<

Make sure that you are installing all the mysql related dependencies in the `apt_deps` configuration parameter. As in the
above case `php*-mysql`

## SSH Challenges

SSH challenges allow participants to connect directly to a container using SSH and interact with a shell environment to solve the challenge. These challenges are ideal for scenarios where participants need to explore a system, find hidden files, or exploit vulnerabilities in a controlled environment.

```toml
[author]
name = "ph03n1x"
email = "author@example.com"
ssh_key = "ssh-rsa AAAAB3NzaC1y..."

[challenge.metadata]
name = "simple-ssh"
flag = "FLAG{SSH_CHALLENGE_FLAG}"
type = "ssh"
points = 150
description = "SSH into the server and find the flag. The creds are ctf-user:ctf-password"

[[challenge.metadata.hints]]
text = "Check for hidden files in the home directory"
points = 10

[challenge.env]
docker_context = "Dockerfile"
port_mappings = ["14442:22"] # Map external port 14442 to container's SSH port 22
```

Participants will be able to connect to this challenge using SSH:
```
ssh ctf-user@challenge-host -p 2222

Copilot AI Mar 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 SSH example maps host port 14442 to container 22, but the connection snippet uses -p 2222. Update the SSH command to use the mapped host port (14442) or change the example mapping to match 2222 so readers can copy/paste successfully.

Suggested change
ssh ctf-user@challenge-host -p 2222
ssh ctf-user@challenge-host -p 14442

Copilot uses AI. Check for mistakes.
Password: ctf-password
```