Initial commit
@@ -0,0 +1,9 @@
|
|||||||
|
MAX_PENDING_TIME=600
|
||||||
|
MAX_STORE_TIME=259200
|
||||||
|
FLASK_DEBUG=0
|
||||||
|
FLASK_ENV=production
|
||||||
|
DB_URI=postgresql://aperiuser:aperipass@postgres:5432/aperisolve
|
||||||
|
RQ_DASHBOARD_REDIS_URL=redis://redis:6379/0
|
||||||
|
POSTGRES_DB=aperisolve
|
||||||
|
POSTGRES_USER=aperiuser
|
||||||
|
POSTGRES_PASSWORD=aperipass
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Linters
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLAKE8_IGNORE: E203,E501,W503
|
||||||
|
PYLINT_IGNORE: W0718,R0903,R0801
|
||||||
|
MYPY_IGNORE: unused-awaitable
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
checks: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-linters:
|
||||||
|
name: Run linters
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out Git repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
pip install isort black flake8 pylint mypy mypy-extensions
|
||||||
|
pip install -r aperisolve/requirements.txt
|
||||||
|
|
||||||
|
- name: Run linters
|
||||||
|
uses: wearerequired/lint-action@v2
|
||||||
|
with:
|
||||||
|
black: true
|
||||||
|
flake8: true
|
||||||
|
flake8_args: "--ignore=E203,E501,W503"
|
||||||
|
pylint: true
|
||||||
|
pylint_args: "--disable W0511,W0718,R0801,R0903,R0914"
|
||||||
|
mypy: true
|
||||||
|
mypy_args: "--disallow-any-generics --disallow-untyped-def --follow-imports skip --ignore-missing-imports --disable-error-code unused-awaitable"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
docker-releases:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get release version
|
||||||
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: downcase username
|
||||||
|
run: echo "lower_actor=${GITHUB_ACTOR,,}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker images
|
||||||
|
uses: docker/build-push-action@v6.9.0
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ env.lower_actor }}/aperisolve:latest
|
||||||
|
ghcr.io/${{ env.lower_actor }}/aperisolve:${{ env.RELEASE_VERSION }}
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
.mypy_cache/
|
||||||
|
results/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
zip \
|
||||||
|
7zip \
|
||||||
|
binwalk \
|
||||||
|
foremost \
|
||||||
|
exiftool \
|
||||||
|
steghide \
|
||||||
|
ruby \
|
||||||
|
binutils \
|
||||||
|
outguess \
|
||||||
|
pngcheck \
|
||||||
|
&& gem install zsteg \
|
||||||
|
&& apt-get clean
|
||||||
|
|
||||||
|
COPY aperisolve/ /aperisolve/
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r /aperisolve/requirements.txt
|
||||||
|
|
||||||
|
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "aperisolve.wsgi:application"]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Aperi'Solve
|
||||||
|
|
||||||
|
[](https://aperisolve.com/)
|
||||||
|
[](https://inventory.raw.pm/tools.html#Aperi'Solve)
|
||||||
|
|
||||||
|
<p align="center"><a href="https://www.aperisolve.com"><img src="https://raw.githubusercontent.com/Zeecka/AperiSolve/master/examples/screenshot.png"/></a></p>
|
||||||
|
|
||||||
|
<b>Try it now: https://www.aperisolve.com</b>
|
||||||
|
|
||||||
|
## What is Aperi'Solve?
|
||||||
|
|
||||||
|
Aperi'Solve is a platform which performs steganalysis on images.<br/>
|
||||||
|
|
||||||
|
The platform uses *layer analysis*, "*zsteg*", "*steghide*", "*outguess*", "*exiftool*", "*binwalk*", "*foremost*" and "*strings*" for deeper steganography analysis.
|
||||||
|
|
||||||
|
<p align="center"><a href="https://www.aperisolve.com"><img src="https://raw.githubusercontent.com/Zeecka/AperiSolve/master/examples/video.gif"/></a></p>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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`...
|
||||||
|
|
||||||
|
The platform allow you to:
|
||||||
|
- **Visualise 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
|
||||||
|
- **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 `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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Zeecka/AperiSolve.git
|
||||||
|
cd aperisolve
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> Default will run on http://localhost:5000/
|
||||||
|
|
||||||
|
## Developpment
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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".
|
||||||
|
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.
|
||||||
|
3. Add your analyzer name to `TOOL_ORDER` in [aperisolve.js](aperisolve/static/js/aperisolve.js).
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
Here is a batch of usefull 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
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note, if you used [docker-compose-dev.yml](docker-compose-dev.yml), you must remove "results" when switching to [production docker](docker-compose.yml).
|
||||||
|
|
||||||
|
### Code quality
|
||||||
|
|
||||||
|
Python code follows the following linters:
|
||||||
|
|
||||||
|
- Black
|
||||||
|
- Flake8 (ignoring E203,E501,W503)
|
||||||
|
- Pylint (ignoring W0718,R0903,R0801)
|
||||||
|
- Mypy (ignoring unused-awaitable)
|
||||||
|
|
||||||
|
GitHub CI will alert in case of failure.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] PngCheck
|
||||||
|
- [ ] Zsteg extract / --all --> **Download `zsteg` files** such as mp3 encoded on LSB
|
||||||
|
- [ ] Test on mobile
|
||||||
|
- [ ] i18n
|
||||||
|
- [ ] Rootless analyzers ?
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Binwalk Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_binwalk(input_img: Path, output_dir: Path) -> None:
|
||||||
|
"""Analyze an image submission using binwalk."""
|
||||||
|
|
||||||
|
image_name = input_img.name
|
||||||
|
extracted_dir = output_dir / f"_{image_name}.extracted"
|
||||||
|
|
||||||
|
try:
|
||||||
|
stderr = ""
|
||||||
|
# Run binwalk
|
||||||
|
data = subprocess.run(
|
||||||
|
["binwalk", "-e", "../" + str(image_name), "--run-as=root"],
|
||||||
|
cwd=output_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
stderr += data.stderr
|
||||||
|
|
||||||
|
zip_exist = False
|
||||||
|
if extracted_dir.exists():
|
||||||
|
# Zip extracted files
|
||||||
|
zip_data = subprocess.run(
|
||||||
|
["7z", "a", "../binwalk.7z", "*"],
|
||||||
|
cwd=extracted_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
zip_exist = True
|
||||||
|
stderr += zip_data.stderr
|
||||||
|
|
||||||
|
if len(stderr) > 0:
|
||||||
|
err = {
|
||||||
|
"binwalk": {
|
||||||
|
"status": "error",
|
||||||
|
"error": stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_data(output_dir, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove the extracted directory
|
||||||
|
if extracted_dir.exists():
|
||||||
|
shutil.rmtree(extracted_dir)
|
||||||
|
|
||||||
|
output_data = {
|
||||||
|
"binwalk": {
|
||||||
|
"status": "ok",
|
||||||
|
"output": data.stdout.split("\n") if data else [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zip_exist:
|
||||||
|
output_data["binwalk"]["download"] = f"/download/{output_dir.name}/binwalk"
|
||||||
|
|
||||||
|
update_data(output_dir, output_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"binwalk": {"status": "error", "error": str(e)}})
|
||||||
|
return None
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Bits Decomposer Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
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)
|
||||||
|
img_np = np.array(img)
|
||||||
|
|
||||||
|
# Handle grayscale, RGB, RGBA, etc.
|
||||||
|
if len(img_np.shape) == 2:
|
||||||
|
channels = 1
|
||||||
|
else:
|
||||||
|
channels = img_np.shape[2]
|
||||||
|
|
||||||
|
if channels > 1:
|
||||||
|
channel_names = ["Red", "Green", "Blue", "Alpha"]
|
||||||
|
else:
|
||||||
|
channel_names = ["Grayscale"]
|
||||||
|
|
||||||
|
image_json = {}
|
||||||
|
|
||||||
|
# Add Superimposed RGB bit planes
|
||||||
|
if channels >= 3:
|
||||||
|
superimposed_json = []
|
||||||
|
for bit in range(8):
|
||||||
|
bit_mask = 1 << bit
|
||||||
|
rgb_planes = [((img_np[..., c] & bit_mask) >> bit) * 255 for c in range(3)]
|
||||||
|
rgb_image = np.stack(rgb_planes, axis=-1).astype(np.uint8)
|
||||||
|
rgb_img = Image.fromarray(rgb_image, mode="RGB")
|
||||||
|
img_name = f"superimposed_bit_{bit}.png"
|
||||||
|
out_path = output_dir / img_name
|
||||||
|
dl_path = Path(output_dir.name) / img_name
|
||||||
|
superimposed_json.append("/image/" + str(dl_path))
|
||||||
|
rgb_img.save(out_path)
|
||||||
|
|
||||||
|
image_json["Superimposed"] = superimposed_json
|
||||||
|
|
||||||
|
# Process individual channels
|
||||||
|
for c in range(channels):
|
||||||
|
channel_data = img_np[..., c] if channels > 1 else img_np
|
||||||
|
channel_label = channel_names[c]
|
||||||
|
channel_json = []
|
||||||
|
for bit in range(8):
|
||||||
|
bit_mask = 1 << bit
|
||||||
|
bit_plane = ((channel_data & bit_mask) >> bit) * 255
|
||||||
|
bit_img = Image.fromarray(bit_plane.astype(np.uint8), mode="L")
|
||||||
|
img_name = f"{channel_label}_bit_{bit}.png"
|
||||||
|
out_path = output_dir / img_name
|
||||||
|
dl_path = Path(output_dir.name) / img_name
|
||||||
|
channel_json.append("/image/" + str(dl_path))
|
||||||
|
bit_img.save(out_path)
|
||||||
|
image_json[channel_label] = channel_json
|
||||||
|
|
||||||
|
update_data(
|
||||||
|
output_dir,
|
||||||
|
{
|
||||||
|
"decomposer": {
|
||||||
|
"status": "ok",
|
||||||
|
"images": image_json,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Exiftool Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_exiftool(input_img: Path, output_dir: Path) -> None:
|
||||||
|
"""Analyze an image submission using exiftool."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = subprocess.run(
|
||||||
|
["exiftool", input_img], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.stderr:
|
||||||
|
update_data(
|
||||||
|
output_dir, {"strings": {"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")
|
||||||
|
|
||||||
|
update_data(
|
||||||
|
output_dir,
|
||||||
|
{"exiftool": {"status": "ok", "output": metadata}},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"exiftool": {"status": "error", "error": str(e)}})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Foremost Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_foremost(input_img: Path, output_dir: Path) -> None:
|
||||||
|
"""Analyze an image submission using foremost."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
foremost_dir = output_dir / "foremost"
|
||||||
|
foremost_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
stderr = ""
|
||||||
|
# Run foremost
|
||||||
|
data = subprocess.run(
|
||||||
|
["foremost", "-o", foremost_dir, "-i", input_img],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
# Note: foremost use stderr as standard output for results :o)
|
||||||
|
# So we can't use it as error dectection :
|
||||||
|
# stderr += data.stderr
|
||||||
|
|
||||||
|
# Zip extracted files
|
||||||
|
zip_data = subprocess.run(
|
||||||
|
["7z", "a", "../foremost.7z", "*"],
|
||||||
|
cwd=foremost_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr += zip_data.stderr
|
||||||
|
|
||||||
|
# Remove the extracted directory
|
||||||
|
shutil.rmtree(foremost_dir)
|
||||||
|
|
||||||
|
data_strings = data.stdout.split("\n") if data else []
|
||||||
|
data_strings = [s for s in data_strings if s]
|
||||||
|
|
||||||
|
update_data(
|
||||||
|
output_dir,
|
||||||
|
{
|
||||||
|
"foremost": {
|
||||||
|
"status": "ok",
|
||||||
|
"output": data_strings,
|
||||||
|
"download": f"/download/{output_dir.name}/foremost",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"foremost": {"status": "error", "error": str(e)}})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Outguess Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_outguess(
|
||||||
|
input_img: Path, output_dir: Path, password: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""Analyze an image submission using stehide."""
|
||||||
|
|
||||||
|
image_name = input_img.name
|
||||||
|
try:
|
||||||
|
outguess_dir = output_dir / "outguess"
|
||||||
|
outguess_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
outguess_file = "outguess.data"
|
||||||
|
stderr = ""
|
||||||
|
cmd: list[str]
|
||||||
|
if password:
|
||||||
|
cmd = [
|
||||||
|
"outguess",
|
||||||
|
"-k",
|
||||||
|
password,
|
||||||
|
"-r",
|
||||||
|
"../../" + str(image_name),
|
||||||
|
outguess_file,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cmd = ["outguess", "-r", "../../" + str(image_name), outguess_file]
|
||||||
|
|
||||||
|
data = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=outguess_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.returncode != 0:
|
||||||
|
stderr += data.stderr
|
||||||
|
err = {
|
||||||
|
"outguess": {
|
||||||
|
"status": "error",
|
||||||
|
"error": stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_data(output_dir, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Zip extracted files
|
||||||
|
zip_data = subprocess.run(
|
||||||
|
["7z", "a", "../outguess.7z", "*"],
|
||||||
|
cwd=outguess_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr += zip_data.stderr
|
||||||
|
|
||||||
|
if len(stderr) > 0:
|
||||||
|
err = {
|
||||||
|
"outguess": {
|
||||||
|
"status": "error",
|
||||||
|
"error": stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_data(output_dir, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove the extracted directory
|
||||||
|
shutil.rmtree(outguess_dir)
|
||||||
|
|
||||||
|
data_strings = data.stderr.split("\n") if data else []
|
||||||
|
data_strings = [s for s in data_strings if s]
|
||||||
|
|
||||||
|
update_data(
|
||||||
|
output_dir,
|
||||||
|
{
|
||||||
|
"outguess": {
|
||||||
|
"status": "ok",
|
||||||
|
"output": data_strings,
|
||||||
|
"download": f"/download/{output_dir.name}/outguess",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"outguess": {"status": "error", "error": str(e)}})
|
||||||
|
return None
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Steghide Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_steghide(
|
||||||
|
input_img: Path, output_dir: Path, password: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""Analyze an image submission using stehide."""
|
||||||
|
|
||||||
|
image_name = input_img.name
|
||||||
|
try:
|
||||||
|
stderr = ""
|
||||||
|
# Get embedded file information first
|
||||||
|
cmd: list[str] = ["steghide", "info", "../" + str(image_name)]
|
||||||
|
if password:
|
||||||
|
cmd += ["-p", password]
|
||||||
|
else:
|
||||||
|
cmd += ["-p", ""]
|
||||||
|
|
||||||
|
data = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=output_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
match = re.search(r'embedded file "([^"]+)"', data.stdout)
|
||||||
|
embedded_filename = match.group(1) if match else None
|
||||||
|
|
||||||
|
# if bad file format or wrong password, raise an error
|
||||||
|
if data.returncode != 0 or embedded_filename is None:
|
||||||
|
stderr += data.stderr.replace(f'"../{image_name}" ', "") # hide file name
|
||||||
|
# the file format of the file \"../foobar.png\" is not supported.
|
||||||
|
# become
|
||||||
|
# the file format of the file is not supported.
|
||||||
|
err = {
|
||||||
|
"steghide": {
|
||||||
|
"status": "error",
|
||||||
|
"error": stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_data(output_dir, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# The passphrase is correct, we can extract the file
|
||||||
|
# prevent path traversal
|
||||||
|
embedded_file = output_dir / "steghide" / str(Path(embedded_filename).name)
|
||||||
|
extracted_dir = embedded_file.parent
|
||||||
|
extracted_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cmd_ext: list[str] = [
|
||||||
|
"steghide",
|
||||||
|
"extract",
|
||||||
|
"-sf",
|
||||||
|
"../" + str(image_name),
|
||||||
|
"-xf",
|
||||||
|
str(embedded_filename),
|
||||||
|
]
|
||||||
|
if password:
|
||||||
|
cmd_ext += ["-p", password]
|
||||||
|
else:
|
||||||
|
cmd_ext += ["-p", ""]
|
||||||
|
|
||||||
|
data = subprocess.run(
|
||||||
|
cmd_ext,
|
||||||
|
cwd=output_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
stderr += data.stderr
|
||||||
|
|
||||||
|
# Zip extracted files
|
||||||
|
zip_data = subprocess.run(
|
||||||
|
["7z", "a", "../steghide.7z", "*"],
|
||||||
|
cwd=extracted_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr += zip_data.stderr
|
||||||
|
|
||||||
|
if len(stderr) > 0:
|
||||||
|
err = {
|
||||||
|
"binwalk": {
|
||||||
|
"status": "error",
|
||||||
|
"error": stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_data(output_dir, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove the extracted directory
|
||||||
|
shutil.rmtree(extracted_dir)
|
||||||
|
|
||||||
|
update_data(
|
||||||
|
output_dir,
|
||||||
|
{
|
||||||
|
"steghide": {
|
||||||
|
"status": "ok",
|
||||||
|
"output": data.stdout.split("\n") if data else [],
|
||||||
|
"download": f"/download/{output_dir.name}/steghide",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"steghide": {"status": "error", "error": str(e)}})
|
||||||
|
return None
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Strings Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_strings(input_img: Path, output_dir: Path) -> None:
|
||||||
|
"""Analyze an image submission using strings."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = subprocess.run(
|
||||||
|
["strings", input_img], capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if data.stderr:
|
||||||
|
update_data(
|
||||||
|
output_dir, {"strings": {"status": "error", "error": data.stderr}}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# strings_dir = output_dir / "strings"
|
||||||
|
# strings_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# with open(strings_dir / "strings.txt", "w", encoding="utf-8") as f:
|
||||||
|
# subprocess.run(["strings", input_img], stdout=f, check=True)
|
||||||
|
|
||||||
|
data_strings = data.stdout.split("\n") if data else []
|
||||||
|
data_strings = [s for s in data_strings if s]
|
||||||
|
|
||||||
|
update_data(
|
||||||
|
output_dir,
|
||||||
|
{
|
||||||
|
"strings": {
|
||||||
|
"status": "ok",
|
||||||
|
"output": data_strings,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"strings": {"status": "error", "error": str(e)}})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Custom Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_myanalyzer(input_img: Path, output_dir: Path) -> None:
|
||||||
|
"""Analyze an image submission using myanalyzer."""
|
||||||
|
|
||||||
|
image_name = input_img.name
|
||||||
|
|
||||||
|
# Dir generated by the tool
|
||||||
|
extracted_dir = output_dir / "myanalyzer_output_dir"
|
||||||
|
|
||||||
|
try:
|
||||||
|
stderr, stdout = "", ""
|
||||||
|
|
||||||
|
# Run myanalyzer
|
||||||
|
data = subprocess.run(
|
||||||
|
["myanalyzer", "-e", "../" + str(image_name)],
|
||||||
|
cwd=output_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr += data.stderr
|
||||||
|
stdout += data.stdout
|
||||||
|
|
||||||
|
if len(stderr) > 0: # Return error if error has been raised
|
||||||
|
err = {
|
||||||
|
"myanalyzer": {
|
||||||
|
"status": "error",
|
||||||
|
"error": stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update_data(output_dir, err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
zip_exist = False
|
||||||
|
if extracted_dir.exists(): # If result file(s) exist, zip them
|
||||||
|
subprocess.run(
|
||||||
|
["7z", "a", "../myanalyzer.7z", "*"],
|
||||||
|
cwd=extracted_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
zip_exist = True
|
||||||
|
|
||||||
|
if extracted_dir.exists(): # Remove the extracted directory
|
||||||
|
shutil.rmtree(extracted_dir)
|
||||||
|
|
||||||
|
output = data.stdout.split("\n") if data else []
|
||||||
|
output_data = {
|
||||||
|
"myanalyzer": {
|
||||||
|
"status": "ok",
|
||||||
|
"output": output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if zip_exist:
|
||||||
|
output_data["myanalyzer"][
|
||||||
|
"download"
|
||||||
|
] = f"/download/{output_dir.name}/myanalyzer"
|
||||||
|
|
||||||
|
update_data(output_dir, output_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"myanalyzer": {"status": "error", "error": str(e)}})
|
||||||
|
return None
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Utility functions for analyzers modules."""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_thread_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def update_data(
|
||||||
|
output_dir: Path, new_data: dict[Any, Any], json_filename: str = "results.json"
|
||||||
|
) -> None:
|
||||||
|
"""Thread-safe and process-safe JSON update using lock file and atomic replace."""
|
||||||
|
json_file = Path(output_dir) / json_filename
|
||||||
|
lock_file = json_file.with_suffix(json_file.suffix + ".lock")
|
||||||
|
tmp_file = json_file.with_suffix(json_file.suffix + ".tmp")
|
||||||
|
|
||||||
|
json_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with _thread_lock: # synchronizes across threads
|
||||||
|
with open(lock_file, "w", encoding="utf-8") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX) # synchronizes across processes
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read existing JSON
|
||||||
|
data: dict[Any, Any] = {}
|
||||||
|
if json_file.exists():
|
||||||
|
try:
|
||||||
|
with open(json_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# Update with new data
|
||||||
|
data.update(new_data)
|
||||||
|
|
||||||
|
# Write safely to a temp file
|
||||||
|
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, sort_keys=False)
|
||||||
|
|
||||||
|
os.replace(tmp_file, json_file) # ensures file write is atomic
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Zsteg Analyzer for Image Submissions."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .utils import update_data
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_zsteg(input_img: Path, output_dir: Path) -> None:
|
||||||
|
"""Analyze an image submission using zsteg."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = subprocess.run(
|
||||||
|
["zsteg", input_img], capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if data.stderr or "PNG::NotSupported" in data.stdout[:100]:
|
||||||
|
error_msg = data.stderr or data.stdout
|
||||||
|
update_data(output_dir, {"zsteg": {"status": "error", "error": error_msg}})
|
||||||
|
return
|
||||||
|
|
||||||
|
# zsteg_dir = output_dir / "zsteg"
|
||||||
|
# zsteg_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# with open(zsteg_dir / "zsteg.txt", "w", encoding="utf-8") as f:
|
||||||
|
# subprocess.run(["zsteg", input_img], stdout=f, check=True)
|
||||||
|
|
||||||
|
update_data(
|
||||||
|
output_dir,
|
||||||
|
{
|
||||||
|
"zsteg": {
|
||||||
|
"status": "ok",
|
||||||
|
"output": data.stdout.split("\n") if data else [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
update_data(output_dir, {"zsteg": {"status": "error", "error": str(e)}})
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""AperiSolve Flask application."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import Flask, Response, abort, jsonify, render_template, request, send_file
|
||||||
|
from redis import Redis
|
||||||
|
from rq import Queue
|
||||||
|
|
||||||
|
from .cleanup import cleanup_old_entries
|
||||||
|
from .config import IMAGE_EXTENSIONS, RESULT_FOLDER, WORKER_FILES
|
||||||
|
from .models import Image, Submission, db, init_db
|
||||||
|
|
||||||
|
app: Flask = Flask(__name__)
|
||||||
|
app.json.sort_keys = False # type: ignore
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DB_URI")
|
||||||
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
init_db(app)
|
||||||
|
redis_conn = Redis(host="redis", port=6379)
|
||||||
|
queue = Queue(connection=redis_conn)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index() -> str:
|
||||||
|
"""Render the main index page."""
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/faq")
|
||||||
|
def faq() -> str:
|
||||||
|
"""Render the FAQ page."""
|
||||||
|
return render_template("faq.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/show")
|
||||||
|
def show() -> str:
|
||||||
|
"""Show all active submissions."""
|
||||||
|
db_images = Image.query.all()
|
||||||
|
images = []
|
||||||
|
for img in db_images:
|
||||||
|
last_sub = img.submissions[-1]
|
||||||
|
images.append({"file": f"/image/{img.hash}", "link": f"/{last_sub.hash}"})
|
||||||
|
return render_template("show.html", images=images)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/<hash_val>", methods=["GET"])
|
||||||
|
def result_page(hash_val: str) -> str:
|
||||||
|
"""Render the result page for a given submission hash."""
|
||||||
|
submission = Submission.query.get_or_404(hash_val) # type: ignore
|
||||||
|
Image.query.get_or_404(submission.image_hash) # type: ignore
|
||||||
|
|
||||||
|
return render_template("result.html", hash_val=hash_val)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/upload", methods=["POST"])
|
||||||
|
def upload_image() -> tuple[Response, int]:
|
||||||
|
"""Handle image upload and initiate analysis."""
|
||||||
|
|
||||||
|
cleanup_old_entries()
|
||||||
|
|
||||||
|
if "image" not in request.files:
|
||||||
|
return jsonify({"error": "No image provided"}), 400
|
||||||
|
|
||||||
|
image = request.files["image"]
|
||||||
|
password = request.form.get("password")
|
||||||
|
deep_analysis = request.form.get("deep") == "true"
|
||||||
|
|
||||||
|
if image.filename is None or image.filename == "":
|
||||||
|
return jsonify({"error": "No image provided"}), 400
|
||||||
|
|
||||||
|
if (
|
||||||
|
"." not in image.filename
|
||||||
|
or Path(image.filename).suffix.lower() not in IMAGE_EXTENSIONS
|
||||||
|
):
|
||||||
|
return jsonify({"error": "Unsupported file type"}), 400
|
||||||
|
|
||||||
|
# Compute hashes and prepare submission
|
||||||
|
image_data = image.read()
|
||||||
|
img_hash = hashlib.md5(image_data).hexdigest()
|
||||||
|
image_name = img_hash + "." + image.filename.rsplit(".", maxsplit=1)[-1].lower()
|
||||||
|
|
||||||
|
submission_data = (
|
||||||
|
image_data
|
||||||
|
+ image.filename.encode()
|
||||||
|
+ (password.encode() if password else b"")
|
||||||
|
+ (b"deep_analysis" if deep_analysis else b"")
|
||||||
|
)
|
||||||
|
submission_hash = hashlib.md5(submission_data).hexdigest()
|
||||||
|
submission_path = RESULT_FOLDER / img_hash / submission_hash
|
||||||
|
|
||||||
|
# If submission already exists, return submission hash
|
||||||
|
if submission_path.exists():
|
||||||
|
submission: Submission = Submission.query.filter_by(
|
||||||
|
hash=submission_hash
|
||||||
|
).first()
|
||||||
|
return jsonify({"submission_hash": submission.hash}), 200
|
||||||
|
|
||||||
|
# If image is new, create a new Image entry
|
||||||
|
new_img_path = RESULT_FOLDER / img_hash / image_name
|
||||||
|
if not new_img_path.parent.exists():
|
||||||
|
new_img_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(new_img_path, "wb") as f: # Write file to disk
|
||||||
|
f.write(image_data)
|
||||||
|
|
||||||
|
new_img = Image(
|
||||||
|
file=str(new_img_path),
|
||||||
|
hash=img_hash,
|
||||||
|
size=len(image_data),
|
||||||
|
upload_count=0,
|
||||||
|
first_submission_date=datetime.now(timezone.utc),
|
||||||
|
last_submission_date=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.session.add(new_img)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
sub_img = Image.query.filter_by(hash=img_hash).first() # type: ignore
|
||||||
|
sub_img.upload_count += 1
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create new Submission entry
|
||||||
|
submission_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
submission = Submission(
|
||||||
|
filename=image.filename,
|
||||||
|
password=password,
|
||||||
|
deep_analysis=deep_analysis,
|
||||||
|
hash=submission_hash,
|
||||||
|
status="pending",
|
||||||
|
date=time.time(),
|
||||||
|
image_hash=sub_img.hash, # type: ignore
|
||||||
|
)
|
||||||
|
db.session.add(submission)
|
||||||
|
|
||||||
|
db.session.commit() # Commit to save the new Image and Submission
|
||||||
|
# Start the analysis jobs
|
||||||
|
queue.enqueue("aperisolve.workers.analyze_image", submission.hash, job_timeout=300)
|
||||||
|
|
||||||
|
return jsonify({"submission_hash": submission.hash}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/status/<hash_val>", methods=["GET"])
|
||||||
|
def get_status(hash_val: str) -> Response:
|
||||||
|
"""Get the processing status of a submission."""
|
||||||
|
submission = Submission.query.get_or_404(hash_val) # type: ignore
|
||||||
|
return jsonify({"status": submission.status}) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/infos/<hash_val>", methods=["GET"])
|
||||||
|
def get_infos(hash_val: str) -> Response:
|
||||||
|
"""Get the metadata and information of a submission."""
|
||||||
|
submission = Submission.query.get_or_404(hash_val)
|
||||||
|
image = Image.query.get_or_404(submission.image_hash) # type: ignore
|
||||||
|
names = [name for name in set(sub.filename for sub in image.submissions) if name]
|
||||||
|
passwords = [pwd for pwd in set(sub.password for sub in image.submissions) if pwd]
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"image_path": "image/" + str(Path(image.file).name),
|
||||||
|
"names": names,
|
||||||
|
"size": image.size,
|
||||||
|
"first_submission_date": image.first_submission_date,
|
||||||
|
"last_submission_date": image.last_submission_date,
|
||||||
|
"upload_count": image.upload_count,
|
||||||
|
"passwords": passwords,
|
||||||
|
}
|
||||||
|
) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/result/<hash_val>", methods=["GET"])
|
||||||
|
def get_result(hash_val: str) -> tuple[Response, int]:
|
||||||
|
"""Get the analysis results of a submission."""
|
||||||
|
submission = Submission.query.get_or_404(hash_val)
|
||||||
|
image = Image.query.get_or_404(submission.image_hash) # type: ignore
|
||||||
|
|
||||||
|
result_path = (
|
||||||
|
RESULT_FOLDER / str(image.hash) / str(submission.hash) / "results.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result_path.exists():
|
||||||
|
return jsonify({"error": "Results not ready yet..."}), 425
|
||||||
|
|
||||||
|
with open(result_path, "r", encoding="utf-8") as f:
|
||||||
|
results = json.load(f)
|
||||||
|
|
||||||
|
return jsonify({"results": results}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/download/<hash_val>/<tool>")
|
||||||
|
def download_output(hash_val: str, tool: str) -> Response:
|
||||||
|
"""Download the output of a specific analyzer for a given submission hash."""
|
||||||
|
|
||||||
|
submission = Submission.query.filter_by(hash=hash_val).first_or_404()
|
||||||
|
image = Image.query.get_or_404(submission.image_hash) # type: ignore
|
||||||
|
|
||||||
|
output_dir = RESULT_FOLDER / str(image.hash) / str(submission.hash)
|
||||||
|
output_file = output_dir / f"{tool}.7z"
|
||||||
|
|
||||||
|
if tool not in WORKER_FILES or not output_file.exists():
|
||||||
|
abort(404, description="Tool output not found.")
|
||||||
|
|
||||||
|
return send_file(output_file, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/image/<img_name>")
|
||||||
|
@app.route("/image/<hash_val>/<img_name>")
|
||||||
|
def get_image(
|
||||||
|
hash_val: Optional[str] = None, img_name: Optional[str] = None
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Download the image with a specific name (usually ones generated with
|
||||||
|
decomposer) for a given submission hash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if img_name is None:
|
||||||
|
return abort(404, description="Image not found or unsupported format")
|
||||||
|
|
||||||
|
if hash_val is not None:
|
||||||
|
submission = Submission.query.filter_by(hash=hash_val).first_or_404()
|
||||||
|
image = Image.query.get_or_404(submission.image_hash) # type: ignore
|
||||||
|
|
||||||
|
output_dir = RESULT_FOLDER / str(image.hash) / str(submission.hash)
|
||||||
|
output_file = output_dir / Path(img_name).name
|
||||||
|
else:
|
||||||
|
hash_val = img_name.split(".")[0] # Extract hash from the image name
|
||||||
|
image = Image.query.filter_by(hash=hash_val).first_or_404()
|
||||||
|
output_file = Path(image.file) # type: ignore
|
||||||
|
|
||||||
|
if (not output_file.exists()) or (
|
||||||
|
output_file.suffix.lower() not in IMAGE_EXTENSIONS
|
||||||
|
):
|
||||||
|
return abort(404, description="Image not found or unsupported format")
|
||||||
|
|
||||||
|
return send_file(output_file, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_mode = os.environ.get("FLASK_DEBUG", "false").lower() == "true"
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=debug_mode)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Cleanup old entries in the database and file system."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from os import getenv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .models import Image, Submission, db
|
||||||
|
|
||||||
|
RESULT_FOLDER = Path("./results")
|
||||||
|
MAX_PENDING_TIME = int(getenv("MAX_PENDING_TIME", "600")) # 10 minutes by default
|
||||||
|
MAX_STORE_TIME = int(getenv("MAX_STORE_TIME", "259200")) # 3 days by default
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_entries() -> None:
|
||||||
|
"""Clean up old entries in the database and file system."""
|
||||||
|
now = time.time()
|
||||||
|
for submission in Submission.query.all(): # type: ignore
|
||||||
|
if (
|
||||||
|
submission.status in ("pending", "running") # type: ignore
|
||||||
|
and now - submission.date > MAX_PENDING_TIME # type: ignore
|
||||||
|
):
|
||||||
|
# Processing took too long, delete
|
||||||
|
db.session.delete(submission) # type: ignore
|
||||||
|
elif submission.status == "done": # type: ignore
|
||||||
|
# Search for buggy results, delete
|
||||||
|
result_path = (
|
||||||
|
RESULT_FOLDER / str(submission.image_hash) / str(submission.hash)
|
||||||
|
)
|
||||||
|
result_file = result_path / "results.json"
|
||||||
|
if not result_file.exists():
|
||||||
|
db.session.delete(submission) # type: ignore
|
||||||
|
shutil.rmtree(result_path)
|
||||||
|
|
||||||
|
# Delete "old" images
|
||||||
|
for img in Image.query.all(): # type: ignore
|
||||||
|
if img.last_submission_date.tzinfo is None:
|
||||||
|
img_date = img.last_submission_date.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
img_date = img.last_submission_date
|
||||||
|
delay = datetime.now(timezone.utc) - img_date
|
||||||
|
|
||||||
|
if delay.total_seconds() > MAX_STORE_TIME:
|
||||||
|
for s in img.submissions:
|
||||||
|
db.session.delete(s) # type: ignore
|
||||||
|
shutil.rmtree(RESULT_FOLDER / img.hash) # type: ignore
|
||||||
|
db.session.delete(img)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""AperiSolve configuration variables."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"]
|
||||||
|
WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg"]
|
||||||
|
|
||||||
|
RESULT_FOLDER = Path(__file__).parent.resolve() / "results"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""This module defines the database models for the Aperisolve application."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String
|
||||||
|
|
||||||
|
db: SQLAlchemy = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class Image(db.Model): # type: ignore
|
||||||
|
"""Model representing an image file in the database."""
|
||||||
|
|
||||||
|
hash: Column[str] = Column(
|
||||||
|
String(64), primary_key=True, unique=True, nullable=False
|
||||||
|
)
|
||||||
|
file: Column[str] = Column(String(128), unique=True, nullable=False)
|
||||||
|
size: Column[int] = Column(Integer, nullable=False)
|
||||||
|
first_submission_date: Column[datetime] = Column(
|
||||||
|
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
last_submission_date: Column[datetime] = Column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
upload_count: Column[int] = Column(Integer)
|
||||||
|
submissions = db.relationship("Submission", backref="image", lazy=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Submission(db.Model): # type: ignore
|
||||||
|
"""Model representing a file submission for analysis.
|
||||||
|
Submissions are defined with a filename, password, image content, and analysis
|
||||||
|
option.
|
||||||
|
"""
|
||||||
|
|
||||||
|
hash: Column[str] = Column(
|
||||||
|
String(128), primary_key=True, unique=True, nullable=False
|
||||||
|
)
|
||||||
|
filename: Column[str] = Column(String(128), nullable=False)
|
||||||
|
password: Column[str] = Column(String(128))
|
||||||
|
deep_analysis: Column[bool] = Column(Boolean, default=False)
|
||||||
|
status: Column[str] = Column(String(20), default="pending")
|
||||||
|
date: Column[Decimal] = Column(
|
||||||
|
Float, nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Foreign key to Image
|
||||||
|
image_hash = Column(String, db.ForeignKey("image.hash"), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(app: Flask) -> None:
|
||||||
|
"""Initialize the database with the given Flask app."""
|
||||||
|
with app.app_context():
|
||||||
|
if not db.engine.dialect.has_table(db.engine.connect(), "image"):
|
||||||
|
print("Creating database...")
|
||||||
|
db.create_all()
|
||||||
|
print("Database created successfully.")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Flask
|
||||||
|
Flask-SQLAlchemy
|
||||||
|
numpy
|
||||||
|
redis
|
||||||
|
rq
|
||||||
|
psycopg2-binary
|
||||||
|
types-Flask-SQLAlchemy
|
||||||
|
sqlalchemy-stubs
|
||||||
|
Pillow
|
||||||
|
gunicorn
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/* ======== Base Layout ======== */
|
||||||
|
::selection {
|
||||||
|
background: #c9fbff;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--theme-dark-blue: #0b121f;
|
||||||
|
--theme-dark-blue-hover: #0d1931;
|
||||||
|
--theme-green: #9fef00;
|
||||||
|
--theme-green-shadow: #9fef0055;
|
||||||
|
--theme-green-shadow2: #9fef00aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
--dark-bg: var(--theme-dark-blue);
|
||||||
|
--dark-bg-hover: var(--theme-dark-blue-hover);
|
||||||
|
--bs-progress-bar-bg: #f00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--dark-bg);
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Typography ======== */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
strong,
|
||||||
|
.highlight {
|
||||||
|
color: var(--theme-green);
|
||||||
|
text-shadow: 0 0 5px var(--theme-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.text-accent {
|
||||||
|
color: var(--theme-green);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #ccff66;
|
||||||
|
text-shadow: 0 0 5px #ccff66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title_link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Background ======== */
|
||||||
|
|
||||||
|
#three-bg {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
#result,
|
||||||
|
form {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Components ======== */
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.form-control,
|
||||||
|
.table,
|
||||||
|
.border,
|
||||||
|
.card-hack {
|
||||||
|
background-color: var(--dark-bg);
|
||||||
|
border: 1px solid var(--theme-green) !important;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-shadow: 0 0 10px var(--theme-green-shadow);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-img-right span {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--theme-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
#water-logo {
|
||||||
|
width: 70px;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.btn-primary,
|
||||||
|
.btn-hack {
|
||||||
|
background-color: var(--theme-green);
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover,
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-hack:hover {
|
||||||
|
background-color: var(--theme-green);
|
||||||
|
box-shadow: 0 0 10px var(--theme-green-shadow2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bootstrap contextual colors override */
|
||||||
|
.text-success,
|
||||||
|
.bg-success {
|
||||||
|
color: var(--theme-green) !important;
|
||||||
|
background-color: #1e2b00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger,
|
||||||
|
.bg-danger {
|
||||||
|
color: #ff4d4d !important;
|
||||||
|
background-color: #330000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning,
|
||||||
|
.bg-warning {
|
||||||
|
color: #ffcc00 !important;
|
||||||
|
background-color: #332b00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-info,
|
||||||
|
.bg-info {
|
||||||
|
color: #00e6e6 !important;
|
||||||
|
background-color: #002e2e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyzer {
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyzer h2+div {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyzer h3 {
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Forms & Inputs ======== */
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
pre {
|
||||||
|
border: 1px solid var(--theme-green);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus,
|
||||||
|
pre {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 10px var(--theme-green);
|
||||||
|
background-color: var(--dark-bg-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
textarea.form-control,
|
||||||
|
pre {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-container {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
margin-top: -40px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
color: #555;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-container:hover .copy-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-icon:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File drop zone */
|
||||||
|
#drop-zone {
|
||||||
|
background-color: var(--dark-bg);
|
||||||
|
border: 2px dashed var(--theme-green);
|
||||||
|
color: var(--theme-green);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#drop-zone:hover,
|
||||||
|
#drop-zone.dragover {
|
||||||
|
background-color: var(--dark-bg-hover);
|
||||||
|
box-shadow: 0 0 10px var(--theme-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload-form {
|
||||||
|
transition: height 0.4s ease, padding 0.4s ease, margin 0.4s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Tables ======== */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--theme-dark-blue);
|
||||||
|
border-collapse: collapse;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--theme-dark-blue);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td:first-child {
|
||||||
|
color: var(--theme-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:first-child td {
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Images ======== */
|
||||||
|
#result img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results_img {
|
||||||
|
width: 25%;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Modal ======== */
|
||||||
|
#image-modal-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#image-modal-container img {
|
||||||
|
width: 60%;
|
||||||
|
border: 2px solid var(--theme-green);
|
||||||
|
box-shadow: 0 0 20px var(--theme-green);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#image-modal-container.modal-visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-images {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== FAQ ======== */
|
||||||
|
|
||||||
|
#cheatsheet>div {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command {
|
||||||
|
background-color: var(--theme-dark-blue);
|
||||||
|
border: 3px solid var(--theme-green);
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cheatsheet ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 5px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cheatsheet li::before {
|
||||||
|
content: "\f054";
|
||||||
|
font-family: "Font Awesome 6 Free";
|
||||||
|
font-weight: 900;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
color: var(--theme-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
#cheatsheet li {
|
||||||
|
padding-left: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cheatsheet img,
|
||||||
|
#cheatsheet table {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Analyzer Blocks ======== */
|
||||||
|
|
||||||
|
.analyzer {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result-infos {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result-infos>div {
|
||||||
|
height: 100%;
|
||||||
|
width: 50%;
|
||||||
|
height: auto;
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.a-exiftool .table-container {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-strings textarea {
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Footer ======== */
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0 20px 20px 0;
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 1800px) {
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
margin: 0 20px 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
footer a i {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======== Utility ======== */
|
||||||
|
|
||||||
|
.glow-border {
|
||||||
|
border: 1px solid var(--theme-green);
|
||||||
|
box-shadow: 0 0 10px var(--theme-green-shadow);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
|
||||||
|
<path style="stroke: rgb(0, 0, 0); fill: rgb(248, 248, 248);" d="M 31.958 348.979 L 46.064 320.766 C 46.047 320.759 47.854 319.528 49.992 320.984 C 235.683 447.434 428.251 360.852 447.82 321.895 C 455.205 307.194 469.197 361.358 470.39 359.7 L 464.183 365.343 C 312.335 459.691 89.515 409.047 39.857 354.622 L 31.958 348.979 Z"/>
|
||||||
|
<g transform="matrix(2.383155, 0, 0, 2.383155, -289.696167, -169.945801)" style="">
|
||||||
|
<path style="stroke: rgb(0, 0, 0); fill: rgb(239, 0, 64);" d="M 140.55 205.12 C 173.246 149.661 211.285 96.449 226.104 85.795 C 227.402 84.861 228.115 84.366 228.115 84.366 C 239.087 83.147 310.831 208.184 310.831 208.184 C 310.831 208.184 309.411 207.705 308.189 209.024 C 277.042 242.645 200.771 242.626 141.917 207.581 C 141.117 207.105 140.069 205.936 140.55 205.12 Z"/>
|
||||||
|
<path style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);" d="M 256.995 160.248 C 248.803 157.518 250.924 171.394 257.679 169.143 C 260.181 168.308 260.764 166.876 259.503 164.353 C 258.837 163.021 259.381 159.336 257.451 159.336"/>
|
||||||
|
<path style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);" d="M 215.943 161.388 C 214.039 161.388 213.999 163.571 213.435 165.266 C 213.326 165.593 212.492 167.06 212.979 167.546 C 213.538 168.106 216.797 170.114 217.768 169.143 C 220.406 166.504 220.814 163.71 217.084 161.845 C 216.557 161.581 216.095 160.476 215.487 160.476"/>
|
||||||
|
<path style="stroke: rgb(0, 0, 0); fill: rgb(159, 239, 0);" d="M 124.977 235.493 C 127.445 230.983 129.889 226.459 132.38 221.962 C 132.953 220.928 133.002 218.705 134.168 218.899 C 138.489 219.619 142.108 222.611 146.166 224.26 C 158.45 229.25 168.269 234.918 183.184 238.812 C 211.525 246.212 242.407 248.212 271.005 241.875 C 291.758 237.276 302.132 226.415 318.49 220.686 C 319.92 220.185 320.587 222.922 321.298 224.26 C 323.495 228.396 330.058 233.337 327.17 237.025 C 295.801 277.087 172.547 281.531 124.977 233.195"/>
|
||||||
|
<path style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);" d="M 221.873 166.178 C 220.414 166.907 219.129 167.561 218.452 168.915 C 217.611 170.597 220.359 174.077 221.645 172.792 C 223.341 171.096 224.223 169.916 225.294 167.774 C 226.197 165.967 223.469 164.01 223.469 163.213"/>
|
||||||
|
<g transform="matrix(0.1, 0, 0, -0.1, 79.254257, 330.714203)" fill="#000000" stroke="none">
|
||||||
|
<path d="M 1380 2413 C 1212 2240 859 1714 557 1187 C 453 1004 438 974 446 951 C 464 900 665 784 845 720 C 1058 645 1282 610 1547 610 C 1807 610 2015 646 2198 724 C 2341 784 2500 896 2500 936 C 2500 963 1966 1886 1812 2125 C 1724 2261 1619 2399 1571 2443 C 1537 2473 1522 2480 1488 2480 C 1449 2480 1441 2475 1380 2413 Z M 1562 2383 C 1681 2259 1833 2018 2249 1293 C 2426 984 2466 903 2429 925 C 2424 929 2420 926 2420 920 C 2420 914 2249 791 2107 741 C 1799 633 1388 625 1013 720 C 885 752 780 790 780 804 C 780 809 809 878 844 959 C 880 1039 970 1251 1045 1429 C 1226 1860 1338 2116 1416 2279 C 1451 2353 1480 2415 1480 2419 C 1480 2422 1468 2407 1453 2386 C 1404 2312 1263 2004 993 1375 C 908 1177 835 1011 831 1007 C 821 997 729 1037 650 1085 C 618 1104 590 1120 586 1120 C 573 1120 581 1097 598 1083 C 632 1056 821 984 815 963 C 811 947 753 817 749 814 C 733 805 513 930 495 959 C 492 965 537 1055 596 1158 C 889 1669 1232 2184 1400 2363 C 1478 2447 1497 2449 1562 2383 Z"/>
|
||||||
|
<path d="M 1505 2392 C 1518 2383 1580 2304 1580 2297 C 1580 2294 1565 2294 1548 2296 C 1520 2299 1519 2298 1537 2290 C 1570 2276 1634 2190 1697 2072 C 1728 2014 1755 1971 1757 1977 C 1760 1984 1754 2002 1745 2019 C 1737 2036 1725 2062 1720 2077 C 1711 2104 1711 2105 1726 2083 C 1735 2071 1753 2049 1766 2033 C 1779 2018 1797 1989 1807 1970 C 1816 1951 1837 1914 1854 1889 C 1871 1863 1892 1830 1901 1814 C 1955 1721 2052 1553 2060 1540 C 2065 1532 2086 1496 2107 1460 C 2127 1424 2167 1353 2197 1303 C 2226 1252 2247 1204 2244 1196 C 2236 1176 2138 1118 2050 1082 C 1859 1004 1678 974 1461 983 C 1299 990 1275 994 1145 1027 C 1090 1041 910 1094 910 1082 C 910 1071 962 1049 1051 1020 C 1248 958 1528 935 1728 964 C 1887 988 1997 1022 2136 1090 C 2269 1156 2290 1172 2273 1198 C 2268 1207 2187 1346 2095 1505 C 1888 1862 1829 1961 1725 2120 C 1635 2257 1523 2400 1506 2400 C 1499 2400 1498 2397 1505 2392 Z"/>
|
||||||
|
<path d="M1501 2247 c-1 -23 8 -52 22 -75 12 -20 28 -48 35 -62 20 -40 113 -163 118 -157 3 3 -17 36 -44 74 -66 91 -114 178 -123 222 l-7 36 -1 -38z"/>
|
||||||
|
<path d="M1474 2146 c-6 -17 2 -36 17 -36 12 0 12 37 -1 45 -6 3 -13 -1 -16 -9z"/>
|
||||||
|
<path d="M1602 2139 c1 -12 9 -24 16 -27 9 -3 13 3 10 19 -1 12 -9 24 -16 27 -9 3 -13 -3 -10 -19z"/>
|
||||||
|
<path d="M1166 1985 c-28 -42 -17 -78 16 -48 18 16 25 73 9 73 -5 0 -16 -11 -25 -25z"/>
|
||||||
|
<path d="M1706 1962 c-3 -6 2 -16 11 -22 17 -11 17 -11 0 -6 -12 4 -17 2 -15 -6 6 -18 38 -22 38 -6 0 22 -26 53 -34 40z"/>
|
||||||
|
<path d="M1404 1936 c-8 -22 3 -49 19 -43 6 2 12 14 12 27 0 27 -22 39 -31 16z"/>
|
||||||
|
<path d="M1562 1923 c2 -14 8 -28 14 -30 17 -6 26 27 13 43 -18 22 -31 16 -27 -13z"/>
|
||||||
|
<path d="M1778 1910 c12 -5 27 -21 33 -34 29 -62 -33 -77 -63 -16 -15 29 -18 31 -18 13 0 -46 59 -80 88 -51 29 29 -4 99 -45 97 -14 0 -13 -2 5 -9z"/>
|
||||||
|
<path d="M1365 1728 c-33 -18 -45 -40 -45 -83 0 -30 6 -47 23 -63 52 -49 127 -12 127 64 -1 42 -13 64 -47 82 -26 14 -32 14 -58 0z m30 -68 c0 -18 -6 -26 -22 -28 -20 -3 -23 1 -23 28 0 27 3 31 23 28 16 -2 22 -10 22 -28z m45 -38 c0 -4 -5 -14 -10 -22 -9 -13 -11 -13 -19 0 -6 9 -6 19 1 27 10 12 28 9 28 -5z"/>
|
||||||
|
<path d="M1755 1728 c-54 -31 -61 -93 -16 -139 16 -16 37 -29 46 -29 29 0 75 55 75 90 0 22 -9 41 -29 61 -32 31 -45 35 -76 17z m30 -77 c-9 -29 -39 -23 -43 8 -2 13 2 27 10 32 19 12 41 -14 33 -40z"/>
|
||||||
|
<path d="M997 1672 c-18 -20 -23 -62 -8 -62 14 0 41 41 41 62 0 23 -13 23 -33 0z"/>
|
||||||
|
<path d="M1524 1556 c-17 -43 31 -116 75 -116 42 0 62 23 74 85 5 30 5 30 -41 30 -26 0 -59 4 -74 9 -22 6 -29 4 -34 -8z m94 -58 c19 -16 14 -38 -10 -38 -21 0 -58 26 -58 40 0 11 53 9 68 -2z"/>
|
||||||
|
<path d="M1822 1514 c-27 -18 -28 -36 -4 -57 23 -21 51 -22 80 -1 26 18 28 37 6 58 -20 20 -53 20 -82 0z m72 -10 c24 -9 19 -33 -9 -40 -33 -8 -75 3 -75 20 0 22 48 34 84 20z"/>
|
||||||
|
<path d="M1292 1488 c-31 -31 -1 -68 55 -68 33 0 53 16 53 42 0 33 -81 53 -108 26z m72 -8 c14 -5 26 -14 26 -20 0 -16 -42 -29 -73 -23 -43 8 -34 34 20 52 1 1 13 -3 27 -9z"/>
|
||||||
|
<path d="M835 1351 c-14 -28 -12 -61 4 -61 17 0 31 26 31 57 0 41 -15 42 -35 4z"/>
|
||||||
|
<path d="M2034 1306 c-10 -25 13 -68 34 -64 25 5 22 48 -5 65 -20 14 -23 14 -29 -1z"/>
|
||||||
|
<path d="M1131 1261 c-15 -27 -14 -46 5 -61 12 -11 18 -10 31 3 13 13 15 23 7 44 -12 34 -30 40 -43 14z"/>
|
||||||
|
<path d="M1401 1231 c-16 -29 -13 -58 6 -65 36 -14 50 47 18 74 -10 9 -16 6 -24 -9z"/>
|
||||||
|
<path d="M1714 1226 c-3 -8 -4 -25 -2 -38 4 -32 42 -32 46 0 6 38 -32 70 -44 38z"/>
|
||||||
|
<path d="M654 1201 c22 -24 196 -114 205 -106 8 9 -19 28 -116 80 -88 49 -117 57 -89 26z"/>
|
||||||
|
<path d="M 2260 1044 C 1912 837 1410 803 947 956 C 904 970 866 979 862 975 C 846 959 1325 845 1325 845 C 1397 831 1617 828 1718 840 C 1832 853 2081 930 2090 930 C 2098 930 2332 1045 2332 1045 L 2349 1022 C 2358 1009 2378 984 2394 967 L 2422 935 L 2394 985 C 2379 1013 2360 1047 2352 1063 C 2345 1078 2338 1090 2337 1089 C 2336 1089 2301 1069 2260 1044"/>
|
||||||
|
<path d=""/>
|
||||||
|
<path d=""/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,478 @@
|
|||||||
|
const TOOL_ORDER = [
|
||||||
|
"decomposer",
|
||||||
|
"exiftool",
|
||||||
|
"binwalk",
|
||||||
|
"foremost",
|
||||||
|
"outguess",
|
||||||
|
"steghide",
|
||||||
|
"zsteg",
|
||||||
|
"strings",
|
||||||
|
];
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (typeof text !== "string") return text;
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(str) {
|
||||||
|
if (typeof str !== "string" || str.length === 0) return str;
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideUp(element, duration = 400) {
|
||||||
|
element.style.height = element.offsetHeight + "px";
|
||||||
|
element.offsetHeight; // force reflow
|
||||||
|
element.style.transitionProperty = "height, margin, padding";
|
||||||
|
element.style.transitionDuration = duration + "ms";
|
||||||
|
element.style.boxSizing = "border-box";
|
||||||
|
element.style.overflow = "hidden";
|
||||||
|
element.style.height = 0;
|
||||||
|
element.style.paddingTop = 0;
|
||||||
|
element.style.paddingBottom = 0;
|
||||||
|
element.style.marginTop = 0;
|
||||||
|
element.style.marginBottom = 0;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
element.style.display = "none";
|
||||||
|
// Optional: reset styles
|
||||||
|
element.style.removeProperty("height");
|
||||||
|
element.style.removeProperty("padding-top");
|
||||||
|
element.style.removeProperty("padding-bottom");
|
||||||
|
element.style.removeProperty("margin-top");
|
||||||
|
element.style.removeProperty("margin-bottom");
|
||||||
|
element.style.removeProperty("overflow");
|
||||||
|
element.style.removeProperty("transition-duration");
|
||||||
|
element.style.removeProperty("transition-property");
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (typeof bytes !== "number" || isNaN(bytes)) return bytes;
|
||||||
|
const units = ["o", "Ko", "Mo", "Go", "To", "Po"];
|
||||||
|
let i = 0;
|
||||||
|
while (bytes >= 1024 && i < units.length - 1) {
|
||||||
|
bytes /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${bytes % 1 === 0 ? bytes : bytes.toFixed(2)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
function showAlert(message, alertType, reset) {
|
||||||
|
const resultDiv = document.getElementById("result-analyzers");
|
||||||
|
if (reset) {
|
||||||
|
resultDiv.innerHTML = "";
|
||||||
|
}
|
||||||
|
resultDiv.innerHTML += `<div class="alert alert-${escapeHtml(
|
||||||
|
alertType
|
||||||
|
)}" role="alert">${escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(message, reset) {
|
||||||
|
showAlert(message, "success", reset);
|
||||||
|
}
|
||||||
|
function showDanger(message, reset) {
|
||||||
|
showAlert(message, "danger", reset);
|
||||||
|
}
|
||||||
|
function showWarning(message, reset) {
|
||||||
|
showAlert(message, "warning", reset);
|
||||||
|
}
|
||||||
|
function showInfo(message, reset) {
|
||||||
|
showAlert(message, "info", reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy to clipboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
// Check if the clicked element is a copy icon
|
||||||
|
if (event.target.classList.contains('copy-icon')) {
|
||||||
|
const icon = event.target;
|
||||||
|
const textarea = icon.closest('.textarea-container').querySelector('textarea');
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
textarea.select();
|
||||||
|
navigator.clipboard.writeText(textarea.value).then(() => {
|
||||||
|
icon.classList.remove('fa-copy');
|
||||||
|
icon.classList.add('fa-check');
|
||||||
|
setTimeout(() => {
|
||||||
|
icon.classList.remove('fa-check');
|
||||||
|
icon.classList.add('fa-copy');
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image preview functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Reference the modal elements
|
||||||
|
const modal = document.getElementById("image-modal-container");
|
||||||
|
const modalImage = document.getElementById("modal-image");
|
||||||
|
|
||||||
|
let currentImages = [];
|
||||||
|
let currentIndex = -1;
|
||||||
|
|
||||||
|
function updateCurrentImages() {
|
||||||
|
currentImages = Array.from(document.querySelectorAll(".results_img img"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle image click (open modal)
|
||||||
|
function openImageModal(src) {
|
||||||
|
updateCurrentImages();
|
||||||
|
currentIndex = currentImages.findIndex((img) => img.src === src);
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
modalImage.src = currentImages[currentIndex].src;
|
||||||
|
modal.classList.add("modal-visible");
|
||||||
|
modal.classList.remove("modal-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImageModal() {
|
||||||
|
modal.classList.remove("modal-visible");
|
||||||
|
modal.classList.add("modal-hidden");
|
||||||
|
currentIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dynamic images added with innerHTML
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
const img = e.target.closest(".results_img img");
|
||||||
|
if (img) {
|
||||||
|
openImageModal(img.src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on modal image to download and close
|
||||||
|
modalImage.addEventListener("click", function () {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = modalImage.src;
|
||||||
|
link.download = modalImage.src.split("/").pop();
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
closeImageModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: Close modal if user clicks outside the image
|
||||||
|
modal.addEventListener("click", function (e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeImageModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard support
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (!modal.classList.contains("modal-visible")) return;
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeImageModal();
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
// Show previous image
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
currentIndex--;
|
||||||
|
modalImage.src = currentImages[currentIndex].src;
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
// Show next image
|
||||||
|
if (currentIndex < currentImages.length - 1) {
|
||||||
|
currentIndex++;
|
||||||
|
modalImage.src = currentImages[currentIndex].src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchImageInfo(submission_hash) {
|
||||||
|
const infoResp = await fetch(`/infos/${submission_hash}`);
|
||||||
|
const infoData = await infoResp.json();
|
||||||
|
|
||||||
|
const resultInfosDiv = document.getElementById("result-infos");
|
||||||
|
resultInfosDiv.innerHTML = ""; // Clear previous info
|
||||||
|
|
||||||
|
const mainImgLeft = document.createElement("div");
|
||||||
|
const mainImgRight = document.createElement("div");
|
||||||
|
const tableInfos = document.createElement("table");
|
||||||
|
mainImgLeft.id = "main-img-left";
|
||||||
|
mainImgRight.id = "main-img-right";
|
||||||
|
resultInfosDiv.appendChild(mainImgLeft);
|
||||||
|
resultInfosDiv.appendChild(mainImgRight);
|
||||||
|
mainImgRight.appendChild(tableInfos);
|
||||||
|
|
||||||
|
mainImgLeft.innerHTML += `<div id="main_image"><img src="${infoData.image_path}"/></div>`;
|
||||||
|
tableInfos.innerHTML += `<tr><td><i class="fa fa-backward-step"></i> First upload:</td><td>${infoData.first_submission_date}</td></tr>`;
|
||||||
|
tableInfos.innerHTML += `<tr><td><i class="fa fa-history"></i> Last upload:</td><td>${infoData.last_submission_date}</td></tr>`;
|
||||||
|
if (Array.isArray(infoData.names)) {
|
||||||
|
const nameList = infoData.names
|
||||||
|
.map((name) => `<code>${escapeHtml(name)}</code>`)
|
||||||
|
.join(", ");
|
||||||
|
tableInfos.innerHTML += `<tr><td><i class="fa fa-list"></i> Name(s):</td><td>${nameList}</td></tr>`;
|
||||||
|
}
|
||||||
|
tableInfos.innerHTML += `<tr><td><i class="fa fa-balance-scale"></i> Size:</td><td>${formatBytes(
|
||||||
|
infoData.size
|
||||||
|
)}</td></tr>`;
|
||||||
|
tableInfos.innerHTML += `<tr><td><i class="fa fa-upload"></i> Upload count:</td><td>${infoData.upload_count}</td></tr>`;
|
||||||
|
if (Array.isArray(infoData.passwords)) {
|
||||||
|
const passwordList = infoData.passwords
|
||||||
|
.map((pwd) => `<code>${escapeHtml(pwd)}</code>`)
|
||||||
|
.join(", ");
|
||||||
|
tableInfos.innerHTML += `<tr><td><i class="fa fa-key"></i> Common password(s):</td><td>${passwordList}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsing results from the server
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseResult(result) {
|
||||||
|
const resultDiv = document.getElementById("result-analyzers");
|
||||||
|
resultDiv.innerHTML = "";
|
||||||
|
|
||||||
|
for (const tool of TOOL_ORDER) {
|
||||||
|
if (!Object.keys(result).includes(tool)) {
|
||||||
|
// Skip if the tool is not in the result
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyzer = document.createElement("div");
|
||||||
|
analyzer.className = `analyzer a-${escapeHtml(tool)}`;
|
||||||
|
resultDiv.appendChild(analyzer);
|
||||||
|
|
||||||
|
analyzer.innerHTML += `<h2>${capitalize(tool)}</h2>`;
|
||||||
|
|
||||||
|
if (result[tool]["status"] === "ok") {
|
||||||
|
if ("images" in result[tool]) {
|
||||||
|
// Parse image output
|
||||||
|
var channels = ["Superimposed", "Red", "Green", "Blue", "Alpha"];
|
||||||
|
if (Object.keys(result[tool]["images"]).length == 1) {
|
||||||
|
channels = ["Greyscale"];
|
||||||
|
} else if (Object.keys(result[tool]["images"]).length == 4) {
|
||||||
|
channels = ["Superimposed", "Red", "Green", "Blue"];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const images = result[tool]["images"][channel];
|
||||||
|
analyzer.innerHTML += `<h3>${capitalize(escapeHtml(channel))}</h3>`;
|
||||||
|
for (const image of images) {
|
||||||
|
analyzer.innerHTML += `<div class='results_img'><img src='${escapeHtml(
|
||||||
|
image
|
||||||
|
)}'/></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parse text output
|
||||||
|
|
||||||
|
if (typeof result[tool]["output"] === "string") {
|
||||||
|
analyzer.innerHTML += `<pre>${escapeHtml(
|
||||||
|
result[tool]["output"]
|
||||||
|
)}</pre>`;
|
||||||
|
} else if (Array.isArray(result[tool]["output"])) {
|
||||||
|
if (result[tool]["output"].length > 0) {
|
||||||
|
var texarea_content = `<div class="textarea-container">`;
|
||||||
|
texarea_content += `<textarea class="form-control w-100 mb-2" rows="8" readonly>`;
|
||||||
|
for (const line of result[tool]["output"]) {
|
||||||
|
texarea_content += escapeHtml(`${line}\n`);
|
||||||
|
}
|
||||||
|
texarea_content += `</textarea>`;
|
||||||
|
texarea_content += `<i class="fas fa-copy copy-icon"></i>`;
|
||||||
|
texarea_content += `</div>`;
|
||||||
|
analyzer.innerHTML += texarea_content;
|
||||||
|
}
|
||||||
|
} else if (typeof result[tool]["output"] === "object") {
|
||||||
|
var table_content = `<div class="table-container">`;
|
||||||
|
table_content += `<table>`;
|
||||||
|
for (const key in result[tool]["output"]) {
|
||||||
|
table_content += `<tr><td>${escapeHtml(key)}</td>`;
|
||||||
|
table_content += `<td>${escapeHtml(
|
||||||
|
result[tool]["output"][key]
|
||||||
|
)}</td></tr>`;
|
||||||
|
}
|
||||||
|
table_content += `</table>`;
|
||||||
|
table_content += `</table>`;
|
||||||
|
analyzer.innerHTML += table_content;
|
||||||
|
} else {
|
||||||
|
// analyzer.innerHTML += `<pre>${result[tool]["output"]}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("download" in result[tool]) {
|
||||||
|
// Parse download link
|
||||||
|
analyzer.innerHTML += `<br/><a href="${escapeHtml(
|
||||||
|
result[tool]["download"]
|
||||||
|
)}" target="_blank" class="btn btn-primary mt-2"><i class="fa fa-download"></i> Download file</a>`;
|
||||||
|
}
|
||||||
|
} else if (result[tool]["status"] === "error") {
|
||||||
|
showDanger(result[tool]["error"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll status of a submission
|
||||||
|
async function pollStatus(submission_hash) {
|
||||||
|
if (!window.location.pathname.endsWith(`/${submission_hash}`)) {
|
||||||
|
window.history.pushState({}, "", `/${submission_hash}`);
|
||||||
|
}
|
||||||
|
const statusResp = await fetch(`/status/${submission_hash}`);
|
||||||
|
const statusData = await statusResp.json();
|
||||||
|
if (statusData.status === "completed") {
|
||||||
|
const resultResp = await fetch(`/result/${submission_hash}`);
|
||||||
|
const resultData = await resultResp.json();
|
||||||
|
fetchImageInfo(submission_hash);
|
||||||
|
if ("error" in resultData){
|
||||||
|
showWarning(`❌ ${resultData.error}`, true);
|
||||||
|
}else if ("results" in resultData) {
|
||||||
|
parseResult(resultData.results);
|
||||||
|
}
|
||||||
|
} else if (statusData.status === "error") {
|
||||||
|
//resultDiv.innerHTML = "";
|
||||||
|
showDanger("❌ Error during the analysis.", true);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => pollStatus(submission_hash), 1000);
|
||||||
|
try {
|
||||||
|
const resultResp = await fetch(`/result/${submission_hash}`);
|
||||||
|
const resultData = await resultResp.json();
|
||||||
|
if ("error" in resultData){
|
||||||
|
// showWarning(`❌ ${resultData.error}`, true);
|
||||||
|
}else if ("results" in resultData) {
|
||||||
|
fetchImageInfo(submission_hash);
|
||||||
|
parseResult(resultData.results);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.dir(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag and drop functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
const dropZone = document.getElementById("drop-zone");
|
||||||
|
const fileInput = document.getElementById("image");
|
||||||
|
const browseBtn = document.getElementById("browse-btn");
|
||||||
|
const dragMsg = document.getElementById("drag-msg");
|
||||||
|
|
||||||
|
function showFilename(filename) {
|
||||||
|
dragMsg.innerHTML = "🖼️ " + escapeHtml(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browseBtn) {
|
||||||
|
// Clicking the button triggers file input
|
||||||
|
browseBtn.addEventListener("click", () => fileInput.click());
|
||||||
|
dropZone.addEventListener("click", () => fileInput.click());
|
||||||
|
dropZone.addEventListener("dragover", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.style.boxShadow = "0 0 20px #9fef00";
|
||||||
|
});
|
||||||
|
dropZone.addEventListener("dragleave", () => {
|
||||||
|
dropZone.style.boxShadow = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag events
|
||||||
|
["dragenter", "dragover"].forEach((eventName) => {
|
||||||
|
dropZone.addEventListener(eventName, (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add("dragover");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
["dragleave", "drop"].forEach((eventName) => {
|
||||||
|
dropZone.addEventListener(eventName, (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove("dragover");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop file
|
||||||
|
dropZone.addEventListener("drop", (e) => {
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
fileInput.files = files; // Attach files to the hidden input
|
||||||
|
showFilename(fileInput.files[0].name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
if (fileInput.files.length > 0) {
|
||||||
|
showFilename(fileInput.files[0].name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File upload Form submission
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("upload-form")
|
||||||
|
.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const fileInput = document.getElementById("image");
|
||||||
|
const passwordInput = document.getElementById("password");
|
||||||
|
const deepCheckbox = document.getElementById("deep");
|
||||||
|
const progressContainer = document.getElementById("progress-container");
|
||||||
|
const progressBar = document.getElementById("progress-bar");
|
||||||
|
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
showDanger("Please select an image.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", fileInput.files[0]);
|
||||||
|
formData.append("password", passwordInput.value);
|
||||||
|
formData.append("deep", deepCheckbox.checked);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "/upload", true);
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("loadstart", () => {
|
||||||
|
progressContainer.classList.remove("d-none");
|
||||||
|
progressBar.style.width = "0%";
|
||||||
|
progressBar.textContent = "0%";
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("progress", (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
|
progressBar.style.width = percent + "%";
|
||||||
|
progressBar.textContent = percent + "%";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
if (response.submission_hash) {
|
||||||
|
pollStatus(response.submission_hash);
|
||||||
|
} else {
|
||||||
|
showDanger(
|
||||||
|
"❌ Invalid server response: missing submission_hash.",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showDanger("❌ Invalid server response.", true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showDanger(`❌ HTTP error ${xhr.status}`, true);
|
||||||
|
}
|
||||||
|
progressBar.style.width = "100%";
|
||||||
|
progressBar.textContent = "Upload complete";
|
||||||
|
const uploadForm = document.getElementById("upload-form");
|
||||||
|
slideUp(uploadForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
showDanger("❌ An error occurred during the transfer", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import Stats from "three/addons/stats.module.js";
|
||||||
|
|
||||||
|
let container, stats;
|
||||||
|
let camera, scene, renderer, mesh;
|
||||||
|
|
||||||
|
const instances = 5000;
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
const moveQ = new THREE.Quaternion(0.5, 0.5, 0.5, 0.0).normalize();
|
||||||
|
const tmpQ = new THREE.Quaternion();
|
||||||
|
const tmpM = new THREE.Matrix4();
|
||||||
|
const currentM = new THREE.Matrix4();
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
container = document.getElementById("three-bg");
|
||||||
|
|
||||||
|
camera = new THREE.PerspectiveCamera(
|
||||||
|
50,
|
||||||
|
window.innerWidth / window.innerHeight,
|
||||||
|
1,
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
// Get the computed background color of the body
|
||||||
|
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
||||||
|
scene.background = new THREE.Color(bodyBg);
|
||||||
|
|
||||||
|
// geometry
|
||||||
|
|
||||||
|
const geometry = new THREE.InstancedBufferGeometry();
|
||||||
|
|
||||||
|
// per mesh data x,y,z,w,u,v,s,t for 4-element alignment
|
||||||
|
// only use x,y,z and u,v; but x, y, z, nx, ny, nz, u, v would be a good layout
|
||||||
|
const vertexBuffer = new THREE.InterleavedBuffer(
|
||||||
|
new Float32Array([
|
||||||
|
// Front
|
||||||
|
-1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, -1, -1, 1, 0, 0, 1, 0, 0,
|
||||||
|
1, -1, 1, 0, 1, 1, 0, 0,
|
||||||
|
// Back
|
||||||
|
1, 1, -1, 0, 1, 0, 0, 0, -1, 1, -1, 0, 0, 0, 0, 0, 1, -1, -1, 0, 1, 1, 0,
|
||||||
|
0, -1, -1, -1, 0, 0, 1, 0, 0,
|
||||||
|
// Left
|
||||||
|
-1, 1, -1, 0, 1, 1, 0, 0, -1, 1, 1, 0, 1, 0, 0, 0, -1, -1, -1, 0, 0, 1, 0,
|
||||||
|
0, -1, -1, 1, 0, 0, 0, 0, 0,
|
||||||
|
// Right
|
||||||
|
1, 1, 1, 0, 1, 0, 0, 0, 1, 1, -1, 0, 1, 1, 0, 0, 1, -1, 1, 0, 0, 0, 0, 0,
|
||||||
|
1, -1, -1, 0, 0, 1, 0, 0,
|
||||||
|
// Top
|
||||||
|
-1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, -1, 1, -1, 0, 0, 1, 0, 0,
|
||||||
|
1, 1, -1, 0, 1, 1, 0, 0,
|
||||||
|
// Bottom
|
||||||
|
1, -1, 1, 0, 1, 0, 0, 0, -1, -1, 1, 0, 0, 0, 0, 0, 1, -1, -1, 0, 1, 1, 0,
|
||||||
|
0, -1, -1, -1, 0, 0, 1, 0, 0,
|
||||||
|
]),
|
||||||
|
8
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use vertexBuffer, starting at offset 0, 3 items in position attribute
|
||||||
|
const positions = new THREE.InterleavedBufferAttribute(vertexBuffer, 3, 0);
|
||||||
|
geometry.setAttribute("position", positions);
|
||||||
|
// Use vertexBuffer, starting at offset 4, 2 items in uv attribute
|
||||||
|
const uvs = new THREE.InterleavedBufferAttribute(vertexBuffer, 2, 4);
|
||||||
|
geometry.setAttribute("uv", uvs);
|
||||||
|
|
||||||
|
const indices = new Uint16Array([
|
||||||
|
0, 2, 1, 2, 3, 1, 4, 6, 5, 6, 7, 5, 8, 10, 9, 10, 11, 9, 12, 14, 13, 14, 15,
|
||||||
|
13, 16, 17, 18, 18, 17, 19, 20, 21, 22, 22, 21, 23,
|
||||||
|
]);
|
||||||
|
|
||||||
|
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
|
||||||
|
// material
|
||||||
|
|
||||||
|
const material = new THREE.MeshBasicMaterial();
|
||||||
|
material.map = new THREE.TextureLoader().load("static/img/octo2.png");
|
||||||
|
material.map.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
material.map.flipY = false;
|
||||||
|
material.transparent = true;
|
||||||
|
material.opacity = 0.3;
|
||||||
|
|
||||||
|
// per instance data
|
||||||
|
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
const offset = new THREE.Vector3();
|
||||||
|
const orientation = new THREE.Quaternion();
|
||||||
|
const scale = new THREE.Vector3(1, 1, 1);
|
||||||
|
let x, y, z, w;
|
||||||
|
|
||||||
|
mesh = new THREE.InstancedMesh(geometry, material, instances);
|
||||||
|
|
||||||
|
for (let i = 0; i < instances; i++) {
|
||||||
|
// offsets
|
||||||
|
|
||||||
|
x = Math.random() * 100 - 50;
|
||||||
|
y = Math.random() * 100 - 50;
|
||||||
|
z = Math.random() * 100 - 50;
|
||||||
|
|
||||||
|
offset.set(x, y, z).normalize();
|
||||||
|
offset.multiplyScalar(5); // move out at least 5 units from center in current direction
|
||||||
|
offset.set(x + offset.x, y + offset.y, z + offset.z);
|
||||||
|
|
||||||
|
// orientations
|
||||||
|
|
||||||
|
x = Math.random() * 2 - 1;
|
||||||
|
y = Math.random() * 2 - 1;
|
||||||
|
z = Math.random() * 2 - 1;
|
||||||
|
w = Math.random() * 2 - 1;
|
||||||
|
|
||||||
|
orientation.set(x, y, z, w).normalize();
|
||||||
|
|
||||||
|
matrix.compose(offset, orientation, scale);
|
||||||
|
|
||||||
|
mesh.setMatrixAt(i, matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer();
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
renderer.setAnimationLoop(animate);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
stats = new Stats();
|
||||||
|
container.appendChild(stats.dom);
|
||||||
|
|
||||||
|
window.addEventListener("resize", onWindowResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowResize() {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
const time = performance.now();
|
||||||
|
|
||||||
|
mesh.rotation.y = time * 0.00005;
|
||||||
|
|
||||||
|
const delta = (time - lastTime) / 5000;
|
||||||
|
tmpQ.set(moveQ.x * delta, moveQ.y * delta, moveQ.z * delta, 1).normalize();
|
||||||
|
tmpM.makeRotationFromQuaternion(tmpQ);
|
||||||
|
|
||||||
|
for (let i = 0, il = instances; i < il; i++) {
|
||||||
|
mesh.getMatrixAt(i, currentM);
|
||||||
|
currentM.multiply(tmpM);
|
||||||
|
mesh.setMatrixAt(i, currentM);
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
mesh.computeBoundingSphere();
|
||||||
|
|
||||||
|
lastTime = time;
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
|
||||||
|
stats.update();
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
var Stats = function () {
|
||||||
|
var mode = 0;
|
||||||
|
|
||||||
|
var container = document.createElement("div");
|
||||||
|
container.style.cssText =
|
||||||
|
"position:fixed;top:0;left:0;cursor:pointer;opacity:0.9;z-index:10000";
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
function addPanel(panel) {
|
||||||
|
container.appendChild(panel.dom);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPanel(id) {
|
||||||
|
for (var i = 0; i < container.children.length; i++) {
|
||||||
|
container.children[i].style.display = i === id ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var beginTime = (performance || Date).now(),
|
||||||
|
prevTime = beginTime,
|
||||||
|
frames = 0;
|
||||||
|
|
||||||
|
var fpsPanel = addPanel(new Stats.Panel("FPS", "#0ff", "#f00"));
|
||||||
|
var msPanel = addPanel(new Stats.Panel("MS", "#0f0", "#f00"));
|
||||||
|
|
||||||
|
if (self.performance && self.performance.memory) {
|
||||||
|
var memPanel = addPanel(new Stats.Panel("MB", "#f08", "#f00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
showPanel(-1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
REVISION: 16,
|
||||||
|
|
||||||
|
dom: container,
|
||||||
|
|
||||||
|
addPanel: addPanel,
|
||||||
|
showPanel: showPanel,
|
||||||
|
|
||||||
|
begin: function () {
|
||||||
|
beginTime = (performance || Date).now();
|
||||||
|
},
|
||||||
|
|
||||||
|
end: function () {
|
||||||
|
frames++;
|
||||||
|
|
||||||
|
var time = (performance || Date).now();
|
||||||
|
|
||||||
|
msPanel.update(time - beginTime, 200);
|
||||||
|
|
||||||
|
if (time >= prevTime + 1000) {
|
||||||
|
fpsPanel.update((frames * 1000) / (time - prevTime), 100);
|
||||||
|
|
||||||
|
prevTime = time;
|
||||||
|
frames = 0;
|
||||||
|
|
||||||
|
if (memPanel) {
|
||||||
|
var memory = performance.memory;
|
||||||
|
memPanel.update(
|
||||||
|
memory.usedJSHeapSize / 1048576,
|
||||||
|
memory.jsHeapSizeLimit / 1048576
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: function () {
|
||||||
|
beginTime = this.end();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backwards Compatibility
|
||||||
|
|
||||||
|
domElement: container,
|
||||||
|
setMode: showPanel,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Stats.Panel = function (name, fg, bg) {
|
||||||
|
var min = Infinity,
|
||||||
|
max = 0,
|
||||||
|
round = Math.round;
|
||||||
|
var PR = round(window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
var WIDTH = 80 * PR,
|
||||||
|
HEIGHT = 48 * PR,
|
||||||
|
TEXT_X = 3 * PR,
|
||||||
|
TEXT_Y = 2 * PR,
|
||||||
|
GRAPH_X = 3 * PR,
|
||||||
|
GRAPH_Y = 15 * PR,
|
||||||
|
GRAPH_WIDTH = 74 * PR,
|
||||||
|
GRAPH_HEIGHT = 30 * PR;
|
||||||
|
|
||||||
|
var canvas = document.createElement("canvas");
|
||||||
|
canvas.width = WIDTH;
|
||||||
|
canvas.height = HEIGHT;
|
||||||
|
canvas.style.cssText = "width:80px;height:48px";
|
||||||
|
|
||||||
|
var context = canvas.getContext("2d");
|
||||||
|
context.font = "bold " + 9 * PR + "px Helvetica,Arial,sans-serif";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
|
||||||
|
context.fillStyle = bg;
|
||||||
|
context.fillRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
context.fillStyle = fg;
|
||||||
|
context.fillText(name, TEXT_X, TEXT_Y);
|
||||||
|
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
|
||||||
|
|
||||||
|
context.fillStyle = bg;
|
||||||
|
context.globalAlpha = 0.9;
|
||||||
|
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom: canvas,
|
||||||
|
|
||||||
|
update: function (value, maxValue) {
|
||||||
|
min = Math.min(min, value);
|
||||||
|
max = Math.max(max, value);
|
||||||
|
|
||||||
|
context.fillStyle = bg;
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.fillRect(0, 0, WIDTH, GRAPH_Y);
|
||||||
|
context.fillStyle = fg;
|
||||||
|
context.fillText(
|
||||||
|
round(value) + " " + name + " (" + round(min) + "-" + round(max) + ")",
|
||||||
|
TEXT_X,
|
||||||
|
TEXT_Y
|
||||||
|
);
|
||||||
|
|
||||||
|
context.drawImage(
|
||||||
|
canvas,
|
||||||
|
GRAPH_X + PR,
|
||||||
|
GRAPH_Y,
|
||||||
|
GRAPH_WIDTH - PR,
|
||||||
|
GRAPH_HEIGHT,
|
||||||
|
GRAPH_X,
|
||||||
|
GRAPH_Y,
|
||||||
|
GRAPH_WIDTH - PR,
|
||||||
|
GRAPH_HEIGHT
|
||||||
|
);
|
||||||
|
|
||||||
|
context.fillRect(GRAPH_X + GRAPH_WIDTH - PR, GRAPH_Y, PR, GRAPH_HEIGHT);
|
||||||
|
|
||||||
|
context.fillStyle = bg;
|
||||||
|
context.globalAlpha = 0.9;
|
||||||
|
context.fillRect(
|
||||||
|
GRAPH_X + GRAPH_WIDTH - PR,
|
||||||
|
GRAPH_Y,
|
||||||
|
PR,
|
||||||
|
round((1 - value / maxValue) * GRAPH_HEIGHT)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stats;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Aperi'Solve</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/img/logo.svg" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fira+Code&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
<link href="/static/css/aperisolve.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="text-light">
|
||||||
|
<div id="three-bg"></div>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<a class="title_link" href="/">
|
||||||
|
<h1 class="mb-4"><img id="water-logo" src="/static/img/logo.svg" /> Aperi'Solve</h1>
|
||||||
|
</a>
|
||||||
|
{% block body_content %} {% endblock %}
|
||||||
|
</div>
|
||||||
|
<!-- Modal Structure -->
|
||||||
|
<div id="image-modal-container">
|
||||||
|
<img id="modal-image" src="" />
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/aperisolve.js"></script>
|
||||||
|
<!-- Background -->
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "./static/js/three.module.min.js",
|
||||||
|
"three/addons/": "./static/js/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/static/js/background.js"></script>
|
||||||
|
{% block footer_content %} {% endblock %}
|
||||||
|
{% include "footer.html" %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block body_content %}
|
||||||
|
<div id="cheatsheet">
|
||||||
|
<h1>Cheatsheet</h1>
|
||||||
|
<h2>Disclaimer</h2>
|
||||||
|
<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 😉.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2>Check file type / metadata</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Check the file extension on the internet if you don't know it. Also check the file format.</li>
|
||||||
|
<li>Use the <span class='highlight'>file</span> command to check that the extension matches the file type. This
|
||||||
|
command is based on the <a href='https://en.wikipedia.org/wiki/List_of_file_signatures' target='_blank'
|
||||||
|
class='highlight'>Magic Bytes</a> at the beginning of the file, so it may return false positives.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command"><span class="highlight">$</span> file data.raw
|
||||||
|
<div class="clipboard" data-value='file data.raw'></div>
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>In case of unknown extension and file, inspect the hexadecimal structure of the file with an editor like <a
|
||||||
|
href='https://hexed.it/' target='_blank' class='highlight'>hexed.it</a> to identify the file structure.</li>
|
||||||
|
<li>Display the file's metadata with the <span class='highlight'>exiftool</span> command.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> exiftool data.raw
|
||||||
|
<div class="clipboard" data-value='file data.raw'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="filestruct">Modify the file structure</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Modify the file structure with a hexadecimal editor like <a href='https://hexed.it/' target='_blank'
|
||||||
|
class='highlight'>hexed.it</a>. PNG chunks can be easily edited with <a
|
||||||
|
href='http://entropymine.com/jason/tweakpng/' target='_blank' class='highlight'>TweakPNG</a> (Windows or
|
||||||
|
Wine).</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img src="/static/img/cheatsheet/tweakpng.png" alt="TweakPNG" />
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
correct these checksums.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> pngcheck -c file.png<br />
|
||||||
|
<span class="highlight">$</span> PCRT.py -v -i file.png
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>For many types of files (PNG, WAV, ...) the media size is contained in the headers. This size can then be
|
||||||
|
reduced to display only a part of the media (beginning of an image, beginning of a sound, ...).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="rawdata">Raw data</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>It is possible to import a raw file into <a href='https://www.audacityteam.org/download/' target='_blank'
|
||||||
|
class='highlight'>Audacity</a> to listen to it as a soundtrack (File > Import > Raw Data).</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img src="/static/img/cheatsheet/audacity_raw.png" alt="Audacity" />
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Similarly, the <a href='https://www.gimp.org/downloads/' target='_blank' class='highlight'>GIMP</a> tool
|
||||||
|
allows image import from raw data (File > Open).</li>
|
||||||
|
<li>The <a href='http://binvis.io/' target='_blank' class='highlight'>BinVis.io</a> tool allows you to
|
||||||
|
graphically view binary files and can give hints as to the file type.</li>
|
||||||
|
<li>The <span class='highlight'>strings</span> command allows you to display the strings present in a file.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> strings -s file.raw<br />
|
||||||
|
<span class="highlight">$</span> strings -S file.raw<br />
|
||||||
|
<span class="highlight">$</span> strings -b file.raw<br />
|
||||||
|
<span class="highlight">$</span> strings -l file.raw<br />
|
||||||
|
<span class="highlight">$</span> strings -B file.raw<br />
|
||||||
|
<span class="highlight">$</span> strings -L file.raw
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="image">Image</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<table class="cstable center">
|
||||||
|
<tr>
|
||||||
|
<th class="table_tl_none"></th>
|
||||||
|
<th>PNG</th>
|
||||||
|
<th>JPG/JPEG</th>
|
||||||
|
<th>BMP</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Aperi'Solve</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Zsteg</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
<td class="hred">KO</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Steghide</td>
|
||||||
|
<td class="hred">KO</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>OutGuess</td>
|
||||||
|
<td class="hred">KO</td>
|
||||||
|
<td class="hgreen">OK</td>
|
||||||
|
<td class="hred">KO</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>The robustness of steganography relies on the algorithm used and the knowledge of the cover medium. A search
|
||||||
|
of the original media allows us to make a comparison and identify the alterations made. A reverse search on <a
|
||||||
|
href='https://images.google.com/' target='_blank' class='highlight'>Google Image</a> or <a
|
||||||
|
href='https://yandex.com/images/' target='_blank' class='highlight'>Yandex Images</a> can find the original
|
||||||
|
media (be sure to check that the file size and type match).</li>
|
||||||
|
<li>The <a href='http://www.caesum.com/handbook/Stegsolve.jar' target='_blank' class='highlight'>Stegsolve</a>
|
||||||
|
tool allows you to perform operations on 2 images, and thus identify the differences between a cover medium
|
||||||
|
and a stegano medium using the XOR operation.</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img src="/static/img/cheatsheet/stegsolve_xor.png" alt="Stegsolve Xor" />
|
||||||
|
<img src="/static/img/cheatsheet/stegsolve_xor2.png" alt="Stegsolve Xor 2" />
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>The <a href='https://github.com/zed-0xff/zsteg' target='_blank' class='highlight'>Zsteg</a> tool allows you
|
||||||
|
to extract messages and binaries encoded on different layers, such as the 2 green LSB.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> zsteg file.png 2b,g,lsb,xy<br />
|
||||||
|
<span class="highlight">$</span> zsteg file.png -E '1b,rgb,lsb'
|
||||||
|
<div class="clipboard" data-value='zsteg file.png'></div>
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Sometimes, bit-layer analysis on the <a href='http://www.caesum.com/handbook/Stegsolve.jar' target='_blank'
|
||||||
|
class='highlight'>Stegsolve</a> tool (or Aperi'Solve) can highlight specific areas that have been altered.
|
||||||
|
Scripting may then be required to extract specific areas. The following python code retrieves an image as a
|
||||||
|
list of pixels: [(100,120,43), (230, 124, 110), ...]</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
# pip install Pillow<br />
|
||||||
|
from PIL import Image<br />
|
||||||
|
stegano_image = Image.open('file.png')<br />
|
||||||
|
width, height = stegano_image.size<br />
|
||||||
|
pxs = list(stegano_image.getdata())<br />
|
||||||
|
print(pxs[:10])
|
||||||
|
<div class="clipboard" data-value='# pip install Pillow
|
||||||
|
from PIL import Image
|
||||||
|
stegano_image = Image.open("file.png")
|
||||||
|
width, height = stegano_image.size
|
||||||
|
pxs = list(stegano_image.getdata())
|
||||||
|
print(pxs[:10])'></div>
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Stegano mediums can rely on an algorithm using an encryption key. This is the case for <a
|
||||||
|
href='http://steghide.sourceforge.net/' target='_blank' class='highlight'>Steghide</a> and <a
|
||||||
|
href='https://github.com/resurrecting-open-source-projects/outguess' target='_blank'
|
||||||
|
class='highlight'>OutGuess</a>. The password can be the name of the file, a string of characters contained
|
||||||
|
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
|
||||||
|
bruteforce of hidden secrets with <a href='http://steghide.sourceforge.net/' target='_blank'
|
||||||
|
class='highlight'>Steghide</a>.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> steghide extract -p "secret" -sf file.jpg<br />
|
||||||
|
<span class="highlight">$</span> stegcracker file.jpg /usr/share/wordlists/rockyou.txt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="png">PNG (Portable Network Graphics)</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Tools like <a href='http://steghide.sourceforge.net/' target='_blank' class='highlight'>Steghide</a> and <a
|
||||||
|
href='https://github.com/resurrecting-open-source-projects/outguess' target='_blank'
|
||||||
|
class='highlight'>OutGuess</a> does not work for PNG files. On the other hand, this type of file allows a
|
||||||
|
backup without compression, and therefore to hide messages on the LSBs. The tool <a
|
||||||
|
href='https://github.com/zed-0xff/zsteg' target='_blank' class='highlight'>Zsteg</a> presented above and
|
||||||
|
available on Aperi'Solve allows the extracting secrets from PNG files.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> zsteg file.png 2b,g,lsb,xy<br />
|
||||||
|
<span class="highlight">$</span> zsteg file.png -E '1b,rgb,lsb'
|
||||||
|
<div class="clipboard" data-value='zsteg file.png'></div>
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>PNG offers an <a href='https://en.wikipedia.org/wiki/APNG' target='_blank' class='highlight'>"APNG"</a>
|
||||||
|
extension allowing to have an animation of images like GIF files. It is possible that the visualization of the
|
||||||
|
PNG does not reflect all of its content (for example, a frame with a zero duration, or a very long first
|
||||||
|
frame). The tool <a href='https://ezgif.com/apng-maker' target='_blank' class='highlight'>APNG Maker</a>
|
||||||
|
allows you to view the different frames of an APNG file. A message can also be encoded on the durations of
|
||||||
|
each frame.</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img src="https://upload.wikimedia.org/wikipedia/commons/1/14/Animated_PNG_example_bouncing_beach_ball.png"
|
||||||
|
alt="APNG" />
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>The tool <a href='http://entropymine.com/jason/tweakpng/' target='_blank' class='highlight'>TweakPNG</a>
|
||||||
|
presented earlier allows to perform several types of operations like recalculate checksums, reorder PNG
|
||||||
|
chunks, increase the visible size of the PNG or modify a header.</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img src="/static/img/cheatsheet/tweakpng.png" alt="TweakPNG" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="jpg">JPEG (Joint Photographic Experts Group)</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>The tool <a href='https://github.com/zed-0xff/zsteg' target='_blank' class='highlight'>Zsteg</a> does not
|
||||||
|
work on JPEG files (jpg, jpeg, ...) because the latter necessarily include a compression algorithm reducing
|
||||||
|
the quality of the image and altering the LSBs. In contrast, the tools <a
|
||||||
|
href='http://steghide.sourceforge.net/' target='_blank' class='highlight'>Steghide</a> and <a
|
||||||
|
href='https://github.com/resurrecting-open-source-projects/outguess' target='_blank'
|
||||||
|
class='highlight'>OutGuess</a> shown above are compatible with this file format.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> outguess -k secret file.jpg output.raw<br />
|
||||||
|
<span class="highlight">$</span> steghide extract -p "secret" -sf file.jpg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="gif">GIF (Graphics Interchange Format)</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>GIFs are generally animated images whose format is generally poorly supported by steganalysis tools. In
|
||||||
|
addition, the image format generally offers a color palette not exceeding 256 colors, against 16 million for
|
||||||
|
PNG (excluding transparency). However, the tool <a href='https://ezgif.com/maker' target='_blank'
|
||||||
|
class='highlight'>GIF Maker</a> makes it easy to manipulate GIF files.</li>
|
||||||
|
<li>GIFs can embed images (frames) with zero duration. These will then not be viewable without an analysis of
|
||||||
|
each frame.</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img src="/static/img/cheatsheet/gif_maker.png" alt="GIF Maker" />
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>A hidden message can be embedded over the duration of the frames (morse, ascii, binary, ...).</li>
|
||||||
|
<li><a href='https://ffmpeg.org/' target='_blank' class='highlight'>Ffmpeg</a> can be used to extract frames
|
||||||
|
from a GIF. Completeness of extracted frames cannot be guaranteed.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> ffmpeg -i file.gif -vsync 0 output/file%d.png
|
||||||
|
<div class="clipboard" data-value='ffmpeg -i file.gif -vsync 0 output/file%d.png'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="audio">Audio</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>The most common technique for sound files is based on the audio spectrum. Indeed, it is possible to draw a
|
||||||
|
visible message on the audio spectrum with tools like <a href='https://www.abc.se/~re/Coagula/Coagula.html'
|
||||||
|
target='_blank' class='highlight'>Coagula</a>. Then this spectrum can be analyzed using spectral analysis
|
||||||
|
tools like <a href='https://www.sonicvisualiser.org/' target='_blank' class='highlight'>Sonic Visualiser</a>,
|
||||||
|
<a href='https://www.audacityteam.org/' target='_blank' class='highlight'>Audacity</a> or the online tool <a
|
||||||
|
href='https://www.dcode.fr/spectral-analysis' target='_blank' class='highlight'>dcode</a>.</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<img src="/static/img/cheatsheet/audacity_1.png" alt="Audacity spectrum" />
|
||||||
|
</div>
|
||||||
|
<br />Right-clicking the scale to zoom out and view the entire spectrum, or switch to logarithm mode, may be
|
||||||
|
required.
|
||||||
|
<div style="text-align: center;"><br />
|
||||||
|
<img src="/static/img/cheatsheet/audacity_2.png" alt="Audacity spectrum" />
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Inaudible frequencies can be used to hide messages. A spectral analysis beyond 20KHz and below 20Hz is
|
||||||
|
recommended, a message encoded in morse or binary can be hidden there.</li>
|
||||||
|
<li>Sounds are simple telecommunications signals that can be used for other purposes. Special encoding methods
|
||||||
|
can be used, such as <a href='https://en.wikipedia.org/wiki/Dual-tone_multi-frequency_signaling'
|
||||||
|
target='_blank' class='highlight'>DTMF Code</a> formerly used in telephony or <a
|
||||||
|
href='https://en.wikipedia.org/wiki/Slow-scan_television' target='_blank' class='highlight'>SSTV</a> used in
|
||||||
|
television for image transfer. These signals are encoded on audible frequencies but their interpretations
|
||||||
|
require special decoders. We will then opt for a <a href='http://dialabc.com/sound/detect/' target='_blank'
|
||||||
|
class='highlight'>DTMF decoder</a> for the first technique, and an SSTV decoder like <a
|
||||||
|
href='https://doc.ubuntu-fr.org/qsstv' target='_blank' class='highlight'>QSSTV</a> for the second one.</li>
|
||||||
|
<li>Just like PNG files, some sound files embed their theoretical sizes in their headers. This is particularly
|
||||||
|
the case for <a href='https://fr.wikipedia.org/wiki/Waveform_Audio_File_Format#En-t%C3%AAte_de_fichier_WAV'
|
||||||
|
target='_blank' class='highlight'>WAV files</a> whose DataSize block can be decreased or increased using a
|
||||||
|
hex editor.</li>
|
||||||
|
<li>The LSB technique can be used on certain audio files. It is then necessary to extract the LSBs from the
|
||||||
|
audio data; the <a href='https://github.com/ragibson/Steganography#WavSteg' target='_blank'
|
||||||
|
class='highlight'>WavSteg</a> tool allows you to perform this manipulation on WAV files.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">$</span> stegolsb wavsteg -r -i file.wav -o output.txt -n 1 -b 1000
|
||||||
|
<div class="clipboard" data-value='stegolsb wavsteg -r -i file.wav -o output.txt -n 1 -b 1000'></div>
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>The tools <a href='http://steghide.sourceforge.net/' target='_blank' class='highlight'>Steghide</a> and <a
|
||||||
|
href='http://jpinsoft.net/deepsound/overview.aspx' target='_blank' class='highlight'>DeepSound</a> are
|
||||||
|
commonly used in CTF to hide messages. These can take a key as a parameter, so you will have to guess this key
|
||||||
|
from the available information (media, file name, challenge name, etc.) or by using a wordlist.</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;"><br />
|
||||||
|
<img src="/static/img/cheatsheet/DeepSound.png" alt="DeepSound" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="polyglot">Polyglot Files</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
This is a file type that is valid for different file formats. For example an image file which can therefore be
|
||||||
|
viewed and which is also a jar file (which can be executed). There are several types of polyglot file types:
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>"Simple" polyglot: This is a simple concatenation of files;</li>
|
||||||
|
<li>"Parasitic" polyglot: This is a file that contains another type of file.</li>
|
||||||
|
<li>"Mille-feuilles" polyglot: The layers are alternated by controlling the internal structure of the file.</li>
|
||||||
|
<li>"Chimera" polyglot: The file has a body (data) and several heads. Since several formats use the same
|
||||||
|
algorithm to store data, such as Zlib's Deflate, the same block of data is used by different headers. (for
|
||||||
|
example, the pixels of an image). Several headers are present so that this image is visible via several
|
||||||
|
formats (jpg, png, ..), within the same file.</li>
|
||||||
|
<li>"Schizophrenic" files: This is a single type of file, but its contents are interpreted differently depending
|
||||||
|
on the tool that is running or accessing this file. These are usually PDF files (interpretation or not of
|
||||||
|
javascript) or Images (like the technique of <a href='https://carlmastrangelo.com/blog/gamma-steganography'
|
||||||
|
target='_blank' class='highlight '>Gamma</a>).</li>
|
||||||
|
<li>Angecryption : The result of an encryption or decryption of a file gives another valid file of the same type
|
||||||
|
or of a different type:</li>
|
||||||
|
</ul>
|
||||||
|
<div style="text-align: center;"><br />
|
||||||
|
<img src="/static/img/cheatsheet/Angecryption.png" alt="Angecryption" />
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Docx, jar, apk, pptx, odf... files are valid archives and can be decompressed. You must then check whether
|
||||||
|
the file in question can be opened with a tool other than the one associated with its extension. The <span
|
||||||
|
class='highlight'>file</span> command does not identify polyglot files.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="text">Encoding and exotic languages</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>ASCII text can be encoded in binary in blocks of <span class='highlight'>8 bits</span>, it is then easily
|
||||||
|
identifiable because each block corresponding to a letter begins with a 0. It is possible to remove this 0 ,
|
||||||
|
the blocks are then only <span class='highlight'>7 bits</span>. The text can also be coded on an integer of
|
||||||
|
type <span class='highlight'>long</span> (sometimes in a base other than base 10), for example the character
|
||||||
|
string “Hello world! encoded on a long type in base 10 can be decoded with the following python 2 code:</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span class="highlight">python2></span> hex(22405534230753963835153736737L)[2:].strip('L').decode("hex")<br />
|
||||||
|
<span class="highlight">python2></span> "48656c6c6f20776f726c6421".decode("hex")
|
||||||
|
<div class="clipboard" data-value='hex(long_var)[2:].strip("L(").decode("hex")'></div>
|
||||||
|
</div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li>Tools like <a href='https://gchq.github.io/CyberChef/' target='_blank' class='highlight'>CyberChef</a> (and
|
||||||
|
the "magic" option) can help detect the encoding used and decode the hidden message. Sometimes a charset
|
||||||
|
change may be necessary (monoalphabetic substitution, or base change).</li>
|
||||||
|
<li>Exotic or esoteric languages are programming languages designed to be unique, difficult to program, or just
|
||||||
|
plain weird. Their design and their charsets can be particular, it is the case of the most found languages in
|
||||||
|
CTF like <a href='https://esolangs.org/wiki/Brainfuck' target='_blank' class='highlight'>BrainFuck</a>, <a
|
||||||
|
href='https://esolangs.org/wiki/Whitespace' target='_blank' class='highlight'>WhiteSpace</a>, <a
|
||||||
|
href='https://esolangs.org/wiki/Piet' target='_blank' class='highlight'>PIET</a> or <a
|
||||||
|
href='https://esolangs.org/wiki/Malbolge' target='_blank' class='highlight'>Malbolge</a>. The website <a
|
||||||
|
href='https://esolangs.org/wiki/Language_list' target='_blank' class='highlight'>EsoLang</a> references
|
||||||
|
these different languages and can be useful in your research.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="command">
|
||||||
|
<span
|
||||||
|
class="highlight">brainfuck></span><br />++++++++++[>+>+++>+++++++>++++++++++<<<<-]>>>++.>+.+++++++..+++.<<++.>>++++++++.--------.+++.------.--------.<<+.
|
||||||
|
<br /><br /><span
|
||||||
|
class="highlight">malbolge></span><br />('&%:9]!~}|z2Vxwv-,POqponl$Hjig%eB@@>}=<M:9wv6WsU2T|nm-,jcL(I&%$#"
|
||||||
|
`CB]V?Tx<uVtT`Rpo3NlF.Jh++FdbCBA@?]!~|4XzyTT43Qsqq(Lnmkj"Fhg${z@>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href='https://esolangs.org/wiki/Piet' target="_blank"><img src="/static/img/cheatsheet/piet.png"
|
||||||
|
alt="PIET" /></a>
|
||||||
|
</div><br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 id="more">To go further</h2>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ul class="fa-ul">
|
||||||
|
<li><a href='https://www.aperikube.fr/cat/steg/' target='_blank' class='highlight'> Aperi'Kube's Blog -
|
||||||
|
Stéganography category</a></li>
|
||||||
|
<li><a href='https://ctftime.org/writeups' target='_blank' class='highlight'>CTF Time - Writeups (Stegano)</a>
|
||||||
|
</li>
|
||||||
|
<li><a href='http://corkami.github.io/' target='_blank' class='highlight'>Corkami</a></li>
|
||||||
|
<li><a href='https://www.root-me.org/en/Challenges/Steganography/' target='_blank' class='highlight'>Root-Me -
|
||||||
|
Steganography</a></li>
|
||||||
|
<li><a href='https://en.wikipedia.org/wiki/Autostereogram' target='_blank' class='highlight'>Stereogram</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<footer class="d-flex justify-content-center gap-4 my-4">
|
||||||
|
<div>
|
||||||
|
<a href="/faq" title="FAQ" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-regular fa-circle-question"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://github.com/Zeecka/AperiSolve" target="_blank" title="GitHub"
|
||||||
|
class="text-light text-decoration-none">
|
||||||
|
<i class="fa-brands fa-github"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://x.com/Zeecka_" target="_blank" title="Twitter" class="text-light text-decoration-none">
|
||||||
|
<i class="fa-brands fa-x-twitter"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block body_content %}
|
||||||
|
<form id="upload-form" class="card p-4" method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div>
|
||||||
|
<h2>What is this ?</h2>
|
||||||
|
<p>Aperi'Solve is an online platform which performs layer analysis on image. The platform also uses zsteg,
|
||||||
|
steghide, outguess, exiftool, binwalk, foremost and strings for deeper steganography analysis. The platform
|
||||||
|
supports the following images format: .png, .jpg, .gif, .bmp, .jpeg, .jfif, .jpe, .tiff...</p>
|
||||||
|
</div>
|
||||||
|
<div id="drop-zone" class="border border-secondary rounded p-4 text-center mb-3">
|
||||||
|
<p id="drag-msg">📂 Drag and drop an image here or click to select</p>
|
||||||
|
<input type="file" id="image" name="image" accept="image/*" hidden required />
|
||||||
|
<button type="button" class="btn btn-secondary mt-2" id="browse-btn">
|
||||||
|
Choose an image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password (optional)</label>
|
||||||
|
<input class="form-control" type="text" name="password" id="password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="deep" />
|
||||||
|
<label class="form-check-label" for="deep">Deep analysis</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
<div class="progress mt-3 d-none" id="progress-container">
|
||||||
|
<div class="progress-bar" id="progress-bar" role="progress-bar" style="width: 0%">
|
||||||
|
0%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="result" class="mt-5">
|
||||||
|
<div id="result-infos" class="mb-3"></div>
|
||||||
|
<div id="result-analyzers" class="mb-3"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block body_content %}
|
||||||
|
<div id="result" class="mt-5">
|
||||||
|
<div id="result-infos" class="mb-3"></div>
|
||||||
|
<div id="result-analyzers" class="mb-3"></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block footer_content %}
|
||||||
|
<script>
|
||||||
|
pollStatus("{{ hash_val }}");
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block body_content %}
|
||||||
|
<div id="result" class="mt-5">
|
||||||
|
{% for img in images %}<!--
|
||||||
|
--><div class="browse-images">
|
||||||
|
<a href="{{ img.link }}" target="_blank"><img src="{{ img.file }}"/></a>
|
||||||
|
</div><!--
|
||||||
|
-->{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Asynchronous worker for analyzing image submissions."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .analyzers.binwalk import analyze_binwalk
|
||||||
|
from .analyzers.decomposer import analyze_decomposer
|
||||||
|
from .analyzers.exiftool import analyze_exiftool
|
||||||
|
from .analyzers.foremost import analyze_foremost
|
||||||
|
from .analyzers.outguess import analyze_outguess
|
||||||
|
from .analyzers.steghide import analyze_steghide
|
||||||
|
from .analyzers.strings import analyze_strings
|
||||||
|
from .analyzers.zsteg import analyze_zsteg
|
||||||
|
from .app import app, db
|
||||||
|
from .config import RESULT_FOLDER
|
||||||
|
from .models import Image, Submission
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_image(submission_hash: str) -> None:
|
||||||
|
"""Analyze an image submission in separate threads."""
|
||||||
|
with app.app_context():
|
||||||
|
submission: Submission = Submission.query.get(submission_hash) # type: ignore
|
||||||
|
image = Image.query.get_or_404(submission.image_hash) # type: ignore
|
||||||
|
|
||||||
|
if not submission or not image: # No submission found
|
||||||
|
return
|
||||||
|
|
||||||
|
submission.status = "running" # type: ignore
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_path: Path = Path(image.file)
|
||||||
|
result_path: Path = RESULT_FOLDER / str(image.hash) / str(submission.hash)
|
||||||
|
result_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
threads: list[threading.Thread] = []
|
||||||
|
|
||||||
|
def run_analyzer(analyzer_func: Any, *args: Any) -> None:
|
||||||
|
"""Run an analyzer function in a separate thread."""
|
||||||
|
try:
|
||||||
|
analyzer_func(*args)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in {analyzer_func.__name__}: {e}")
|
||||||
|
|
||||||
|
analyzers = [
|
||||||
|
(analyze_binwalk, img_path, result_path),
|
||||||
|
(analyze_decomposer, img_path, result_path),
|
||||||
|
(analyze_exiftool, img_path, result_path),
|
||||||
|
(analyze_foremost, img_path, result_path),
|
||||||
|
(analyze_strings, img_path, result_path),
|
||||||
|
(analyze_steghide, img_path, result_path, submission.password),
|
||||||
|
(analyze_zsteg, img_path, result_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Deep analysis only
|
||||||
|
if submission.deep_analysis:
|
||||||
|
analyzers.extend(
|
||||||
|
[
|
||||||
|
(analyze_outguess, img_path, result_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for analyzer in analyzers:
|
||||||
|
thread = threading.Thread(target=run_analyzer, args=analyzer)
|
||||||
|
threads.append(thread)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
submission.status = "completed"
|
||||||
|
except Exception:
|
||||||
|
submission.status = "error"
|
||||||
|
finally:
|
||||||
|
db.session.commit()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Aperi'Solve WSGI runner."""
|
||||||
|
|
||||||
|
from .app import app as application
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
application.run()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
#image: ghcr.io/zeecka/aperisolve:latest
|
||||||
|
build: .
|
||||||
|
container_name: aperisolve-web
|
||||||
|
restart: always
|
||||||
|
command: ["flask", "--app", "aperisolve.app", "run", "--host=0.0.0.0", "--port=5000"]
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./aperisolve:/aperisolve
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
worker:
|
||||||
|
condition: service_started
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- FLASK_DEBUG=true
|
||||||
|
- FLASK_ENV=development
|
||||||
|
|
||||||
|
worker:
|
||||||
|
#image: ghcr.io/zeecka/aperisolve:latest
|
||||||
|
build: .
|
||||||
|
container_name: aperisolve-worker
|
||||||
|
restart: always
|
||||||
|
command: rq worker default --url redis://redis:6379/0
|
||||||
|
volumes:
|
||||||
|
- ./aperisolve:/aperisolve
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- postgres
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- FLASK_DEBUG=true
|
||||||
|
- FLASK_ENV=development
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
container_name: redis
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:14
|
||||||
|
container_name: postgres
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U aperiuser -d aperisolve"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
|
||||||
|
rqdashboard:
|
||||||
|
image: eoranged/rq-dashboard
|
||||||
|
container_name: rq-dashboard
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "9181:9181"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ghcr.io/zeecka/aperisolve:latest
|
||||||
|
#build: .
|
||||||
|
container_name: aperisolve-web
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- apresults:/aperisolve/results
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
worker:
|
||||||
|
condition: service_started
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: ghcr.io/zeecka/aperisolve:latest
|
||||||
|
#build: .
|
||||||
|
container_name: aperisolve-worker
|
||||||
|
restart: always
|
||||||
|
command: rq worker default --url redis://redis:6379/0
|
||||||
|
volumes:
|
||||||
|
- apresults:/aperisolve/results
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- postgres
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:14
|
||||||
|
container_name: postgres
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U aperiuser -d aperisolve"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
container_name: redis
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
rqdashboard:
|
||||||
|
image: eoranged/rq-dashboard
|
||||||
|
container_name: rq-dashboard
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "9181:9181"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
apresults:
|
||||||
|
pgdata:
|
||||||