Merge branch 'main' into prod

This commit is contained in:
Zeecka
2025-12-10 22:47:56 +01:00
11 changed files with 334 additions and 35 deletions
+28
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim AS build
FROM python:3.14-slim AS build
WORKDIR /
+26 -21
View File
@@ -10,6 +10,12 @@
<b>Try it now: https://www.aperisolve.com</b>
<b> Support Aperi'Solve:</b>
<a href="https://buymeacoffee.com/aperisolve">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" height="40" />
</a>
## What is Aperi'Solve?
Aperi'Solve is a platform which performs steganalysis on images.<br/>
@@ -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
+4 -4
View File
@@ -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",
+249
View File
@@ -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)}})
+1 -1
View File
@@ -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"
+18 -3
View File
@@ -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 += `<h2>${capitalize(tool)}</h2>`;
analyzer.innerHTML += `<h2>${capitalize(tool.replace("_"," "))}</h2>`;
if (result[tool]["status"] === "ok") {
if ("images" in result[tool]) {
@@ -270,8 +271,17 @@ function parseResult(result) {
}
}
}
if ("image" in result[tool]) {
// Parse image output
analyzer.innerHTML += `<div class='results_img'><img src='${escapeHtml(
result[tool]["image"]
)}'/></div>`;
}
// Parse text output
if (typeof result[tool]["output"] === "string") {
analyzer.innerHTML += `<pre>${escapeHtml(
result[tool]["output"]
@@ -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";
+4 -4
View File
@@ -6,8 +6,8 @@
<div>
<div>
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 😉.
</div>
</div>
<h2>Check file type / metadata</h2>
@@ -48,7 +48,7 @@
<ul class="fa-ul">
<li>Verify the checksums of the file. These are usually CRC32, some tools like <a
href='http://www.libpng.org/pub/png/apps/pngcheck.html' target='_blank' class='highlight'>PngCheck</a> or <a
href='https://github.com/sherlly/PCRT' target='_blank' class='highlight'>PCRT</a> are usefull to verify and
href='https://github.com/sherlly/PCRT' target='_blank' class='highlight'>PCRT</a> are useful to verify and
correct these checksums.</li>
</ul>
<div class="command">
@@ -176,7 +176,7 @@ print(pxs[:10])'></div>
in the file (<span class='highlight'>strings</span> & <span class='highlight'>exiftool</span>), or the object
represented by the image. In some cases, the password cannot be retrieved and a bruteforce must be performed.
The tool <a href='https://github.com/Paradoxis/StegCracker' target='_blank' class='highlight'>StegCracker</a>
and <a href='https://github.com/ R4yGM/stegbrute' target='_blank' class='highlight'>Stegbrute</a> allows
and <a href='https://github.com/R4yGM/stegbrute' target='_blank' class='highlight'>Stegbrute</a> allows
bruteforce of hidden secrets with <a href='http://steghide.sourceforge.net/' target='_blank'
class='highlight'>Steghide</a>.</li>
</ul>
+2
View File
@@ -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
View File