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
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..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 -f docker-compose-dev.yml up # Start application in developpement mode
-rm -rf aperisolve/results # Remove results from developpement mode
+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 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 [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) setup.
### Code quality
@@ -94,7 +100,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/binwalk.py b/aperisolve/analyzers/binwalk.py
index e8dafba..32d6fec 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",
diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py
new file mode 100644
index 0000000..c9ed704
--- /dev/null
+++ b/aperisolve/analyzers/image_resize.py
@@ -0,0 +1,249 @@
+"""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: 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).
+ """
+ # 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)}})
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..6e98b57 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(
@@ -367,8 +377,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";
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 @@