|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +if (!defined('ABSPATH')) { |
| 6 | + exit; |
| 7 | +} |
| 8 | + |
| 9 | +/** |
| 10 | + * Deceptive tripwire responses (deception layer, beyond @webdecoy/node parity). |
| 11 | + * |
| 12 | + * WordPress owns the whole HTTP response, so a tripwire can *deceive* a scanner |
| 13 | + * instead of just denying it. A deceived scanner keeps digging (each fetch |
| 14 | + * another confidence-100 violation feeding enforcement) rather than pivoting |
| 15 | + * tools. Modes: |
| 16 | + * |
| 17 | + * - block : 403 (default; handled by the caller, not here) |
| 18 | + * - notfound: 404 — indistinguishable from an unprotected site |
| 19 | + * - decoy : 200 with believable fake content containing UNIQUE per-site |
| 20 | + * canary credentials |
| 21 | + * - tarpit : a slow-drip response that burns scanner time (bounded) |
| 22 | + * |
| 23 | + * Safety rails: decoy content is generated ENTIRELY from templates seeded by a |
| 24 | + * per-site secret — it never reads a real configuration value. If a template |
| 25 | + * can't be produced for the requested path, it fails closed (returns false so |
| 26 | + * the caller serves the normal 403). |
| 27 | + * |
| 28 | + * Canary credentials are deterministic per site and recomputable, so any later |
| 29 | + * *use* of one (e.g. a login attempt with the fake DB password) is attributable |
| 30 | + * evidence of exfiltration — see {@see is_canary_credential()}. |
| 31 | + */ |
| 32 | +class WebDecoy_Decoy_Response |
| 33 | +{ |
| 34 | + /** Max wall-clock seconds a tarpit will hold a connection. */ |
| 35 | + private const TARPIT_MAX_SECONDS = 10; |
| 36 | + |
| 37 | + /** |
| 38 | + * Per-site canary secret (lazily created). Distinct from other secrets so |
| 39 | + * canaries can't be derived from an unrelated leaked value. |
| 40 | + */ |
| 41 | + private static function secret(): string |
| 42 | + { |
| 43 | + $secret = get_option('webdecoy_canary_secret', ''); |
| 44 | + if (!is_string($secret) || $secret === '') { |
| 45 | + $secret = bin2hex(random_bytes(16)); |
| 46 | + add_option('webdecoy_canary_secret', $secret, '', 'yes'); |
| 47 | + } |
| 48 | + return $secret; |
| 49 | + } |
| 50 | + |
| 51 | + private static function derive(string $label, int $len = 16): string |
| 52 | + { |
| 53 | + return substr(hash_hmac('sha256', $label, self::secret()), 0, $len); |
| 54 | + } |
| 55 | + |
| 56 | + /** |
| 57 | + * The full set of canary credentials for this site (deterministic). |
| 58 | + * |
| 59 | + * @return array<string,string> |
| 60 | + */ |
| 61 | + public static function canaries(): array |
| 62 | + { |
| 63 | + return [ |
| 64 | + 'db_name' => 'wp_' . self::derive('db_name', 6), |
| 65 | + 'db_user' => 'wpuser_' . self::derive('db_user', 6), |
| 66 | + 'db_password' => 'Wd' . self::derive('db_password', 20), |
| 67 | + 'db_host' => 'localhost', |
| 68 | + 'auth_key' => self::derive('auth_key', 40), |
| 69 | + 'admin_user' => 'admin_' . self::derive('admin_user', 6), |
| 70 | + 'admin_password' => 'Wd' . self::derive('admin_password', 18), |
| 71 | + 'aws_key' => 'AKIA' . strtoupper(self::derive('aws_key', 16)), |
| 72 | + 'aws_secret' => self::derive('aws_secret', 40), |
| 73 | + ]; |
| 74 | + } |
| 75 | + |
| 76 | + /** |
| 77 | + * Is a submitted credential value one of this site's canaries? A match means |
| 78 | + * the value could only have come from a decoy we served — strong exfil |
| 79 | + * evidence. Compared with hash_equals to avoid timing leaks. |
| 80 | + */ |
| 81 | + public static function is_canary_credential(string $value): bool |
| 82 | + { |
| 83 | + if ($value === '') { |
| 84 | + return false; |
| 85 | + } |
| 86 | + foreach (self::canaries() as $canary) { |
| 87 | + if (hash_equals($canary, $value)) { |
| 88 | + return true; |
| 89 | + } |
| 90 | + } |
| 91 | + return false; |
| 92 | + } |
| 93 | + |
| 94 | + /** |
| 95 | + * Serve a deceptive response for a tripwire hit and exit. Returns false |
| 96 | + * (without emitting anything) when the mode is 'block' or when a decoy can't |
| 97 | + * be produced for this path — the caller then serves the normal 403. |
| 98 | + * |
| 99 | + * @return bool false = fall back to the default block |
| 100 | + */ |
| 101 | + public function serve(string $path, string $mode): bool |
| 102 | + { |
| 103 | + if ($mode === 'notfound') { |
| 104 | + $this->serve_404(); |
| 105 | + return true; // exits |
| 106 | + } |
| 107 | + |
| 108 | + if ($mode === 'tarpit') { |
| 109 | + $this->serve_tarpit(); |
| 110 | + return true; // exits |
| 111 | + } |
| 112 | + |
| 113 | + if ($mode === 'decoy') { |
| 114 | + $content = $this->decoy_for($path); |
| 115 | + if ($content === null) { |
| 116 | + return false; // no believable template — fail closed to 403 |
| 117 | + } |
| 118 | + $this->serve_body($content['body'], $content['type']); |
| 119 | + return true; // exits |
| 120 | + } |
| 121 | + |
| 122 | + return false; // 'block' or unknown — caller handles |
| 123 | + } |
| 124 | + |
| 125 | + /** |
| 126 | + * Build believable fake content for a known bait path, embedding canaries. |
| 127 | + * Returns null when the path has no template (caller falls back to 403). |
| 128 | + * |
| 129 | + * @return array{body:string,type:string}|null |
| 130 | + */ |
| 131 | + private function decoy_for(string $path): ?array |
| 132 | + { |
| 133 | + $p = strtolower($path); |
| 134 | + $c = self::canaries(); |
| 135 | + |
| 136 | + // Fake .env |
| 137 | + if (substr($p, -4) === '.env' || strpos($p, '/.env') !== false) { |
| 138 | + $body = "APP_ENV=production\n" |
| 139 | + . "APP_DEBUG=false\n" |
| 140 | + . "APP_KEY=base64:" . base64_encode($c['auth_key']) . "\n" |
| 141 | + . "DB_CONNECTION=mysql\n" |
| 142 | + . "DB_HOST={$c['db_host']}\n" |
| 143 | + . "DB_PORT=3306\n" |
| 144 | + . "DB_DATABASE={$c['db_name']}\n" |
| 145 | + . "DB_USERNAME={$c['db_user']}\n" |
| 146 | + . "DB_PASSWORD={$c['db_password']}\n" |
| 147 | + . "AWS_ACCESS_KEY_ID={$c['aws_key']}\n" |
| 148 | + . "AWS_SECRET_ACCESS_KEY={$c['aws_secret']}\n"; |
| 149 | + return ['body' => $body, 'type' => 'text/plain']; |
| 150 | + } |
| 151 | + |
| 152 | + // Fake wp-config backup |
| 153 | + if (strpos($p, 'wp-config') !== false) { |
| 154 | + $body = "<?php\n" |
| 155 | + . "// WordPress configuration\n" |
| 156 | + . "define('DB_NAME', '{$c['db_name']}');\n" |
| 157 | + . "define('DB_USER', '{$c['db_user']}');\n" |
| 158 | + . "define('DB_PASSWORD', '{$c['db_password']}');\n" |
| 159 | + . "define('DB_HOST', '{$c['db_host']}');\n" |
| 160 | + . "define('AUTH_KEY', '{$c['auth_key']}');\n" |
| 161 | + . "\$table_prefix = 'wp_';\n"; |
| 162 | + // Serve as text/plain so it isn't executed anywhere and is readable. |
| 163 | + return ['body' => $body, 'type' => 'text/plain']; |
| 164 | + } |
| 165 | + |
| 166 | + // Fake SQL dump |
| 167 | + if (substr($p, -4) === '.sql' || strpos($p, 'backup') !== false || strpos($p, 'dump') !== false) { |
| 168 | + $body = "-- MySQL dump\n" |
| 169 | + . "-- Host: {$c['db_host']} Database: {$c['db_name']}\n" |
| 170 | + . "CREATE TABLE `wp_users` (\n" |
| 171 | + . " `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n" |
| 172 | + . " `user_login` varchar(60) NOT NULL,\n" |
| 173 | + . " `user_pass` varchar(255) NOT NULL,\n" |
| 174 | + . " PRIMARY KEY (`ID`)\n" |
| 175 | + . ") ENGINE=InnoDB;\n" |
| 176 | + . "INSERT INTO `wp_users` VALUES " |
| 177 | + . "(1,'{$c['admin_user']}','\$P\$B" . self::derive('pw_hash', 30) . "');\n"; |
| 178 | + return ['body' => $body, 'type' => 'text/plain']; |
| 179 | + } |
| 180 | + |
| 181 | + // Fake phpinfo |
| 182 | + if (strpos($p, 'phpinfo') !== false) { |
| 183 | + $body = "<!DOCTYPE html><html><head><title>phpinfo()</title></head><body>" |
| 184 | + . "<h1>PHP Version 7.4.33</h1>" |
| 185 | + . "<table><tr><td>System</td><td>Linux web01 5.4.0</td></tr>" |
| 186 | + . "<tr><td>DOCUMENT_ROOT</td><td>/var/www/html</td></tr>" |
| 187 | + . "<tr><td>DB_USER</td><td>{$c['db_user']}</td></tr></table>" |
| 188 | + . "</body></html>"; |
| 189 | + return ['body' => $body, 'type' => 'text/html']; |
| 190 | + } |
| 191 | + |
| 192 | + return null; // no believable template for this path |
| 193 | + } |
| 194 | + |
| 195 | + /** |
| 196 | + * The canaries served for a given path (for recording in detection metadata). |
| 197 | + * Empty when the path has no decoy template. |
| 198 | + * |
| 199 | + * @return array<string,string> |
| 200 | + */ |
| 201 | + public function served_canaries(string $path): array |
| 202 | + { |
| 203 | + return $this->decoy_for($path) === null ? [] : self::canaries(); |
| 204 | + } |
| 205 | + |
| 206 | + private function serve_404(): void |
| 207 | + { |
| 208 | + nocache_headers(); |
| 209 | + status_header(404); |
| 210 | + header('Content-Type: text/html; charset=UTF-8'); |
| 211 | + echo '<!DOCTYPE html><html><head><title>404 Not Found</title></head><body><h1>Not Found</h1><p>The requested URL was not found on this server.</p></body></html>'; |
| 212 | + exit; |
| 213 | + } |
| 214 | + |
| 215 | + private function serve_body(string $body, string $type): void |
| 216 | + { |
| 217 | + nocache_headers(); |
| 218 | + status_header(200); |
| 219 | + header('Content-Type: ' . $type . '; charset=UTF-8'); |
| 220 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- template content, not user input |
| 221 | + echo $body; |
| 222 | + exit; |
| 223 | + } |
| 224 | + |
| 225 | + /** |
| 226 | + * Slow-drip response to burn scanner time, streamed and bounded. Ties up a |
| 227 | + * PHP worker for up to TARPIT_MAX_SECONDS — hence off by default and |
| 228 | + * documented as such. |
| 229 | + */ |
| 230 | + private function serve_tarpit(): void |
| 231 | + { |
| 232 | + nocache_headers(); |
| 233 | + status_header(200); |
| 234 | + header('Content-Type: text/html; charset=UTF-8'); |
| 235 | + |
| 236 | + // Cap by both our limit and any configured max_execution_time headroom. |
| 237 | + $maxExec = (int) ini_get('max_execution_time'); |
| 238 | + $budget = self::TARPIT_MAX_SECONDS; |
| 239 | + if ($maxExec > 0) { |
| 240 | + $budget = min($budget, max(1, $maxExec - 2)); |
| 241 | + } |
| 242 | + |
| 243 | + echo '<!DOCTYPE html><html><head><title>Loading…</title></head><body>'; |
| 244 | + $start = time(); |
| 245 | + $i = 0; |
| 246 | + while ((time() - $start) < $budget) { |
| 247 | + echo '<!-- ' . str_repeat('.', 8) . " {$i} -->\n"; |
| 248 | + if (function_exists('flush')) { |
| 249 | + @flush(); // phpcs:ignore |
| 250 | + } |
| 251 | + $i++; |
| 252 | + usleep(500000); // 0.5s between drips |
| 253 | + } |
| 254 | + echo '</body></html>'; |
| 255 | + exit; |
| 256 | + } |
| 257 | +} |
0 commit comments