From c8401494c10e31942571f608415e65c1fd5822ab Mon Sep 17 00:00:00 2001 From: GangGreenTemperTatum <104169244+GangGreenTemperTatum@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:02:11 -0400 Subject: [PATCH] feat(web-security): add exiftool support for EXIF metadata manipulation Adds ExifTool toolset wrapping the exiftool CLI with four tool methods: exif_read, exif_write, exif_strip, exif_copy. Primary use cases are injecting XSS/SSRF payloads into image metadata fields and testing whether upload handlers strip metadata. Includes dependency check, install script entry, agent prompt guidance, and 13 unit tests. Closes CAP-1028 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web-security/agents/web-security.md | 1 + capabilities/web-security/capability.yaml | 5 +- .../web-security/scripts/install_tools.sh | 5 + .../web-security/tests/test_exiftool.py | 244 ++++++++++++++++++ capabilities/web-security/tools/exiftool.py | 172 ++++++++++++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 capabilities/web-security/tests/test_exiftool.py create mode 100644 capabilities/web-security/tools/exiftool.py 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)