diff --git a/Dockerfile b/Dockerfile index fb991f9..67235ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim AS build +FROM python:3.14-slim AS build WORKDIR / diff --git a/README.md b/README.md index ced6f7b..f253234 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,13 @@ Here is a batch of usefull commands. ```bash docker compose down -v # Remove volume containings results -docker compose -f docker-compose-dev.yml up # Start application in developpement mode +docker compose -f compose.dev.yml up # Start application in developpement mode rm -rf aperisolve/results # Remove results from developpement mode docker exec -it postgres psql -U aperiuser -d aperisolve # Get postgresql shell docker exec -it aperisolve-web bash # Get shell in running web app ``` -> Note, if you used [docker-compose-dev.yml](docker-compose-dev.yml), you must remove "results" when switching to [production docker](docker-compose.yml). +> Note, if you used [compose.dev.yml](compose.dev.yml), you must remove "results" when switching to [production docker](compose.yml). ### Code quality @@ -94,7 +94,6 @@ GitHub CI will alert in case of failure. ## Roadmap -- [x] PngCheck - [ ] Zsteg extract / --all --> **Download `zsteg` files** such as mp3 encoded on LSB - [ ] Test on mobile - [ ] i18n diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py new file mode 100644 index 0000000..1a8f5e7 --- /dev/null +++ b/aperisolve/analyzers/image_resize.py @@ -0,0 +1,190 @@ +"""Image size bruteforcer for common PNG sizes.""" + +import zlib +import shutil +from pathlib import Path +from .utils import update_data + +# Full list of expected sizes +EXPECTED_SIZES = [ + # ------------------------- + # ICONS (Square) + # ------------------------- + (16, 16), (20, 20), (24, 24), (28, 28), + (32, 32), (48, 48), (64, 64), (72, 72), + (96, 96), (128, 128), (192, 192), (256, 256), + (384, 384), (512, 512), (768, 768), (1024, 1024), + (1536, 1536), (2048, 2048), (4096, 4096), + + # ------------------------- + # 1:1 Square (General) + # ------------------------- + (300, 300), (500, 500), (800, 800), (1000, 1000), + (1500, 1500), (2000, 2000), (2500, 2500), (3000, 3000), + + # ------------------------- + # 4:3 Aspect (Classic Screens) + # ------------------------- + (320, 240), (640, 480), (800, 600), (960, 720), + (1024, 768), (1280, 960), (1600, 1200), (2048, 1536), + (2560, 1920), (3200, 2400), (4096, 3072), + + # ------------------------- + # 3:2 Aspect (Photography) + # ------------------------- + (600, 400), (900, 600), (1200, 800), (1500, 1000), + (1800, 1200), (2400, 1600), (3000, 2000), (3600, 2400), + (4500, 3000), (6000, 4000), + + # ------------------------- + # 16:9 Aspect (Video / Web) + # ------------------------- + (426, 240), (640, 360), (854, 480), (960, 540), + (1280, 720), (1366, 768), (1600, 900), (1920, 1080), + (2560, 1440), (3200, 1800), (3440, 1935), (3840, 2160), + (5120, 2880), (7680, 4320), (10240, 5760), + + # ------------------------- + # 16:10 Aspect (Laptops) + # ------------------------- + (1280, 800), (1440, 900), (1680, 1050), + (1920, 1200), (2560, 1600), (2880, 1800), + (3840, 2400), (5120, 3200), + + # ------------------------- + # Mobile Portrait (9:16) + # ------------------------- + (720, 1280), (1080, 1920), (1440, 2560), + (2160, 3840), (1440, 2960), (1080, 2400), + (1170, 2532), (1284, 2778), (1290, 2796), + + # ------------------------- + # Social Media Sizes + # ------------------------- + # Instagram + (1080, 1080), (1080, 1350), (1080, 1920), + + # Facebook + (1200, 628), (1200, 1200), + + # Twitter/X + (1200, 675), (1500, 500), + + # YouTube Thumbnails + (1280, 720), (1920, 1080), + + # ------------------------- + # Banners / Headers + # ------------------------- + (1600, 500), (1920, 500), (2560, 700), + (3000, 1000), (3840, 1200), + + # ------------------------- + # Print-like Digital Sizes + # ------------------------- + (2550, 3300), # Letter @ 300 DPI + (2480, 3508), # A4 @ 300 DPI + (3508, 4961), # A3 @ 300 DPI + (4961, 7016), # A2 @ 300 DPI + (7016, 9933), # A1 @ 300 DPI + +] + +def calc_checksum(header_chunk, width_bytes, height_bytes): + """ + Calculates the CRC32 of the IHDR chunk with new dimensions. + header_chunk: The full IHDR chunk (Type + Data). + """ + # Reconstruct IHDR: [Type(4)] + [New Width(4)] + [New Height(4)] + [Rest of Data(5)] + new_header = header_chunk[:4] + width_bytes + height_bytes + header_chunk[12:] + + # Calculate CRC and return as 4 bytes (Big Endian) + return bytearray((zlib.crc32(new_header) & 0xffffffff).to_bytes(4, byteorder='big')) + +def analyze_image_resize(input_img: Path, output_dir: Path) -> None: + """ + Analyze an image submission using python-based resize bruteforce. + Attempts to recover PNGs with modified resolutions but valid CRCs. + """ + + # 1. Setup Paths + input_img = Path(input_img) + output_dir = Path(output_dir) + + # 3. Initialize Logs + logs = [] + recovered_image = None + + try: + with input_img.open("rb") as image: + f = image.read() + + b = bytearray(f) + + # Find IHDR start + IHDR = b.find(b'\x49\x48\x44\x52') + if IHDR == -1: + logs.append("Failure: PNG header (IHDR) not found.") + update_data(output_dir, {"image_resize": {"status": "error", "error": "PNG header not found."}}) + return + + # Calculate Chunk Length (The 4 bytes BEFORE IHDR tag) + chunk_length = int.from_bytes((b[IHDR- 4: IHDR]), byteorder="big") + 4 + + # Extract Target CRC (The 4 bytes AFTER the chunk data) + target_crc = b[IHDR + chunk_length:IHDR + chunk_length + 4] + + # Isolate the header chunk (Type + Data) + header_chunk = b[IHDR:IHDR + chunk_length] + + logs.append(f"Target CRC found: 0x{target_crc.hex()}") + + match_found = False + + # Brute-force Loop + for size in EXPECTED_SIZES: + width, height = size + + # Convert dimensions to 4-byte Big Endian arrays + width_bytes = bytearray(width.to_bytes(4, byteorder='big')) + height_bytes = bytearray(height.to_bytes(4, byteorder='big')) + + # Check if this size matches the file's CRC + if target_crc == calc_checksum(header_chunk, width_bytes, height_bytes): + match_found = True + logs.append(f"Success: Match found! Dimensions: {width}x{height}") + + # Create Output Filename + img_name = f"recovered_{width}x{height}.png" + + # Create parent directories if they don't exist + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / img_name + + # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] + full_png_data = b[:IHDR+4] + width_bytes + height_bytes + b[IHDR+12:] + + # Write the recovered file to disk + with output_path.open("wb") as out_f: + out_f.write(full_png_data) + + recovered_image = Path(output_dir.name) / img_name + break # Stop looking after finding the match + + if not match_found: + logs.append("Failure: No matching dimensions found.") + + # 4. Final Data Update + output_data = { + "image_resize": { # Key name must match your module name in AperiSolve + "status": "ok", + "output": logs + } + } + if recovered_image: + output_data["image_resize"]["image"] = "/image/" + str(recovered_image) + update_data(output_dir, output_data) + + except Exception as e: + update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) + return None \ No newline at end of file diff --git a/aperisolve/config.py b/aperisolve/config.py index 89f3350..fe000d3 100644 --- a/aperisolve/config.py +++ b/aperisolve/config.py @@ -3,6 +3,6 @@ from pathlib import Path IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"] -WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg"] +WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize"] RESULT_FOLDER = Path(__file__).parent.resolve() / "results" diff --git a/aperisolve/static/js/aperisolve.js b/aperisolve/static/js/aperisolve.js index 3127aa6..771bfa8 100644 --- a/aperisolve/static/js/aperisolve.js +++ b/aperisolve/static/js/aperisolve.js @@ -8,6 +8,7 @@ const TOOL_ORDER = [ "steghide", "zsteg", "strings", + "image_resize", ]; function escapeHtml(text) { @@ -248,7 +249,7 @@ function parseResult(result) { analyzer.className = `analyzer a-${escapeHtml(tool)}`; resultDiv.appendChild(analyzer); - analyzer.innerHTML += `
${escapeHtml(
diff --git a/aperisolve/templates/faq.html b/aperisolve/templates/faq.html
index 2b72bff..ccf4083 100644
--- a/aperisolve/templates/faq.html
+++ b/aperisolve/templates/faq.html
@@ -6,8 +6,8 @@
This cheatsheet is intended to guide CTF players in their research. This cheatsheet is not representative of
- modern steganography/seganalysis techniques, and its content does not match with the creation of an interesting
- challenges 😉.
+ modern steganography/steganalysis techniques, and its content does not match with the creation of an interesting
+ challenge 😉.
Check file type / metadata
@@ -48,7 +48,7 @@