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:
|
||||