refactored the cli and forensics codebases as classes and into seperate

folders for readability and consistent API access
This commit is contained in:
venus
2026-07-19 03:50:06 -05:00
parent 058b5c1eb5
commit 75614f6a21
19 changed files with 991 additions and 763 deletions

View File

@@ -1,6 +1,7 @@
# src/ctf/analysis.py # src/ctf/analysis.py
# {{{ imports # {{{ imports
import re import re
from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
# }}} # }}}
@@ -23,16 +24,41 @@ class AnalysisResult:
routes: list[RouteResult] routes: list[RouteResult]
# }}} # }}}
# {{{ run_metadata_route # {{{ BaseRoute
def run_metadata_route(file_path: Path) -> RouteResult: class BaseRoute(ABC):
"""Extracts file metadata and scans for potential flags or hints.""" """Abstract base class representing an analysis/solver route."""
def __init__(self, route_name: str):
self.route_name = route_name
@abstractmethod
def is_applicable(self, file_path: Path) -> bool:
"""Determines if this route is applicable to the given file."""
pass
@abstractmethod
def execute(self, file_path: Path) -> RouteResult:
"""Executes the analysis route on the target file."""
pass
# }}}
# {{{ MetadataRoute
class MetadataRoute(BaseRoute):
"""Route that extracts metadata and parses comments/EXIF for flags."""
def __init__(self):
super().__init__("File Metadata Extraction")
def is_applicable(self, file_path: Path) -> bool:
# Metadata parsing is applicable to any file
return True
def execute(self, file_path: Path) -> RouteResult:
from ctf.forensics import get_metadata from ctf.forensics import get_metadata
from ctf.decoding import check_for_flag from ctf.decoding import check_for_flag
file_path = Path(file_path).resolve() file_path = Path(file_path).resolve()
if not file_path.exists(): if not file_path.exists():
return RouteResult( return RouteResult(
route_name="File Metadata Extraction", route_name=self.route_name,
executed=True, executed=True,
success=False, success=False,
message=f"File not found: {file_path}" message=f"File not found: {file_path}"
@@ -73,7 +99,7 @@ def run_metadata_route(file_path: Path) -> RouteResult:
} }
return RouteResult( return RouteResult(
route_name="File Metadata Extraction", route_name=self.route_name,
executed=True, executed=True,
success=success, success=success,
message=msg, message=msg,
@@ -82,29 +108,48 @@ def run_metadata_route(file_path: Path) -> RouteResult:
) )
except Exception as e: except Exception as e:
return RouteResult( return RouteResult(
route_name="File Metadata Extraction", route_name=self.route_name,
executed=True, executed=True,
success=False, success=False,
message=f"Error extracting metadata: {str(e)}" message=f"Error extracting metadata: {str(e)}"
) )
# }}} # }}}
# {{{ run_analysis # {{{ RoutingEngine
def run_analysis(file_path: Path | None = None) -> AnalysisResult: class RoutingEngine:
"""Executes all registered analysis routes to analyze and solve a challenge.""" """Manages CTF challenge solvers and dynamic route execution."""
routes = [] def __init__(self):
self.routes: list[BaseRoute] = []
# Route 1: Metadata Extraction def register_route(self, route: BaseRoute):
if file_path: self.routes.append(route)
routes.append(run_metadata_route(Path(file_path)))
else: def run_all(self, file_path: Path | None) -> AnalysisResult:
routes.append(RouteResult( executed_results = []
if not file_path:
# Add a non-executed default result for the metadata route
executed_results.append(RouteResult(
route_name="File Metadata Extraction", route_name="File Metadata Extraction",
executed=False, executed=False,
success=False, success=False,
message="No file attached to analyze." message="No file attached to analyze."
)) ))
return AnalysisResult(success=False, routes=executed_results)
success = any(r.success and r.extracted_flags for r in routes) resolved_path = Path(file_path).resolve()
return AnalysisResult(success=success, routes=routes) for route in self.routes:
if route.is_applicable(resolved_path):
executed_results.append(route.execute(resolved_path))
success = any(r.success and r.extracted_flags for r in executed_results)
return AnalysisResult(success=success, routes=executed_results)
# }}}
# {{{ run_analysis
def run_analysis(file_path: Path | None = None) -> AnalysisResult:
"""Compatibility function that instantiates the RoutingEngine and runs all routes."""
engine = RoutingEngine()
engine.register_route(MetadataRoute())
return engine.run_all(file_path)
# }}} # }}}

34
src/ctf/cli/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
# src/ctf/cli/__init__.py
# {{{ imports
import click
import sys
from ctf.cli.basic import basic_group
from ctf.cli.forensics import forensics_group
from ctf.cli.steg import steg_group
from ctf.cli.analyse import analyse_cmd
from ctf.cli.flag import flag_cmd
# }}}
# {{{ debug_exception_handler
def debug_exception_handler(type, value, tb):
"""Unhandled exception hook to start post-mortem debugging in pdb."""
import traceback
import pdb
traceback.print_exception(type, value, tb)
print("\n[!] Unhandled exception. Entering post-mortem debugger...")
pdb.post_mortem(tb)
# }}}
# {{{ cli
@click.group()
@click.option("-d", "--debug", is_flag=True, help="Enable post-mortem debugging on errors.")
def cli(debug):
if debug:
sys.excepthook = debug_exception_handler
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
cli.add_command(steg_group)
cli.add_command(analyse_cmd)
# }}}

View File

@@ -1,4 +1,4 @@
# src/ctf/cli_analyse.py # src/ctf/cli/analyse.py
# {{{ imports # {{{ imports
import click import click
from pathlib import Path from pathlib import Path

View File

