adding more features inclided steg analysis, decoding engines, and more
tests
This commit is contained in:
@@ -13,21 +13,17 @@ def forensics_group():
|
||||
pass
|
||||
# }}}
|
||||
|
||||
# {{{ tf
|
||||
@forensics_group.command()
|
||||
def tf():
|
||||
click.echo("hello from forensics")
|
||||
# }}}
|
||||
|
||||
# {{{ inspect
|
||||
@forensics_group.command()
|
||||
@click.argument('path', type=click.Path(exists=True))
|
||||
@click.option('-i', '--inode', is_flag=True, help="Print only POSIX inode metadata (without EXIF data).")
|
||||
@click.option('-e', '--exif', is_flag=True, help="Print only EXIF metadata (without POSIX inode metadata).")
|
||||
def inspect(path, inode, exif):
|
||||
@click.option('-p', '--physical', is_flag=True, help="Print only physical image parameters.")
|
||||
def inspect(path, inode, exif, physical):
|
||||
"""Lists all basic inode metadata and EXIF data about a file"""
|
||||
if inode and exif:
|
||||
raise click.UsageError("Cannot specify both -i/--inode and -e/--exif.")
|
||||
flags = [inode, exif, physical]
|
||||
if sum(flags) > 1:
|
||||
raise click.UsageError("Cannot specify more than one of -i/--inode, -e/--exif, and -p/--physical.")
|
||||
|
||||
meta = get_metadata(Path(path))
|
||||
|
||||
@@ -37,7 +33,7 @@ def inspect(path, inode, exif):
|
||||
|
||||
console = Console()
|
||||
|
||||
if not exif:
|
||||
if not exif and not physical:
|
||||
table = Table(title=f"Metadata: {meta.filename}", show_header=False)
|
||||
table.add_column("Key", style="bold cyan")
|
||||
table.add_column("Value")
|
||||
@@ -60,8 +56,28 @@ def inspect(path, inode, exif):
|
||||
table.add_row("Extended Attributes", xattr_str)
|
||||
console.print(table)
|
||||
|
||||
if physical:
|
||||
if meta.physical_data:
|
||||
table = Table(title="Physical Metadata", show_header=True)
|
||||
table.add_column("Attribute", style="bold cyan")
|
||||
table.add_column("Value")
|
||||
for k, v in sorted(meta.physical_data.items()):
|
||||
table.add_row(k, str(v))
|
||||
console.print(table)
|
||||
else:
|
||||
console.print("[bold yellow]No physical metadata found.[/bold yellow]")
|
||||
|
||||
if not inode and not exif and not physical:
|
||||
if meta.physical_data:
|
||||
table = Table(title="Physical Metadata", show_header=True)
|
||||
table.add_column("Attribute", style="bold cyan")
|
||||
table.add_column("Value")
|
||||
for k, v in sorted(meta.physical_data.items()):
|
||||
table.add_row(k, str(v))
|
||||
console.print(table)
|
||||
|
||||
exif_displayed = False
|
||||
if not inode:
|
||||
if not inode and not physical:
|
||||
if meta.exif_data:
|
||||
exif_table = Table(title=f"EXIF Metadata: {meta.filename}", show_header=True)
|
||||
exif_table.add_column("Tag", style="bold green")
|
||||
@@ -90,6 +106,16 @@ def inspect(path, inode, exif):
|
||||
|
||||
if exif and not exif_displayed:
|
||||
console.print("[bold yellow]No EXIF or comment metadata found.[/bold yellow]")
|
||||
|
||||
if meta.decoded_hints:
|
||||
hints_table = Table(title="Decoded Metadata Hints (Hex/Base64)", show_header=True)
|
||||
hints_table.add_column("Source Tag", style="bold yellow")
|
||||
hints_table.add_column("Encoding", style="bold green")
|
||||
hints_table.add_column("Decoded Value")
|
||||
for src, dec_dict in sorted(meta.decoded_hints.items()):
|
||||
for enc, dec_val in sorted(dec_dict.items()):
|
||||
hints_table.add_row(src, enc, dec_val)
|
||||
console.print(hints_table)
|
||||
|
||||
return meta
|
||||
# }}}
|
||||
|
||||
68
src/ctf/cli_steg.py
Normal file
68
src/ctf/cli_steg.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# src/ctf/cli_steg.py
|
||||
# {{{ imports
|
||||
import click
|
||||
from pathlib import Path
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from ctf.steg import crack_steghide
|
||||
# }}}
|
||||
|
||||
# {{{ steg_group
|
||||
@click.group(name="steg")
|
||||
def steg_group():
|
||||
""" Steganography tools and solvers """
|
||||
pass
|
||||
# }}}
|
||||
|
||||
# {{{ crack_steghide_cmd
|
||||
@steg_group.command(name="crack-steghide")
|
||||
@click.argument('path', type=click.Path(exists=True))
|
||||
@click.option('-w', '--wordlist', type=click.Path(exists=True), required=True, help="Path to password wordlist.")
|
||||
@click.option('-o', '--output', type=click.Path(), help="Path to write the extracted payload (if cracked).")
|
||||
def crack_steghide_cmd(path, wordlist, output):
|
||||
"""Attempt to crack steghide passphrases using a wordlist"""
|
||||
console = Console()
|
||||
|
||||
# Read the wordlist
|
||||
try:
|
||||
with open(wordlist, "r", errors="ignore") as f:
|
||||
words = [line.strip() for line in f if line.strip()]
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]Failed to read wordlist: {e}[/bold red]")
|
||||
return
|
||||
|
||||
console.print(f"[*] Cracking {path} using {len(words)} passwords...")
|
||||
result = crack_steghide(Path(path), words)
|
||||
|
||||
if result.success:
|
||||
console.print(Panel(
|
||||
f"[bold green]✓ Successfully cracked![/bold green]\n"
|
||||
f"[bold cyan]Password:[/bold cyan] {result.password}\n"
|
||||
f"[bold cyan]Payload Size:[/bold cyan] {len(result.payload)} bytes",
|
||||
title="Crack Result",
|
||||
border_style="green"
|
||||
))
|
||||
|
||||
# If output destination is supplied, write payload; otherwise print preview
|
||||
if output:
|
||||
try:
|
||||
out_path = Path(output).resolve()
|
||||
out_path.write_bytes(result.payload)
|
||||
console.print(f"[green]✓ Extracted payload written to {out_path}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]Failed to write output file: {e}[/bold red]")
|
||||
else:
|
||||
# Show a safe, printable preview of the payload
|
||||
try:
|
||||
preview = result.payload.decode("utf-8")
|
||||
# Truncate if long
|
||||
if len(preview) > 300:
|
||||
preview = preview[:300] + "\n..."
|
||||
console.print(Panel(preview, title="Payload Preview", border_style="blue"))
|
||||
except UnicodeDecodeError:
|
||||
# Binary payload
|
||||
hex_preview = result.payload.hex()[:100] + "..."
|
||||
console.print(f"[yellow]Binary payload (hex preview): {hex_preview}[/yellow]")
|
||||
else:
|
||||
console.print(f"[bold red]✗ Cracking failed: {result.error_message}[/bold red]")
|
||||
# }}}
|
||||
119
src/ctf/decoding.py
Normal file
119
src/ctf/decoding.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# src/ctf/decoding.py
|
||||
# {{{ imports
|
||||
from chepy import Chepy
|
||||
import re
|
||||
from typing import Dict, Set
|
||||
# }}}
|
||||
|
||||
# {{{ is_valid_flag
|
||||
def is_valid_flag(s: str, original: str = "") -> bool:
|
||||
"""
|
||||
Checks if the string contains a flag matching the active pattern or a generic pattern.
|
||||
If original is provided, ensures that the decoded string contains a new unique flag
|
||||
that was not already present in the original string.
|
||||
"""
|
||||
try:
|
||||
from ctf.utils import load_config
|
||||
config = load_config()
|
||||
flag_format = config.get("Competition", {}).get("flag_format", "")
|
||||
except Exception:
|
||||
flag_format = ""
|
||||
|
||||
patterns = []
|
||||
if flag_format:
|
||||
patterns.append(flag_format.strip("^$"))
|
||||
patterns.append(r"(?i)[a-z0-9_-]+{[a-z0-9_!@#$%^&*()\-+=]+}")
|
||||
|
||||
decoded_flags = set()
|
||||
for pattern in patterns:
|
||||
for match in re.finditer(pattern, s):
|
||||
decoded_flags.add(match.group(0))
|
||||
|
||||
if not decoded_flags:
|
||||
return False
|
||||
|
||||
if original:
|
||||
original_flags = set()
|
||||
for pattern in patterns:
|
||||
for match in re.finditer(pattern, original):
|
||||
original_flags.add(match.group(0))
|
||||
# If there are no new flags in decoded_flags, return False
|
||||
if not (decoded_flags - original_flags):
|
||||
return False
|
||||
|
||||
return True
|
||||
# }}}
|
||||
|
||||
# {{{ attempt_decode
|
||||
def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_check=None, validator=None) -> str | None:
|
||||
"""Helper to run regex matching, dynamic Chepy decoding, and string printability checks."""
|
||||
# Find all matches sequentially and try decoding
|
||||
for match in re.finditer(pattern, val):
|
||||
matched_val = match.group(0)
|
||||
if len_check and not len_check(matched_val):
|
||||
continue
|
||||
|
||||
try:
|
||||
chepy_inst = Chepy(matched_val)
|
||||
method = getattr(chepy_inst, method_name)
|
||||
decoded = method().state
|
||||
if isinstance(decoded, bytes):
|
||||
dec_str = decoded.decode("utf-8")
|
||||
elif isinstance(decoded, str):
|
||||
dec_str = decoded
|
||||
else:
|
||||
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):
|
||||
continue
|
||||
return dec_str
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
# }}}
|
||||
|
||||
# {{{ try_decode_metadata
|
||||
def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
|
||||
"""Attempts to decode a metadata value using defined formats recursively."""
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
results = {}
|
||||
if not isinstance(val, str) or not val.strip() or max_depth <= 0:
|
||||
return results
|
||||
|
||||
cleaned_val = val.strip()
|
||||
if cleaned_val in seen:
|
||||
return results
|
||||
seen.add(cleaned_val)
|
||||
|
||||
# We define our decoders here using unanchored patterns. Note: for rot13 and reversed, we only allow them
|
||||
# if they produce a valid flag to avoid spamming everyday metadata fields.
|
||||
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:
|
||||
chain_name = f"{current_chain}->{name}" if current_chain else name
|
||||
results[chain_name] = decoded_val
|
||||
|
||||
# Recurse with copy of seen to allow different branching paths to process same strings
|
||||
nested_results = try_decode_metadata(
|
||||
decoded_val,
|
||||
current_chain=chain_name,
|
||||
max_depth=max_depth - 1,
|
||||
seen=set(seen)
|
||||
)
|
||||
results.update(nested_results)
|
||||
|
||||
return results
|
||||
# }}}
|
||||
@@ -73,6 +73,12 @@ class FileMetadata:
|
||||
# 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 = ""
|
||||
# }}}
|
||||
@@ -158,6 +164,39 @@ def get_metadata(path: Path) -> FileMetadata:
|
||||
|
||||
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,
|
||||
@@ -177,10 +216,13 @@ def get_metadata(path: Path) -> FileMetadata:
|
||||
device=device,
|
||||
extended_attributes=extended_attributes,
|
||||
exif_data=exif_data,
|
||||
physical_data=physical_data,
|
||||
decoded_hints=decoded_hints,
|
||||
comment=comment
|
||||
)
|
||||
# }}}
|
||||
|
||||
# {{{ EXIF tags
|
||||
EXIF_TAGS = {
|
||||
0x010e: "ImageDescription",
|
||||
0x010f: "Make",
|
||||
@@ -218,7 +260,7 @@ EXIF_TAGS = {
|
||||
0xa403: "WhiteBalance",
|
||||
0xa405: "FocalLengthIn35mmFilm",
|
||||
0xa406: "SceneCaptureType",
|
||||
}
|
||||
}# }}}
|
||||
|
||||
# {{{ parse_tiff
|
||||
def parse_tiff(data: bytes) -> Dict[str, Any]:
|
||||
@@ -396,6 +438,20 @@ def get_exif(path: Path) -> Dict[str, Any]:
|
||||
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
|
||||
# }}}
|
||||
|
||||
@@ -425,7 +481,7 @@ def extract_jpeg_comment(data: bytes) -> str:
|
||||
|
||||
# {{{ get_comment
|
||||
def get_comment(path: Path) -> str:
|
||||
"""Reads file, checks headers, and extracts JPEG COM comments."""
|
||||
"""Reads file, checks headers, and extracts JPEG COM / GIF comments."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
@@ -434,16 +490,21 @@ def get_comment(path: Path) -> str:
|
||||
|
||||
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 = {
|
||||
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
|
||||
"http://creativecommons.org/ns#": "cc",
|
||||
"http://purl.org/dc/elements/1.1/": "dc",
|
||||
"http://ns.adobe.com/xap/1.0/": "xmp",
|
||||
"http://ns.adobe.com/exif/1.0/": "exif",
|
||||
"http://ns.adobe.com/tiff/1.0/": "tiff",
|
||||
}
|
||||
#}}}
|
||||
|
||||
# {{{ parse_xmp
|
||||
def parse_xmp(xmp_str: str) -> Dict[str, Any]:
|
||||
@@ -470,7 +531,13 @@ def parse_xmp(xmp_str: str) -> Dict[str, Any]:
|
||||
|
||||
for elem in root.iter():
|
||||
clean_tag = get_clean_name(elem.tag)
|
||||
if clean_tag in ("rdf:RDF", "rdf:Description", "x:xmpmeta"):
|
||||
if clean_tag == "rdf:Description":
|
||||
for k, v in elem.attrib.items():
|
||||
clean_k = get_clean_name(k)
|
||||
if clean_k not in ("rdf:about", "rdf:Description"):
|
||||
metadata[clean_k] = v
|
||||
continue
|
||||
if clean_tag in ("rdf:RDF", "x:xmpmeta"):
|
||||
continue
|
||||
|
||||
val = None
|
||||
@@ -482,7 +549,7 @@ def parse_xmp(xmp_str: str) -> Dict[str, Any]:
|
||||
if "rdf:resource" in attribs:
|
||||
val = attribs["rdf:resource"]
|
||||
elif attribs:
|
||||
val = ", ".join(f"{k}={v}" for k, v in attribs.items())
|
||||
val = ", ".join(f"{k} | {v}" if v else k for k, v in sorted(attribs.items()))
|
||||
|
||||
if val:
|
||||
metadata[clean_tag] = val
|
||||
@@ -516,3 +583,368 @@ def extract_jpeg_xmp(data: bytes) -> str:
|
||||
return ""
|
||||
# }}}
|
||||
|
||||
# {{{ get_jpeg_physical
|
||||
def get_jpeg_physical(data: bytes) -> Dict[str, Any]:
|
||||
"""Parses JPEG APP0 and SOF segments for physical dimensions and metadata."""
|
||||
physical = {}
|
||||
if not data.startswith(b"\xff\xd8\xff"):
|
||||
return physical
|
||||
|
||||
idx = 2
|
||||
width = None
|
||||
height = None
|
||||
encoding_process = None
|
||||
jfif_version = None
|
||||
|
||||
while idx < len(data) - 4:
|
||||
if data[idx] == 0xff:
|
||||
marker = data[idx+1]
|
||||
if marker == 0xd9: # EOI
|
||||
break
|
||||
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
|
||||
idx += 2
|
||||
continue
|
||||
|
||||
length = struct.unpack(">H", data[idx+2:idx+4])[0]
|
||||
segment_data = data[idx+4:idx+4+length-2]
|
||||
|
||||
# APP0 (JFIF)
|
||||
if marker == 0xe0 and segment_data.startswith(b"JFIF\x00"):
|
||||
if len(segment_data) >= 7:
|
||||
major = segment_data[5]
|
||||
minor = segment_data[6]
|
||||
jfif_version = f"{major}.{minor:02d}"
|
||||
physical["JFIF Version"] = jfif_version
|
||||
|
||||
# SOF markers
|
||||
elif marker in (0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf):
|
||||
if len(segment_data) >= 5:
|
||||
precision = segment_data[0]
|
||||
height = struct.unpack(">H", segment_data[1:3])[0]
|
||||
width = struct.unpack(">H", segment_data[3:5])[0]
|
||||
|
||||
SOF_MARKERS = {
|
||||
0xc0: "Baseline DCT, Huffman coding",
|
||||
0xc1: "Extended Sequential DCT, Huffman coding",
|
||||
0xc2: "Progressive DCT, Huffman coding",
|
||||
0xc3: "Lossless, Huffman coding",
|
||||
0xc5: "Differential Sequential DCT, Huffman coding",
|
||||
0xc6: "Differential Progressive DCT, Huffman coding",
|
||||
0xc7: "Differential Lossless, Huffman coding",
|
||||
0xc9: "Extended Sequential DCT, Arithmetic coding",
|
||||
0xca: "Progressive DCT, Arithmetic coding",
|
||||
0xcb: "Lossless, Arithmetic coding",
|
||||
0xcd: "Differential Sequential DCT, Arithmetic coding",
|
||||
0xce: "Differential Progressive DCT, Arithmetic coding",
|
||||
0xcf: "Differential Lossless, Arithmetic coding",
|
||||
}
|
||||
encoding_process = SOF_MARKERS.get(marker, "Unknown")
|
||||
|
||||
idx += 2 + length
|
||||
else:
|
||||
idx += 1
|
||||
|
||||
if width is not None and height is not None:
|
||||
physical["Image Size"] = f"{width}x{height}"
|
||||
mp = (width * height) / 1000000.0
|
||||
physical["Megapixels"] = f"{mp:.1f}"
|
||||
if encoding_process is not None:
|
||||
physical["Encoding Process"] = encoding_process
|
||||
|
||||
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
|
||||
def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
|
||||
"""Extracts IPTC/NAA metadata (Record 2) from Photoshop APP13 segments."""
|
||||
iptc_metadata = {}
|
||||
if not data.startswith(b"\xff\xd8\xff"):
|
||||
return iptc_metadata
|
||||
|
||||
IPTC_TAGS = {
|
||||
5: "ObjectName",
|
||||
25: "Keywords",
|
||||
40: "SpecialInstructions",
|
||||
80: "By-line",
|
||||
85: "By-lineTitle",
|
||||
90: "City",
|
||||
95: "Province-State",
|
||||
101: "Country-PrimaryLocationName",
|
||||
105: "Headline",
|
||||
110: "Credit",
|
||||
115: "Source",
|
||||
116: "CopyrightNotice",
|
||||
120: "Caption-Abstract",
|
||||
122: "Writer-Editor"
|
||||
}
|
||||
|
||||
idx = 2
|
||||
while idx < len(data) - 4:
|
||||
if data[idx] == 0xff:
|
||||
marker = data[idx+1]
|
||||
if marker == 0xd9: # EOI
|
||||
break
|
||||
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
|
||||
idx += 2
|
||||
continue
|
||||
length = struct.unpack(">H", data[idx+2:idx+4])[0]
|
||||
if marker == 0xed: # APP13 Photoshop block
|
||||
app13_data = data[idx+4:idx+4+length-2]
|
||||
if app13_data.startswith(b"Photoshop 3.0\x00"):
|
||||
offset = 14
|
||||
while offset < len(app13_data) - 12:
|
||||
if app13_data[offset:offset+4] == b"8BIM":
|
||||
offset += 4
|
||||
irb_id = app13_data[offset:offset+2]
|
||||
offset += 2
|
||||
name_len = app13_data[offset]
|
||||
offset += 1
|
||||
name = app13_data[offset:offset+name_len]
|
||||
offset += name_len
|
||||
if (name_len + 1) % 2 != 0:
|
||||
offset += 1
|
||||
|
||||
if offset + 4 > len(app13_data):
|
||||
break
|
||||
size = struct.unpack(">I", app13_data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
|
||||
if offset + size > len(app13_data):
|
||||
break
|
||||
irb_data = app13_data[offset:offset+size]
|
||||
offset += size
|
||||
if size % 2 != 0:
|
||||
offset += 1
|
||||
|
||||
if irb_id == b"\x04\x04":
|
||||
iptc_offset = 0
|
||||
while iptc_offset < len(irb_data) - 5:
|
||||
if irb_data[iptc_offset] == 0x1c:
|
||||
record = irb_data[iptc_offset+1]
|
||||
dataset = irb_data[iptc_offset+2]
|
||||
ds_size = struct.unpack(">H", irb_data[iptc_offset+3:iptc_offset+5])[0]
|
||||
iptc_offset += 5
|
||||
if iptc_offset + ds_size <= len(irb_data):
|
||||
ds_data = irb_data[iptc_offset:iptc_offset+ds_size]
|
||||
iptc_offset += ds_size
|
||||
if record == 2:
|
||||
tag_name = IPTC_TAGS.get(dataset, f"IPTC_2_{dataset}")
|
||||
val = ds_data.decode("utf-8", errors="ignore")
|
||||
iptc_metadata[tag_name] = val
|
||||
else:
|
||||
break
|
||||
else:
|
||||
iptc_offset += 1
|
||||
idx += 2 + length
|
||||
else:
|
||||
idx += 1
|
||||
|
||||
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)
|
||||
# }}}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from ctf.commands import basic_group
|
||||
from ctf.cli_forensics import forensics_group, inspect
|
||||
from ctf.cli_steg import steg_group
|
||||
import click
|
||||
import sys
|
||||
import re
|
||||
@@ -93,6 +94,7 @@ cli.add_command(forensics_group)
|
||||
cli.add_command(basic_group)
|
||||
cli.add_command(flag_cmd)
|
||||
cli.add_command(inspect)
|
||||
cli.add_command(steg_group)
|
||||
# }}}
|
||||
|
||||
# {{{ main
|
||||
|
||||
49
src/ctf/steg.py
Normal file
49
src/ctf/steg.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# src/ctf/steg.py
|
||||
# {{{ imports
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
# }}}
|
||||
|
||||
# {{{ SteghideCrackResult
|
||||
@dataclass
|
||||
class SteghideCrackResult:
|
||||
success: bool
|
||||
password: str | None = None
|
||||
payload: bytes | None = None
|
||||
error_message: str | None = None
|
||||
# }}}
|
||||
|
||||
# {{{ crack_steghide
|
||||
def crack_steghide(file_path: Path, wordlist: list[str]) -> SteghideCrackResult:
|
||||
"""Tries to extract data from a file using steghide with a wordlist."""
|
||||
if not shutil.which("steghide"):
|
||||
return SteghideCrackResult(success=False, error_message="steghide executable not found on system PATH.")
|
||||
|
||||
file_path = Path(file_path).resolve()
|
||||
if not file_path.exists():
|
||||
return SteghideCrackResult(success=False, error_message=f"File not found: {file_path}")
|
||||
|
||||
for password in wordlist:
|
||||
password = password.strip()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
out_file = Path(tmpdir) / "extracted_payload"
|
||||
cmd = ["steghide", "extract", "-sf", str(file_path), "-p", password, "-xf", str(out_file), "-f"]
|
||||
try:
|
||||
# Capture standard outputs; timeout to prevent indefinite hangs
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
|
||||
if result.returncode == 0 and out_file.exists():
|
||||
return SteghideCrackResult(
|
||||
success=True,
|
||||
password=password,
|
||||
payload=out_file.read_bytes()
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
return SteghideCrackResult(success=False, error_message=str(e))
|
||||
|
||||
return SteghideCrackResult(success=False, error_message="Password not found in wordlist.")
|
||||
# }}}
|
||||
Reference in New Issue
Block a user