From 55e705ac7463a7af43d2b7aa14e79a00612ca294 Mon Sep 17 00:00:00 2001 From: 0xlildoudou <67382584+0xlildoudou@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:42:18 +0000 Subject: [PATCH 01/11] feat: add pre-commit --- .pre-commit-config.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..16a1f1f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: +# - repo: https://github.com/pre-commit/pre-commit-hooks +# rev: v6.0.0 +# hooks: +# - id: check-yaml +# - id: end-of-file-fixer +# - id: trailing-whitespace +- repo: https://github.com/psf/black + rev: 25.11.0 + hooks: + - id: black +- repo: https://github.com/PyCQA/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + args: ['--extend-ignore','E203,E501,W503'] +- repo: https://github.com/pylint-dev/pylint + rev: v4.0.2 + hooks: + - id: pylint + args: ['--disable', 'W0511,W0718,R0801,R0903,R0914'] + language: system +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.18.2 + hooks: + - id: mypy + args: ["--disallow-any-generics", "--disallow-untyped-def", "--follow-imports", "skip", "--ignore-missing-imports", "--disable-error-code", "unused-awaitable"] + language: system \ No newline at end of file From 119b6398bc8b508e0c5c0540ba2e25dcfbd65634 Mon Sep 17 00:00:00 2001 From: Philip-Zimmermann Date: Mon, 17 Nov 2025 23:04:29 +0100 Subject: [PATCH 02/11] Fixed broken stegbrute URL and minor typos --- aperisolve/templates/faq.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 @@
@@ -176,7 +176,7 @@ print(pxs[:10])'>
in the file (strings & exiftool), or the object represented by the image. In some cases, the password cannot be retrieved and a bruteforce must be performed. The tool StegCracker - and Stegbrute allows + and Stegbrute allows bruteforce of hidden secrets with Steghide. @@ -406,4 +406,4 @@ print(pxs[:10])'> -{% endblock %} \ No newline at end of file +{% endblock %} From 76f76226ea0d70ecf839187ce9293dcda57cd3c2 Mon Sep 17 00:00:00 2001 From: SuppliedOrange <70258998+SuppliedOrange@users.noreply.github.com> Date: Sat, 22 Nov 2025 02:36:57 +0530 Subject: [PATCH 03/11] Fix double file browser opening issue --- aperisolve/static/js/aperisolve.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/aperisolve/static/js/aperisolve.js b/aperisolve/static/js/aperisolve.js index 3127aa6..f3760f6 100644 --- a/aperisolve/static/js/aperisolve.js +++ b/aperisolve/static/js/aperisolve.js @@ -367,8 +367,13 @@ function showFilename(filename) { if (browseBtn) { // Clicking the button triggers file input - browseBtn.addEventListener("click", () => fileInput.click()); - dropZone.addEventListener("click", () => fileInput.click()); + browseBtn.addEventListener("click", (e) => { + e.stopPropagation(); + fileInput.click(); + }); + dropZone.addEventListener("click", () => { + fileInput.click(); + }); dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.style.boxShadow = "0 0 20px #9fef00"; From 049cec75a948dd98b527c66ec6076c505dc8a7fe Mon Sep 17 00:00:00 2001 From: mint Date: Fri, 28 Nov 2025 16:01:14 +0800 Subject: [PATCH 04/11] pr: flooding possible if error is caused during binwalk (Denial of service) --- aperisolve/analyzers/binwalk.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aperisolve/analyzers/binwalk.py b/aperisolve/analyzers/binwalk.py index e8dafba..908f713 100644 --- a/aperisolve/analyzers/binwalk.py +++ b/aperisolve/analyzers/binwalk.py @@ -40,6 +40,10 @@ def analyze_binwalk(input_img: Path, output_dir: Path) -> None: zip_exist = True stderr += zip_data.stderr + # Remove the extracted directory + if extracted_dir.exists(): + shutil.rmtree(extracted_dir) + if len(stderr) > 0: err = { "binwalk": { @@ -50,10 +54,6 @@ def analyze_binwalk(input_img: Path, output_dir: Path) -> None: update_data(output_dir, err) return None - # Remove the extracted directory - if extracted_dir.exists(): - shutil.rmtree(extracted_dir) - output_data = { "binwalk": { "status": "ok", From c81d3e6d3da0963fd28371aee8c72a5a74c5c9ce Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Tue, 9 Dec 2025 16:07:10 +0100 Subject: [PATCH 05/11] Added automatic PNG resizing solver --- aperisolve/analyzers/image_resize.py | 189 +++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 aperisolve/analyzers/image_resize.py diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py new file mode 100644 index 0000000..a0710db --- /dev/null +++ b/aperisolve/analyzers/image_resize.py @@ -0,0 +1,189 @@ +"""Image size bruteforcer for common PNG sizes.""" + +import zlib +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) + extracted_dir = output_dir / "image_resize_output_dir" + + # 2. CREATE THE DIRECTORY (Crucial Step!) + extracted_dir.mkdir(parents=True, exist_ok=True) + + # 3. Initialize Logs + logs = [] + recovered_images = [] + + try: + with open(input_img, "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 + output_filename = f"recovered_{width}x{height}.png" + output_path = extracted_dir / output_filename + + # 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 open(output_path, "wb") as out_f: + out_f.write(full_png_data) + + recovered_images.append(output_filename) + 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, + "images": recovered_images, # Tells frontend to display the 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 From b97a66d07267db45ab26c91511f01cdb103d67da Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Tue, 9 Dec 2025 17:01:53 +0100 Subject: [PATCH 06/11] Added PNG resize solver to workers and config --- aperisolve/config.py | 2 +- aperisolve/workers.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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/workers.py b/aperisolve/workers.py index 963bb1e..956c328 100644 --- a/aperisolve/workers.py +++ b/aperisolve/workers.py @@ -13,6 +13,7 @@ from .analyzers.steghide import analyze_steghide from .analyzers.strings import analyze_strings from .analyzers.zsteg import analyze_zsteg from .analyzers.pngcheck import analyze_pngcheck +from .analyzers.image_resize import analyze_image_resize from .app import app, db from .config import RESULT_FOLDER from .models import Image, Submission @@ -53,6 +54,7 @@ def analyze_image(submission_hash: str) -> None: (analyze_pngcheck, img_path, result_path), (analyze_steghide, img_path, result_path, submission.password), (analyze_zsteg, img_path, result_path), + (analyze_image_resize, img_path, result_path), ] # Deep analysis only From e271c15b5636247104b2a68f65c7a71e882ed638 Mon Sep 17 00:00:00 2001 From: Zeecka Date: Tue, 9 Dec 2025 18:24:15 +0100 Subject: [PATCH 07/11] Optimize analyzer resize, Bump python version, Change compose name --- Dockerfile | 2 +- README.md | 5 +- aperisolve/analyzers/image_resize.py | 84 ++++++++++++----------- docker-compose-dev.yml => compose.dev.yml | 0 docker-compose.yml => compose.yml | 0 5 files changed, 48 insertions(+), 43 deletions(-) rename docker-compose-dev.yml => compose.dev.yml (100%) rename docker-compose.yml => compose.yml (100%) 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 index a0710db..377b550 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -1,6 +1,7 @@ """Image size bruteforcer for common PNG sizes.""" import zlib +import shutil from pathlib import Path from .utils import update_data @@ -116,74 +117,79 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: # 3. Initialize Logs logs = [] - recovered_images = [] + recovered_image = None try: with open(input_img, "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 + b = bytearray(f) - # Calculate Chunk Length (The 4 bytes BEFORE IHDR tag) - chunk_length = int.from_bytes((b[IHDR- 4: IHDR]), byteorder="big") + 4 + # 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."}}) + if extracted_dir.exists(): + shutil.rmtree(extracted_dir, ignore_errors=True) + return - # Extract Target CRC (The 4 bytes AFTER the chunk data) - target_crc = b[IHDR + chunk_length:IHDR + chunk_length + 4] + # Calculate Chunk Length (The 4 bytes BEFORE IHDR tag) + chunk_length = int.from_bytes((b[IHDR- 4: IHDR]), byteorder="big") + 4 - # Isolate the header chunk (Type + Data) - header_chunk = b[IHDR:IHDR + chunk_length] + # Extract Target CRC (The 4 bytes AFTER the chunk data) + target_crc = b[IHDR + chunk_length:IHDR + chunk_length + 4] - logs.append(f"Target CRC found: 0x{target_crc.hex()}") + # Isolate the header chunk (Type + Data) + header_chunk = b[IHDR:IHDR + chunk_length] - match_found = False + logs.append(f"Target CRC found: 0x{target_crc.hex()}") - # Brute-force Loop - for size in EXPECTED_SIZES: - width, height = size + match_found = False - # 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')) + # Brute-force Loop + for size in EXPECTED_SIZES: + width, height = size - # 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}") + # 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')) - # Create Output Filename - output_filename = f"recovered_{width}x{height}.png" - output_path = extracted_dir / output_filename + # 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}") - # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] - full_png_data = b[:IHDR+4] + width_bytes + height_bytes + b[IHDR+12:] + # Create Output Filename + output_filename = f"recovered_{width}x{height}.png" + output_path = extracted_dir / output_filename - # Write the recovered file to disk - with open(output_path, "wb") as out_f: - out_f.write(full_png_data) + # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] + full_png_data = b[:IHDR+4] + width_bytes + height_bytes + b[IHDR+12:] - recovered_images.append(output_filename) - break # Stop looking after finding the match + # Write the recovered file to disk + with output_path.open("wb") as out_f: + out_f.write(full_png_data) - if not match_found: - logs.append("Failure: No matching dimensions found.") + recovered_image = output_filename + 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, - "images": recovered_images, # Tells frontend to display the image + "images": recovered_image, # Tells frontend to display the image } } update_data(output_dir, output_data) except Exception as e: update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) + if extracted_dir.exists(): + shutil.rmtree(extracted_dir, ignore_errors=True) return None \ No newline at end of file diff --git a/docker-compose-dev.yml b/compose.dev.yml similarity index 100% rename from docker-compose-dev.yml rename to compose.dev.yml diff --git a/docker-compose.yml b/compose.yml similarity index 100% rename from docker-compose.yml rename to compose.yml From 6d75f55a732ef9bc8556444ebbf8337c0202d9be Mon Sep 17 00:00:00 2001 From: Zeecka Date: Tue, 9 Dec 2025 22:16:53 +0100 Subject: [PATCH 08/11] Frontend is now working --- aperisolve/analyzers/image_resize.py | 25 ++++++++++--------------- aperisolve/static/js/aperisolve.js | 12 +++++++++++- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index 377b550..1a8f5e7 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -110,17 +110,13 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: # 1. Setup Paths input_img = Path(input_img) output_dir = Path(output_dir) - extracted_dir = output_dir / "image_resize_output_dir" - - # 2. CREATE THE DIRECTORY (Crucial Step!) - extracted_dir.mkdir(parents=True, exist_ok=True) # 3. Initialize Logs logs = [] recovered_image = None try: - with open(input_img, "rb") as image: + with input_img.open("rb") as image: f = image.read() b = bytearray(f) @@ -130,8 +126,6 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: if IHDR == -1: logs.append("Failure: PNG header (IHDR) not found.") update_data(output_dir, {"image_resize": {"status": "error", "error": "PNG header not found."}}) - if extracted_dir.exists(): - shutil.rmtree(extracted_dir, ignore_errors=True) return # Calculate Chunk Length (The 4 bytes BEFORE IHDR tag) @@ -161,8 +155,11 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: logs.append(f"Success: Match found! Dimensions: {width}x{height}") # Create Output Filename - output_filename = f"recovered_{width}x{height}.png" - output_path = extracted_dir / 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:] @@ -171,7 +168,7 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: with output_path.open("wb") as out_f: out_f.write(full_png_data) - recovered_image = output_filename + recovered_image = Path(output_dir.name) / img_name break # Stop looking after finding the match if not match_found: @@ -181,15 +178,13 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: output_data = { "image_resize": { # Key name must match your module name in AperiSolve "status": "ok", - "output": logs, - "images": recovered_image, # Tells frontend to display the image + "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)}}) - if extracted_dir.exists(): - shutil.rmtree(extracted_dir, ignore_errors=True) - return None \ No newline at end of file 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 += `