@@ -1,9 +1,8 @@
# functions for commands needed # src/ctf/cli/basic.py
# src/commands.py # {{{ imports
# vim foldmethod=marker
import click import click
from pathlib import Path from pathlib import Path
# }}}
# {{{ basic_group # {{{ basic_group
# This defines a group with name basic which will nest other comands to be imported in the main loop # This defines a group with name basic which will nest other comands to be imported in the main loop
@@ -37,7 +36,7 @@ def set_flag_format(pattern: str, original: str):
You can either specify the regex PATTERN directly, or provide an example flag You can either specify the regex PATTERN directly, or provide an example flag
via the -o/--original option to generate and select from a list of suggested patterns. via the -o/--original option to generate and select from a list of suggested patterns.
""" """
from ctf.config import load_config, write_config from ctf.config import Config
from ctf.utils import suggest_patterns from ctf.utils import suggest_patterns
if pattern is None and original is None: if pattern is None and original is None:
@@ -45,13 +44,12 @@ def set_flag_format(pattern: str, original: str):
if pattern is not None and original is not None: if pattern is not None and original is not None:
raise click.UsageError("Cannot specify both PATTERN and --original/-o option.") raise click.UsageError("Cannot specify both PATTERN and --original/-o option.")
config_path = "/home/venus/code/ctf/config.toml" cfg = Config()
config = load_config(config_path)
if "Competition" not in config: if "Competition" not in cfg.data:
config["Competition"] = {} cfg.data["Competition"] = {}
comp_name = config["Competition"].get("competition", "") comp_name = cfg.data["Competition"].get("competition", "")
if original: if original:
if not original.strip(): if not original.strip():
@@ -67,8 +65,8 @@ def set_flag_format(pattern: str, original: str):
val = click.prompt("Select a pattern index", type=click.IntRange(1, len(patterns))) val = click.prompt("Select a pattern index", type=click.IntRange(1, len(patterns)))
pattern = patterns[val - 1] pattern = patterns[val - 1]
config["Competition"]["flag_format"] = pattern cfg.data["Competition"]["flag_format"] = pattern
write_config(config, config_path) cfg.save(cfg.data)
click.echo(f"Flag format set to: {pattern}") click.echo(f"Flag format set to: {pattern}")
# }}} # }}}
@@ -78,16 +76,13 @@ def set_flag_format(pattern: str, original: str):
@click.argument("name") @click.argument("name")
def set_competition(name: str): def set_competition(name: str):
"""Set the name of the active competition.""" """Set the name of the active competition."""
from ctf.config import load_config, write_config from ctf.config import Config
config_path = "/home/venus/code/ctf/config.toml" cfg = Config()
config = load_config(config_path)
if "Competition" not in config: if "Competition" not in cfg.data:
config["Competition"] = {} cfg.data["Competition"] = {}
cfg.data["Competition"]["competition"] = name
config["Competition"]["competition"] = name cfg.save(cfg.data)
write_config(config, config_path)
click.echo(f"Competition name set to: {name}") click.echo(f"Competition name set to: {name}")
# }}} # }}}

47
src/ctf/cli/flag.py Normal file
View File

@@ -0,0 +1,47 @@
# src/ctf/cli/flag.py
# {{{ imports
import click
from ctf.helpers import detector_state
# }}}
# {{{ flag_cmd
@click.command(name="flag")
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
@click.option("-l", "--list", "list_format", is_flag=True, help="List the current flag format.")
@click.option("-s", "--set", "set_format", type=str, help="Set the current flag format.")
@click.pass_context
def flag_cmd(ctx, plain, list_format, set_format):
"""Retrieve the last detected flag from config, or list/set flag format options."""
from ctf.config import Config
if list_format:
cfg = Config()
flag_format = cfg.data.get("Competition", {}).get("flag_format", "")
if plain:
click.echo(flag_format)
else:
click.echo(f"Current flag format: {flag_format}" if flag_format else "No flag format configured.")
return
if set_format is not None:
from ctf.commands import set_flag_format
ctx.invoke(set_flag_format, pattern=set_format, original=None)
return
# TODO add support for multiple flags and flag selection
if detector_state["active"] is not None:
detector_state["active"].detecting = False
try:
cfg = Config()
last_flag = cfg.data.get("Competition", {}).get("last_flag", "")
if not last_flag:
if not plain:
click.echo("No flag has been detected yet.")
return
if plain:
click.echo(last_flag)
else:
click.echo(f"Last detected flag: {last_flag}")
finally:
if detector_state["active"] is not None:
detector_state["active"].detecting = True
# }}}

View File

@@ -1,10 +1,9 @@
# src/ctf/cli_forensics.py # src/ctf/cli/forensics.py
# CLI wrapper and rendering layer for forensics commands # {{{ imports
# vim foldmethod=marker
import click import click
from pathlib import Path from pathlib import Path
from ctf.forensics import get_metadata, COMMON_SIGNATURES from ctf.forensics import get_metadata, COMMON_SIGNATURES
# }}}
# {{{ forensics_group # {{{ forensics_group
@click.group(name="forensics") @click.group(name="forensics")
@@ -140,4 +139,3 @@ def list_signatures():
console.print(table) console.print(table)
# }}} # }}}

View File

@@ -1,4 +1,4 @@
# src/ctf/cli_steg.py # src/ctf/cli/steg.py
# {{{ imports # {{{ imports
import click import click
from pathlib import Path from pathlib import Path

View File

@@ -1,30 +0,0 @@
# src/ctf/cli_helpers.py
# {{{ imports
import click
from ctf.helpers import detector_state
# }}}
# {{{ flag_cmd
@click.command(name="flag")
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
def flag_cmd(plain):
"""Retrieve the last detected flag from config."""
# TODO add support for multiple flags and flag selection
if detector_state["active"] is not None:
detector_state["active"].detecting = False
try:
from ctf.config import load_config
config = load_config("/home/venus/code/ctf/config.toml")
last_flag = config.get("Competition", {}).get("last_flag", "")
if not last_flag:
if not plain:
click.echo("No flag has been detected yet.")
return
if plain:
click.echo(last_flag)
else:
click.echo(f"Last detected flag: {last_flag}")
finally:
if detector_state["active"] is not None:
detector_state["active"].detecting = True
# }}}

View File

@@ -1,26 +1,38 @@
# src/ctf/config.py # src/ctf/config.py
# {{{ imports # {{{ imports
import toml import toml
import os
from pathlib import Path from pathlib import Path
from platformdirs import user_config_dir
# }}} # }}}
# {{{ load_config # {{{ Config
def load_config(config = f"{user_config_dir()}/ctf-config.toml") -> dict: class Config:
p = Path(config) """Manages CTF challenge configuration loading and persistence."""
if p.exists(): def __init__(self, path: str | Path | None = None):
return toml.load(p) if path is None:
# Check environment variable first, then fallback to hardcoded path
path = os.environ.get("CTF_CONFIG_PATH", "/home/venus/code/ctf/config.toml")
self.path = Path(path)
self.data = self._load()
def _load(self) -> dict:
if self.path.exists():
try:
return toml.load(self.path)
except Exception:
return {}
return {} return {}
# }}}
# {{{ write_config def save(self, data: dict):
def write_config(data: dict, config = f"{user_config_dir()}/ctf"): self.data = data
with open(config, "w") as f: self.path.parent.mkdir(parents=True, exist_ok=True)
toml.dump(data, f) with open(self.path, "w") as f:
toml.dump(self.data, f)
# }}} # }}}
# {{{ exports # {{{ exports
config_data = load_config("/home/venus/code/ctf/config.toml") # Load config instance to expose default values
competition = config_data.get("Competition", {}) _cfg = Config()
enviroment = config_data.get("Enviroment", {}) competition = _cfg.data.get("Competition", {})
enviroment = _cfg.data.get("Enviroment", {})
# }}} # }}}

