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
159 changes: 159 additions & 0 deletions ctfcli/core/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class Challenge(dict):
"host",
"connection_info",
"healthcheck",
"solution",
"attempts",
"logic",
"flags",
Expand Down Expand Up @@ -442,6 +443,127 @@ def _create_hints(self):
r = self.api.post("/api/v1/hints", json=hint_payload)
r.raise_for_status()

def _parse_solution_definition(self) -> tuple[str, str] | None:
solution = self.get("solution", None)
if not solution:
return None

if type(solution) == str:
return solution, "hidden"

if type(solution) != dict:
click.secho(
"The solution field must be a string path or an object with path and state",
fg="red",
)
return None

solution_path = solution.get("path")
if type(solution_path) != str or not solution_path:
click.secho("The solution object must define a non-empty string path field", fg="red")
return None

solution_state = solution.get("state", "hidden")
if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
click.secho("The solution state must be one of: hidden, visible, solved", fg="red")
return None

return solution_path, solution_state

def _resolve_solution_path(self) -> tuple[Path, str] | None:
parsed_solution = self._parse_solution_definition()
if not parsed_solution:
return None

solution_path_string, solution_state = parsed_solution
solution_path = self.challenge_directory / solution_path_string
if not solution_path.is_file():
click.secho(
f"Solution file '{solution_path_string}' specified, but not found at {solution_path}",
fg="red",
)
return None

return solution_path, solution_state

def _delete_existing_solution(self):
remote_solutions = self.api.get("/api/v1/solutions").json()["data"]
for solution in remote_solutions:
if solution["challenge_id"] == self.challenge_id:
r = self.api.delete(f"/api/v1/solutions/{solution['id']}")
r.raise_for_status()

def _get_existing_solution_id(self) -> int | None:
r = self.api.get("/api/v1/solutions")
r.raise_for_status()
remote_solutions = r.json().get("data") or []
for solution in remote_solutions:
if solution["challenge_id"] == self.challenge_id:
return solution["id"]
return None

def _create_solution(self):
resolved_solution = self._resolve_solution_path()
if not resolved_solution:
return
solution_path, solution_state = resolved_solution

solution_id = self._get_existing_solution_id()
if solution_id is None:
solution_payload_create = {"challenge_id": self.challenge_id, "state": solution_state, "content": ""}

r = self.api.post("/api/v1/solutions", json=solution_payload_create)
r.raise_for_status()
solution_id = r.json()["data"]["id"]
else:
# Keep solution state in sync and clear stale content before rebuilding references.
r = self.api.patch(
f"/api/v1/solutions/{solution_id}",
json={"state": solution_state, "content": ""},
)
r.raise_for_status()

with solution_path.open("r") as solution_file:
content = solution_file.read()

# Find all images in the content (markdown format; ignore html format)
# Markdown format: ![alt text](image_url)
# Returns tuples: (full_match, alt_text, image_path)
markdown_images = re.findall(r"(!\[([^\]]*)\]\(([^\)]+)\))", content)

# Find all snippet includes (MkDocs style: --8<-- "filename")
# Returns tuples: (full_match, filename)
snippet_includes = re.findall(r'(--8<--\s+["\']([^"\']+)["\'])', content)

for mdx, alt, path in markdown_images:
new_file = ("file", open(solution_path.parent / path, mode="rb"))
file_payload = {
"type": "solution",
"solution_id": solution_id,
}

# Specifically use data= here to send multipart/form-data
r = self.api.post("/api/v1/files", files=[new_file], data=file_payload)
r.raise_for_status()
resp = r.json()
server_location = resp["data"][0]["location"]
content = content.replace(mdx, f"![{alt}](/files/{server_location})")

# Process snippet includes (--8<-- "filename")
for full_match, filename in snippet_includes:
snippet_file_path = solution_path.parent / filename
if snippet_file_path.exists():
with snippet_file_path.open("r") as snippet_file:
snippet_content = snippet_file.read()
# Replace the --8<-- directive with the actual file content
content = content.replace(full_match, snippet_content)
else:
log.warning(f"Snippet file not found: {filename}")

solution_payload_patch = {"content": content}
r = self.api.patch(f"/api/v1/solutions/{solution_id}", json=solution_payload_patch)
r.raise_for_status()

def _set_required_challenges(self):
remote_challenges = self.load_installed_challenges()
required_challenges = []
Expand Down Expand Up @@ -796,6 +918,10 @@ def sync(self, ignore: tuple[str] = ()) -> None:
if "next" not in ignore:
self._set_next(_next)

if "solution" not in ignore:
# self._delete_existing_solution()
self._create_solution()

make_challenge_visible = False

# Bring back the challenge to be visible if:
Expand Down Expand Up @@ -880,6 +1006,10 @@ def create(self, ignore: tuple[str] = ()) -> None:
if "next" not in ignore:
self._set_next(_next)

# Add solution
if "solution" not in ignore:
self._create_solution()

# Bring back the challenge if it's supposed to be visible
# Either explicitly, or by assuming the default value (possibly because the state is ignored)
if challenge.get("state", "visible") == "visible" or "state" in ignore:
Expand Down Expand Up @@ -950,6 +1080,35 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool:
f"Challenge file '{challenge_file}' specified, but not found at {challenge_file_path}"
)

# Check that the optional solution file exists
solution = self.get("solution", None)
if solution:
solution_file = None
solution_state = "hidden"

if type(solution) == str:
solution_file = solution
elif type(solution) == dict:
solution_file = solution.get("path")
if "visibility" in solution:
issues["fields"].append("The solution object no longer supports visibility. Use state instead.")
solution_state = solution.get("state", "hidden")

if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
issues["fields"].append("The solution state must be one of: hidden, visible, solved")

else:
issues["fields"].append("The solution field must be a string path or an object with path and state")

if type(solution_file) != str or not solution_file:
issues["fields"].append("The solution object must define a non-empty string path field")
else:
solution_file_path = self.challenge_directory / solution_file
if solution_file_path.is_file() is False:
issues["files"].append(
f"Solution file '{solution_file}' specified, but not found at {solution_file_path}"
)

# Check that files don't have a flag in them
for challenge_file in files:
challenge_file_path = self.challenge_directory / challenge_file
Expand Down
9 changes: 9 additions & 0 deletions ctfcli/spec/challenge-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ connection_info: nc hostname 12345
# ./writeup/exploit.sh --connection-info "nc hostname 12345"
healthcheck: writeup/exploit.sh

# solution is used to provide a path to the challenge solution document.
# The file path is relative to this challenge.yml file.
# If provided as a string path, ctfcli uploads it as a hidden CTFd solution during sync.
# You can also use an object:
# solution:
# path: writeup/WRITEUP.md
# state: solved # hidden | visible | solved
solution: writeup/WRITEUP.md

# Can be removed if unused
attempts: 5

Expand Down
Loading