diff --git a/.gitignore b/.gitignore
index 646de31..5482ea4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
__pycache__/
.mypy_cache/
results/
+.env
diff --git a/Dockerfile b/Dockerfile
index 5e4eb5c..fe69aa9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,20 +2,34 @@ FROM python:3.14-slim AS build
WORKDIR /
-# Install dependencies
+# Install build tools and dependencies
RUN apt-get update && apt-get install -y \
+ wget \
+ default-jre \
+ ruby \
zip \
- 7zip \
+ 7zip
+
+# Install steganography and forensics tools
+RUN apt-get update && apt-get install -y \
binwalk \
foremost \
exiftool \
steghide \
- ruby \
binutils \
outguess \
pngcheck \
- && gem install zsteg \
- && apt-get clean
+ && gem install zsteg
+
+# 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/
@@ -25,4 +39,4 @@ RUN pip install --no-cache-dir -r /aperisolve/requirements.txt
ENV PYTHONUNBUFFERED=1
-CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "--capture-output", "aperisolve.wsgi:application"]
\ No newline at end of file
+CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "--capture-output", "aperisolve.wsgi:application"]
diff --git a/README.md b/README.md
index 6f7f2eb..467109a 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,6 @@ Support Aperi'Solve:
-
---
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).
- Browse and download each bit-layer image.
- Integrates and displays outputs from:
- - zsteg (LSB text/data extraction)
- - steghide (extraction with password)
- - outguess (extraction with password)
- - exiftool (metadata and geolocation)
- - binwalk (embedded archives)
- - foremost (carved files)
- - pngcheck
- - strings
+ - [zsteg](https://github.com/zed-0xff/zsteg) (LSB text/data extraction)
+ - [steghide](https://steghide.sourceforge.net/) (extraction with password)
+ - [outguess](https://www.rbcafe.com/software/outguess/) (extraction with password)
+ - [openstego](https://www.openstego.com/) (extraction with password)
+ - [exiftool](https://exiftool.org/) (metadata and geolocation)
+ - [binwalk](https://github.com/ReFirmLabs/binwalk) (embedded archives)
+ - [foremost](https://foremost.sourceforge.net/) (carved files)
+ - [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).
- 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
```
-Note: If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts:
-```bash
-rm -rf aperisolve/results
-```
+> [!WARNING]
+> If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts:
+> ```bash
+> rm -rf aperisolve/results
+> ```
+
### Adding a new analyzer
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.
-Tips:
-- Keep analyzers idempotent and write outputs to the provided `results_dir`.
-- Return structured JSON so the frontend can render links/downloads automatically.
+> [!TIP]
+> - Keep analyzers idempotent and write outputs to the provided `results_dir`.
+> - Return structured JSON so the frontend can render links/downloads automatically.
## Configuration & environment variables
@@ -146,7 +148,8 @@ Main environment variables (examples):
- SECRET_KEY=change_me
- 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
@@ -168,11 +171,17 @@ This separation keeps heavy tools (binwalk, foremost, zsteg, etc.) isolated and
## Roadmap
-- [ ] zsteg: full extraction (--all) and download of discovered files (mp3, etc.)
-- [ ] Mobile-friendly UI / test on mobile
-- [ ] i18n (internationalization)
-- [ ] Rootless / unprivileged analyzers
-- [ ] Improve analyzer sandboxing (e.g., per-analyzer containers)
+- [ ] **[Bug]** Error 500 when file already exist on disc
+- [ ] **[Bug]** Multiple bug on buggy Jpeg filebugSomething isn't working
+- [ ] **[Bug]** Binwalk Extractor errorbugSomething isn't working
+- [ ] **[Bug]** Grayscale on a RGB image ?bugSomething isn't working
+- [ ] **[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
@@ -181,20 +190,21 @@ Contributions are welcome! Please:
2. Create a feature branch
3. Open a pull request describing your change
-Please follow the code style and run linters before submitting.
-The project adheres to:
-- Black
-- Flake8 (ignoring E203, E501, W503)
-- Pylint (ignoring W0718, R0903, R0801)
-- Mypy (ignoring unused-awaitable)
+> [!IMPORTANT]
+> Please follow the code style and run linters before submitting. The project adheres to:
+> - Black
+> - Flake8 (ignoring E203, E501, W503)
+> - Pylint (ignoring W0718, R0903, R0801)
+> - Mypy (ignoring unused-awaitable)
CI will run these checks on each PR.
## 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
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), ...).
diff --git a/aperisolve/analyzers/decomposer.py b/aperisolve/analyzers/decomposer.py
index b923fed..e6440d3 100644
--- a/aperisolve/analyzers/decomposer.py
+++ b/aperisolve/analyzers/decomposer.py
@@ -11,6 +11,12 @@ from .utils import update_data
def analyze_decomposer(input_img: Path, output_dir: Path) -> None:
"""Analyze an image submission using bits decomposition."""
img = Image.open(input_img)
+ converted = False
+
+ if img.mode == "P":
+ img = img.convert("RGB")
+ converted = True
+
img_np = np.array(img)
# Handle grayscale, RGB, RGBA, etc.
@@ -58,12 +64,15 @@ def analyze_decomposer(input_img: Path, output_dir: Path) -> None:
bit_img.save(out_path)
image_json[channel_label] = channel_json
- update_data(
- output_dir,
- {
- "decomposer": {
- "status": "ok",
- "images": image_json,
- }
- },
- )
+ output = {
+ "decomposer": {
+ "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)
diff --git a/aperisolve/analyzers/exiftool.py b/aperisolve/analyzers/exiftool.py
index 23ec7bd..e13e2e9 100644
--- a/aperisolve/analyzers/exiftool.py
+++ b/aperisolve/analyzers/exiftool.py
@@ -18,21 +18,24 @@ def analyze_exiftool(input_img: Path, output_dir: Path) -> None:
timeout=MAX_PENDING_TIME,
)
- if data.stderr:
- update_data(
- output_dir, {"exiftool": {"status": "error", "error": data.stderr}}
- )
- return
-
metadata: dict[str, str] = {}
for line in data.stdout.split("\n"):
if ":" in line:
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip()
- # exiftool_dir = output_dir / "exiftool"
- # exiftool_dir.mkdir(parents=True, exist_ok=True)
- # update_data(exiftool_dir, metadata, "data.json")
+ if data.stderr:
+ update_data(
+ output_dir,
+ {
+ "exiftool": {
+ "status": "error",
+ "output": metadata,
+ "error": data.stderr,
+ }
+ },
+ )
+ return
update_data(
output_dir,
diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py
index b7b4589..bf30897 100644
--- a/aperisolve/analyzers/image_resize.py
+++ b/aperisolve/analyzers/image_resize.py
@@ -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}")
+ 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 = []
db_matches = lookup_crc(png)
diff --git a/aperisolve/analyzers/openstego.py b/aperisolve/analyzers/openstego.py
new file mode 100644
index 0000000..597356a
--- /dev/null
+++ b/aperisolve/analyzers/openstego.py
@@ -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
diff --git a/aperisolve/analyzers/pngcheck.py b/aperisolve/analyzers/pngcheck.py
index 5c9a945..a398bd5 100644
--- a/aperisolve/analyzers/pngcheck.py
+++ b/aperisolve/analyzers/pngcheck.py
@@ -24,6 +24,19 @@ def analyze_pngcheck(input_img: Path, output_dir: Path) -> None:
# )
# 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 = [s for s in data_pngcheck if s]
diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py
index d17adf7..da86987 100644
--- a/aperisolve/analyzers/utils.py
+++ b/aperisolve/analyzers/utils.py
@@ -180,17 +180,24 @@ class PNG:
self.height = size[1]
self.bit_depth = bit_depth
self.color_type = color_type
- self.crc = (
- crc
- or zlib.crc32(
+ self.interlace = interlace
+ self.crc = crc or self.compute_crc()
+
+ 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"
+ self.width.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
)
- self.interlace = interlace
@classmethod
def from_packed(cls, packed: int, crc: int | None = None) -> Self:
diff --git a/aperisolve/config.py b/aperisolve/config.py
index 117e852..96016c3 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", "image_resize"]
+WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize", "openstego"]
RESULT_FOLDER = Path(__file__).parent.resolve() / "results"
diff --git a/aperisolve/static/js/aperisolve.js b/aperisolve/static/js/aperisolve.js
index 2867666..54ecf94 100644
--- a/aperisolve/static/js/aperisolve.js
+++ b/aperisolve/static/js/aperisolve.js
@@ -6,6 +6,7 @@ const TOOL_ORDER = [
"outguess",
"pngcheck",
"steghide",
+ "openstego",
"zsteg",
"strings",
"image_resize",
@@ -286,7 +287,7 @@ function parseResult(result) {
}
// Parse text output
-
+
if (typeof result[tool]["output"] === "string") {
analyzer.innerHTML += `
${escapeHtml(
@@ -326,9 +327,15 @@ function parseResult(result) {
result[tool]["download"]
)}" target="_blank" class="btn btn-primary mt-2"> Download file`;
}
- } else if (result[tool]["status"] === "error") {
+ }
+
+ if (result[tool]["error"] && (/[^\s]/.test(result[tool]["error"]))) {
showDanger(result[tool]["error"]);
}
+
+ if (result[tool]["note"] && (/[^\s]/.test(result[tool]["note"]))) {
+ showInfo(result[tool]["note"]);
+ }
}
}
diff --git a/aperisolve/static/js/background.js b/aperisolve/static/js/background.js
index 9fc6eb6..bcdc421 100644
--- a/aperisolve/static/js/background.js
+++ b/aperisolve/static/js/background.js
@@ -13,7 +13,27 @@ const tmpQ = new THREE.Quaternion();
const tmpM = 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() {
container = document.getElementById("three-bg");
@@ -163,5 +183,5 @@ function animate() {
renderer.render(scene, camera);
- stats.update();
+ //stats.update();
}
diff --git a/aperisolve/workers.py b/aperisolve/workers.py
index acb6b8e..c5be8c7 100644
--- a/aperisolve/workers.py
+++ b/aperisolve/workers.py
@@ -9,6 +9,7 @@ from .analyzers.decomposer import analyze_decomposer
from .analyzers.exiftool import analyze_exiftool
from .analyzers.foremost import analyze_foremost
from .analyzers.image_resize import analyze_image_resize
+from .analyzers.openstego import analyze_openstego
from .analyzers.outguess import analyze_outguess
from .analyzers.pngcheck import analyze_pngcheck
from .analyzers.steghide import analyze_steghide
@@ -77,6 +78,7 @@ def analyze_image(submission_hash: str) -> None:
(analyze_strings, img_path, result_path),
(analyze_pngcheck, img_path, result_path),
(analyze_steghide, img_path, result_path, submission.password),
+ (analyze_openstego, img_path, result_path, submission.password),
(analyze_zsteg, img_path, result_path),
(analyze_image_resize, img_path, result_path),
]
diff --git a/examples/example3.png b/examples/example3.png
new file mode 100644
index 0000000..843ad8c
Binary files /dev/null and b/examples/example3.png differ
diff --git a/examples/size_change_png.png b/examples/size_change_png.png
deleted file mode 100644
index 064c96a..0000000
Binary files a/examples/size_change_png.png and /dev/null differ