View File

@@ -1,22 +1,38 @@
# src/ctf/decoding.py # src/ctf/decoding.py
# {{{ imports
from abc import ABC, abstractmethod
from chepy import Chepy from chepy import Chepy
import re import re
from typing import Dict, Set from typing import Dict, Set, List
from ctf.helpers import check_for_flag, is_valid_flag from ctf.helpers import check_for_flag, is_valid_flag
# }}} # }}}
# {{{ attempt_decode # {{{ BaseDecoder
def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_check=None, validator=None) -> str | None: class BaseDecoder(ABC):
"""Helper to run regex matching, dynamic Chepy decoding, and string printability checks.""" """Abstract base class representing a Chepy-based metadata decoder."""
# Find all matches sequentially and try decoding def __init__(self, name: str, pattern: str, method_name: str):
for match in re.finditer(pattern, val): self.name = name
self.pattern = pattern
self.method_name = method_name
def is_applicable(self, matched_val: str) -> bool:
"""Determines if the matched substring is applicable for decoding (e.g. length checks)."""
return True
def validate(self, decoded_str: str, original_str: str) -> bool:
"""Validates if the decoded string is expected/correct (e.g. flag regex checks)."""
return True
def attempt_single(self, val: str) -> str | None:
"""Searches val for matches and returns the first successfully decoded printable string."""
for match in re.finditer(self.pattern, val):
matched_val = match.group(0) matched_val = match.group(0)
if len_check and not len_check(matched_val): if not self.is_applicable(matched_val):
continue continue
try: try:
chepy_inst = Chepy(matched_val) chepy_inst = Chepy(matched_val)
method = getattr(chepy_inst, method_name) method = getattr(chepy_inst, self.method_name)
decoded = method().state decoded = method().state
if isinstance(decoded, bytes): if isinstance(decoded, bytes):
dec_str = decoded.decode("utf-8") dec_str = decoded.decode("utf-8")
@@ -26,7 +42,7 @@ def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_chec
dec_str = "" dec_str = ""
if dec_str.strip() and all(32 <= ord(c) < 127 or c in "\r\n\t" for c in dec_str): if dec_str.strip() and all(32 <= ord(c) < 127 or c in "\r\n\t" for c in dec_str):
if validator and not validator(dec_str, val): if not self.validate(dec_str, val):
continue continue
return dec_str return dec_str
except Exception: except Exception:
@@ -34,9 +50,58 @@ def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_chec
return None return None
# }}} # }}}
# {{{ try_decode_metadata # {{{ Concrete Decoders
def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]: class HexDecoder(BaseDecoder):
"""Attempts to decode a metadata value using defined formats recursively.""" def __init__(self):
super().__init__("hex", r"[0-9a-fA-F]{4,}", "from_hex")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 2 == 0
class Base64Decoder(BaseDecoder):
def __init__(self):
super().__init__("base64", r"[A-Za-z0-9+/=]{4,}", "from_base64")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 4 == 0
class Base32Decoder(BaseDecoder):
def __init__(self):
super().__init__("base32", r"[A-Za-z2-7=]{4,}", "from_base32")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 8 == 0
class BinaryDecoder(BaseDecoder):
def __init__(self):
super().__init__("binary", r"[01]{8,}", "from_binary")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 8 == 0
class UrlDecoder(BaseDecoder):
def __init__(self):
super().__init__("url", r"(?:%[0-9a-fA-F]{2})+", "from_url_encoding")
class Rot13Decoder(BaseDecoder):
def __init__(self):
super().__init__("rot13", r"[\x20-\x7E\s]{4,}", "rot_13")
def validate(self, decoded_str: str, original_str: str) -> bool:
return is_valid_flag(decoded_str, original_str)
class ReversedDecoder(BaseDecoder):
def __init__(self):
super().__init__("reversed", r"[\x20-\x7E\s]{4,}", "reverse")
def validate(self, decoded_str: str, original_str: str) -> bool:
return is_valid_flag(decoded_str, original_str)
# }}}
# {{{ DecodingRegistry
class DecodingRegistry:
"""Orchestrates decoding metadata using registered BaseDecoder classes."""
def __init__(self):
self.decoders: List[BaseDecoder] = []
def register_decoder(self, decoder: BaseDecoder):
self.decoders.append(decoder)
def try_decode(self, val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
if seen is None: if seen is None:
seen = set() seen = set()
@@ -49,26 +114,14 @@ def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10,
return results return results
seen.add(cleaned_val) seen.add(cleaned_val)
# We define our decoders here using unanchored patterns. Note: for rot13 and reversed, we only allow them for decoder in self.decoders:
# if they produce a valid flag to avoid spamming everyday metadata fields. decoded_val = decoder.attempt_single(cleaned_val)
formats = [
("hex", r"[0-9a-fA-F]{4,}", "from_hex", lambda s: len(s) % 2 == 0, None),
("base64", r"[A-Za-z0-9+/=]{4,}", "from_base64", lambda s: len(s) % 4 == 0, None),
("base32", r"[A-Za-z2-7=]{4,}", "from_base32", lambda s: len(s) % 8 == 0, None),
("binary", r"[01]{8,}", "from_binary", lambda s: len(s) % 8 == 0, None),
("url", r"(?:%[0-9a-fA-F]{2})+", "from_url_encoding", None, None),
("rot13", r"[\x20-\x7E\s]{4,}", "rot_13", None, is_valid_flag),
("reversed", r"[\x20-\x7E\s]{4,}", "reverse", None, is_valid_flag),
]
for name, pattern, method, len_check, validator in formats:
decoded_val = attempt_decode(cleaned_val, name, pattern, method, len_check, validator)
if decoded_val is not None: if decoded_val is not None:
chain_name = f"{current_chain}->{name}" if current_chain else name chain_name = f"{current_chain}->{decoder.name}" if current_chain else decoder.name
results[chain_name] = decoded_val results[chain_name] = decoded_val
# Recurse with copy of seen to allow different branching paths to process same strings # Recurse
nested_results = try_decode_metadata( nested_results = self.try_decode(
decoded_val, decoded_val,
current_chain=chain_name, current_chain=chain_name,
max_depth=max_depth - 1, max_depth=max_depth - 1,
@@ -77,4 +130,23 @@ def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10,
results.update(nested_results) results.update(nested_results)
return results return results
@classmethod
def get_default_registry(cls) -> "DecodingRegistry":
registry = cls()
registry.register_decoder(HexDecoder())
registry.register_decoder(Base64Decoder())
registry.register_decoder(Base32Decoder())
registry.register_decoder(BinaryDecoder())
registry.register_decoder(UrlDecoder())
registry.register_decoder(Rot13Decoder())
registry.register_decoder(ReversedDecoder())
return registry
# }}}
# {{{ try_decode_metadata
def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
"""Compatibility wrapper that routes decoding requests to the default registry."""
registry = DecodingRegistry.get_default_registry()
return registry.try_decode(val, current_chain, max_depth, seen)
# }}} # }}}

View File

@@ -0,0 +1,32 @@
# src/ctf/forensics/__init__.py
# {{{ imports
from ctf.forensics.base import FileMetadata, FormatParser, ParserFactory, FallbackParser, COMMON_SIGNATURES
from ctf.forensics.jpeg import JpegParser
from ctf.forensics.png import PngParser
from ctf.forensics.gif import GifParser
from ctf.forensics.metadata import get_metadata
from pathlib import Path
from typing import Dict, Any
# }}}
# {{{ get_exif
def get_exif(path: Path) -> Dict[str, Any]:
"""Compatibility wrapper for extracting EXIF tags."""
try:
with open(path, "rb") as f:
data = f.read()
return ParserFactory.get_parser(data).get_exif_tags(data)
except Exception:
return {}
# }}}
# {{{ get_comment
def get_comment(path: Path) -> str:
"""Compatibility wrapper for extracting comments."""
try:
with open(path, "rb") as f:
data = f.read()
return ParserFactory.get_parser(data).get_comment(data)
except Exception:
return ""
# }}}

114
src/ctf/forensics/base.py Normal file
View File

@@ -0,0 +1,114 @@
# src/ctf/forensics/base.py
# {{{ imports
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, Any
# }}}
# {{{ Common Signatures
COMMON_SIGNATURES = {
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
b"%PDF": ("PDF Document", [".pdf"]),
b"PK\x03\x04": ("ZIP Archive", [".zip"]),
b"\x7fELF": ("ELF Executable", [".elf"]),
b"MZ": ("PE Executable", [".exe", ".dll"]),
b"GIF87a": ("GIF Image", [".gif"]),
b"GIF89a": ("GIF Image", [".gif"]),
b"7z\xbc\xaf\x27\x1c": ("7-Zip Archive", [".7z"]),
b"\x1f\x8b": ("GZIP Archive", [".gz"]),
b"Rar!\x1a\x07\x00": ("RAR Archive", [".rar"]),
b"Rar!\x1a\x07\x01\x00": ("RAR Archive", [".rar"]),
b"BZh": ("BZIP2 Archive", [".bz2"]),
b"BM": ("BMP Image", [".bmp"]),
b"ID3": ("MP3 Audio", [".mp3"]),
}
# }}}
# {{{ FileMetadata
@dataclass
class FileMetadata:
filename: str
size: int
magic: str
extension: str
detected_type: str
# POSIX Permissions
permissions_octal: str
permissions_symbolic: str
# Ownership Identity
owner_uid: int
owner_username: str
owner_gid: int
owner_groupname: str
# Allocation Metrics
allocated_size: int
# Hard Links
hard_links: int
# Inode & Device Identifiers
inode: int
device: int
# Extended Attributes
extended_attributes: Dict[str, str] = field(default_factory=dict)
# EXIF Data
exif_data: Dict[str, Any] = field(default_factory=dict)
# Physical Image Data
physical_data: Dict[str, Any] = field(default_factory=dict)
# Decoded metadata hints
decoded_hints: Dict[str, Dict[str, str]] = field(default_factory=dict)
# JPEG/PNG Comments
comment: str = ""
# }}}
# {{{ FormatParser
class FormatParser(ABC):
"""Abstract base class representing a file format metadata parser."""
@abstractmethod
def is_matching(self, data: bytes) -> bool:
pass
@abstractmethod
def parse_physical(self, data: bytes) -> Dict[str, Any]:
pass
@abstractmethod
def get_comment(self, data: bytes) -> str:
pass
@abstractmethod
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
pass
# }}}
# {{{ FallbackParser
class FallbackParser(FormatParser):
def is_matching(self, data: bytes) -> bool:
return True
def parse_physical(self, data: bytes) -> Dict[str, Any]:
return {}
def get_comment(self, data: bytes) -> str:
return ""
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
return {}
# }}}
# {{{ ParserFactory
class ParserFactory:
@staticmethod
def get_parser(data: bytes) -> FormatParser:
from ctf.forensics.jpeg import JpegParser
from ctf.forensics.png import PngParser
from ctf.forensics.gif import GifParser
parsers = [JpegParser(), PngParser(), GifParser()]
for parser in parsers:
if parser.is_matching(data):
return parser
return FallbackParser()
# }}}

98
src/ctf/forensics/gif.py Normal file
View File

@@ -0,0 +1,98 @@
# src/ctf/forensics/gif.py
# {{{ imports
import struct
from typing import Dict, Any
from ctf.forensics.base import FormatParser
# }}}
# {{{ GifParser
class GifParser(FormatParser):
def is_matching(self, data: bytes) -> bool:
return data.startswith(b"GIF87a") or data.startswith(b"GIF89a")
def parse_physical(self, data: bytes) -> Dict[str, Any]:
return get_gif_physical(data)
def get_comment(self, data: bytes) -> str:
return extract_gif_comments(data)
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
return {}
# }}}
# {{{ get_gif_physical
def get_gif_physical(data: bytes) -> Dict[str, Any]:
"""Parses GIF logical screen descriptor for physical size."""
physical = {}
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return physical
if len(data) >= 10:
width = struct.unpack("<H", data[6:8])[0]
height = struct.unpack("<H", data[8:10])[0]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Encoding Process"] = "LZW"
return physical
# }}}
# {{{ extract_gif_comments
def extract_gif_comments(data: bytes) -> str:
"""Extracts comments from GIF comment extensions (\x21\xfe) sequentially."""
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return ""
comments = []
idx = 6
if idx + 7 > len(data):
return ""
packed_byte = data[idx+4]
global_color_table_present = bool(packed_byte & 0x80)
global_color_table_size = 2 ** ((packed_byte & 0x07) + 1)
idx += 7
if global_color_table_present:
idx += 3 * global_color_table_size
while idx < len(data) - 2:
intro = data[idx]
if intro == 0x21: # Extension Introducer
ext_label = data[idx+1]
idx += 2
if ext_label == 0xfe: # Comment Extension
comment_parts = []
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
if idx + block_len <= len(data):
comment_parts.append(data[idx:idx+block_len].decode("utf-8", errors="ignore"))
idx += block_len
else:
break
comments.append("".join(comment_parts))
else:
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x2c: # Image Descriptor
if idx + 10 > len(data):
break
packed = data[idx+9]
local_table = bool(packed & 0x80)
local_table_size = 2 ** ((packed & 0x07) + 1)
idx += 10
if local_table:
idx += 3 * local_table_size
idx += 1
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x3b: # Trailer
break
else:
idx += 1
return "\n".join(comments)
# }}}

View File

@@ -1,225 +1,35 @@
# src/ctf/forensics.py # src/ctf/forensics/jpeg.py
# Library for forensic analysis (pure functions only) # {{{ imports
# vim foldmethod=marker
from dataclasses import dataclass, field
from pathlib import Path
import stat
import os
import sys
import struct import struct
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import List, Dict, Any from typing import Dict, Any
from ctf.forensics.base import FormatParser
try:
import pwd
import grp
except ImportError:
pwd = None
grp = None
# {{{ Common Signatures
COMMON_SIGNATURES = {
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
b"%PDF": ("PDF Document", [".pdf"]),
b"PK\x03\x04": ("ZIP Archive", [".zip"]),
b"\x7fELF": ("ELF Executable", [".elf"]),
b"MZ": ("PE Executable", [".exe", ".dll"]),
b"GIF87a": ("GIF Image", [".gif"]),
b"GIF89a": ("GIF Image", [".gif"]),
b"7z\xbc\xaf\x27\x1c": ("7-Zip Archive", [".7z"]),
b"\x1f\x8b": ("GZIP Archive", [".gz"]),
b"Rar!\x1a\x07\x00": ("RAR Archive", [".rar"]),
b"Rar!\x1a\x07\x01\x00": ("RAR Archive", [".rar"]),
b"BZh": ("BZIP2 Archive", [".bz2"]),
b"BM": ("BMP Image", [".bmp"]),
b"ID3": ("MP3 Audio", [".mp3"]),
}
# }}} # }}}
# {{{ FileMetadata # {{{ JpegParser
@dataclass class JpegParser(FormatParser):
class FileMetadata: def is_matching(self, data: bytes) -> bool:
filename: str return data.startswith(b"\xff\xd8\xff")
size: int def parse_physical(self, data: bytes) -> Dict[str, Any]:
magic: str return get_jpeg_physical(data)
extension: str def get_comment(self, data: bytes) -> str:
detected_type: str return extract_jpeg_comment(data)
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
# Task 1: POSIX Permissions tags = {}
permissions_octal: str exif_data = extract_jpeg_exif(data)
permissions_symbolic: str if exif_data:
# Task 2: Ownership Identity
owner_uid: int
owner_username: str
owner_gid: int
owner_groupname: str
# Task 3: Allocation Metrics
allocated_size: int
# Task 4: Hard Links
hard_links: int
# Task 5: Inode & Device Identifiers
inode: int
device: int
# Task 6: Extended Attributes
extended_attributes: Dict[str, str] = field(default_factory=dict)
# EXIF Data
exif_data: Dict[str, Any] = field(default_factory=dict)
# Physical Image Data
physical_data: Dict[str, Any] = field(default_factory=dict)
# Decoded metadata hints
decoded_hints: Dict[str, Dict[str, str]] = field(default_factory=dict)
# JPEG/PNG Comments
comment: str = ""
# }}}
# {{{ get_metadata
def get_metadata(path: Path) -> FileMetadata:
"""Extracts metadata attributes from a file without any console rendering."""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"File not found: {p}")
stat_info = p.stat()
# Apparent size & extension
size = stat_info.st_size
extension = p.suffix
# Magic bytes
try: try:
with open(p, 'rb') as f: tags.update(parse_tiff(exif_data))
magic = f.read(8).hex().upper() except Exception: pass
except Exception: xmp_str = extract_jpeg_xmp(data)
magic = "" if xmp_str:
detected_type = "Unknown"
try: try:
magic_bytes = bytes.fromhex(magic) tags.update(parse_xmp(xmp_str))
for signature, (type_name, exts) in COMMON_SIGNATURES.items(): except Exception: pass
if magic_bytes.startswith(signature):
detected_type = type_name
break
except Exception:
pass
# POSIX Permissions
mode = stat_info.st_mode
permissions_octal = oct(stat.S_IMODE(mode))
permissions_symbolic = stat.filemode(mode)
# Ownership Identity
owner_uid = stat_info.st_uid
owner_gid = stat_info.st_gid
owner_username = str(owner_uid)
owner_groupname = str(owner_gid)
if pwd is not None:
try: try:
owner_username = pwd.getpwuid(owner_uid).pw_name tags.update(extract_jpeg_iptc(data))
except KeyError: except Exception: pass
pass return tags
if grp is not None:
try:
owner_groupname = grp.getgrgid(owner_gid).gr_name
except KeyError:
pass
# Allocation Metrics
if hasattr(stat_info, "st_blocks"):
allocated_size = stat_info.st_blocks * 512
else:
allocated_size = size
# Hard Links
hard_links = stat_info.st_nlink
# Inode & Device Identifiers
inode = stat_info.st_ino
device = stat_info.st_dev
# Extended Attributes
extended_attributes = {}
if hasattr(os, "listxattr") and hasattr(os, "getxattr"):
try:
attrs = os.listxattr(p)
for attr in attrs:
try:
val = os.getxattr(p, attr)
extended_attributes[attr] = val.decode("utf-8", errors="ignore")
except OSError:
pass
except OSError:
pass
exif_data = get_exif(p)
comment = get_comment(p)
physical_data = {}
try:
with open(p, "rb") as f:
file_bytes = f.read()
if file_bytes.startswith(b"\xff\xd8\xff"):
physical_data = get_jpeg_physical(file_bytes)
elif file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
physical_data = get_png_physical(file_bytes)
elif file_bytes.startswith(b"GIF87a") or file_bytes.startswith(b"GIF89a"):
physical_data = get_gif_physical(file_bytes)
except Exception:
pass
decoded_hints = {}
try:
from ctf.decoding import try_decode_metadata
if comment:
dec = try_decode_metadata(comment)
if dec:
decoded_hints["Comment"] = dec
for tag, val in exif_data.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"EXIF:{tag}"] = dec
for attr, val in extended_attributes.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"xattr:{attr}"] = dec
except Exception:
pass
return FileMetadata(
filename=p.name,
size=size,
magic=magic,
extension=extension,
detected_type=detected_type,
permissions_octal=permissions_octal,
permissions_symbolic=permissions_symbolic,
owner_uid=owner_uid,
owner_username=owner_username,
owner_gid=owner_gid,
owner_groupname=owner_groupname,
allocated_size=allocated_size,
hard_links=hard_links,
inode=inode,
device=device,
extended_attributes=extended_attributes,
exif_data=exif_data,
physical_data=physical_data,
decoded_hints=decoded_hints,
comment=comment
)
# }}} # }}}
# {{{ EXIF tags # {{{ EXIF tags
@@ -260,7 +70,8 @@ EXIF_TAGS = {
0xa403: "WhiteBalance", 0xa403: "WhiteBalance",
0xa405: "FocalLengthIn35mmFilm", 0xa405: "FocalLengthIn35mmFilm",
0xa406: "SceneCaptureType", 0xa406: "SceneCaptureType",
}# }}} }
# }}}
# {{{ parse_tiff # {{{ parse_tiff
def parse_tiff(data: bytes) -> Dict[str, Any]: def parse_tiff(data: bytes) -> Dict[str, Any]:
@@ -371,7 +182,6 @@ def extract_jpeg_exif(data: bytes) -> bytes:
marker = data[idx+1] marker = data[idx+1]
if marker == 0xd9: # EOI if marker == 0xd9: # EOI
break break
# Markers without length parameters
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7: if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2 idx += 2
continue continue
@@ -386,75 +196,6 @@ def extract_jpeg_exif(data: bytes) -> bytes:
return b"" return b""
# }}} # }}}
# {{{ extract_png_exif
def extract_png_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from PNG eXIf chunks."""
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return b""
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
if chunk_type == b"eXIf":
return data[idx+8:idx+8+length]
elif chunk_type == b"IEND":
break
idx += 12 + length
return b""
# }}}
# {{{ get_exif
def get_exif(path: Path) -> Dict[str, Any]:
"""Reads file, extracts Exif and XMP segments, and parses them to a tag dictionary."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return {}
tags = {}
# 1. Parse standard EXIF
exif_data = b""
if data.startswith(b"\xff\xd8\xff"):
exif_data = extract_jpeg_exif(data)
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
exif_data = extract_png_exif(data)
if exif_data:
try:
tags.update(parse_tiff(exif_data))
except Exception:
pass
# 2. Parse XMP (Adobe)
xmp_str = ""
if data.startswith(b"\xff\xd8\xff"):
xmp_str = extract_jpeg_xmp(data)
if xmp_str:
try:
tags.update(parse_xmp(xmp_str))
except Exception:
pass
# 3. Parse IPTC (Photoshop APP13) if JPEG
if data.startswith(b"\xff\xd8\xff"):
try:
tags.update(extract_jpeg_iptc(data))
except Exception:
pass
# 4. Parse PNG text chunks if PNG
if data.startswith(b"\x89PNG\r\n\x1a\n"):
try:
tags.update(parse_png_text_chunks(data))
except Exception:
pass
return tags
# }}}
# {{{ extract_jpeg_comment # {{{ extract_jpeg_comment
def extract_jpeg_comment(data: bytes) -> str: def extract_jpeg_comment(data: bytes) -> str:
"""Extracts raw comment string from JPEG COM (0xfe) segments.""" """Extracts raw comment string from JPEG COM (0xfe) segments."""
@@ -479,22 +220,6 @@ def extract_jpeg_comment(data: bytes) -> str:
return "" return ""
# }}} # }}}
# {{{ get_comment
def get_comment(path: Path) -> str:
"""Reads file, checks headers, and extracts JPEG COM / GIF comments."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return ""
if data.startswith(b"\xff\xd8\xff"):
return extract_jpeg_comment(data)
elif data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
return extract_gif_comments(data)
return ""
# }}}
# {{{ NS_map # {{{ NS_map
NS_MAP = { NS_MAP = {
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
@@ -654,84 +379,6 @@ def get_jpeg_physical(data: bytes) -> Dict[str, Any]:
return physical return physical
# }}} # }}}
# {{{ get_png_physical
def get_png_physical(data: bytes) -> Dict[str, Any]:
"""Parses PNG IHDR and pHYs chunks for physical properties."""
physical = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return physical
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"IHDR":
if len(chunk_data) >= 13:
width = struct.unpack(">I", chunk_data[0:4])[0]
height = struct.unpack(">I", chunk_data[4:8])[0]
bit_depth = chunk_data[8]
color_type = chunk_data[9]
compression = chunk_data[10]
filter_method = chunk_data[11]
interlace = chunk_data[12]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Bit Depth"] = f"{bit_depth} bits/sample"
color_types = {
0: "Grayscale",
2: "Truecolor",
3: "Indexed-color",
4: "Grayscale with Alpha",
6: "Truecolor with Alpha"
}
physical["Color Type"] = color_types.get(color_type, f"Unknown ({color_type})")
if compression == 0:
physical["Encoding Process"] = "Deflate/Inflate"
else:
physical["Encoding Process"] = f"Unknown compression ({compression})"
interlace_methods = {
0: "Noninterlaced",
1: "Adam7 Interlace"
}
physical["Interlace Method"] = interlace_methods.get(interlace, f"Unknown ({interlace})")
elif chunk_type == b"pHYs":
if len(chunk_data) >= 9:
x_res = struct.unpack(">I", chunk_data[0:4])[0]
y_res = struct.unpack(">I", chunk_data[4:8])[0]
unit = chunk_data[8]
unit_str = " meters" if unit == 1 else " (unknown unit)"
physical["Pixels Per Unit X"] = f"{x_res}{unit_str}"
physical["Pixels Per Unit Y"] = f"{y_res}{unit_str}"
elif chunk_type == b"IEND":
break
idx += 12 + length
return physical
# }}}
# {{{ get_gif_physical
def get_gif_physical(data: bytes) -> Dict[str, Any]:
"""Parses GIF logical screen descriptor for physical size."""
physical = {}
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return physical
if len(data) >= 10:
width = struct.unpack("<H", data[6:8])[0]
height = struct.unpack("<H", data[8:10])[0]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Encoding Process"] = "LZW"
return physical
# }}}
# {{{ extract_jpeg_iptc # {{{ extract_jpeg_iptc
def extract_jpeg_iptc(data: bytes) -> Dict[str, str]: def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
"""Extracts IPTC/NAA metadata (Record 2) from Photoshop APP13 segments.""" """Extracts IPTC/NAA metadata (Record 2) from Photoshop APP13 segments."""
@@ -819,132 +466,3 @@ def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
return iptc_metadata return iptc_metadata
# }}} # }}}
# {{{ parse_png_text_chunks
def parse_png_text_chunks(data: bytes) -> Dict[str, str]:
"""Decompresses and extracts standard tEXt/zTXt/iTXt PNG text metadata."""
import zlib
text_metadata = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return text_metadata
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"tEXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
val = parts[1].decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"zTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) > 1:
comp_method = remaining[0]
comp_text = remaining[1:]
if comp_method == 0:
try:
val = zlib.decompress(comp_text).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
elif chunk_type == b"iTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) >= 2:
comp_flag = remaining[0]
comp_method = remaining[1]
rem = remaining[2:]
parts2 = rem.split(b"\x00", 1)
if len(parts2) == 2:
lang_tag = parts2[0].decode("utf-8", errors="ignore")
rem = parts2[1]
parts3 = rem.split(b"\x00", 1)
if len(parts3) == 2:
trans_key = parts3[0].decode("utf-8", errors="ignore")
text_bytes = parts3[1]
if comp_flag == 1 and comp_method == 0:
try:
val = zlib.decompress(text_bytes).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
else:
val = text_bytes.decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"IEND":
break
idx += 12 + length
return text_metadata
# }}}
# {{{ extract_gif_comments
def extract_gif_comments(data: bytes) -> str:
"""Extracts comments from GIF comment extensions (\x21\xfe) sequentially."""
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return ""
comments = []
idx = 6
if idx + 7 > len(data):
return ""
packed_byte = data[idx+4]
global_color_table_present = bool(packed_byte & 0x80)
global_color_table_size = 2 ** ((packed_byte & 0x07) + 1)
idx += 7
if global_color_table_present:
idx += 3 * global_color_table_size
while idx < len(data) - 2:
intro = data[idx]
if intro == 0x21: # Extension Introducer
ext_label = data[idx+1]
idx += 2
if ext_label == 0xfe: # Comment Extension
comment_parts = []
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
if idx + block_len <= len(data):
comment_parts.append(data[idx:idx+block_len].decode("utf-8", errors="ignore"))
idx += block_len
else:
break
comments.append("".join(comment_parts))
else:
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x2c: # Image Descriptor
if idx + 10 > len(data):
break
packed = data[idx+9]
local_table = bool(packed & 0x80)
local_table_size = 2 ** ((packed & 0x07) + 1)
idx += 10
if local_table:
idx += 3 * local_table_size
idx += 1
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x3b: # Trailer
break
else:
idx += 1
return "\n".join(comments)
# }}}

View File

@@ -0,0 +1,139 @@
# src/ctf/forensics/metadata.py
# {{{ imports
from pathlib import Path
import os
import stat
from ctf.forensics.base import FileMetadata, COMMON_SIGNATURES, ParserFactory
try:
import pwd
import grp
except ImportError:
pwd = None
grp = None
# }}}
# {{{ get_metadata
def get_metadata(path: Path) -> FileMetadata:
"""Extracts metadata attributes from a file without any console rendering."""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"File not found: {p}")
stat_info = p.stat()
size = stat_info.st_size
extension = p.suffix
try:
with open(p, 'rb') as f:
magic = f.read(8).hex().upper()
except Exception:
magic = ""
detected_type = "Unknown"
try:
magic_bytes = bytes.fromhex(magic)
for signature, (type_name, exts) in COMMON_SIGNATURES.items():
if magic_bytes.startswith(signature):
detected_type = type_name
break
except Exception:
pass
mode = stat_info.st_mode
permissions_octal = oct(stat.S_IMODE(mode))
permissions_symbolic = stat.filemode(mode)
owner_uid = stat_info.st_uid
owner_gid = stat_info.st_gid
owner_username = str(owner_uid)
owner_groupname = str(owner_gid)
if pwd is not None:
try:
owner_username = pwd.getpwuid(owner_uid).pw_name
except KeyError:
pass
if grp is not None:
try:
owner_groupname = grp.getgrgid(owner_gid).gr_name
except KeyError:
pass
if hasattr(stat_info, "st_blocks"):
allocated_size = stat_info.st_blocks * 512
else:
allocated_size = size
hard_links = stat_info.st_nlink
inode = stat_info.st_ino
device = stat_info.st_dev
extended_attributes = {}
if hasattr(os, "listxattr") and hasattr(os, "getxattr"):
try:
attrs = os.listxattr(p)
for attr in attrs:
try:
val = os.getxattr(p, attr)
extended_attributes[attr] = val.decode("utf-8", errors="ignore")
except OSError:
pass
except OSError:
pass
try:
with open(p, "rb") as f:
file_bytes = f.read()
parser = ParserFactory.get_parser(file_bytes)
exif_data = parser.get_exif_tags(file_bytes)
comment = parser.get_comment(file_bytes)
physical_data = parser.parse_physical(file_bytes)
except Exception:
exif_data = {}
comment = ""
physical_data = {}
decoded_hints = {}
try:
from ctf.decoding import try_decode_metadata
if comment:
dec = try_decode_metadata(comment)
if dec:
decoded_hints["Comment"] = dec
for tag, val in exif_data.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"EXIF:{tag}"] = dec
for attr, val in extended_attributes.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"xattr:{attr}"] = dec
except Exception:
pass
return FileMetadata(
filename=p.name,
size=size,
magic=magic,
extension=extension,
detected_type=detected_type,
permissions_octal=permissions_octal,
permissions_symbolic=permissions_symbolic,
owner_uid=owner_uid,
owner_username=owner_username,
owner_gid=owner_gid,
owner_groupname=owner_groupname,
allocated_size=allocated_size,
hard_links=hard_links,
inode=inode,
device=device,
extended_attributes=extended_attributes,
exif_data=exif_data,
physical_data=physical_data,
decoded_hints=decoded_hints,
comment=comment
)
# }}}

171
src/ctf/forensics/png.py Normal file
View File

@@ -0,0 +1,171 @@
# src/ctf/forensics/png.py
# {{{ imports
import struct
from typing import Dict, Any
from ctf.forensics.base import FormatParser
# }}}
# {{{ PngParser
class PngParser(FormatParser):
def is_matching(self, data: bytes) -> bool:
return data.startswith(b"\x89PNG\r\n\x1a\n")
def parse_physical(self, data: bytes) -> Dict[str, Any]:
return get_png_physical(data)
def get_comment(self, data: bytes) -> str:
return ""
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
tags = {}
exif_data = extract_png_exif(data)
if exif_data:
try:
from ctf.forensics.jpeg import parse_tiff
tags.update(parse_tiff(exif_data))
except Exception: pass
try:
tags.update(parse_png_text_chunks(data))
except Exception: pass
return tags
# }}}
# {{{ extract_png_exif
def extract_png_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from PNG eXIf chunks."""
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return b""
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
if chunk_type == b"eXIf":
return data[idx+8:idx+8+length]
elif chunk_type == b"IEND":
break
idx += 12 + length
return b""
# }}}
# {{{ get_png_physical
def get_png_physical(data: bytes) -> Dict[str, Any]:
"""Parses PNG IHDR and pHYs chunks for physical properties."""
physical = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return physical
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"IHDR":
if len(chunk_data) >= 13:
width = struct.unpack(">I", chunk_data[0:4])[0]
height = struct.unpack(">I", chunk_data[4:8])[0]
bit_depth = chunk_data[8]
color_type = chunk_data[9]
compression = chunk_data[10]
filter_method = chunk_data[11]
interlace = chunk_data[12]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Bit Depth"] = f"{bit_depth} bits/sample"
color_types = {
0: "Grayscale",
2: "Truecolor",
3: "Indexed-color",
4: "Grayscale with Alpha",
6: "Truecolor with Alpha"
}
physical["Color Type"] = color_types.get(color_type, f"Unknown ({color_type})")
if compression == 0:
physical["Encoding Process"] = "Deflate/Inflate"
else:
physical["Encoding Process"] = f"Unknown compression ({compression})"
interlace_methods = {
0: "Noninterlaced",
1: "Adam7 Interlace"
}
physical["Interlace Method"] = interlace_methods.get(interlace, f"Unknown ({interlace})")
elif chunk_type == b"pHYs":
if len(chunk_data) >= 9:
x_res = struct.unpack(">I", chunk_data[0:4])[0]
y_res = struct.unpack(">I", chunk_data[4:8])[0]
unit = chunk_data[8]
unit_str = " meters" if unit == 1 else " (unknown unit)"
physical["Pixels Per Unit X"] = f"{x_res}{unit_str}"
physical["Pixels Per Unit Y"] = f"{y_res}{unit_str}"
elif chunk_type == b"IEND":
break
idx += 12 + length
return physical
# }}}
# {{{ parse_png_text_chunks
def parse_png_text_chunks(data: bytes) -> Dict[str, str]:
"""Decompresses and extracts standard tEXt/zTXt/iTXt PNG text metadata."""
import zlib
text_metadata = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return text_metadata
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"tEXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
val = parts[1].decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"zTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) > 1:
comp_method = remaining[0]
comp_text = remaining[1:]
if comp_method == 0:
try:
val = zlib.decompress(comp_text).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
elif chunk_type == b"iTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) >= 2:
comp_flag = remaining[0]
comp_method = remaining[1]
rem = remaining[2:]
parts2 = rem.split(b"\x00", 1)
if len(parts2) == 2:
lang_tag = parts2[0].decode("utf-8", errors="ignore")
rem = parts2[1]
parts3 = rem.split(b"\x00", 1)
if len(parts3) == 2:
trans_key = parts3[0].decode("utf-8", errors="ignore")
text_bytes = parts3[1]
if comp_flag == 1 and comp_method == 0:
try:
val = zlib.decompress(text_bytes).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
else:
val = text_bytes.decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"IEND":
break
idx += 12 + length
return text_metadata
# }}}

