diff --git a/capabilities/web-security/agents/web-security.md b/capabilities/web-security/agents/web-security.md
index 2e4f217..462d4f9 100644
--- a/capabilities/web-security/agents/web-security.md
+++ b/capabilities/web-security/agents/web-security.md
@@ -91,6 +91,7 @@ Use tools proactively when they reduce uncertainty or verify a finding. Match th
- Use IP rotation (`flareprox_*` tools or the local `fireprox` CLI at `~/git/fireprox/fire.py`) only when `IPROTATE_ENABLED` is set and the target is rate-limiting, IP-banning, or WAF-blocking normal requests. Load the `ip-rotation` skill for backend selection and lifecycle. Always clean up fireprox proxies to avoid AWS charges.
- Use the local `pacu` CLI when an authorized test yields AWS credentials, cloud metadata access, or another AWS-impact lead that needs validation. Load the `pacu-aws-exploitation` skill first, confirm AWS scope, and start with identity/read-only enumeration before any mutating module.
- When the target accepts or extracts archive uploads (ZIP, TAR, etc.), use the local `archive-alchemist` CLI at `~/git/archivealchemist/archive-alchemist.py` to craft malicious archives. Load the `archive-path-traversal` skill for the full attack pattern catalog and iterative workflow.
+- Use `exif_read`, `exif_write`, `exif_strip`, and `exif_copy` for EXIF/XMP/IPTC metadata manipulation on image and document files. Primary use cases: injecting XSS payloads into metadata fields (Comment, Artist, Copyright, ImageDescription) that get rendered by the target, crafting images with SSRF-triggering metadata for server-side processing, testing whether upload handlers strip metadata, and transplanting payloads between file formats. Strip metadata first with `exif_strip` to create a clean baseline, then inject specific payloads with `exif_write`.
- Use `log_image_output`, `log_audio_output`, and `log_video_output` when another tool has already written useful PoC media to disk and you need it attached to the current Dreadnode run as typed output. Use `log_file_artifact` when you want the raw file uploaded as an artifact instead of rendered media.
- When a finding is browser-visible or a screenshot materially improves reproducibility, capture the screenshot and attach it to the run. Treat screenshot logging as standard evidence collection, not an optional flourish.
- Use `bbscope_find` at the start of an engagement to check if a target is covered by any bug bounty program and retrieve scope boundaries. Use `bbscope_program` to get full in-scope/out-of-scope details for a specific program. Use `bbscope_targets` to enumerate targets by type (wildcards, domains, URLs, IPs, CIDRs) for reconnaissance. Use `bbscope_updates` to find freshly added targets that may be under-tested.
diff --git a/capabilities/web-security/capability.yaml b/capabilities/web-security/capability.yaml
index e695f57..f48df0d 100644
--- a/capabilities/web-security/capability.yaml
+++ b/capabilities/web-security/capability.yaml
@@ -9,7 +9,8 @@ description: >
tooling, Caido proxy integration via MCP, credential management, DNS
rebinding, AWS exploitation with Pacu, phone verification,
vulnerability verification, IP rotation helpers (Flareprox, fireprox),
- and archive extraction vulnerability crafting with archivealchemist.
+ archive extraction vulnerability crafting with archivealchemist,
+ and EXIF metadata manipulation via exiftool.
mcp:
servers:
@@ -114,6 +115,8 @@ checks:
command: 'test -f "$HOME/git/fireprox/fire.py"'
- name: archivealchemist
command: 'test -f "$HOME/git/archivealchemist/archive-alchemist.py"'
+ - name: exiftool
+ command: command -v exiftool
- name: jxscout
command: command -v jxscout-pro-v2
diff --git a/capabilities/web-security/scripts/install_tools.sh b/capabilities/web-security/scripts/install_tools.sh
index d557838..79c8f81 100755
--- a/capabilities/web-security/scripts/install_tools.sh
+++ b/capabilities/web-security/scripts/install_tools.sh
@@ -96,6 +96,11 @@ elif ! command -v jxscout-pro-v2 &>/dev/null; then
echo "WARN: jxscout-pro-v2 not found. Set JXSCOUT_BINARY_URL to install, or place binary on PATH."
fi
+# -- exiftool (EXIF metadata manipulation) ---------------------------------
+if ! command -v exiftool &>/dev/null; then
+ apt-get install -y --no-install-recommends libimage-exiftool-perl
+fi
+
# -- Node.js + agent-browser -----------------------------------------------
if ! command -v node &>/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
diff --git a/capabilities/web-security/tests/test_exiftool.py b/capabilities/web-security/tests/test_exiftool.py
new file mode 100644
index 0000000..d9d2fac
--- /dev/null
+++ b/capabilities/web-security/tests/test_exiftool.py
@@ -0,0 +1,244 @@
+"""Tests for exiftool wrapper tools."""
+
+from __future__ import annotations
+
+import asyncio
+import importlib.util
+import sys
+import types
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+def _install_dreadnode_tools_stub() -> None:
+ existing = sys.modules.get("dreadnode.agents.tools")
+ if existing is not None and hasattr(existing, "FunctionCall"):
+ return
+
+ dreadnode = types.ModuleType("dreadnode")
+ agents = types.ModuleType("dreadnode.agents")
+ tools = types.ModuleType("dreadnode.agents.tools")
+
+ class _Tool:
+ def __init__(self, name: str, description: str, catch: bool) -> None:
+ self.name = name
+ self.description = description
+ self.catch = catch
+ self.parameters_schema = {"properties": {}}
+
+ def tool_method(*, name: str, catch: bool = False):
+ def decorator(fn):
+ fn._tool_metadata = {
+ "name": name,
+ "catch": catch,
+ "description": fn.__doc__ or "",
+ }
+ return fn
+
+ return decorator
+
+ class Toolset:
+ def get_tools(self):
+ discovered = []
+ for attr_name in dir(self):
+ value = getattr(self, attr_name)
+ meta = getattr(value, "_tool_metadata", None)
+ if meta:
+ discovered.append(
+ _Tool(meta["name"], meta["description"], meta["catch"])
+ )
+ return discovered
+
+ tools.Toolset = Toolset
+ tools.tool_method = tool_method
+ agents.tools = tools
+ dreadnode.agents = agents
+
+ sys.modules["dreadnode"] = dreadnode
+ sys.modules["dreadnode.agents"] = agents
+ sys.modules["dreadnode.agents.tools"] = tools
+
+
+_install_dreadnode_tools_stub()
+
+MODULE_PATH = Path(__file__).resolve().parent.parent / "tools" / "exiftool.py"
+SPEC = importlib.util.spec_from_file_location("exiftool", MODULE_PATH)
+assert SPEC and SPEC.loader
+MODULE = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(MODULE)
+
+ExifTool = MODULE.ExifTool
+
+
+@pytest.fixture
+def toolset() -> ExifTool:
+ return ExifTool()
+
+
+def _mock_process(stdout: str = "", stderr: str = "", returncode: int = 0):
+ """Create a mock subprocess for asyncio.create_subprocess_exec."""
+ proc = AsyncMock()
+ proc.communicate = AsyncMock(return_value=(stdout.encode(), stderr.encode()))
+ proc.returncode = returncode
+ proc.kill = AsyncMock()
+ return proc
+
+
+class TestToolDiscovery:
+ def test_tools_discovered(self, toolset: ExifTool) -> None:
+ names = {tool.name for tool in toolset.get_tools()}
+ assert names == {"exif_read", "exif_write", "exif_strip", "exif_copy"}
+
+
+class TestExifRead:
+ @pytest.mark.asyncio
+ async def test_read_file_not_found(self, toolset: ExifTool) -> None:
+ result = await toolset.exif_read("/nonexistent/file.jpg")
+ assert "Error" in result
+
+ @pytest.mark.asyncio
+ async def test_read_all_tags(self, toolset: ExifTool, tmp_path: Path) -> None:
+ img = tmp_path / "test.jpg"
+ img.write_bytes(b"\xff\xd8\xff\xe0") # minimal JPEG header
+
+ mock_proc = _mock_process(stdout='[{"File:FileName": "test.jpg"}]')
+ with (
+ patch("shutil.which", return_value="/usr/bin/exiftool"),
+ patch("asyncio.create_subprocess_exec", return_value=mock_proc),
+ ):
+ result = await toolset.exif_read(str(img))
+ assert "test.jpg" in result
+
+ @pytest.mark.asyncio
+ async def test_read_specific_tags(self, toolset: ExifTool, tmp_path: Path) -> None:
+ img = tmp_path / "test.jpg"
+ img.write_bytes(b"\xff\xd8\xff\xe0")
+
+ mock_proc = _mock_process(stdout='[{"EXIF:Comment": "hello"}]')
+ with (
+ patch("shutil.which", return_value="/usr/bin/exiftool"),
+ patch(
+ "asyncio.create_subprocess_exec", return_value=mock_proc
+ ) as mock_exec,
+ ):
+ result = await toolset.exif_read(str(img), tags=["Comment"])
+ # Verify -Comment flag was passed
+ call_args = mock_exec.call_args[0]
+ assert "-Comment" in call_args
+ assert "hello" in result
+
+
+class TestExifWrite:
+ @pytest.mark.asyncio
+ async def test_write_file_not_found(self, toolset: ExifTool) -> None:
+ result = await toolset.exif_write("/nonexistent/file.jpg", {"Comment": "test"})
+ assert "Error" in result
+
+ @pytest.mark.asyncio
+ async def test_write_tags(self, toolset: ExifTool, tmp_path: Path) -> None:
+ img = tmp_path / "test.jpg"
+ img.write_bytes(b"\xff\xd8\xff\xe0")
+
+ mock_proc = _mock_process(stdout="1 image files updated")
+ with (
+ patch("shutil.which", return_value="/usr/bin/exiftool"),
+ patch(
+ "asyncio.create_subprocess_exec", return_value=mock_proc
+ ) as mock_exec,
+ ):
+ result = await toolset.exif_write(
+ str(img), {"Comment": "", "Artist": "attacker"}
+ )
+ call_args = mock_exec.call_args[0]
+ assert "-Comment=" in call_args
+ assert "-Artist=attacker" in call_args
+ assert "-overwrite_original" in call_args
+ assert "updated" in result
+
+ @pytest.mark.asyncio
+ async def test_write_with_backup(self, toolset: ExifTool, tmp_path: Path) -> None:
+ img = tmp_path / "test.jpg"
+ img.write_bytes(b"\xff\xd8\xff\xe0")
+
+ mock_proc = _mock_process(stdout="1 image files updated")
+ with (
+ patch("shutil.which", return_value="/usr/bin/exiftool"),
+ patch(
+ "asyncio.create_subprocess_exec", return_value=mock_proc
+ ) as mock_exec,
+ ):
+ await toolset.exif_write(str(img), {"Comment": "test"}, no_backup=False)
+ call_args = mock_exec.call_args[0]
+ assert "-overwrite_original" not in call_args
+
+
+class TestExifStrip:
+ @pytest.mark.asyncio
+ async def test_strip_file_not_found(self, toolset: ExifTool) -> None:
+ result = await toolset.exif_strip("/nonexistent/file.jpg")
+ assert "Error" in result
+
+ @pytest.mark.asyncio
+ async def test_strip_all(self, toolset: ExifTool, tmp_path: Path) -> None:
+ img = tmp_path / "test.jpg"
+ img.write_bytes(b"\xff\xd8\xff\xe0")
+
+ mock_proc = _mock_process(stdout="1 image files updated")
+ with (
+ patch("shutil.which", return_value="/usr/bin/exiftool"),
+ patch(
+ "asyncio.create_subprocess_exec", return_value=mock_proc
+ ) as mock_exec,
+ ):
+ result = await toolset.exif_write(str(img), {"Comment": "test"})
+ assert "updated" in result
+
+
+class TestExifCopy:
+ @pytest.mark.asyncio
+ async def test_copy_source_not_found(
+ self, toolset: ExifTool, tmp_path: Path
+ ) -> None:
+ dst = tmp_path / "dst.jpg"
+ dst.write_bytes(b"\xff\xd8\xff\xe0")
+ result = await toolset.exif_copy("/nonexistent/src.jpg", str(dst))
+ assert "source" in result.lower() and "Error" in result
+
+ @pytest.mark.asyncio
+ async def test_copy_dest_not_found(self, toolset: ExifTool, tmp_path: Path) -> None:
+ src = tmp_path / "src.jpg"
+ src.write_bytes(b"\xff\xd8\xff\xe0")
+ result = await toolset.exif_copy(str(src), "/nonexistent/dst.jpg")
+ assert "destination" in result.lower() and "Error" in result
+
+ @pytest.mark.asyncio
+ async def test_copy_tags(self, toolset: ExifTool, tmp_path: Path) -> None:
+ src = tmp_path / "src.jpg"
+ dst = tmp_path / "dst.jpg"
+ src.write_bytes(b"\xff\xd8\xff\xe0")
+ dst.write_bytes(b"\xff\xd8\xff\xe0")
+
+ mock_proc = _mock_process(stdout="1 image files updated")
+ with (
+ patch("shutil.which", return_value="/usr/bin/exiftool"),
+ patch(
+ "asyncio.create_subprocess_exec", return_value=mock_proc
+ ) as mock_exec,
+ ):
+ result = await toolset.exif_copy(str(src), str(dst))
+ call_args = mock_exec.call_args[0]
+ assert "-TagsFromFile" in call_args
+ assert "updated" in result
+
+
+class TestExifToolNotFound:
+ @pytest.mark.asyncio
+ async def test_missing_exiftool(self, toolset: ExifTool, tmp_path: Path) -> None:
+ img = tmp_path / "test.jpg"
+ img.write_bytes(b"\xff\xd8\xff\xe0")
+
+ with patch("shutil.which", return_value=None):
+ with pytest.raises(FileNotFoundError, match="exiftool not found"):
+ await toolset.exif_read(str(img))
diff --git a/capabilities/web-security/tools/exiftool.py b/capabilities/web-security/tools/exiftool.py
new file mode 100644
index 0000000..52de8c3
--- /dev/null
+++ b/capabilities/web-security/tools/exiftool.py
@@ -0,0 +1,172 @@
+"""ExifTool wrapper for EXIF metadata reading, writing, and injection.
+
+Wraps the ``exiftool`` CLI for reading and manipulating image/document
+metadata. Primary security use cases: injecting XSS payloads into EXIF
+fields (Comment, Artist, Copyright, ImageDescription), crafting images
+with metadata that triggers SSRF when processed server-side, and
+stripping metadata to test upload sanitization.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import shutil
+from pathlib import Path
+from typing import Annotated
+
+from dreadnode.agents.tools import Toolset, tool_method
+
+_MAX_OUTPUT = 50_000
+
+
+def _find_exiftool() -> str:
+ path = shutil.which("exiftool")
+ if path is None:
+ raise FileNotFoundError(
+ "exiftool not found on PATH. Install via: apt-get install libimage-exiftool-perl"
+ )
+ return path
+
+
+async def _run(args: list[str], timeout: int = 30) -> str:
+ exiftool = _find_exiftool()
+ proc = await asyncio.create_subprocess_exec(
+ exiftool,
+ *args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ proc.kill()
+ return "Error: exiftool timed out"
+
+ output = stdout.decode(errors="replace")
+ if proc.returncode != 0:
+ err = stderr.decode(errors="replace").strip()
+ if err:
+ output = f"{output}\nstderr: {err}" if output else f"Error: {err}"
+ return output[:_MAX_OUTPUT]
+
+
+class ExifTool(Toolset):
+ """Read, write, and strip EXIF/XMP/IPTC metadata on image and document files.
+
+ Wraps the exiftool CLI for metadata manipulation during web security
+ testing. Use for injecting payloads into metadata fields, reading
+ metadata from downloaded files, and testing upload sanitization.
+ """
+
+ @tool_method(name="exif_read", catch=True)
+ async def exif_read(
+ self,
+ path: Annotated[str, "Path to the file to read metadata from."],
+ tags: Annotated[
+ list[str] | None,
+ "Specific tags to read (e.g. ['Comment', 'Artist']). Omit to read all.",
+ ] = None,
+ ) -> str:
+ """Read EXIF/XMP/IPTC metadata from a file.
+
+ Returns all metadata tags by default, or specific tags if provided.
+ Useful for inspecting uploaded files, checking if metadata survives
+ server-side processing, or examining downloaded images for information leakage.
+ """
+ file_path = Path(path)
+ if not file_path.is_file():
+ return f"Error: file not found: {path}"
+
+ args = ["-j", "-G"]
+ if tags:
+ args.extend(f"-{tag}" for tag in tags)
+ args.append(str(file_path))
+ return await _run(args)
+
+ @tool_method(name="exif_write", catch=True)
+ async def exif_write(
+ self,
+ path: Annotated[str, "Path to the file to modify."],
+ tags: Annotated[
+ dict[str, str],
+ "Tag-value pairs to write (e.g. {'Comment': '', 'Artist': 'test'}).",
+ ],
+ no_backup: Annotated[
+ bool,
+ "Skip creating a backup (_original) file. Default true for cleaner workflow.",
+ ] = True,
+ ) -> str:
+ """Write metadata tags to a file.
+
+ Injects arbitrary values into EXIF/XMP/IPTC fields. Common security
+ payloads: XSS in Comment/Artist/Copyright/ImageDescription, SSRF URLs
+ in GPSImgDirection or XMP fields, command injection strings in metadata
+ that gets logged or processed by backend tools.
+ """
+ file_path = Path(path)
+ if not file_path.is_file():
+ return f"Error: file not found: {path}"
+
+ args: list[str] = []
+ if no_backup:
+ args.append("-overwrite_original")
+ for tag, value in tags.items():
+ args.append(f"-{tag}={value}")
+ args.append(str(file_path))
+ return await _run(args)
+
+ @tool_method(name="exif_strip", catch=True)
+ async def exif_strip(
+ self,
+ path: Annotated[str, "Path to the file to strip metadata from."],
+ no_backup: Annotated[
+ bool,
+ "Skip creating a backup (_original) file. Default true.",
+ ] = True,
+ ) -> str:
+ """Strip all metadata from a file.
+
+ Removes every EXIF/XMP/IPTC tag. Use to create a clean baseline file
+ before injecting specific payloads, or to test whether a server-side
+ upload handler strips metadata by comparing before/after.
+ """
+ file_path = Path(path)
+ if not file_path.is_file():
+ return f"Error: file not found: {path}"
+
+ args = ["-all="]
+ if no_backup:
+ args.append("-overwrite_original")
+ args.append(str(file_path))
+ return await _run(args)
+
+ @tool_method(name="exif_copy", catch=True)
+ async def exif_copy(
+ self,
+ source: Annotated[str, "Path to the source file to copy metadata from."],
+ destination: Annotated[
+ str, "Path to the destination file to copy metadata to."
+ ],
+ no_backup: Annotated[
+ bool,
+ "Skip creating a backup (_original) file. Default true.",
+ ] = True,
+ ) -> str:
+ """Copy all metadata from one file to another.
+
+ Transfers EXIF/XMP/IPTC tags between files. Use to transplant payloads
+ from a crafted file into a target-compatible format, or to replicate
+ metadata from a legitimate file into a malicious one.
+ """
+ src = Path(source)
+ dst = Path(destination)
+ if not src.is_file():
+ return f"Error: source file not found: {source}"
+ if not dst.is_file():
+ return f"Error: destination file not found: {destination}"
+
+ args = ["-TagsFromFile", str(src)]
+ if no_backup:
+ args.append("-overwrite_original")
+ args.append(str(dst))
+ return await _run(args)