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 / WORKDIR /
+26 -21
View File
@@ -10,6 +10,12 @@
<b>Try it now: https://www.aperisolve.com</b> <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? ## What is Aperi'Solve?
Aperi'Solve is a platform which performs steganalysis on images.<br/> 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? ### 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 ### 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: The platform allows you to:
- **Visualise each bit layer** of each channel for a given image (ie. LSB of Red channel). - **Visualize each bit layer** of each channel for a given image (ie. LSB of Red channel).
- **Browse** and **Download each bit layer image**. - **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 `steghide` files** using a defined password
- **Download `outguess` files** using a defined password - **Download `outguess` files** using a defined password
- **Visualise `exiftool` informations** such as geolocation or author - **Visualise `exiftool` information** such as geolocation or author
- **Visualise `binwalk` informations** - **Visualise `binwalk` information**
- **Download `binwalk` files** such as zip in png headers - **Download `binwalk` files** such as ZIP file in PNG headers
- **Download `foremost` files** such as zip in png headers - **Download `foremost` files** such as ZIP file in PNG headers
- **Visualise `pngcheck` output** - **Visualise `pngcheck` output**
- **Visualise `strings` output** - **Visualise `strings` output**
## Installation ## 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 ```bash
git clone https://github.com/Zeecka/AperiSolve.git git clone https://github.com/Zeecka/AperiSolve.git
@@ -54,32 +60,32 @@ docker compose up -d
## Development ## Development
Aperi'Solve platform is a *Flask* web service (python 3.11+) with python workers performing analysis. 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. Jobs are stacked in a redis-queue, and image information is stored in a postgresql database.
All services are well contained inside Docker containers. All services run inside Docker containers.
### Adding new analyzer ### Adding new analyzer
Adding new analyzer is easy. 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). 2. Add this analyzer to [workers.py](aperisolve/workers.py).
- Import your function `from .analyzers.analyzer import analyze_myanalyzer` - 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). 3. Add your analyzer name to `TOOL_ORDER` in [aperisolve.js](aperisolve/static/js/aperisolve.js).
### Tips ### Tips
Here is a batch of usefull commands. Here is a batch of useful commands.
```bash ```bash
docker compose down -v # Remove volume containings results docker compose down -v # Remove the 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 rm -rf aperisolve/results # Remove results from development mode
docker exec -it postgres psql -U aperiuser -d aperisolve # Get postgresql shell 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 ### Code quality
@@ -94,7 +100,6 @@ GitHub CI will alert in case of failure.
## Roadmap ## Roadmap
- [x] PngCheck
- [ ] Zsteg extract / --all --> **Download `zsteg` files** such as mp3 encoded on LSB - [ ] Zsteg extract / --all --> **Download `zsteg` files** such as mp3 encoded on LSB
- [ ] Test on mobile - [ ] Test on mobile
- [ ] i18n - [ ] i18n
+4 -4
View File
@@ -40,6 +40,10 @@ def analyze_binwalk(input_img: Path, output_dir: Path) -> None:
zip_exist = True zip_exist = True
stderr += zip_data.stderr stderr += zip_data.stderr
# Remove the extracted directory
if extracted_dir.exists():
shutil.rmtree(extracted_dir)
if len(stderr) > 0: if len(stderr) > 0:
err = { err = {
"binwalk": { "binwalk": {
@@ -50,10 +54,6 @@ def analyze_binwalk(input_img: Path, output_dir: Path) -> None:
update_data(output_dir, err) update_data(output_dir, err)
return None return None
# Remove the extracted directory
if extracted_dir.exists():
shutil.rmtree(extracted_dir)
output_data = { output_data = {
"binwalk": { "binwalk": {
"status": "ok", "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 from pathlib import Path
IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"] 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" RESULT_FOLDER = Path(__file__).parent.resolve() / "results"
+18 -3
View File
@@ -8,6 +8,7 @@ const TOOL_ORDER = [
"steghide", "steghide",
"zsteg", "zsteg",
"strings", "strings",
"image_resize",
]; ];
function escapeHtml(text) { function escapeHtml(text) {
@@ -248,7 +249,7 @@ function parseResult(result) {
analyzer.className = `analyzer a-${escapeHtml(tool)}`; analyzer.className = `analyzer a-${escapeHtml(tool)}`;
resultDiv.appendChild(analyzer); resultDiv.appendChild(analyzer);
analyzer.innerHTML += `<h2>${capitalize(tool)}</h2>`; analyzer.innerHTML += `<h2>${capitalize(tool.replace("_"," "))}</h2>`;
if (result[tool]["status"] === "ok") { if (result[tool]["status"] === "ok") {
if ("images" in result[tool]) { 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 // Parse text output
if (typeof result[tool]["output"] === "string") { if (typeof result[tool]["output"] === "string") {
analyzer.innerHTML += `<pre>${escapeHtml( analyzer.innerHTML += `<pre>${escapeHtml(
result[tool]["output"] result[tool]["output"]
@@ -367,8 +377,13 @@ function showFilename(filename) {
if (browseBtn) { if (browseBtn) {
// Clicking the button triggers file input // Clicking the button triggers file input
browseBtn.addEventListener("click", () => fileInput.click()); browseBtn.addEventListener("click", (e) => {
dropZone.addEventListener("click", () => fileInput.click()); e.stopPropagation();
fileInput.click();
});
dropZone.addEventListener("click", () => {
fileInput.click();
});
dropZone.addEventListener("dragover", (e) => { dropZone.addEventListener("dragover", (e) => {
e.preventDefault(); e.preventDefault();
dropZone.style.boxShadow = "0 0 20px #9fef00"; dropZone.style.boxShadow = "0 0 20px #9fef00";
+4 -4
View File
@@ -6,8 +6,8 @@
<div> <div>
<div> <div>
This cheatsheet is intended to guide CTF players in their research. This cheatsheet is not representative of 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 modern steganography/steganalysis techniques, and its content does not match with the creation of an interesting
challenges 😉. challenge 😉.
</div> </div>
</div> </div>
<h2>Check file type / metadata</h2> <h2>Check file type / metadata</h2>
@@ -48,7 +48,7 @@
<ul class="fa-ul"> <ul class="fa-ul">
<li>Verify the checksums of the file. These are usually CRC32, some tools like <a <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='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> correct these checksums.</li>
</ul> </ul>
<div class="command"> <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 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. 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> 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' bruteforce of hidden secrets with <a href='http://steghide.sourceforge.net/' target='_blank'
class='highlight'>Steghide</a>.</li> class='highlight'>Steghide</a>.</li>
</ul> </ul>
+2
View File
@@ -13,6 +13,7 @@ from .analyzers.steghide import analyze_steghide
from .analyzers.strings import analyze_strings from .analyzers.strings import analyze_strings
from .analyzers.zsteg import analyze_zsteg from .analyzers.zsteg import analyze_zsteg
from .analyzers.pngcheck import analyze_pngcheck from .analyzers.pngcheck import analyze_pngcheck
from .analyzers.image_resize import analyze_image_resize
from .app import app, db from .app import app, db
from .config import RESULT_FOLDER from .config import RESULT_FOLDER
from .models import Image, Submission from .models import Image, Submission
@@ -53,6 +54,7 @@ def analyze_image(submission_hash: str) -> None:
(analyze_pngcheck, img_path, result_path), (analyze_pngcheck, img_path, result_path),
(analyze_steghide, img_path, result_path, submission.password), (analyze_steghide, img_path, result_path, submission.password),
(analyze_zsteg, img_path, result_path), (analyze_zsteg, img_path, result_path),
(analyze_image_resize, img_path, result_path),
] ]
# Deep analysis only # Deep analysis only
View File