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