${capitalize(tool)}

`; + analyzer.innerHTML += `

${capitalize(tool.replace("_"," "))}

`; if (result[tool]["status"] === "ok") { if ("images" in result[tool]) { @@ -270,7 +271,16 @@ function parseResult(result) { } } } + + if ("image" in result[tool]) { + // Parse image output + analyzer.innerHTML += `
`; + } + // Parse text output + if (typeof result[tool]["output"] === "string") { analyzer.innerHTML += `
${escapeHtml(

From 83bce75b2f60cf933ea5638900bbf453fc87bd4d Mon Sep 17 00:00:00 2001
From: Zeecka <24873615+Zeecka@users.noreply.github.com>
Date: Tue, 9 Dec 2025 23:05:47 +0100
Subject: [PATCH 09/11] Edit README, fix typo and add coffee link

---
 README.md | 44 +++++++++++++++++++++++++-------------------
 1 file changed, 25 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md
index f253234..1628e41 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,12 @@
 
 Try it now: https://www.aperisolve.com
 
+ Support Aperi'Solve:
+
+
+  
+
+
 ## What is Aperi'Solve?
 
 Aperi'Solve is a platform which performs steganalysis on images.
@@ -20,28 +26,28 @@ The platform uses *layer analysis*, "*zsteg*", "*steghide*", "*outguess*", "*exi ### Why Aperi'Solve? -Aperi'Solve has been created in order to have an "easy to use" platform which performs common steganalysis tests such as LSB or `steghide`. The platform is also a quick alternative for people who didn't manage to install tools like `zsteg` (ruby gem) properly. +Aperi'Solve has been created in order to have an "easy to use" platform which performs common steganalysis tests such as LSB or `steghide`. The platform is also a quick alternative for people who were note able to install tools like `zsteg` (ruby gem) properly. ### Features -Aperi'Solve is based on Python3 with Flask and PIL module, the platform supports the most common images formats like`.png`, `.jpg`, `.gif`, `.bmp`, `.jpeg`, `.jfif`, `.jpe`, `.tiff`... +Aperi'Solve is based on Python3 with Flask and PIL module, the platform supports the most common image formats like`.png`, `.jpg`, `.gif`, `.bmp`, `.jpeg`, `.jfif`, `.jpe`, `.tiff`... -The platform allow you to: -- **Visualise each bit layer** of each channel for a given image (ie. LSB of Red channel). +The platform allows you to: +- **Visualize each bit layer** of each channel for a given image (ie. LSB of Red channel). - **Browse** and **Download each bit layer image**. -- **Visualise `zsteg` informations** such as text encoded on LSB +- **Visualise `zsteg` information** such as text encoded on LSB - **Download `steghide` files** using a defined password - **Download `outguess` files** using a defined password -- **Visualise `exiftool` informations** such as geolocation or author -- **Visualise `binwalk` informations** -- **Download `binwalk` files** such as zip in png headers -- **Download `foremost` files** such as zip in png headers +- **Visualise `exiftool` information** such as geolocation or author +- **Visualise `binwalk` information** +- **Download `binwalk` files** such as ZIP file in PNG headers +- **Download `foremost` files** such as ZIP file in PNG headers - **Visualise `pngcheck` output** - **Visualise `strings` output** ## Installation -Even if Aperi'Solve is available at the URL https://www.aperisolve.com/, you can set-up your own instance with the following command: +Even if Aperi'Solve is available at the URL https://www.aperisolve.com/, you can set up your own instance with the following command: ```bash git clone https://github.com/Zeecka/AperiSolve.git @@ -54,32 +60,32 @@ docker compose up -d ## Development Aperi'Solve platform is a *Flask* web service (python 3.11+) with python workers performing analysis. -Jobs are stacked in a redis-queue, and images informations are stored in a postgresql database. -All services are well contained inside Docker containers. +Jobs are stacked in a redis-queue, and image information is stored in a postgresql database. +All services run inside Docker containers. ### Adding new analyzer Adding new analyzer is easy. -1. Copy and addapt the [template_analyzer.py](aperisolve/analyzers/template_analyzer.py) file with you name, ie. "myanalyzer.py". +1. Copy and adapt the [template_analyzer.py](aperisolve/analyzers/template_analyzer.py) file with your name, ie. "myanalyzer.py". 2. Add this analyzer to [workers.py](aperisolve/workers.py). - Import your function `from .analyzers.analyzer import analyze_myanalyzer` - - Add the function to `analyzers` variables, like other analyzers. + - Add the function to the `analyzers` variable, like other analyzers. 3. Add your analyzer name to `TOOL_ORDER` in [aperisolve.js](aperisolve/static/js/aperisolve.js). ### Tips -Here is a batch of usefull commands. +Here is a batch of useful commands. ```bash -docker compose down -v # Remove volume containings results +docker compose down -v # Remove the volume containings results docker compose -f compose.dev.yml up # Start application in developpement mode -rm -rf aperisolve/results # Remove results from developpement mode +rm -rf aperisolve/results # Remove results from development 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 +docker exec -it aperisolve-web bash # Get a shell inside the running web app ``` -> Note, if you used [compose.dev.yml](compose.dev.yml), you must remove "results" when switching to [production docker](compose.yml). +> Note, if you used [compose.dev.yml](compose.dev.yml), you must remove "results" when switching to [production Docker](compose.yml) setup. ### Code quality From d1fb8b3760e956bcd430145b245c56e67edf89c3 Mon Sep 17 00:00:00 2001 From: Zeecka Date: Wed, 10 Dec 2025 22:23:54 +0100 Subject: [PATCH 10/11] Lint --- aperisolve/analyzers/binwalk.py | 2 +- aperisolve/analyzers/image_resize.py | 172 ++++++++++++++++++--------- 2 files changed, 117 insertions(+), 57 deletions(-) diff --git a/aperisolve/analyzers/binwalk.py b/aperisolve/analyzers/binwalk.py index 908f713..32d6fec 100644 --- a/aperisolve/analyzers/binwalk.py +++ b/aperisolve/analyzers/binwalk.py @@ -43,7 +43,7 @@ def analyze_binwalk(input_img: Path, output_dir: Path) -> None: # Remove the extracted directory if extracted_dir.exists(): shutil.rmtree(extracted_dir) - + if len(stderr) > 0: err = { "binwalk": { diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index 1a8f5e7..05d3645 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -1,7 +1,6 @@ """Image size bruteforcer for common PNG sizes.""" import zlib -import shutil from pathlib import Path from .utils import update_data @@ -10,75 +9,128 @@ 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), - + (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), - + (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), - + (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), - + (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), - + (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), - + (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), - + (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), - + (1080, 1080), + (1080, 1350), + (1080, 1920), # Facebook - (1200, 628), (1200, 1200), - + (1200, 628), + (1200, 1200), # Twitter/X - (1200, 675), (1500, 500), - + (1200, 675), + (1500, 500), # YouTube Thumbnails - (1280, 720), (1920, 1080), - + (1280, 720), + (1920, 1080), # ------------------------- # Banners / Headers # ------------------------- - (1600, 500), (1920, 500), (2560, 700), - (3000, 1000), (3840, 1200), - + (1600, 500), + (1920, 500), + (2560, 700), + (3000, 1000), + (3840, 1200), # ------------------------- # Print-like Digital Sizes # ------------------------- @@ -87,10 +139,12 @@ EXPECTED_SIZES = [ (3508, 4961), # A3 @ 300 DPI (4961, 7016), # A2 @ 300 DPI (7016, 9933), # A1 @ 300 DPI - ] -def calc_checksum(header_chunk, width_bytes, height_bytes): + +def calc_checksum( + header_chunk: bytearray, width_bytes: bytearray, height_bytes: bytearray +) -> bytearray: """ Calculates the CRC32 of the IHDR chunk with new dimensions. header_chunk: The full IHDR chunk (Type + Data). @@ -99,7 +153,8 @@ def calc_checksum(header_chunk, width_bytes, height_bytes): 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')) + return bytearray((zlib.crc32(new_header) & 0xFFFFFFFF).to_bytes(4, byteorder="big")) + def analyze_image_resize(input_img: Path, output_dir: Path) -> None: """ @@ -122,20 +177,23 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: b = bytearray(f) # Find IHDR start - IHDR = b.find(b'\x49\x48\x44\x52') + 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."}}) + 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 + 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] + target_crc = b[IHDR + chunk_length : IHDR + chunk_length + 4] # Isolate the header chunk (Type + Data) - header_chunk = b[IHDR:IHDR + chunk_length] + header_chunk = b[IHDR : IHDR + chunk_length] logs.append(f"Target CRC found: 0x{target_crc.hex()}") @@ -146,8 +204,8 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: 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')) + 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): @@ -162,14 +220,16 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: 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:] + 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 + break # Stop looking after finding the match if not match_found: logs.append("Failure: No matching dimensions found.") @@ -178,7 +238,7 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: output_data = { "image_resize": { # Key name must match your module name in AperiSolve "status": "ok", - "output": logs + "output": logs, } } if recovered_image: @@ -187,4 +247,4 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: except Exception as e: update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) - return None \ No newline at end of file + return None From d86416c97d508c14fdda12297fb510d6e7080b13 Mon Sep 17 00:00:00 2001 From: Zeecka Date: Wed, 10 Dec 2025 22:30:46 +0100 Subject: [PATCH 11/11] Lint part 2 --- aperisolve/analyzers/image_resize.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index 05d3645..c9ed704 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -177,8 +177,8 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: b = bytearray(f) # Find IHDR start - IHDR = b.find(b"\x49\x48\x44\x52") - if IHDR == -1: + ihdr = b.find(b"\x49\x48\x44\x52") + if ihdr == -1: logs.append("Failure: PNG header (IHDR) not found.") update_data( output_dir, @@ -187,13 +187,13 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: return # Calculate Chunk Length (The 4 bytes BEFORE IHDR tag) - chunk_length = int.from_bytes((b[IHDR - 4 : IHDR]), byteorder="big") + 4 + 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] + target_crc = b[ihdr + chunk_length : ihdr + chunk_length + 4] # Isolate the header chunk (Type + Data) - header_chunk = b[IHDR : IHDR + chunk_length] + header_chunk = b[ihdr : ihdr + chunk_length] logs.append(f"Target CRC found: 0x{target_crc.hex()}") @@ -221,7 +221,7 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] full_png_data = ( - b[: IHDR + 4] + width_bytes + height_bytes + b[IHDR + 12 :] + b[: ihdr + 4] + width_bytes + height_bytes + b[ihdr + 12 :] ) # Write the recovered file to disk @@ -247,4 +247,3 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: except Exception as e: update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) - return None