View File

@@ -18,9 +18,9 @@ def check_for_flag(text: str, pattern: str | None = None) -> list[str]:
patterns.append(pattern.strip("^$")) patterns.append(pattern.strip("^$"))
else: else:
try: try:
from ctf.config import load_config from ctf.config import Config
config = load_config() cfg = Config()
flag_format = config.get("Competition", {}).get("flag_format", "") flag_format = cfg.data.get("Competition", {}).get("flag_format", "")
except Exception: except Exception:
flag_format = "" flag_format = ""
if flag_format: if flag_format:
@@ -74,13 +74,12 @@ class FlagDetectorStream:
for match in matches: for match in matches:
self.original_stream.write(f" \033[1;32m✓ {match}\033[0m\n") self.original_stream.write(f" \033[1;32m✓ {match}\033[0m\n")
try: try:
from ctf.config import load_config, write_config from ctf.config import Config
config_path = "/home/venus/code/ctf/config.toml" cfg = Config()
config = load_config(config_path) if "Competition" not in cfg.data:
if "Competition" not in config: cfg.data["Competition"] = {}
config["Competition"] = {} cfg.data["Competition"]["last_flag"] = match
config["Competition"]["last_flag"] = match cfg.save(cfg.data)
write_config(config, config_path)
except Exception: except Exception:
pass pass
self._flag_buffer = "" self._flag_buffer = ""

