diff --git a/.gitignore b/.gitignore index 2f45034..a4383ac 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,6 @@ venv.bak/ dmypy.json # node -node_modules/ \ No newline at end of file +node_modules/ +*.json +/writeups diff --git a/README.md b/README.md new file mode 100644 index 0000000..d99f998 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# CTF Writeup Generator + +A Python tool to automatically generate CTF writeup templates and organize challenge files. Currently supports Hackropole platform with extensibility for other CTF platforms. + +### Supported CTF Websites : +- https://hackropole.fr + +### Will add : +- https://imaginaryctf.org +- https://theblackside.fr + +## Features + +- Automated writeup template generation +- Challenge information scraping +- File downloading and organization +- Support for multiple CTF platforms +- Hugo-compatible markdown generation +- Multilingual support (English/French) +- Cookie-based and token-based authentication + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/nohamr/LetCTF.git +cd LetCTF +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + + + +## Usage + +### Basic Usage + +```python +from pathlib import Path +from src.platforms.hackropole import HackropolePlatform +from src.generator import WriteupGenerator + +# Initialize platform with auth if needed +platform = HackropolePlatform( + config_file="config.json", + cookie_file="cookies.json" +) + +# Create generator +generator = WriteupGenerator(platform, Path("./writeups")) + +# Generate writeups +generator.fetch_challenges() +generator.generate_writeup_structure( + hugo_header=True, # Include Hugo front matter + translated=True # Generate French translations +) +``` + +### Output Structure + +``` +writeups/ +├── Platform1/ +│ ├── Challenge1/ +│ │ ├── index.md +│ │ ├── index.fr.md +│ │ └── challenge_files/ +│ └── Challenge2/ +└── Platform2/ + └── Challenge3/ +``` + +## Adding New Platforms + +1. Create a new platform file in `src/platforms/`: +```python +from .base import CTFPlatform +from ..models import Challenge + +class NewPlatform(CTFPlatform): + def login(self, credentials: Dict) -> bool: + # Implement login + pass + + def get_challenges(self) -> List[Challenge]: + # Implement challenge fetching + pass + + def get_challenge(self) -> Challenge: + # Implement specific challenge by URL + pass + + def generate_template(self, challenge: Challenge, hugo_header: bool = False, translated: bool = False): + # Implement generating writeup template for challenge + pass +``` + +2. Use the new platform: +```python +from src.platforms.new_platform import NewPlatform + +platform = NewPlatform() +``` + +## Template Format + +The generated writeup templates include: +- Challenge metadata (author, category, difficulty) +- Challenge description +- File information with hashes +- Basic writeup structure +- Hugo front matter (optional) + +Example template: +```markdown +--- +title: "Challenge Name" +date: "1970-00-00T00:00:00" +tags: ["category", "badge1", "badge2"] +author: "Author" +--- + +- Author: Challenge Author +- Category: Web +- Difficulty: ⭐⭐⭐ (3/5) +- Challenge URL: [Challenge Name - Platform](https://platform/challenges/123) +- Files provided: [file.zip](https://url/file.zip) *(SHA256: hash)* + +## Writeup +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..b815534 --- /dev/null +++ b/main.py @@ -0,0 +1,18 @@ +from src.platforms.hackropole import HackropolePlatform +from src.generator import WriteupGenerator +from pathlib import Path + +challenge_url = 'https://hackropole.fr/fr/challenges/reverse/fcsc2023-reverse-chaussette-xs/' +# challenge_url = 'https://hackropole.fr/fr/challenges/crypto/fcsc2022-crypto-t-rex/' +# challenge_url = 'https://hackropole.fr/fr/challenges/crypto/fcsc2022-crypto-a-laise/' + +def main(): + platform = HackropolePlatform() + + generator = WriteupGenerator(platform, Path("./writeups")) + generator.fetch_challenge(challenge_url=challenge_url) + print(generator.challenges) + generator.generate_writeup_structure(hugo_header=True, translated=True) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c18520a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +beautifulsoup4==4.13.3 +Requests==2.32.3 diff --git a/src/generator.py b/src/generator.py new file mode 100644 index 0000000..0f4b497 --- /dev/null +++ b/src/generator.py @@ -0,0 +1,55 @@ +from pathlib import Path +from typing import List +from .platforms.base import CTFPlatform +from .models import Challenge + +class WriteupGenerator: + """Main class to handle writeup generation""" + def __init__(self, platform: CTFPlatform, output_dir: Path): + self.platform = platform + self.output_dir = output_dir + self.challenges = [] + + def fetch_challenges(self): + """Fetch all challenges from the platform""" + self.challenges = self.platform.get_challenges() + + def fetch_challenge(self, challenge_url: str) -> Challenge: + """Fetch a specific challenge""" + self.challenges = [] if not self.challenges else self.challenges + challenge = self.platform.get_challenge(challenge_url) + self.challenges.append(challenge) + + def generate_writeup_structure(self, hugo_header: bool = False, translated: bool = False): + """Generate folder structure and writeup templates""" + for challenge in self.challenges: + platform_dir = self.output_dir / challenge.platform.lower() + platform_dir.mkdir(parents=True, exist_ok=True) + + challenge_dir = platform_dir / self._sanitize_filename(challenge.id) + challenge_dir.mkdir(exist_ok=True) + + files_dir = challenge_dir / "files" + files_dir.mkdir(exist_ok=True) + self.platform.download_challenge_files(challenge, files_dir) + + self.platform.generate_template(challenge, hugo_header, translated) + + if (challenge_dir / "index.md").exists(): + print(f"Writeup for {challenge.id} already exists. Skipping...") + continue + else: + (challenge_dir / "index.md").write_text(challenge.template) + + if translated: + if (challenge_dir / "index.fr.md").exists(): + print(f"Writeup for {challenge.id} already exists. Skipping...") + continue + else: + (challenge_dir / "index.fr.md").write_text(challenge.template_translated) + + + @staticmethod + def _sanitize_filename(filename: str) -> str: + """Sanitize filename for filesystem""" + return "".join(c for c in filename if c.isalnum() or c in (' ', '-', '_')).strip() \ No newline at end of file diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..64db6bb --- /dev/null +++ b/src/models.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from typing import List, Dict + +@dataclass +class Challenge: + id: str + url: str + platform: str + name: str + author: str + category: str + description: str + difficulty: str + points: int + files: List[str] + additional_info: Dict = None + template: str = None + template_translated: str = None + +@dataclass +class File: + name: str + hash: str + url: str \ No newline at end of file diff --git a/src/platforms/base.py b/src/platforms/base.py new file mode 100644 index 0000000..65d5d19 --- /dev/null +++ b/src/platforms/base.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from typing import List, Dict, Optional +import requests +from http.cookiejar import CookieJar +from pathlib import Path +from ..models import Challenge + +class CTFPlatform(ABC): + """Abstract base class for CTF platforms""" + def __init__(self, url: str, cookies: Optional[CookieJar] = None): + self.base_url = url + self.session = requests.Session() + if cookies: + self.session.cookies = cookies + + @abstractmethod + def login(self, credentials: Dict) -> bool: + pass + + @abstractmethod + def get_challenges(self) -> List[Challenge]: + pass + + @abstractmethod + def get_challenge(self, challenge_url: str) -> Challenge: + pass + + @abstractmethod + def download_challenge_files(self, challenge: Challenge, destination: Path) -> List[Path]: + pass + + @abstractmethod + def generate_template(self, challenge: Challenge, hugo_header: bool, translated: bool) -> str: + pass \ No newline at end of file diff --git a/src/platforms/hackropole.py b/src/platforms/hackropole.py new file mode 100644 index 0000000..6ada000 --- /dev/null +++ b/src/platforms/hackropole.py @@ -0,0 +1,267 @@ +from typing import List, Dict +from pathlib import Path +import requests +from .base import CTFPlatform +from ..models import Challenge, File +from ..utils.config_handler import load_config +from ..utils.challenge_handler import download_files +from bs4 import BeautifulSoup +import unicodedata +from datetime import datetime +import textwrap + + +class HackropolePlatform(CTFPlatform): + def __init__( + self, url: str = "https://hackropole.fr", config_file: str | Path = None + ): + super().__init__(url) + if config_file: + self.load_config(config_file) + self.headers = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + "cache-control": "no-cache", + "pragma": "no-cache", + "priority": "u=0, i", + "referer": "https://hackropole.fr/", + "sec-ch-ua": '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + } + + def load_config(self, config_file: str | Path): + """Load configuration from file""" + config = load_config(config_file) + self.token = config.get("token") + self.provider = config.get("provider") + + def login(self) -> bool: + """ + Not implementing traditional login as we're using cookies + Returns True if we can access authenticated endpoints + """ + try: + data = { + "token": self.token, + "provider": self.provider, + } + response = self.session.post( + "https://hackropole.fr/api/hackropole/user/self", + headers=self.headers, + json=data, + ) + return response.status_code == 200 + except requests.RequestException: + return False + + def get_challenges(self) -> List[Challenge]: + raise NotImplementedError("Method not implemented") + + def get_challenge(self, challenge_url: str) -> Challenge: + """Get a specific challenge by URL""" + try: + response = self.session.get(challenge_url, headers=self.headers) + if response.status_code != 200: + raise Exception( + f"Error fetching challenge {challenge_url}: {response.status_code}" + ) + except requests.RequestException as e: + raise Exception(f"Error fetching challenge {challenge_url}: {e}") + + response.encoding = "utf-8" # Force UTF-8 encoding + soup = BeautifulSoup(response.text, "html.parser") + + title = unicodedata.normalize( + "NFKD", soup.select_one(".jumbotron h1").get_text(strip=True) + ) + id = "-".join([word.lower() for word in title.split()]) + + badges = [ + unicodedata.normalize("NFKD", badge.get_text(strip=True)) + for badge in soup.select(".jumbotron .badge") + if "résolu le" + not in unicodedata.normalize("NFKD", badge.get_text(strip=True)) + ] + description = unicodedata.normalize( + "NFKD", soup.select_one(".markdown p").get_text(strip=True) + ) + + file_links = [] + file_names = [] + file_hashes = {} + file_elements = soup.select(".list-file li") + for element in file_elements: + link = element.select_one("a")["href"] + name = ( + element.select_one("a").get("download") + or element.select_one("a")["href"].split("/")[-1] + ) + file_links.append(link) + file_names.append(name) + hash_element = element.select_one(".clip-sha256") + if hash_element: + hash_text = hash_element.get_text(strip=True) + file_hash = hash_text.split("–")[-1].strip() + file_hashes[name] = file_hash + + author_name = unicodedata.normalize( + "NFKD", + soup.select_one(".col.text-center .font-monospace").get_text(strip=True), + ) + author_avatar = soup.select_one(".col.text-center img")["src"] + + stars = len( + [ + star + for star in soup.select("svg.text-warning") + if star.select_one("title").get_text(strip=True) == "star" + ] + ) + + available_categories = [ + "crypto", + "forensics", + "hardware", + "misc", + "pwn", + "reverse", + "web", + ] + for badge in badges: + if badge.lower() in available_categories: + category = badge + break + + files = [ + File(name=file_name, hash=file_hashes.get(file_name), url=file_link) + for file_name, file_link in zip(file_names, file_links) + ] + + return Challenge( + id=id, + url=challenge_url, + platform="Hackropole", + name=title, + author=author_name, + category=category if category else "Uncategorized", + description=description, + difficulty=stars, + points=None, + files=files, + additional_info={"badges": badges, "author_avatar": author_avatar}, + ) + + def download_challenge_files(self, challenge: Challenge, output_dir: Path): + download_files(self, challenge, output_dir) + + def generate_template(self, challenge: Challenge, hugo_header: bool = False, translated: bool = False): + """Generate writeup template for challenge""" + + all_tags = list(set([challenge.category] + challenge.additional_info["badges"])) # Remove duplicates + tags_str = '", "'.join(all_tags) + + hugo_header_template = textwrap.dedent( + f"""\ + --- + title: "{challenge.name}" + date: "{datetime.now().isoformat()}" + tags: ["{tags_str}"] + author: "Noham" + summary: "Writeup for {challenge.name} from {challenge.platform}. A {challenge.category} challenge with a difficulty of {challenge.difficulty if challenge.difficulty > 0 else 1}/5." + showToc: false + TocOpen: false + draft: false + hidemeta: false + comments: true + disableHLJS: false + disableShare: false + hideSummary: false + searchHidden: false + ShowReadingTime: true + ShowBreadCrumbs: true + searchHidden: true + ShowPostNavLinks: true + ShowWordCount: true + ShowRssButtonInSectionTermList: true + UseHugoToc: true + --- + """ + ) + + hugo_header_template_fr = textwrap.dedent( + f"""\ + --- + title: "{challenge.name}" + date: "{datetime.now().isoformat()}" + tags: ["{tags_str}"] + author: "Noham" + summary: "Writeup pour {challenge.name} de {challenge.platform}. Un challenge de {challenge.category} avec une difficulté de {challenge.difficulty if challenge.difficulty > 0 else 1}/5." + showToc: false + TocOpen: false + draft: false + hidemeta: false + comments: true + disableHLJS: false + disableShare: false + hideSummary: false + searchHidden: false + ShowReadingTime: true + ShowBreadCrumbs: true + searchHidden: true + ShowPostNavLinks: true + ShowWordCount: true + ShowRssButtonInSectionTermList: true + UseHugoToc: true + --- + """ + ) + + stars = "⭐" * (challenge.difficulty if challenge.difficulty > 0 else 1) + + files_section = "" + for file in challenge.files: + hash_text = f" *(SHA256: {file.hash})*" if file.hash else "" + files_section += f"[{file.name}]({file.url}){hash_text}, " + files_section = files_section.rstrip(", ") + + main_content = textwrap.dedent( + f"""\ + - Author: {challenge.author} + - Category: {challenge.category} + - Difficulty: {stars} ({challenge.difficulty if challenge.difficulty > 0 else 1}/5) + - Challenge URL: [{challenge.name} - {challenge.platform}]({challenge.url}) + - Challenge description: {challenge.description} + - Files provided: {files_section} + + ## Writeup + """ + ) + + main_content_fr = textwrap.dedent( + f"""\ + - Auteur: {challenge.author} + - Catégorie: {challenge.category} + - Difficulté: {stars} ({challenge.difficulty if challenge.difficulty > 0 else 1}/5) + - URL du challenge: [{challenge.name} - {challenge.platform}]({challenge.url}) + - Description du challenge: {challenge.description} + - Fichiers fournis: {files_section} + + ## Writeup + """ + ) + + if hugo_header: + challenge.template = hugo_header_template + main_content + if translated: + challenge.template_translated = hugo_header_template_fr + main_content_fr + else: + challenge.template = main_content + if translated: + challenge.template_translated = main_content_fr \ No newline at end of file diff --git a/src/utils/challenge_handler.py b/src/utils/challenge_handler.py new file mode 100644 index 0000000..e4f4ec4 --- /dev/null +++ b/src/utils/challenge_handler.py @@ -0,0 +1,19 @@ +from ..models import Challenge, File +from typing import List +from pathlib import Path +import requests + +def download_files(self, challenge: Challenge, destination: Path) -> List[Path]: + """Download challenge files to the specified directory""" + for file in challenge.files: + file_url = file.url + name = file.name + name = name.replace("public.yml", ".yml") + try: + response = self.session.get(file_url) + if response.status_code == 200: + file_path = destination / name + file_path.write_bytes(response.content) + print(f"Downloaded {name} to {destination}") + except requests.RequestException as e: + raise f"Error downloading file {file_url}: {e}" \ No newline at end of file diff --git a/src/utils/config_handler.py b/src/utils/config_handler.py new file mode 100644 index 0000000..d73c5b3 --- /dev/null +++ b/src/utils/config_handler.py @@ -0,0 +1,18 @@ +import json +from typing import Dict +from pathlib import Path + +def load_config(file_path: str | Path) -> Dict: + """Load configuration from a JSON file + Expected format: + { + "token": "your_token", + "provider": "provider_name" + } + """ + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {file_path}") + + with open(path, 'r') as f: + return json.load(f) \ No newline at end of file diff --git a/src/utils/cookie_handler.py b/src/utils/cookie_handler.py new file mode 100644 index 0000000..414a98a --- /dev/null +++ b/src/utils/cookie_handler.py @@ -0,0 +1,19 @@ +import json +from typing import Dict +from pathlib import Path + +def load_cookies_from_file(file_path: str | Path) -> Dict: + """ + Load cookies from a JSON file. + Expected format: + { + "cookie_name1": "cookie_value1", + "cookie_name2": "cookie_value2" + } + """ + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"Cookie file not found: {file_path}") + + with open(path, 'r') as f: + return json.load(f) \ No newline at end of file