first push

This commit is contained in:
√(noham)²
2025-02-17 01:02:43 +01:00
parent 334683af6c
commit 49375f3dd1
11 changed files with 624 additions and 1 deletions

4
.gitignore vendored
View File

@@ -126,4 +126,6 @@ venv.bak/
dmypy.json
# node
node_modules/
node_modules/
*.json
/writeups

165
README.md Normal file
View File

@@ -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
```
<!-- ## Configuration
### Authentication
The tool supports both cookie-based and token-based authentication. Create one or both of the following files:
1. `config.json` for token-based auth:
```json
{
"token": "your-token-here"
}
```
2. `cookies.json` for cookie-based auth:
```json
{
"session": "your-session-cookie",
"other_cookie": "other-cookie-value"
}
``` -->
## 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.

18
main.py Normal file
View File

@@ -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()

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
beautifulsoup4==4.13.3
Requests==2.32.3

55
src/generator.py Normal file
View File

@@ -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()

24
src/models.py Normal file
View File

@@ -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

34
src/platforms/base.py Normal file
View File

@@ -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

267
src/platforms/hackropole.py Normal file
View File

@@ -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

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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)