View File

@@ -1,31 +1,15 @@
# src/main.py # src/main.py
# Parses and calls commands # Parses and calls commands
from ctf.commands import basic_group
from ctf.cli_forensics import forensics_group
from ctf.cli_steg import steg_group
from ctf.cli_analyse import analyse_cmd
from ctf.cli_helpers import flag_cmd
from ctf.helpers import FlagDetectorStream, detector_state
import click
import sys import sys
from ctf.cli import cli
# {{{ cli from ctf.helpers import FlagDetectorStream, detector_state
@click.group()
def cli(): pass
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
cli.add_command(steg_group)
cli.add_command(analyse_cmd)
# }}}
# {{{ main # {{{ main
def main(): def main():
from ctf.config import load_config from ctf.config import Config
config = load_config("/home/venus/code/ctf/config.toml") cfg = Config()
flag_format = config.get("Competition", {}).get("flag_format", "") flag_format = cfg.data.get("Competition", {}).get("flag_format", "")
if flag_format: if flag_format:
detector = FlagDetectorStream(sys.stdout, flag_format) detector = FlagDetectorStream(sys.stdout, flag_format)

View File

@@ -2,7 +2,7 @@
# basic utilities # basic utilities
from pathlib import Path from pathlib import Path
from ctf.config import load_config, competition, enviroment from ctf.config import competition, enviroment
# return a list of path objects for each catagory in a competition # return a list of path objects for each catagory in a competition
def active_categories(p: Path) -> list: def active_categories(p: Path) -> list: