[GEMINI] Write tests for forensics and stage baseline implementation
This commit is contained in:
@@ -4,7 +4,7 @@ competition = "picoctf"
|
|||||||
catagory = "textCat"
|
catagory = "textCat"
|
||||||
challenge = "testChal"
|
challenge = "testChal"
|
||||||
flag_format = "picoCTF\\{.*\\}"
|
flag_format = "picoCTF\\{.*\\}"
|
||||||
last_flag = "picoCTF{flag}"
|
last_flag = "flag{my_flag_here}"
|
||||||
|
|
||||||
[Enviroment]
|
[Enviroment]
|
||||||
ctf_dir = "/home/venus/ctf"
|
ctf_dir = "/home/venus/ctf"
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ def tf():
|
|||||||
# {{{ inspect
|
# {{{ inspect
|
||||||
@forensics_group.command()
|
@forensics_group.command()
|
||||||
@click.argument('path', type=click.Path(exists=True))
|
@click.argument('path', type=click.Path(exists=True))
|
||||||
def inspect(path):
|
@click.option('-i', '--inode', is_flag=True, help="Print only POSIX inode metadata (without EXIF data).")
|
||||||
"""Lists all basic inode metadata about a file"""
|
@click.option('-e', '--exif', is_flag=True, help="Print only EXIF metadata (without POSIX inode metadata).")
|
||||||
|
def inspect(path, inode, exif):
|
||||||
|
"""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.")
|
||||||
|
|
||||||
meta = get_metadata(Path(path))
|
meta = get_metadata(Path(path))
|
||||||
|
|
||||||
# Present using Rich Table
|
# Present using Rich Table
|
||||||
@@ -31,6 +36,8 @@ def inspect(path):
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
if not exif:
|
||||||
table = Table(title=f"Metadata: {meta.filename}", show_header=False)
|
table = Table(title=f"Metadata: {meta.filename}", show_header=False)
|
||||||
table.add_column("Key", style="bold cyan")
|
table.add_column("Key", style="bold cyan")
|
||||||
table.add_column("Value")
|
table.add_column("Value")
|
||||||
@@ -51,8 +58,39 @@ def inspect(path):
|
|||||||
if meta.extended_attributes:
|
if meta.extended_attributes:
|
||||||
xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items())
|
xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items())
|
||||||
table.add_row("Extended Attributes", xattr_str)
|
table.add_row("Extended Attributes", xattr_str)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
exif_displayed = False
|
||||||
|
if not inode:
|
||||||
|
if meta.exif_data:
|
||||||
|
exif_table = Table(title=f"EXIF Metadata: {meta.filename}", show_header=True)
|
||||||
|
exif_table.add_column("Tag", style="bold green")
|
||||||
|
exif_table.add_column("Value")
|
||||||
|
|
||||||
|
for tag, val in sorted(meta.exif_data.items()):
|
||||||
|
if isinstance(val, bytes):
|
||||||
|
val_str = val.hex().upper()
|
||||||
|
if len(val_str) > 60:
|
||||||
|
val_str = val_str[:57] + "..."
|
||||||
|
else:
|
||||||
|
val_str = str(val)
|
||||||
|
exif_table.add_row(tag, val_str)
|
||||||
|
|
||||||
|
console.print(exif_table)
|
||||||
|
exif_displayed = True
|
||||||
|
|
||||||
|
if meta.comment:
|
||||||
|
from rich.panel import Panel
|
||||||
|
console.print(Panel(
|
||||||
|
meta.comment,
|
||||||
|
title=f"[bold green]Comment: {meta.filename}[/bold green]",
|
||||||
|
border_style="green"
|
||||||
|
))
|
||||||
|
exif_displayed = True
|
||||||
|
|
||||||
|
if exif and not exif_displayed:
|
||||||
|
console.print("[bold yellow]No EXIF or comment metadata found.[/bold yellow]")
|
||||||
|
|
||||||
return meta
|
return meta
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from pathlib import Path
|
|||||||
import stat
|
import stat
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import List, Dict
|
import struct
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pwd
|
import pwd
|
||||||
@@ -16,6 +18,7 @@ except ImportError:
|
|||||||
pwd = None
|
pwd = None
|
||||||
grp = None
|
grp = None
|
||||||
|
|
||||||
|
# {{{ Common Signatures
|
||||||
COMMON_SIGNATURES = {
|
COMMON_SIGNATURES = {
|
||||||
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
|
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
|
||||||
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
|
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
|
||||||
@@ -33,6 +36,7 @@ COMMON_SIGNATURES = {
|
|||||||
b"BM": ("BMP Image", [".bmp"]),
|
b"BM": ("BMP Image", [".bmp"]),
|
||||||
b"ID3": ("MP3 Audio", [".mp3"]),
|
b"ID3": ("MP3 Audio", [".mp3"]),
|
||||||
}
|
}
|
||||||
|
# }}}
|
||||||
|
|
||||||
# {{{ FileMetadata
|
# {{{ FileMetadata
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -65,6 +69,12 @@ class FileMetadata:
|
|||||||
|
|
||||||
# Task 6: Extended Attributes
|
# Task 6: Extended Attributes
|
||||||
extended_attributes: Dict[str, str] = field(default_factory=dict)
|
extended_attributes: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# EXIF Data
|
||||||
|
exif_data: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# JPEG/PNG Comments
|
||||||
|
comment: str = ""
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
# {{{ get_metadata
|
# {{{ get_metadata
|
||||||
@@ -146,6 +156,9 @@ def get_metadata(path: Path) -> FileMetadata:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
exif_data = get_exif(p)
|
||||||
|
comment = get_comment(p)
|
||||||
|
|
||||||
return FileMetadata(
|
return FileMetadata(
|
||||||
filename=p.name,
|
filename=p.name,
|
||||||
size=size,
|
size=size,
|
||||||
@@ -162,7 +175,344 @@ def get_metadata(path: Path) -> FileMetadata:
|
|||||||
hard_links=hard_links,
|
hard_links=hard_links,
|
||||||
inode=inode,
|
inode=inode,
|
||||||
device=device,
|
device=device,
|
||||||
extended_attributes=extended_attributes
|
extended_attributes=extended_attributes,
|
||||||
|
exif_data=exif_data,
|
||||||
|
comment=comment
|
||||||
)
|
)
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
EXIF_TAGS = {
|
||||||
|
0x010e: "ImageDescription",
|
||||||
|
0x010f: "Make",
|
||||||
|
0x0110: "Model",
|
||||||
|
0x0112: "Orientation",
|
||||||
|
0x011a: "XResolution",
|
||||||
|
0x011b: "YResolution",
|
||||||
|
0x0128: "ResolutionUnit",
|
||||||
|
0x0131: "Software",
|
||||||
|
0x0132: "DateTime",
|
||||||
|
0x013b: "Artist",
|
||||||
|
0x8298: "Copyright",
|
||||||
|
0x829a: "ExposureTime",
|
||||||
|
0x829d: "FNumber",
|
||||||
|
0x8822: "ExposureProgram",
|
||||||
|
0x8825: "GPSInfo",
|
||||||
|
0x8827: "ISOSpeedRatings",
|
||||||
|
0x9000: "ExifVersion",
|
||||||
|
0x9003: "DateTimeOriginal",
|
||||||
|
0x9004: "DateTimeDigitized",
|
||||||
|
0x9201: "ShutterSpeedValue",
|
||||||
|
0x9202: "ApertureValue",
|
||||||
|
0x9204: "ExposureBiasValue",
|
||||||
|
0x9205: "MaxApertureValue",
|
||||||
|
0x9207: "MeteringMode",
|
||||||
|
0x9208: "LightSource",
|
||||||
|
0x9209: "Flash",
|
||||||
|
0x920a: "FocalLength",
|
||||||
|
0x9286: "UserComment",
|
||||||
|
0xa000: "FlashpixVersion",
|
||||||
|
0xa001: "ColorSpace",
|
||||||
|
0xa002: "ExifImageWidth",
|
||||||
|
0xa003: "ExifImageHeight",
|
||||||
|
0xa402: "ExposureMode",
|
||||||
|
0xa403: "WhiteBalance",
|
||||||
|
0xa405: "FocalLengthIn35mmFilm",
|
||||||
|
0xa406: "SceneCaptureType",
|
||||||
|
}
|
||||||
|
|
||||||
|
# {{{ parse_tiff
|
||||||
|
def parse_tiff(data: bytes) -> Dict[str, Any]:
|
||||||
|
"""Parses raw TIFF data block into EXIF tag dictionary."""
|
||||||
|
tags = {}
|
||||||
|
if len(data) < 8:
|
||||||
|
return tags
|
||||||
|
|
||||||
|
byte_order = data[0:2]
|
||||||
|
if byte_order == b"II":
|
||||||
|
endian = "<"
|
||||||
|
elif byte_order == b"MM":
|
||||||
|
endian = ">"
|
||||||
|
else:
|
||||||
|
return tags
|
||||||
|
|
||||||
|
magic = struct.unpack(f"{endian}H", data[2:4])[0]
|
||||||
|
if magic != 42:
|
||||||
|
return tags
|
||||||
|
|
||||||
|
ifd_offset = struct.unpack(f"{endian}I", data[4:8])[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
while ifd_offset != 0 and ifd_offset < len(data):
|
||||||
|
if ifd_offset + 2 > len(data):
|
||||||
|
break
|
||||||
|
num_entries = struct.unpack(f"{endian}H", data[ifd_offset:ifd_offset+2])[0]
|
||||||
|
entry_offset = ifd_offset + 2
|
||||||
|
|
||||||
|
for _ in range(num_entries):
|
||||||
|
if entry_offset + 12 > len(data):
|
||||||
|
break
|
||||||
|
tag, field_type, count, val_offset = struct.unpack(
|
||||||
|
f"{endian}HHII", data[entry_offset:entry_offset+12]
|
||||||
|
)
|
||||||
|
entry_offset += 12
|
||||||
|
|
||||||
|
# Resolve value based on type and count
|
||||||
|
val = None
|
||||||
|
type_sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8}
|
||||||
|
size = type_sizes.get(field_type, 1) * count
|
||||||
|
|
||||||
|
if size <= 4:
|
||||||
|
raw_val = struct.pack(f"{endian}I", val_offset)[:size]
|
||||||
|
else:
|
||||||
|
if val_offset + size <= len(data):
|
||||||
|
raw_val = data[val_offset:val_offset+size]
|
||||||
|
else:
|
||||||
|
raw_val = b""
|
||||||
|
|
||||||
|
if field_type == 2: # ASCII
|
||||||
|
val = raw_val.split(b"\x00")[0].decode("utf-8", errors="ignore")
|
||||||
|
elif field_type == 3: # SHORT
|
||||||
|
fmt = f"{endian}" + "H" * count
|
||||||
|
if len(raw_val) >= 2 * count:
|
||||||
|
val = struct.unpack(fmt, raw_val[:2*count])
|
||||||
|
if count == 1:
|
||||||
|
val = val[0]
|
||||||
|
elif field_type == 4: # LONG
|
||||||
|
fmt = f"{endian}" + "I" * count
|
||||||
|
if len(raw_val) >= 4 * count:
|
||||||
|
val = struct.unpack(fmt, raw_val[:4*count])
|
||||||
|
if count == 1:
|
||||||
|
val = val[0]
|
||||||
|
elif field_type == 5: # RATIONAL
|
||||||
|
fmt = f"{endian}" + "II" * count
|
||||||
|
if len(raw_val) >= 8 * count:
|
||||||
|
unpacked = struct.unpack(fmt, raw_val[:8*count])
|
||||||
|
rationals = [f"{unpacked[i*2]}/{unpacked[i*2+1]}" for i in range(count)]
|
||||||
|
val = rationals[0] if count == 1 else rationals
|
||||||
|
elif field_type == 7: # UNDEFINED
|
||||||
|
val = raw_val
|
||||||
|
elif field_type == 9: # SLONG
|
||||||
|
fmt = f"{endian}" + "i" * count
|
||||||
|
if len(raw_val) >= 4 * count:
|
||||||
|
val = struct.unpack(fmt, raw_val[:4*count])
|
||||||
|
if count == 1:
|
||||||
|
val = val[0]
|
||||||
|
elif field_type == 10: # SRATIONAL
|
||||||
|
fmt = f"{endian}" + "ii" * count
|
||||||
|
if len(raw_val) >= 8 * count:
|
||||||
|
unpacked = struct.unpack(fmt, raw_val[:8*count])
|
||||||
|
rationals = [f"{unpacked[i*2]}/{unpacked[i*2+1]}" for i in range(count)]
|
||||||
|
val = rationals[0] if count == 1 else rationals
|
||||||
|
else:
|
||||||
|
val = raw_val
|
||||||
|
|
||||||
|
tag_name = EXIF_TAGS.get(tag, f"Tag_{hex(tag)}")
|
||||||
|
tags[tag_name] = val
|
||||||
|
|
||||||
|
if entry_offset + 4 > len(data):
|
||||||
|
break
|
||||||
|
ifd_offset = struct.unpack(f"{endian}I", data[entry_offset:entry_offset+4])[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return tags
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ extract_jpeg_exif
|
||||||
|
def extract_jpeg_exif(data: bytes) -> bytes:
|
||||||
|
"""Extracts raw Exif/TIFF payload from JPEG APP1 segments."""
|
||||||
|
if not data.startswith(b"\xff\xd8\xff"):
|
||||||
|
return b""
|
||||||
|
idx = 2
|
||||||
|
while idx < len(data) - 4:
|
||||||
|
if data[idx] == 0xff:
|
||||||
|
marker = data[idx+1]
|
||||||
|
if marker == 0xd9: # EOI
|
||||||
|
break
|
||||||
|
# Markers without length parameters
|
||||||
|
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 == 0xe1: # APP1
|
||||||
|
app1_data = data[idx+4:idx+4+length-2]
|
||||||
|
if app1_data.startswith(b"Exif\x00\x00"):
|
||||||
|
return app1_data[6:]
|
||||||
|
idx += 2 + length
|
||||||
|
else:
|
||||||
|
idx += 1
|
||||||
|
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
|
||||||
|
|
||||||
|
return tags
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ extract_jpeg_comment
|
||||||
|
def extract_jpeg_comment(data: bytes) -> str:
|
||||||
|
"""Extracts raw comment string from JPEG COM (0xfe) segments."""
|
||||||
|
if not data.startswith(b"\xff\xd8\xff"):
|
||||||
|
return ""
|
||||||
|
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 == 0xfe: # COM
|
||||||
|
comment_data = data[idx+4:idx+4+length-2]
|
||||||
|
return comment_data.decode("utf-8", errors="ignore")
|
||||||
|
idx += 2 + length
|
||||||
|
else:
|
||||||
|
idx += 1
|
||||||
|
return ""
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ get_comment
|
||||||
|
def get_comment(path: Path) -> str:
|
||||||
|
"""Reads file, checks headers, and extracts JPEG COM 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)
|
||||||
|
return ""
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
NS_MAP = {
|
||||||
|
"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]:
|
||||||
|
"""Parses Adobe XMP XML string and returns namespace-prefixed tags."""
|
||||||
|
metadata = {}
|
||||||
|
try:
|
||||||
|
start_idx = xmp_str.find("<x:xmpmeta")
|
||||||
|
if start_idx == -1:
|
||||||
|
return metadata
|
||||||
|
end_idx = xmp_str.find("</x:xmpmeta>")
|
||||||
|
if end_idx == -1:
|
||||||
|
return metadata
|
||||||
|
xml_data = xmp_str[start_idx:end_idx + len("</x:xmpmeta>")]
|
||||||
|
|
||||||
|
root = ET.fromstring(xml_data)
|
||||||
|
|
||||||
|
def get_clean_name(tag: str) -> str:
|
||||||
|
if "}" in tag:
|
||||||
|
ns, name = tag.split("}")
|
||||||
|
ns = ns.lstrip("{")
|
||||||
|
prefix = NS_MAP.get(ns, ns.split("/")[-1].split("#")[0])
|
||||||
|
return f"{prefix}:{name}"
|
||||||
|
return tag
|
||||||
|
|
||||||
|
for elem in root.iter():
|
||||||
|
clean_tag = get_clean_name(elem.tag)
|
||||||
|
if clean_tag in ("rdf:RDF", "rdf:Description", "x:xmpmeta"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
val = None
|
||||||
|
if elem.text and elem.text.strip():
|
||||||
|
val = elem.text.strip()
|
||||||
|
else:
|
||||||
|
attribs = {get_clean_name(k): v for k, v in elem.attrib.items()}
|
||||||
|
attribs.pop("rdf:about", None)
|
||||||
|
if "rdf:resource" in attribs:
|
||||||
|
val = attribs["rdf:resource"]
|
||||||
|
elif attribs:
|
||||||
|
val = ", ".join(f"{k}={v}" for k, v in attribs.items())
|
||||||
|
|
||||||
|
if val:
|
||||||
|
metadata[clean_tag] = val
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return metadata
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ extract_jpeg_xmp
|
||||||
|
def extract_jpeg_xmp(data: bytes) -> str:
|
||||||
|
"""Extracts XMP XML string from JPEG APP1 segments."""
|
||||||
|
if not data.startswith(b"\xff\xd8\xff"):
|
||||||
|
return ""
|
||||||
|
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 == 0xe1: # APP1
|
||||||
|
app1_data = data[idx+4:idx+4+length-2]
|
||||||
|
if app1_data.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
|
||||||
|
return app1_data[29:].decode("utf-8", errors="ignore")
|
||||||
|
idx += 2 + length
|
||||||
|
else:
|
||||||
|
idx += 1
|
||||||
|
return ""
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Parses and calls commands
|
# Parses and calls commands
|
||||||
|
|
||||||
from ctf.commands import basic_group
|
from ctf.commands import basic_group
|
||||||
from ctf.cli_forensics import forensics_group
|
from ctf.cli_forensics import forensics_group, inspect
|
||||||
import click
|
import click
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
@@ -64,6 +64,7 @@ class FlagDetectorStream:
|
|||||||
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
|
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
|
||||||
def flag_cmd(plain):
|
def flag_cmd(plain):
|
||||||
"""Retrieve the last detected flag from config."""
|
"""Retrieve the last detected flag from config."""
|
||||||
|
# TODO add support for multiple flags and flag selection
|
||||||
global _active_detector
|
global _active_detector
|
||||||
if _active_detector is not None:
|
if _active_detector is not None:
|
||||||
_active_detector.detecting = False
|
_active_detector.detecting = False
|
||||||
@@ -76,7 +77,7 @@ def flag_cmd(plain):
|
|||||||
click.echo("No flag has been detected yet.")
|
click.echo("No flag has been detected yet.")
|
||||||
return
|
return
|
||||||
if plain:
|
if plain:
|
||||||
click.echo(last_flag, nl=False)
|
click.echo(last_flag)
|
||||||
else:
|
else:
|
||||||
click.echo(f"Last detected flag: {last_flag}")
|
click.echo(f"Last detected flag: {last_flag}")
|
||||||
finally:
|
finally:
|
||||||
@@ -84,15 +85,18 @@ def flag_cmd(plain):
|
|||||||
_active_detector.detecting = True
|
_active_detector.detecting = True
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
# {{{ cli
|
||||||
|
@click.group()
|
||||||
|
def cli(): pass
|
||||||
|
|
||||||
|
cli.add_command(forensics_group)
|
||||||
|
cli.add_command(basic_group)
|
||||||
|
cli.add_command(flag_cmd)
|
||||||
|
cli.add_command(inspect)
|
||||||
|
# }}}
|
||||||
|
|
||||||
# {{{ main
|
# {{{ main
|
||||||
def main():
|
def main():
|
||||||
@click.group()
|
|
||||||
def cli(): pass
|
|
||||||
|
|
||||||
cli.add_command(forensics_group)
|
|
||||||
cli.add_command(basic_group)
|
|
||||||
cli.add_command(flag_cmd)
|
|
||||||
|
|
||||||
from ctf.utils import load_config
|
from ctf.utils import load_config
|
||||||
config = load_config("/home/venus/code/ctf/config.toml")
|
config = load_config("/home/venus/code/ctf/config.toml")
|
||||||
flag_format = config.get("Competition", {}).get("flag_format", "")
|
flag_format = config.get("Competition", {}).get("flag_format", "")
|
||||||
|
|||||||
2
tests/env/clean_no_exif.png
vendored
Normal file
2
tests/env/clean_no_exif.png
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<EFBFBD>PNG
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 8 B |
BIN
tests/env/comment_only.jpg
vendored
Normal file
BIN
tests/env/comment_only.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 B |
BIN
tests/env/mock_comment.jpg
vendored
Normal file
BIN
tests/env/mock_comment.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 B |
BIN
tests/env/mock_exif_image.png
vendored
Normal file
BIN
tests/env/mock_exif_image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
2
tests/env/test_file_nested.png
vendored
Normal file
2
tests/env/test_file_nested.png
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<EFBFBD>PNG
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 8 B |
2
tests/env/test_file_root.png
vendored
Normal file
2
tests/env/test_file_root.png
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<EFBFBD>PNG
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 8 B |
@@ -461,7 +461,149 @@ def test_adobe_xmp_cli():
|
|||||||
assert "cc:license" in result.output
|
assert "cc:license" in result.output
|
||||||
assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output
|
assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output
|
||||||
|
|
||||||
|
def test_jpeg_physical_parsing():
|
||||||
|
"""
|
||||||
|
Verifies that get_metadata successfully parses JPEG physical parameters (JFIF & SOF).
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
app0_payload = b"JFIF\x00\x01\x02\x01\x00\x48\x00\x48\x00\x00"
|
||||||
|
app0_block = b"\xff\xe0" + struct.pack(">H", len(app0_payload) + 2) + app0_payload
|
||||||
|
|
||||||
|
sof_payload = b"\x08\x03\xe8\x05\xdc\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01"
|
||||||
|
sof_block = b"\xff\xc0" + struct.pack(">H", len(sof_payload) + 2) + sof_payload
|
||||||
|
|
||||||
|
mock_jpeg = b"\xff\xd8" + app0_block + sof_block + b"\xff\xd9"
|
||||||
|
|
||||||
|
test_file = TEST_ENV / "mock_physical.jpg"
|
||||||
|
with open(test_file, "wb") as f:
|
||||||
|
f.write(mock_jpeg)
|
||||||
|
|
||||||
|
meta = get_metadata(test_file)
|
||||||
|
assert meta.physical_data.get("JFIF Version") == "1.02"
|
||||||
|
assert meta.physical_data.get("Image Size") == "1500x1000"
|
||||||
|
assert meta.physical_data.get("Megapixels") == "1.5"
|
||||||
|
assert meta.physical_data.get("Encoding Process") == "Baseline DCT, Huffman coding"
|
||||||
|
|
||||||
|
def test_jpeg_iptc_parsing():
|
||||||
|
"""
|
||||||
|
Verifies that get_metadata successfully parses JPEG IPTC metadata from APP13.
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
iptc_ds = b"\x1c\x02\x74\x00\x0ePicoCTF Rights"
|
||||||
|
|
||||||
|
irb_id = b"\x04\x04"
|
||||||
|
irb_name = b"\x00\x00"
|
||||||
|
irb_size = struct.pack(">I", len(iptc_ds))
|
||||||
|
irb_block = b"8BIM" + irb_id + irb_name + irb_size + iptc_ds
|
||||||
|
|
||||||
|
app13_payload = b"Photoshop 3.0\x00" + irb_block
|
||||||
|
app13_block = b"\xff\xed" + struct.pack(">H", len(app13_payload) + 2) + app13_payload
|
||||||
|
|
||||||
|
mock_jpeg = b"\xff\xd8" + app13_block + b"\xff\xd9"
|
||||||
|
|
||||||
|
test_file = TEST_ENV / "mock_iptc.jpg"
|
||||||
|
with open(test_file, "wb") as f:
|
||||||
|
f.write(mock_jpeg)
|
||||||
|
|
||||||
|
meta = get_metadata(test_file)
|
||||||
|
assert meta.exif_data.get("CopyrightNotice") == "PicoCTF Rights"
|
||||||
|
|
||||||
|
def test_cli_physical_option():
|
||||||
|
"""
|
||||||
|
Verifies the ctf inspect CLI supports -p/--physical and mutual exclusion rules.
|
||||||
|
"""
|
||||||
|
from ctf.main import cli
|
||||||
|
runner = CliRunner()
|
||||||
|
test_file = TEST_ENV / "mock_physical.jpg"
|
||||||
|
|
||||||
|
# Mutual exclusion check
|
||||||
|
result_err = runner.invoke(cli, ["inspect", "-p", "-e", str(test_file)])
|
||||||
|
assert result_err.exit_code != 0
|
||||||
|
assert "Cannot specify more than one" in result_err.output
|
||||||
|
|
||||||
|
# Physical view check
|
||||||
|
result_phys = runner.invoke(cli, ["inspect", "-p", str(test_file)])
|
||||||
|
assert result_phys.exit_code == 0
|
||||||
|
assert "Physical Metadata" in result_phys.output
|
||||||
|
assert "Image Size" in result_phys.output
|
||||||
|
assert "1500x1000" in result_phys.output
|
||||||
|
assert "Metadata: mock_physical.jpg" not in result_phys.output
|
||||||
|
assert "EXIF Metadata" not in result_phys.output
|
||||||
|
def test_png_text_chunks_decompression():
|
||||||
|
"""
|
||||||
|
Verifies that get_metadata successfully parses tEXt and compressed zTXt chunks.
|
||||||
|
"""
|
||||||
|
import zlib
|
||||||
|
import struct
|
||||||
|
|
||||||
|
# 1. tEXt chunk: Keyword (Copyright) + NUL + Text (PicoCTF)
|
||||||
|
text_data = b"Copyright\x00PicoCTF"
|
||||||
|
text_chunk = struct.pack(">I", len(text_data)) + b"tEXt" + text_data + b"\x00\x00\x00\x00"
|
||||||
|
|
||||||
|
# 2. zTXt chunk: Keyword (Author) + NUL + CompMethod(0) + Deflated Text (John Doe)
|
||||||
|
deflated = zlib.compress(b"John Doe")
|
||||||
|
ztxt_data = b"Author\x00\x00" + deflated
|
||||||
|
ztxt_chunk = struct.pack(">I", len(ztxt_data)) + b"zTXt" + ztxt_data + b"\x00\x00\x00\x00"
|
||||||
|
|
||||||
|
png_data = b"\x89PNG\r\n\x1a\n" + text_chunk + ztxt_chunk + b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
|
||||||
|
test_file = TEST_ENV / "mock_text_chunks.png"
|
||||||
|
with open(test_file, "wb") as f:
|
||||||
|
f.write(png_data)
|
||||||
|
|
||||||
|
meta = get_metadata(test_file)
|
||||||
|
assert meta.exif_data.get("Copyright") == "PicoCTF"
|
||||||
|
assert meta.exif_data.get("Author") == "John Doe"
|
||||||
|
|
||||||
|
def test_gif_comment_parsing():
|
||||||
|
"""
|
||||||
|
Verifies that get_metadata successfully parses GIF comment blocks.
|
||||||
|
"""
|
||||||
|
gif_data = (
|
||||||
|
b"GIF89a"
|
||||||
|
b"\x01\x00\x01\x00\x00\x00\x00"
|
||||||
|
b"\x21\xfe\x0aGIFComment\x00"
|
||||||
|
b"\x3b"
|
||||||
|
)
|
||||||
|
test_file = TEST_ENV / "mock_comment.gif"
|
||||||
|
with open(test_file, "wb") as f:
|
||||||
|
f.write(gif_data)
|
||||||
|
|
||||||
|
meta = get_metadata(test_file)
|
||||||
|
assert meta.comment == "GIFComment"
|
||||||
|
|
||||||
|
def test_adobe_xmp_formatting():
|
||||||
|
"""
|
||||||
|
Verifies that get_metadata parses rdf:Description attributes and formats other
|
||||||
|
attributes with ' | ' instead of '='.
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
xmp_xml = (
|
||||||
|
b"<x:xmpmeta xmlns:x='adobe:ns:meta/'>\n"
|
||||||
|
b"<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>\n"
|
||||||
|
b" <rdf:Description rdf:about='' xmlns:xmp='http://ns.adobe.com/xap/1.0/' xmlns:exif='http://ns.adobe.com/exif/1.0/'>\n"
|
||||||
|
b" <xmp:CreatorTool>Photoshop</xmp:CreatorTool>\n"
|
||||||
|
b" <exif:Flash exif:Fired='True' exif:Mode='1'/>\n"
|
||||||
|
b" </rdf:Description>\n"
|
||||||
|
b"</rdf:RDF>\n"
|
||||||
|
b"</x:xmpmeta>"
|
||||||
|
)
|
||||||
|
xmp_prefix = b"http://ns.adobe.com/xap/1.0/\x00"
|
||||||
|
app1_payload = xmp_prefix + xmp_xml
|
||||||
|
app1_len = len(app1_payload) + 2
|
||||||
|
mock_jpeg = (
|
||||||
|
b"\xff\xd8"
|
||||||
|
b"\xff\xe1"
|
||||||
|
+ struct.pack(">H", app1_len)
|
||||||
|
+ app1_payload
|
||||||
|
+ b"\xff\xd9"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_file = TEST_ENV / "mock_xmp_formatting.jpg"
|
||||||
|
with open(test_file, "wb") as f:
|
||||||
|
f.write(mock_jpeg)
|
||||||
|
|
||||||
|
meta = get_metadata(test_file)
|
||||||
|
assert meta.exif_data.get("xmp:CreatorTool") == "Photoshop"
|
||||||
|
assert meta.exif_data.get("exif:Flash") == "exif:Fired | True, exif:Mode | 1"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user