Merge pull request #117 from Zeecka/main

## What's Changed
* CRC Database lookup for image_resize.py, fix #96 by @Philip-Zimmermann in https://github.com/Zeecka/AperiSolve/pull/106
* OpenStego by @Zeecka in https://github.com/Zeecka/AperiSolve/pull/111
* fix #77 threejs on cpu by @Zeecka in https://github.com/Zeecka/AperiSolve/pull/112
* fix 69 by @Zeecka in https://github.com/Zeecka/AperiSolve/pull/116


**Full Changelog**: https://github.com/Zeecka/AperiSolve/compare/3.0.7...3.1.0
This commit is contained in:
Zeecka
2025-12-28 18:42:16 +01:00
committed by GitHub
15 changed files with 275 additions and 64 deletions
+1
View File
@@ -2,3 +2,4 @@
__pycache__/ __pycache__/
.mypy_cache/ .mypy_cache/
results/ results/
.env
+19 -5
View File
@@ -2,20 +2,34 @@ FROM python:3.14-slim AS build
WORKDIR / WORKDIR /
# Install dependencies # Install build tools and dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
wget \
default-jre \
ruby \
zip \ zip \
7zip \ 7zip
# Install steganography and forensics tools
RUN apt-get update && apt-get install -y \
binwalk \ binwalk \
foremost \ foremost \
exiftool \ exiftool \
steghide \ steghide \
ruby \
binutils \ binutils \
outguess \ outguess \
pngcheck \ pngcheck \
&& gem install zsteg \ && gem install zsteg
&& apt-get clean
# Install OpenStego
RUN wget https://github.com/syvaidya/openstego/releases/download/openstego-0.8.6/openstego_0.8.6-1_all.deb -O /tmp/openstego.deb && \
dpkg -i /tmp/openstego.deb && \
rm /tmp/openstego.deb
# Clean up apt cache and remove useless packages
RUN apt-get remove -y wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY aperisolve/ /aperisolve/ COPY aperisolve/ /aperisolve/
+40 -30
View File
@@ -20,7 +20,6 @@ Support Aperi'Solve:
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" height="40" /> <img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" height="40" />
</a> </a>
--- ---
Aperi'Solve is an open-source steganalysis web platform that performs automated analysis on images to detect and extract hidden data using common steganography tools and techniques. Aperi'Solve is an open-source steganalysis web platform that performs automated analysis on images to detect and extract hidden data using common steganography tools and techniques.
@@ -41,14 +40,15 @@ Table of contents
- Visualize each bit layer (LSB and other layers) per image channel (R/G/B/Alpha). - Visualize each bit layer (LSB and other layers) per image channel (R/G/B/Alpha).
- Browse and download each bit-layer image. - Browse and download each bit-layer image.
- Integrates and displays outputs from: - Integrates and displays outputs from:
- zsteg (LSB text/data extraction) - [zsteg](https://github.com/zed-0xff/zsteg) (LSB text/data extraction)
- steghide (extraction with password) - [steghide](https://steghide.sourceforge.net/) (extraction with password)
- outguess (extraction with password) - [outguess](https://www.rbcafe.com/software/outguess/) (extraction with password)
- exiftool (metadata and geolocation) - [openstego](https://www.openstego.com/) (extraction with password)
- binwalk (embedded archives) - [exiftool](https://exiftool.org/) (metadata and geolocation)
- foremost (carved files) - [binwalk](https://github.com/ReFirmLabs/binwalk) (embedded archives)
- pngcheck - [foremost](https://foremost.sourceforge.net/) (carved files)
- strings - [pngcheck](https://www.libpng.org/pub/png/apps/pngcheck.html)
- [strings](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/strings.html)
- Worker queue architecture for offloading heavy/slow analyzers (Redis + background workers). - Worker queue architecture for offloading heavy/slow analyzers (Redis + background workers).
- Results stored for later browsing and download. - Results stored for later browsing and download.
@@ -86,10 +86,12 @@ docker exec -it aperisolve-web bash
docker exec -it postgres psql -U aperiuser -d aperisolve docker exec -it postgres psql -U aperiuser -d aperisolve
``` ```
Note: If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts: > [!WARNING]
```bash > If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts:
rm -rf aperisolve/results > ```bash
``` > rm -rf aperisolve/results
> ```
### Adding a new analyzer ### Adding a new analyzer
Adding a custom analyzer is straightforward: Adding a custom analyzer is straightforward:
@@ -128,9 +130,9 @@ Adding a custom analyzer is straightforward:
4. Test locally: run the worker and submit jobs to ensure outputs are produced and displayed. 4. Test locally: run the worker and submit jobs to ensure outputs are produced and displayed.
Tips: > [!TIP]
- Keep analyzers idempotent and write outputs to the provided `results_dir`. > - Keep analyzers idempotent and write outputs to the provided `results_dir`.
- Return structured JSON so the frontend can render links/downloads automatically. > - Return structured JSON so the frontend can render links/downloads automatically.
## Configuration & environment variables ## Configuration & environment variables
@@ -146,7 +148,8 @@ Main environment variables (examples):
- SECRET_KEY=change_me - SECRET_KEY=change_me
- FLASK_ENV=production/development - FLASK_ENV=production/development
If using Docker Compose, defaults are set in the compose files. For production deployments, set secure secrets via your orchestrator or environment. > [!NOTE]
> If using Docker Compose, defaults are set in the compose files. For production deployments, set secure secrets via your orchestrator or environment.
## Architecture ## Architecture
@@ -168,11 +171,17 @@ This separation keeps heavy tools (binwalk, foremost, zsteg, etc.) isolated and
## Roadmap ## Roadmap
- [ ] zsteg: full extraction (--all) and download of discovered files (mp3, etc.) - [ ] **[Bug]** Error 500 when file already exist on disc
- [ ] Mobile-friendly UI / test on mobile - [ ] **[Bug]** Multiple bug on buggy Jpeg filebugSomething isn't working
- [ ] i18n (internationalization) - [ ] **[Bug]** Binwalk Extractor errorbugSomething isn't working
- [ ] Rootless / unprivileged analyzers - [ ] **[Bug]** Grayscale on a RGB image ?bugSomething isn't working
- [ ] Improve analyzer sandboxing (e.g., per-analyzer containers) - [ ] **[Feature]** Implement healthcheck and auto reboot
- [ ] **[Feature]** Implement Sentry for better error handling
- [ ] **[Feature]** Zsteg: full extraction (--all) and download of discovered files (mp3, etc.)
- [ ] **[Feature]** Mobile-friendly UI / test on mobile
- [ ] **[Feature]** i18n (internationalization)
- [ ] **[Feature]** Rootless / unprivileged analyzers
- [ ] **[Feature]** Improve analyzer sandboxing (e.g., per-analyzer containers)
## Contributing ## Contributing
@@ -181,20 +190,21 @@ Contributions are welcome! Please:
2. Create a feature branch 2. Create a feature branch
3. Open a pull request describing your change 3. Open a pull request describing your change
Please follow the code style and run linters before submitting. > [!IMPORTANT]
The project adheres to: > Please follow the code style and run linters before submitting. The project adheres to:
- Black > - Black
- Flake8 (ignoring E203, E501, W503) > - Flake8 (ignoring E203, E501, W503)
- Pylint (ignoring W0718, R0903, R0801) > - Pylint (ignoring W0718, R0903, R0801)
- Mypy (ignoring unused-awaitable) > - Mypy (ignoring unused-awaitable)
CI will run these checks on each PR. CI will run these checks on each PR.
## Security ## Security
- If you discover a security issue, please report it privately to the repository owner instead of opening a public issue. > [!WARNING]
> If you discover a security issue, please report it privately to the repository owner instead of opening a public issue.
## Credits ## Credits
Acknowledgements: Acknowledgements:
- Thanks to contributors and the open-source community for the tools integrated (zsteg, steghide, binwalk, foremost, exiftool). - Thanks to contributors and the open-source community for the tools integrated ([zsteg](https://github.com/zed-0xff/zsteg), [steghide](https://steghide.sourceforge.net/), [outguess](https://www.rbcafe.com/software/outguess/), [openstego](https://www.openstego.com/), [exiftool](https://exiftool.org/), [binwalk](https://github.com/ReFirmLabs/binwalk), [foremost](https://foremost.sourceforge.net/), [pngcheck](https://www.libpng.org/pub/png/apps/pngcheck.html), [strings](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/strings.html), ...).
+18 -9
View File
@@ -11,6 +11,12 @@ from .utils import update_data
def analyze_decomposer(input_img: Path, output_dir: Path) -> None: def analyze_decomposer(input_img: Path, output_dir: Path) -> None:
"""Analyze an image submission using bits decomposition.""" """Analyze an image submission using bits decomposition."""
img = Image.open(input_img) img = Image.open(input_img)
converted = False
if img.mode == "P":
img = img.convert("RGB")
converted = True
img_np = np.array(img) img_np = np.array(img)
# Handle grayscale, RGB, RGBA, etc. # Handle grayscale, RGB, RGBA, etc.
@@ -58,12 +64,15 @@ def analyze_decomposer(input_img: Path, output_dir: Path) -> None:
bit_img.save(out_path) bit_img.save(out_path)
image_json[channel_label] = channel_json image_json[channel_label] = channel_json
update_data( output = {
output_dir, "decomposer": {
{ "status": "ok",
"decomposer": { "images": image_json,
"status": "ok", }
"images": image_json, }
} if converted:
}, output["decomposer"][
) "note"
] = "Image contains a color palette and was converted to RGB for processing."
update_data(output_dir, output)
+12 -9
View File
@@ -18,21 +18,24 @@ def analyze_exiftool(input_img: Path, output_dir: Path) -> None:
timeout=MAX_PENDING_TIME, timeout=MAX_PENDING_TIME,
) )
if data.stderr:
update_data(
output_dir, {"exiftool": {"status": "error", "error": data.stderr}}
)
return
metadata: dict[str, str] = {} metadata: dict[str, str] = {}
for line in data.stdout.split("\n"): for line in data.stdout.split("\n"):
if ":" in line: if ":" in line:
key, value = line.split(":", 1) key, value = line.split(":", 1)
metadata[key.strip()] = value.strip() metadata[key.strip()] = value.strip()
# exiftool_dir = output_dir / "exiftool" if data.stderr:
# exiftool_dir.mkdir(parents=True, exist_ok=True) update_data(
# update_data(exiftool_dir, metadata, "data.json") output_dir,
{
"exiftool": {
"status": "error",
"output": metadata,
"error": data.stderr,
}
},
)
return
update_data( update_data(
output_dir, output_dir,
+11
View File
@@ -146,6 +146,17 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None:
logs.append(f"Target CRC found: 0x{png.crc:08x}") logs.append(f"Target CRC found: 0x{png.crc:08x}")
if png.crc == png.compute_crc():
note = (
f"PNG is already valid with dimensions {png.width}x{png.height} "
f"and crc 0x{png.crc:08x}."
)
output_no_compute: dict[str, Any] = {
"image_resize": {"status": "ok", "note": note}
}
update_data(output_dir, output_no_compute)
return
saved_img_urls = [] saved_img_urls = []
db_matches = lookup_crc(png) db_matches = lookup_crc(png)
+114
View File
@@ -0,0 +1,114 @@
"""OpenStego Analyzer for Image Submissions."""
import shutil
import subprocess
from pathlib import Path
from .utils import MAX_PENDING_TIME, update_data
def analyze_openstego(input_img: Path, output_dir: Path, password: str = "") -> None:
"""Analyze an image submission using openstego."""
image_name = input_img.name
try:
stderr = ""
# Try to extract the embedded file
extracted_dir = output_dir / "openstego"
extracted_dir.mkdir(parents=True, exist_ok=True)
for algo in ["AES128", "AES256"]:
cmd: list[str] = [
"openstego",
"extract",
"-a",
"randomlsb",
"--cryptalgo",
algo,
"-sf",
"../" + str(image_name),
"-xd",
str(extracted_dir),
"-p",
password,
]
data = subprocess.run(
cmd,
cwd=output_dir,
capture_output=True,
text=True,
check=False,
timeout=MAX_PENDING_TIME,
)
# Check if extraction was successful
if data.returncode != 0:
stderr += data.stderr.replace(
f'"../{image_name}" ', ""
) # hide file name
err = {
"openstego": {
"status": "error",
"error": stderr,
}
}
update_data(output_dir, err)
return None
# Find the extracted file
extracted_files = list(extracted_dir.glob("*"))
if not extracted_files:
err = {
"openstego": {
"status": "error",
"error": "No file extracted, password may be incorrect.",
}
}
update_data(output_dir, err)
return None
# Filter stdout for success messages
stdout = []
for f in extracted_files:
stdout.append(f"Recovered file: {f.name}")
# Zip extracted files
zip_data = subprocess.run(
["7z", "a", "../openstego.7z", "*"],
cwd=extracted_dir,
capture_output=True,
text=True,
check=False,
timeout=MAX_PENDING_TIME,
)
stderr += zip_data.stderr
if len(stderr) > 0:
err = {
"openstego": {
"status": "error",
"error": stderr,
}
}
update_data(output_dir, err)
return None
# Remove the extracted directory
shutil.rmtree(extracted_dir)
update_data(
output_dir,
{
"openstego": {
"status": "ok",
"output": stdout,
"download": f"/download/{output_dir.name}/openstego",
}
},
)
except Exception as e:
update_data(output_dir, {"openstego": {"status": "error", "error": str(e)}})
return None
+13
View File
@@ -24,6 +24,19 @@ def analyze_pngcheck(input_img: Path, output_dir: Path) -> None:
# ) # )
# return # return
if "this is neither a PNG or JNG image nor a MNG stream" in data.stdout:
update_data(
output_dir,
{
"pngcheck": {
"status": "error",
"error": "ERRORS DETECTED: this is neither a PNG or JNG image nor a "
"MNG stream.",
}
},
)
return
data_pngcheck = data.stdout.split("\n") if data else [] data_pngcheck = data.stdout.split("\n") if data else []
data_pngcheck = [s for s in data_pngcheck if s] data_pngcheck = [s for s in data_pngcheck if s]
+12 -5
View File
@@ -180,17 +180,24 @@ class PNG:
self.height = size[1] self.height = size[1]
self.bit_depth = bit_depth self.bit_depth = bit_depth
self.color_type = color_type self.color_type = color_type
self.crc = ( self.interlace = interlace
crc self.crc = crc or self.compute_crc()
or zlib.crc32(
def compute_crc(self) -> int:
"""Compute the CRC checksum for the PNG IHDR chunk.
Returns:
int: The computed CRC checksum.
"""
return (
zlib.crc32(
b"IHDR" b"IHDR"
+ self.width.to_bytes(4, "big") + self.width.to_bytes(4, "big")
+ self.height.to_bytes(4, "big") + self.height.to_bytes(4, "big")
+ bytes((bit_depth, color_type, 0, 0, interlace)) + bytes((self.bit_depth, self.color_type, 0, 0, self.interlace))
) )
& 0xFFFFFFFF & 0xFFFFFFFF
) )
self.interlace = interlace
@classmethod @classmethod
def from_packed(cls, packed: int, crc: int | None = None) -> Self: def from_packed(cls, packed: int, crc: int | None = None) -> Self:
+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", "image_resize"] WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize", "openstego"]
RESULT_FOLDER = Path(__file__).parent.resolve() / "results" RESULT_FOLDER = Path(__file__).parent.resolve() / "results"
+8 -1
View File
@@ -6,6 +6,7 @@ const TOOL_ORDER = [
"outguess", "outguess",
"pngcheck", "pngcheck",
"steghide", "steghide",
"openstego",
"zsteg", "zsteg",
"strings", "strings",
"image_resize", "image_resize",
@@ -326,9 +327,15 @@ function parseResult(result) {
result[tool]["download"] result[tool]["download"]
)}" target="_blank" class="btn btn-primary mt-2"><i class="fa fa-download"></i> Download file</a>`; )}" target="_blank" class="btn btn-primary mt-2"><i class="fa fa-download"></i> Download file</a>`;
} }
} else if (result[tool]["status"] === "error") { }
if (result[tool]["error"] && (/[^\s]/.test(result[tool]["error"]))) {
showDanger(result[tool]["error"]); showDanger(result[tool]["error"]);
} }
if (result[tool]["note"] && (/[^\s]/.test(result[tool]["note"]))) {
showInfo(result[tool]["note"]);
}
} }
} }
+22 -2
View File
@@ -13,7 +13,27 @@ const tmpQ = new THREE.Quaternion();
const tmpM = new THREE.Matrix4(); const tmpM = new THREE.Matrix4();
const currentM = new THREE.Matrix4(); const currentM = new THREE.Matrix4();
init();
function isSoftwareRenderer() {
const canvas = document.createElement("canvas");
const gl =
canvas.getContext("webgl") ||
canvas.getContext("experimental-webgl");
if (!gl) return true;
const ext = gl.getExtension("WEBGL_debug_renderer_info");
if (!ext) return false;
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
return /swiftshader|software/i.test(renderer);
}
if (isSoftwareRenderer()) {
console.warn("Software rendering detected — disabling Three.js");
} else {
init();
}
function init() { function init() {
container = document.getElementById("three-bg"); container = document.getElementById("three-bg");
@@ -163,5 +183,5 @@ function animate() {
renderer.render(scene, camera); renderer.render(scene, camera);
stats.update(); //stats.update();
} }
+2
View File
@@ -9,6 +9,7 @@ from .analyzers.decomposer import analyze_decomposer
from .analyzers.exiftool import analyze_exiftool from .analyzers.exiftool import analyze_exiftool
from .analyzers.foremost import analyze_foremost from .analyzers.foremost import analyze_foremost
from .analyzers.image_resize import analyze_image_resize from .analyzers.image_resize import analyze_image_resize
from .analyzers.openstego import analyze_openstego
from .analyzers.outguess import analyze_outguess from .analyzers.outguess import analyze_outguess
from .analyzers.pngcheck import analyze_pngcheck from .analyzers.pngcheck import analyze_pngcheck
from .analyzers.steghide import analyze_steghide from .analyzers.steghide import analyze_steghide
@@ -77,6 +78,7 @@ def analyze_image(submission_hash: str) -> None:
(analyze_strings, img_path, result_path), (analyze_strings, img_path, result_path),
(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_openstego, 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), (analyze_image_resize, img_path, result_path),
] ]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB