Skip to content

Commit 56448ed

Browse files
ColdHeatvellvoid
andauthored
Add support for solutions based on writeups (#198)
* Add support for solutions based on writeups * add solution to spec explicitly (#199) * add solution to spec explicitly * remove visibility alias * Remove visibility references --------- Co-authored-by: Miłosz <milosz.skaza@ctfd.io>
1 parent 84bb18b commit 56448ed

8 files changed

Lines changed: 477 additions & 1 deletion

File tree

ctfcli/core/challenge.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class Challenge(dict):
5656
"host",
5757
"connection_info",
5858
"healthcheck",
59+
"solution",
5960
"attempts",
6061
"logic",
6162
"flags",
@@ -442,6 +443,127 @@ def _create_hints(self):
442443
r = self.api.post("/api/v1/hints", json=hint_payload)
443444
r.raise_for_status()
444445

446+
def _parse_solution_definition(self) -> tuple[str, str] | None:
447+
solution = self.get("solution", None)
448+
if not solution:
449+
return None
450+
451+
if type(solution) == str:
452+
return solution, "hidden"
453+
454+
if type(solution) != dict:
455+
click.secho(
456+
"The solution field must be a string path or an object with path and state",
457+
fg="red",
458+
)
459+
return None
460+
461+
solution_path = solution.get("path")
462+
if type(solution_path) != str or not solution_path:
463+
click.secho("The solution object must define a non-empty string path field", fg="red")
464+
return None
465+
466+
solution_state = solution.get("state", "hidden")
467+
if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
468+
click.secho("The solution state must be one of: hidden, visible, solved", fg="red")
469+
return None
470+
471+
return solution_path, solution_state
472+
473+
def _resolve_solution_path(self) -> tuple[Path, str] | None:
474+
parsed_solution = self._parse_solution_definition()
475+
if not parsed_solution:
476+
return None
477+
478+
solution_path_string, solution_state = parsed_solution
479+
solution_path = self.challenge_directory / solution_path_string
480+
if not solution_path.is_file():
481+
click.secho(
482+
f"Solution file '{solution_path_string}' specified, but not found at {solution_path}",
483+
fg="red",
484+
)
485+
return None
486+
487+
return solution_path, solution_state
488+
489+
def _delete_existing_solution(self):
490+
remote_solutions = self.api.get("/api/v1/solutions").json()["data"]
491+
for solution in remote_solutions:
492+
if solution["challenge_id"] == self.challenge_id:
493+
r = self.api.delete(f"/api/v1/solutions/{solution['id']}")
494+
r.raise_for_status()
495+
496+
def _get_existing_solution_id(self) -> int | None:
497+
r = self.api.get("/api/v1/solutions")
498+
r.raise_for_status()
499+
remote_solutions = r.json().get("data") or []
500+
for solution in remote_solutions:
501+
if solution["challenge_id"] == self.challenge_id:
502+
return solution["id"]
503+
return None
504+
505+
def _create_solution(self):
506+
resolved_solution = self._resolve_solution_path()
507+
if not resolved_solution:
508+
return
509+
solution_path, solution_state = resolved_solution
510+
511+
solution_id = self._get_existing_solution_id()
512+
if solution_id is None:
513+
solution_payload_create = {"challenge_id": self.challenge_id, "state": solution_state, "content": ""}
514+
515+
r = self.api.post("/api/v1/solutions", json=solution_payload_create)
516+
r.raise_for_status()
517+
solution_id = r.json()["data"]["id"]
518+
else:
519+
# Keep solution state in sync and clear stale content before rebuilding references.
520+
r = self.api.patch(
521+
f"/api/v1/solutions/{solution_id}",
522+
json={"state": solution_state, "content": ""},
523+
)
524+
r.raise_for_status()
525+
526+
with solution_path.open("r") as solution_file:
527+
content = solution_file.read()
528+
529+
# Find all images in the content (markdown format; ignore html format)
530+
# Markdown format: ![alt text](image_url)
531+
# Returns tuples: (full_match, alt_text, image_path)
532+
markdown_images = re.findall(r"(!\[([^\]]*)\]\(([^\)]+)\))", content)
533+
534+
# Find all snippet includes (MkDocs style: --8<-- "filename")
535+
# Returns tuples: (full_match, filename)
536+
snippet_includes = re.findall(r'(--8<--\s+["\']([^"\']+)["\'])', content)
537+
538+
for mdx, alt, path in markdown_images:
539+
new_file = ("file", open(solution_path.parent / path, mode="rb"))
540+
file_payload = {
541+
"type": "solution",
542+
"solution_id": solution_id,
543+
}
544+
545+
# Specifically use data= here to send multipart/form-data
546+
r = self.api.post("/api/v1/files", files=[new_file], data=file_payload)
547+
r.raise_for_status()
548+
resp = r.json()
549+
server_location = resp["data"][0]["location"]
550+
content = content.replace(mdx, f"![{alt}](/files/{server_location})")
551+
552+
# Process snippet includes (--8<-- "filename")
553+
for full_match, filename in snippet_includes:
554+
snippet_file_path = solution_path.parent / filename
555+
if snippet_file_path.exists():
556+
with snippet_file_path.open("r") as snippet_file:
557+
snippet_content = snippet_file.read()
558+
# Replace the --8<-- directive with the actual file content
559+
content = content.replace(full_match, snippet_content)
560+
else:
561+
log.warning(f"Snippet file not found: {filename}")
562+
563+
solution_payload_patch = {"content": content}
564+
r = self.api.patch(f"/api/v1/solutions/{solution_id}", json=solution_payload_patch)
565+
r.raise_for_status()
566+
445567
def _set_required_challenges(self):
446568
remote_challenges = self.load_installed_challenges()
447569
required_challenges = []
@@ -796,6 +918,10 @@ def sync(self, ignore: tuple[str] = ()) -> None:
796918
if "next" not in ignore:
797919
self._set_next(_next)
798920

921+
if "solution" not in ignore:
922+
# self._delete_existing_solution()
923+
self._create_solution()
924+
799925
make_challenge_visible = False
800926

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

1009+
# Add solution
1010+
if "solution" not in ignore:
1011+
self._create_solution()
1012+
8831013
# Bring back the challenge if it's supposed to be visible
8841014
# Either explicitly, or by assuming the default value (possibly because the state is ignored)
8851015
if challenge.get("state", "visible") == "visible" or "state" in ignore:
@@ -950,6 +1080,33 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool:
9501080
f"Challenge file '{challenge_file}' specified, but not found at {challenge_file_path}"
9511081
)
9521082

1083+
# Check that the optional solution file exists
1084+
solution = self.get("solution", None)
1085+
if solution:
1086+
solution_file = None
1087+
solution_state = "hidden"
1088+
1089+
if type(solution) == str:
1090+
solution_file = solution
1091+
elif type(solution) == dict:
1092+
solution_file = solution.get("path")
1093+
solution_state = solution.get("state", "hidden")
1094+
1095+
if type(solution_state) != str or solution_state not in ["hidden", "visible", "solved"]:
1096+
issues["fields"].append("The solution state must be one of: hidden, visible, solved")
1097+
1098+
else:
1099+
issues["fields"].append("The solution field must be a string path or an object with path and state")
1100+
1101+
if type(solution_file) != str or not solution_file:
1102+
issues["fields"].append("The solution object must define a non-empty string path field")
1103+
else:
1104+
solution_file_path = self.challenge_directory / solution_file
1105+
if solution_file_path.is_file() is False:
1106+
issues["files"].append(
1107+
f"Solution file '{solution_file}' specified, but not found at {solution_file_path}"
1108+
)
1109+
9531110
# Check that files don't have a flag in them
9541111
for challenge_file in files:
9551112
challenge_file_path = self.challenge_directory / challenge_file

ctfcli/spec/challenge-example.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ connection_info: nc hostname 12345
4848
# ./writeup/exploit.sh --connection-info "nc hostname 12345"
4949
healthcheck: writeup/exploit.sh
5050

51+
# solution is used to provide a path to the challenge solution document.
52+
# The file path is relative to this challenge.yml file.
53+
# If provided as a string path, ctfcli uploads it as a hidden CTFd solution during sync.
54+
# You can also use an object:
55+
# solution:
56+
# path: writeup/WRITEUP.md
57+
# state: solved # hidden | visible | solved
58+
solution: writeup/WRITEUP.md
59+
5160
# Can be removed if unused
5261
attempts: 5
5362

0 commit comments

Comments
 (0)