From 058c0f20695602537b43af64c39c3d6c77f7da30 Mon Sep 17 00:00:00 2001 From: ShaneMain Date: Sat, 29 Aug 2026 09:22:44 -0400 Subject: [PATCH 1/2] feat: fix telemetry provenance, add secrets/exploit traps, budget slow responses Analysis of six weeks of captures found the dashboard was mostly measuring things that were not attacker behaviour, and that the busiest attacker objective had no trap at all. This addresses both, plus the correctness issues the new code surfaced. Telemetry (migration 004): - `synthetic` flags rows written by the backfill importers. They were 56% of the table, carry no POST body, and in the drop-recovery case an inferred source IP; aggregating them with live captures fabricated behaviour that was never observed. - `planted_user`/`planted_pass` separate honeytokens WE serve from credentials an attacker sends. The `.env` trap wrote its plants into `submitted_*`, inflating every credential metric roughly fourfold (730 -> 175). - `cloudflare_ranges` + `honeypot_event_live` exclude traffic originating from Cloudflare's own infrastructure, which was the single largest "attacker" path in the dashboard. - The Worker forwards `cf-ipcountry` from `request.cf`: it is not a request header by default, so the column was NULL on every row ever recorded. - The xmlrpc parser took the last two values, which on publishing calls (metaWeblog.newPost) captured the post title and body instead of the credentials. Traps for what attackers actually do (secrets harvesting was ~46% of real traffic and answered 404): - Secret-file honeytokens: .aws/credentials (with an optional real AWS canary token), .git-credentials, .gitconfig, .gitlab-ci.yml, .github/workflows, .npmrc, .docker/config.json. - Fake phpinfo() and Spring Boot Actuator, both with planted credentials in the environment they exist to dump; /actuator/heapdump streams slowly. - admin-ajax.php and plugin PHP endpoints: parses the backdoor account a privilege-escalation creates, answers injection with a fabricated wp_users dump. Closes the observed drop-off where scanners read the version bait and left because the advertised plugin endpoint 404'd. - wp-json/batch/v1 with real batch semantics, so amplification batches arrive instead of stopping at the capability probe. - phpMyAdmin login across the 15 probed spellings. - XML-RPC content-injection canary, served back only to the injecting IP, with noindex and escaped: attacker content reachable by anyone else would make this a spam relay. Credibility: - Responses carry PHP/WordPress headers (X-Powered-By, the 1984 Expires, wordpress_test_cookie, the REST Link) via middleware, and the Worker strips `server: Google Frontend`. A bot reading headers could previously tell this was not PHP before submitting anything. - Core JS/CSS is served; 404ing jquery.js identified the install as fake. Resource safety: - A shared slow-response budget bounds how many responses may be held open at once. Cloud Run gives 80 concurrent requests across 3 instances; unbounded tarpits would fill the pool and stop the service recording new probes. Over budget, traps answer immediately and log a delay of 0, so response_delay_ms reflects time actually spent. - Body limits now apply: they were set on empty Routers before routes were added, so axum never wrapped them and everything used the 2 MiB default. - Batch captures are capped at 25. One 141 KB request previously wrote 2000 rows and 8 MB of duplicated body text and held a pool connection 38 seconds. - The unbudgeted heapdump path buffered the full dump; it ran at peak concurrency, making the fallback the memory-hungry branch on a 256 MiB container. - admin-ajax only registers an instant-grant pair for real privilege escalation. Any pair used to qualify, which was a two-request oracle for identifying the honeypot and a way to skip stuffer churn on common passwords. - Planted AWS keys and phpass hashes derive entropy across their full length; they previously shared a constant tail across every IP. Claude-Session: https://claude.ai/code/session_01MSq1R1A6xQ9dk1CzBVf6Xk --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 100 ++++++- cloudflare-worker.js | 51 +++- migrations/004_event_provenance.sql | 94 ++++++ src/actuator.rs | 317 ++++++++++++++++++++ src/ajax.rs | 438 ++++++++++++++++++++++++++++ src/assets.rs | 170 +++++++++++ src/canary.rs | 108 +++++++ src/cms.rs | 80 +++++ src/config.rs | 123 ++++++++ src/facade.rs | 135 +++++++++ src/git.rs | 21 +- src/handlers.rs | 105 ++++++- src/headers.rs | 83 ++++++ src/main.rs | 106 +++++-- src/parsers.rs | 144 ++++++++- src/php.rs | 302 +++++++++++++++++++ src/restapi.rs | 277 ++++++++++++++++++ src/secrets.rs | 354 ++++++++++++++++++++++ src/sink.rs | 110 ++++++- src/sticky.rs | 104 +++++++ src/tarpit.rs | 83 ++++++ src/templates.rs | 2 +- 24 files changed, 3251 insertions(+), 58 deletions(-) create mode 100644 migrations/004_event_provenance.sql create mode 100644 src/actuator.rs create mode 100644 src/ajax.rs create mode 100644 src/assets.rs create mode 100644 src/facade.rs create mode 100644 src/php.rs create mode 100644 src/restapi.rs create mode 100644 src/secrets.rs create mode 100644 src/tarpit.rs diff --git a/Cargo.lock b/Cargo.lock index 6d8931c..ec89ac5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,7 @@ dependencies = [ "serde_json", "sqlx", "tokio", + "tokio-stream", "tracing", "tracing-subscriber", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 044f3aa..91c7fa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ description = "Self-hosted exploit-path honeypot with sticky deception — traps [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "json"] } governor = "0.8" serde_json = "1" diff --git a/README.md b/README.md index c4c85b4..8faa603 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,24 @@ Bots race to complete WordPress's setup wizard on fresh installs — whoever fin `GET /.env` returns a realistic `.env` file containing a per-IP planted DB password (`fk` + 12 chars, deterministic from IP hash). The password is inserted into `granted_credentials`. If an attacker reads the `.env` and later submits that password at any login form, the submission is captured and matchable to the original probe — correlating the attacker across vectors. +The planted pair is recorded in `honeypot_event.planted_user` / `planted_pass` — **never** in `submitted_user` / `submitted_pass`. Those two columns mean "the attacker sent us this", exclusively. (They did not always: the trap originally wrote plants into the `submitted_*` columns, which made the honeypot's own output indistinguishable from attacker input and inflated every credential metric roughly fourfold. Migration `004` separates the historical rows.) + ## Attacker engagement Beyond passive capture, RustyPot actively wastes attacker resources: - **Tarpit escalation** — after each fake-success grant, the tarpit delay for failed attempts increases: 30s → 60s → 120s → 240s (capped below Cloud Run's timeout). The attacker's throughput drops progressively. +- **Slow-response budget** — every delaying trap reserves a slot before it + holds a request open (`SLOW_RESPONSE_BUDGET`, default 64). Cloud Run gives + this service `containerConcurrency` 80 across `maxScale` 3 — 240 request + slots — and a held response occupies one for its full duration. Without a cap, + enough parallel tarpits would fill the pool and the honeypot would stop being + able to record new probes: tarpitting itself out of existence. When the budget + is spent the trap answers immediately instead, and logs a delay of 0, so + `response_delay_ms` always reflects time actually spent rather than time + intended. A fast response is unremarkable to an attacker; a request the + platform kills at its 300 s timeout is a 504 that identifies the trap. +- **Recon tarpit** — the `.env` and `.git` families get their own, much shorter ladder (`RECON_TARPIT_ESCALATION`, default `0,2,5,10,20` s), stepped by how many recon paths the IP has swept (`RECON_TARPIT_STEP`, default 10). Secrets harvesting is the most common objective observed, and it used to be the one family that cost the attacker nothing. The ladder is deliberately short: sweepers hit hundreds of paths, and a 30–240 s hold per path would pin every instance and starve the credential traps. A one-off probe sits on rung 0; a 300-path enumerator climbs. - **Canary links** — every link in the fake admin dashboard carries a per-IP tracking token (`?fk=...`). When a bot clicks any link, the token is logged, mapping their post-exploitation path sequence. - **Git loop** — `/.git/config` returns a realistic git config. `/.git/objects/` returns HTML directory listings. Each object page links to 3 more subdirectories, each with 10 objects — an infinite chain for HTML-following scanners. Pack files return 8KB with valid `PACK` headers. - **Cookie bombing** — the first fake-success response sets 20 cookies of 400 bytes each (~9KB). The attacker's HTTP client echoes all cookies on every subsequent request, cutting effective throughput. @@ -52,17 +65,27 @@ Beyond passive capture, RustyPot actively wastes attacker resources: | `/.env*` (any variant: `.env.dev`, `.envrc`, `.env_copy`, ...) and `/{subdir}/.env*` | any | Fake `.env` with per-IP planted credential — matches any path segment containing `.env` | | **Active traps** | | | | `/.git/*` | any | Infinite git-object chain (config → HEAD → refs → objects → loop) | +| `/wp-admin/admin-ajax.php` | any | Plugin exploit surface: parses the account a privilege-escalation creates (recorded `origin='ajax'`, grants instantly at login), answers injection attempts with a fabricated `wp_users` dump | +| `/wp-content/plugins/*/*.php` | any | Plugin entry points — the exploit the fingerprint bait advertises now lands somewhere instead of 404ing | | `/wp-admin/*` | any | Fake dashboard with canary links. POST: capture body | | `/admin/*` `/administrator/*` | any | Drupal/Django/Joomla post-login capture | +| `/wp-json/batch/v1` | any | Real batch semantics — one row per bundled sub-request, so a 50-attempt amplification batch reads as 50 attempts | | `/wp-json/*` | any | GET: 200 `[]`. POST: capture body, return 201 | +| `/.aws/credentials` `/.git-credentials` `/.gitconfig` `/.gitlab-ci.yml` `/.github/workflows/*` `/.npmrc` `/.docker/config.json` | any | Secret-file honeytokens with a per-IP planted credential (`origin='secret'`) | +| `/phpinfo.php` | any | Full fake `phpinfo()` (~27 KB) with planted credentials in the environment block | +| `/actuator` `/actuator/env` `/actuator/health` `/actuator/mappings` `/actuator/configprops` | any | Spring Boot Actuator with planted datasource credentials | +| `/actuator/heapdump` | any | Valid HPROF header, then a slow trickle — the one endpoint attackers expect to be huge and slow. Holds a budget slot for the stream, released on hang-up; served whole and fast when the budget is spent | +| `/phpmyadmin/*` `/pma/*` `/dbadmin/*` `/adminer.php` (15 spellings) | any | phpMyAdmin login form + credential capture | +| `/wp-includes/js/*` `/wp-includes/css/*` `/wp-admin/css/*` | any | Core JS/CSS. A real WordPress always serves these; 404ing them identified the install as fake | | **Passive 404 + log** | | | | `/.svn/*` `/.hg/*` | any | VCS exposure | -| `/.aws/*` `/.ssh/*` | any | Cloud key / SSH key probes | -| `/actuator/*` `/_ignition/*` | any | Spring Boot / Laravel debug endpoints | +| `/.ssh/*` | any | SSH key probes | +| `/_ignition/*` | any | Laravel debug endpoint | | `/solr/*` `/server-status` `/server-info` | any | Service exposure | | `/composer.json` `/package.json` | GET | Dependency file probes | -| `/phpinfo.php` `/shell.php` `/c99.php` `/r57.php` `/webshell.php` `/index.php` | any | PHP shell probes | -| `/phpmyadmin/*` `/phpMyAdmin/*` `/pma/*` `/dbadmin/*` `/mysql/*` `/sqlmanager/*` `/adminer.php` | any | DB admin variants | +| `/shell.php` `/c99.php` `/r57.php` `/webshell.php` | any | PHP shell probes | +| `/index.php` | any | PHP probe; also serves an injected XML-RPC canary post back to the IP that injected it | +| `/mysql/*` `/sqlmanager/*` | any | DB admin variants | | **Fingerprint bait** | | | | `/wp-includes/version.php` | any | Raw core `version.php` naming an outdated `$wp_version` | | `/readme.html` | any | Core readme naming the same version | @@ -71,6 +94,75 @@ Beyond passive capture, RustyPot actively wastes attacker resources: | **Catch-all** | | | | anything else the edge routes here | any | Logged, then 404 — including method mismatches (`GET /xmlrpc.php`) | +## Secret-file honeytokens + +Beyond `.env`, every file an attacker reads specifically to extract a +credential is a honeytoken vector on the same model: `.aws/credentials`, +`.aws/config`, `.git-credentials`, `.gitconfig`, `.gitlab-ci.yml`, +`.github/workflows/*.yml`, `.npmrc`, `.docker/config.json`. Each carries a +deterministic per-IP secret recorded with `origin='secret'`. + +`.aws/credentials` is the highest-intel member. Set `AWS_CANARY_ACCESS_KEY_ID` +and `AWS_CANARY_SECRET_ACCESS_KEY` to a **real** AWS canary token (a +permissionless IAM user with a CloudTrail alarm) and you learn the attacker's +IP at the moment they *use* the key — the only signal here that survives their +infrastructure rotation. A genuine canary is necessarily one fixed credential, +so per-IP attribution comes from the `honeypot_event` row that recorded serving +it, matched on time. Unset, the file carries a per-IP fake with the right shape. + +`/phpinfo.php` and `/actuator/env` plant the same way: both are pages whose +whole purpose is dumping the process environment, so credentials in them look +like a misconfiguration rather than bait. + +## Impersonated crawlers + +User-agents branded as ChatGPT-User, PerplexityBot, Amazonbot, GPTBot and +friends show up requesting `.env`, `.aws/credentials` and login forms. No +legitimate crawler does that; the branding is chosen because sites commonly +allowlist those crawlers. Requests matching a crawler user-agent **on a path no +crawler would request** get a distinct honeytoken prefix (`fk` → `fkx`), so a +credential surfacing later carries "this actor impersonates AI crawlers" as a +tooling fingerprint without needing a join. + +## Wire-level disguise + +Every HTML trap response is dressed as PHP-served WordPress by middleware — +`X-Powered-By`, WordPress's fixed 1984 `Expires`, the no-cache pair, the +`wordpress_test_cookie` on `wp-login.php`, and the `Link: rel="https://api.w.org/"` +REST advertisement. `cloudflare-worker.js` strips the hosting platform's +`server: Google Frontend` and `x-cloud-trace-context` on the way back, which +the container cannot do itself. Applied centrally so a new trap cannot forget +it: the missing headers were a single tell that undermined every trap at once. + +## Content-injection canary + +`metaWeblog.newPost` probes inject a unique token as the post title and body, +then search the web for it — if it appears, the site accepts unauthenticated +publishing and joins a spam farm. RustyPot reports success with a post id and +serves the token back at `/index.php?p=`, which earns the follow-up visit. + +**The injected content is served only to the IP that injected it, only with +`X-Robots-Tag: noindex`, and always HTML-escaped.** Attacker-supplied content +reachable by anyone else, or indexable, would turn this service into a spam +relay for whatever they inject next. The store is bounded at 512 posts. + +## Reading the data + +Query **`honeypot_event_live`**, not `honeypot_event`. The raw table also holds: + +- **Synthetic rows** (`synthetic = TRUE`) — written by the out-of-band backfill + importers, not captured by this service. They carry no POST body, no real + headers, and in the `drop-recovery` case an *inferred* source IP. Aggregating + them with live captures fabricates attacker behaviour that was never observed. +- **Cloudflare-origin rows** — requests whose client is Cloudflare itself + (`cf-connecting-ip` is a Cloudflare address), not an attacker proxied through + it. `is_cloudflare_origin(source_ip)` tests this against the `cloudflare_ranges` + table; refresh that table from https://www.cloudflare.com/ips/ when the + published prefixes change. + +The view excludes both. `honeypot_event` remains the place to answer questions +*about* capture coverage — which is what the provenance panel does. + Every request that reaches the service is recorded, including ones it answers with 404 or 503 — the paths RustyPot does *not* yet trap are the feed for deciding which trap to build next, so they must not be dropped silently. diff --git a/cloudflare-worker.js b/cloudflare-worker.js index 7610815..41031d7 100644 --- a/cloudflare-worker.js +++ b/cloudflare-worker.js @@ -23,25 +23,68 @@ function isHoneypotPath(pathname) { return HONEYPOT_PREFIXES.test(pathname) || ENV_ANYWHERE.test(pathname); } +/** + * Cloudflare exposes the visitor's country on `request.cf`, NOT as a request + * header — `cf-ipcountry` only reaches an origin if the "Add visitor location + * headers" Managed Transform is enabled. It was not, so RustyPot's + * `cf_ipcountry` column was NULL on every row ever recorded and every geo panel + * was structurally empty rather than merely sparse. Forward it explicitly. + */ +function withGeoHeaders(request) { + const headers = new Headers(request.headers); + const cf = request.cf; + if (cf?.country) headers.set("cf-ipcountry", cf.country); + if (cf?.asn) headers.set("cf-asn", String(cf.asn)); + if (cf?.asOrganization) headers.set("cf-as-org", cf.asOrganization); + return headers; +} + +/** + * Strip the hosting platform's fingerprints from honeypot responses. + * + * Cloud Run stamps `server: Google Frontend` and `x-cloud-trace-context` on + * every response. A honeypot serving fake WordPress from an origin that + * announces itself as Google Frontend is identifiable before a bot ever + * submits a credential, which undermines every trap behind it. The origin + * cannot remove these itself — they are added downstream of the container — so + * the rewrite has to happen here. + * + * The application sets the PHP/WordPress headers; this only removes the + * contradicting ones and supplies the `server` a PHP host would send. + */ +function disguiseOrigin(upstream) { + const headers = new Headers(upstream.headers); + headers.delete("x-cloud-trace-context"); + headers.delete("alt-svc"); + headers.set("server", "nginx/1.24.0"); + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +} + export default { async fetch(request, env) { const url = new URL(request.url); + const hasBody = request.method !== "GET" && request.method !== "HEAD"; if (isHoneypotPath(url.pathname)) { const target = new URL(url.pathname + url.search, env.HONEYPOT_BACKEND); - return fetch(target, { + const upstream = await fetch(target, { method: request.method, - headers: request.headers, - body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined, + headers: withGeoHeaders(request), + body: hasBody ? request.body : undefined, redirect: "manual", }); + return disguiseOrigin(upstream); } const appTarget = new URL(url.pathname + url.search, env.APP_BACKEND); return fetch(appTarget, { method: request.method, headers: request.headers, - body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined, + body: hasBody ? request.body : undefined, redirect: "manual", }); }, diff --git a/migrations/004_event_provenance.sql b/migrations/004_event_provenance.sql new file mode 100644 index 0000000..1cfbe26 --- /dev/null +++ b/migrations/004_event_provenance.sql @@ -0,0 +1,94 @@ +-- Event provenance + plant/submission separation. +-- +-- Three problems this fixes, all of which made the dashboard unreadable: +-- +-- 1. `synthetic` — rows imported by out-of-band backfill scripts (they tagged +-- themselves with a `source` key in request_headers: 'gap-recovery', +-- 'drop-recovery') were indistinguishable from live captures. They carry no +-- POST body, no real headers, and in the 'drop-recovery' case an *inferred* +-- source_ip. Aggregating them with real captures inflates every panel and +-- fabricates attacker behaviour that was never observed. +-- +-- 2. `planted_user` / `planted_pass` — the `.env` honeytoken wrote the +-- credential IT PLANTED into `submitted_user`/`submitted_pass`, the columns +-- that are supposed to mean "the attacker sent us this". Any +-- "credentials captured" panel counted our own plants; the real number was +-- roughly a quarter of what was displayed. Plants now have their own +-- columns, and `submitted_*` means attacker-supplied, exclusively. +-- +-- 3. `cloudflare_ranges` + `honeypot_event_live` — traffic originating from +-- Cloudflare's own infrastructure (cf-connecting-ip is itself a Cloudflare +-- address) is not an attacker. It was the single largest "attacker" path in +-- the dashboard. The view filters it, and Grafana should query the view. + +ALTER TABLE honeypot_event + ADD COLUMN IF NOT EXISTS synthetic BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS planted_user TEXT, + ADD COLUMN IF NOT EXISTS planted_pass TEXT; + +-- Backfill 1: tag every row an importer wrote. +UPDATE honeypot_event + SET synthetic = TRUE + WHERE request_headers ? 'source' + AND NOT synthetic; + +-- Backfill 2: move honeytoken plants out of the submitted_* columns. A plant is +-- an env-trap row whose password carries the honeytoken prefix; the attacker +-- never typed it, we generated and served it. +UPDATE honeypot_event + SET planted_user = submitted_user, + planted_pass = submitted_pass, + submitted_user = NULL, + submitted_pass = NULL + WHERE submitted_pass IS NOT NULL + AND submitted_pass ~ '^fk[A-Za-z0-9]' + AND path ~ '\.env' + AND planted_pass IS NULL; + +-- Published Cloudflare edge prefixes (https://www.cloudflare.com/ips/). +-- Seeded rather than hardcoded in a WHERE clause so the list can be refreshed +-- without a code change when Cloudflare adds a range. +CREATE TABLE IF NOT EXISTS cloudflare_ranges (prefix INET PRIMARY KEY); + +INSERT INTO cloudflare_ranges (prefix) VALUES + ('173.245.48.0/20'), ('103.21.244.0/22'), ('103.22.200.0/22'), + ('103.31.4.0/22'), ('141.101.64.0/18'), ('108.162.192.0/18'), + ('190.93.240.0/20'), ('188.114.96.0/20'), ('197.234.240.0/22'), + ('198.41.128.0/17'), ('162.158.0.0/15'), ('104.16.0.0/13'), + ('104.24.0.0/14'), ('172.64.0.0/13'), ('131.0.72.0/22'), + ('2400:cb00::/32'), ('2606:4700::/32'), ('2803:f800::/32'), + ('2405:b500::/32'), ('2405:8100::/32'), ('2a06:98c0::/29'), + ('2c0f:f248::/32') +ON CONFLICT (prefix) DO NOTHING; + +-- source_ip is TEXT on purpose (we log whatever the proxy sent, including +-- malformed values). Cast defensively so one bad row can't error a dashboard. +CREATE OR REPLACE FUNCTION try_inet(txt TEXT) RETURNS INET AS $$ +BEGIN + RETURN txt::INET; +EXCEPTION WHEN others THEN + RETURN NULL; +END; +$$ LANGUAGE plpgsql IMMUTABLE RETURNS NULL ON NULL INPUT; + +-- TRUE when the client is Cloudflare itself rather than an attacker proxied +-- through it. Note this tests source_ip, which extract_source_ip() sets from +-- cf-connecting-ip — so a real attacker behind Cloudflare is NOT matched here. +CREATE OR REPLACE FUNCTION is_cloudflare_origin(txt TEXT) RETURNS BOOLEAN AS $$ + SELECT EXISTS ( + SELECT 1 FROM cloudflare_ranges r WHERE try_inet(txt) <<= r.prefix + ); +$$ LANGUAGE sql STABLE; + +-- The view Grafana should point at: genuine, externally-originated captures. +CREATE OR REPLACE VIEW honeypot_event_live AS + SELECT * FROM honeypot_event + WHERE NOT synthetic + AND NOT is_cloudflare_origin(source_ip); + +CREATE INDEX IF NOT EXISTS honeypot_event_synthetic_idx + ON honeypot_event (ts DESC) WHERE NOT synthetic; + +-- Replaces honeypot_event_has_creds_idx's intent: attacker-submitted only. +CREATE INDEX IF NOT EXISTS honeypot_event_submitted_idx + ON honeypot_event (ts DESC) WHERE submitted_user IS NOT NULL AND NOT synthetic; diff --git a/src/actuator.rs b/src/actuator.rs new file mode 100644 index 0000000..2c6c05e --- /dev/null +++ b/src/actuator.rs @@ -0,0 +1,317 @@ +//! Spring Boot Actuator traps. +//! +//! `/actuator/env` and friends were answering 404 despite steady demand. +//! Actuator is a secrets-disclosure target: `env` prints the whole property +//! source including datasource passwords, so it is a natural honeytoken vector, +//! and `heapdump` is the rare endpoint an attacker *expects* to be enormous and +//! slow — which makes it the most credible tarpit in the whole surface. A bot +//! that starts a heapdump download will wait, because a fast one would be the +//! suspicious outcome. + +use axum::body::Body; +use axum::extract::{OriginalUri, State}; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use std::net::IpAddr; +use std::time::Duration; +use tokio::sync::OwnedSemaphorePermit; + +use crate::sink; +use crate::sticky::planted_credential; +use crate::{Error, HoneypotState}; + +/// Actuator masks values it considers sensitive with `******`. Leaving a few +/// masked is what makes the unmasked ones look like a real misconfiguration +/// rather than bait. +fn env_json(secret: &str, db_pass: &str) -> String { + format!( + r#"{{ + "activeProfiles": ["prod"], + "propertySources": [ + {{ + "name": "systemEnvironment", + "properties": {{ + "SPRING_DATASOURCE_URL": {{"value": "jdbc:postgresql://10.0.4.17:5432/fillerkiller"}}, + "SPRING_DATASOURCE_USERNAME": {{"value": "app_user"}}, + "SPRING_DATASOURCE_PASSWORD": {{"value": "{db_pass}"}}, + "SPRING_REDIS_PASSWORD": {{"value": "{secret}"}}, + "JWT_SIGNING_KEY": {{"value": "{secret}"}}, + "AWS_SECRET_ACCESS_KEY": {{"value": "{secret}"}}, + "MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE": {{"value": "*"}}, + "PATH": {{"value": "/usr/local/bin:/usr/bin:/bin"}} + }} + }}, + {{ + "name": "applicationConfig: [classpath:/application-prod.yml]", + "properties": {{ + "server.port": {{"value": 8080}}, + "spring.jpa.hibernate.ddl-auto": {{"value": "validate"}}, + "management.endpoint.heapdump.enabled": {{"value": true}}, + "app.admin.token": {{"value": "******"}} + }} + }} + ] +}}"# + ) +} + +const HEALTH_JSON: &str = r#"{"status":"UP","components":{"db":{"status":"UP","details":{"database":"PostgreSQL","validationQuery":"isValid()"}},"diskSpace":{"status":"UP","details":{"total":103068663808,"free":41203781632,"threshold":10485760}},"ping":{"status":"UP"}}}"#; + +const MAPPINGS_JSON: &str = r#"{"contexts":{"application":{"mappings":{"dispatcherServlets":{"dispatcherServlet":[{"handler":"ApiController#login(LoginRequest)","predicate":"{POST /api/v1/auth/login}"},{"handler":"ApiController#users()","predicate":"{GET /api/v1/users}"},{"handler":"AdminController#exec(String)","predicate":"{POST /api/v1/admin/exec}"}]}}}}}"#; + +const CONFIGPROPS_JSON: &str = r#"{"contexts":{"application":{"beans":{"spring.datasource-org.springframework.boot.autoconfigure.jdbc.DataSourceProperties":{"prefix":"spring.datasource","properties":{"url":"jdbc:postgresql://10.0.4.17:5432/fillerkiller","username":"app_user","driverClassName":"org.postgresql.Driver"}}}}}}"#; + +/// A Java heap dump begins with the HPROF magic string and a header. Kits +/// check the magic before committing to a long download. +fn hprof_header() -> Vec { + let mut v = Vec::from(&b"JAVA PROFILE 1.0.2\0"[..]); + v.extend_from_slice(&4u32.to_be_bytes()); // identifier size + v.extend_from_slice(&0u64.to_be_bytes()); // timestamp + v +} + +const HEAPDUMP_CHUNKS: usize = 60; + +/// Stream `total_bytes` over `seconds`, so the transfer looks like a real +/// multi-megabyte heapdump crawling over a slow link. Each chunk is sent then +/// awaited; the receiver disconnecting ends the task. +/// +/// `permit` is the slow-response slot this stream occupies. It is moved into +/// the streaming task and dropped when the stream ends — including when the +/// attacker hangs up — so an abandoned download returns its slot immediately +/// instead of holding it for the full duration. +/// +/// The filler is allocated once as `Bytes`; cloning it is a refcount bump, not +/// a copy. Cloning a `Vec` per chunk instead would put +/// `chunk_size * chunks * concurrent_streams` through the allocator and, at the +/// upper end of `HEAPDUMP_BYTES`, threaten a 256 MiB container. +fn heapdump_body(total_bytes: usize, seconds: u64, permit: OwnedSemaphorePermit) -> Body { + let header = hprof_header(); + let chunk_size = total_bytes.saturating_sub(header.len()) / HEAPDUMP_CHUNKS.max(1); + let gap = Duration::from_millis(seconds.saturating_mul(1000) / HEAPDUMP_CHUNKS as u64); + + let (tx, rx) = tokio::sync::mpsc::channel::>(1); + tokio::spawn(async move { + // Held for the life of the stream; dropped on return, hang-up included. + let _permit = permit; + if tx.send(Ok(header.into())).await.is_err() { + return; + } + // Heap contents are mostly repeated object headers and string data, so + // a low-entropy filler reads like the real thing. + let filler = axum::body::Bytes::from(vec![b'\0'; chunk_size]); + for _ in 0..HEAPDUMP_CHUNKS { + tokio::time::sleep(gap).await; + if tx.send(Ok(filler.clone())).await.is_err() { + return; + } + } + }); + Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx)) +} + +/// Cap on the unbudgeted heapdump body. +/// +/// This path runs precisely when the slow-response budget is spent — that is, +/// when concurrency is highest — so it must be the *cheaper* of the two, not +/// the more expensive one. Buffering the full `HEAPDUMP_BYTES` here would +/// allocate up to 16 MiB per in-flight request with no ceiling on how many, +/// against a 256 MiB container: the fallback would OOM the service under the +/// exact load it exists to survive. A truncated dump is unremarkable — real +/// ones are interrupted all the time — and the HPROF magic still reads. +const HEAPDUMP_IMMEDIATE_CAP: usize = 64 * 1024; + +/// The dump served whole, for when the slow-response budget is spent. A fast +/// heapdump is unremarkable; a request the platform kills at its timeout is a +/// 504 that identifies the trap. +/// Bytes the unbudgeted path buffers. Split out so the cap is directly +/// testable without reaching into an opaque `Body`. +fn immediate_len(total_bytes: usize) -> usize { + total_bytes + .min(HEAPDUMP_IMMEDIATE_CAP) + .max(hprof_header().len()) +} + +fn heapdump_immediate(total_bytes: usize) -> Body { + let mut v = hprof_header(); + v.resize(immediate_len(total_bytes), 0); + Body::from(v) +} + +pub async fn actuator( + State(state): State, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + method: Method, +) -> Result { + let path = uri.path(); + let ip_str = crate::headers::extract_source_ip(&headers); + let ip: IpAddr = ip_str.parse().unwrap_or(IpAddr::from([0, 0, 0, 0])); + let prefix = + crate::sticky::honeytoken_prefix(&state.settings.honeytoken_prefix, &headers, path); + + let tail = path.trim_start_matches("/actuator").trim_matches('/'); + let json_ct = "application/vnd.spring-boot.actuator.v3+json"; + + if tail == "heapdump" { + let permit = crate::tarpit::try_reserve(&state.slow_budget); + let held_secs = crate::tarpit::effective_delay(state.settings.heapdump_seconds, &permit); + sink::log_event( + &state, + &headers, + &method, + path, + uri.query(), + None, + None, + None, + 200, + u32::try_from(held_secs * 1000).unwrap_or(0), + ) + .await?; + let body = match permit { + Some(p) => heapdump_body(state.settings.heapdump_bytes, held_secs, p), + None => heapdump_immediate(state.settings.heapdump_bytes), + }; + return Ok(( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, "application/octet-stream"), + ( + axum::http::header::CONTENT_DISPOSITION, + "attachment; filename=\"heapdump\"", + ), + ], + body, + ) + .into_response()); + } + + let (body, planted) = match tail { + "env" => { + let secret = planted_credential(&ip, "/actuator/env#secret", &prefix); + let db_pass = planted_credential(&ip, "/actuator/env#db", &prefix); + (env_json(&secret, &db_pass), Some(db_pass)) + } + "health" => (HEALTH_JSON.to_owned(), None), + "mappings" => (MAPPINGS_JSON.to_owned(), None), + "configprops" => (CONFIGPROPS_JSON.to_owned(), None), + // The index lists what is exposed, which is how kits discover heapdump. + "" => ( + r#"{"_links":{"self":{"href":"/actuator","templated":false},"env":{"href":"/actuator/env","templated":false},"health":{"href":"/actuator/health","templated":false},"heapdump":{"href":"/actuator/heapdump","templated":false},"mappings":{"href":"/actuator/mappings","templated":false},"configprops":{"href":"/actuator/configprops","templated":false}}}"#.to_owned(), + None, + ), + _ => { + return crate::handlers::config_probe(State(state), OriginalUri(uri), headers, method) + .await + } + }; + + if let Some(ref db_pass) = planted { + let _ = sink::record_granted_credential( + &state.pool, + "app_user", + db_pass, + &ip_str, + sink::ORIGIN_SECRET, + ) + .await; + } + + sink::log_planted_event( + &state, + &headers, + &method, + path, + uri.query(), + planted.as_ref().map(|_| "app_user"), + planted.as_deref(), + 200, + 0, + ) + .await?; + + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, json_ct)], + body, + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_json_plants_credentials_and_keeps_one_masked() { + let j = env_json("fkSECRET", "fkDBPASS"); + assert!(j.contains("fkDBPASS")); + assert!(j.contains("fkSECRET")); + assert!(j.contains("******"), "a masked value sells the rest"); + assert!(j.contains("SPRING_DATASOURCE_PASSWORD")); + } + + #[test] + fn immediate_body_is_capped_and_keeps_the_hprof_magic() { + // Never buffers more than the cap, whatever HEAPDUMP_BYTES says. + for requested in [0, 1024, 16 * 1024 * 1024] { + let len = immediate_len(requested); + assert!( + len <= HEAPDUMP_IMMEDIATE_CAP, + "requested {requested} buffered {len}" + ); + assert!(len >= hprof_header().len(), "magic must survive"); + } + assert_eq!(immediate_len(1024), 1024, "small dumps pass through"); + } + + #[test] + fn unbudgeted_path_is_cheaper_than_the_streamed_one() { + // The whole point: the fallback runs at peak concurrency, so it must + // not be the memory-hungrier branch. + let streamed_chunk = (16 * 1024 * 1024) / HEAPDUMP_CHUNKS; + assert!( + HEAPDUMP_IMMEDIATE_CAP <= streamed_chunk * 2, + "fallback must stay comparable to one streamed chunk" + ); + } + + #[test] + fn heapdump_starts_with_hprof_magic() { + let h = hprof_header(); + assert!(h.starts_with(b"JAVA PROFILE 1.0.2\0")); + assert_eq!(h.len(), 19 + 4 + 8); + } + + #[test] + fn actuator_json_payloads_parse() { + for j in [HEALTH_JSON, MAPPINGS_JSON, CONFIGPROPS_JSON] { + serde_json::from_str::(j).expect("valid JSON"); + } + serde_json::from_str::(&env_json("a", "b")).expect("env is valid JSON"); + } + + #[test] + fn concurrent_heapdumps_cannot_exhaust_container_memory() { + // Worst case resident filler = chunk_size * every slow-response slot. + // The container limit is 256 MiB; stay well clear of it. + let s = crate::config::Settings::default(); + let max_bytes = 16 * 1024 * 1024; // the HEAPDUMP_BYTES ceiling + let chunk = max_bytes / HEAPDUMP_CHUNKS; + let worst_case = chunk * s.slow_response_budget; + assert!( + worst_case < 32 * 1024 * 1024, + "worst-case heapdump memory {worst_case} B is too close to the 256 MiB limit" + ); + } + + #[test] + fn heapdump_duration_stays_under_cloud_run_timeout() { + let s = crate::config::Settings::default(); + assert!( + s.heapdump_seconds < 300, + "a response past the platform timeout returns 504 and reveals the trap" + ); + } +} diff --git a/src/ajax.rs b/src/ajax.rs new file mode 100644 index 0000000..84a9efd --- /dev/null +++ b/src/ajax.rs @@ -0,0 +1,438 @@ +//! `wp-admin/admin-ajax.php` — plugin exploit endpoint. +//! +//! This is where WordPress plugins expose unauthenticated actions, and it is +//! the single richest capture surface this service has. Two real payloads were +//! already sitting unparsed in the catch-all before this module existed: +//! +//! - `action=pods_admin&method=save_user&user_login=…&user_pass=…&role=administrator` +//! — a Pods privilege-escalation creating a backdoor admin. The credentials +//! the kit CHOOSES are exactly the capture the installer-claim trap was built +//! for and never got, because the kits use this path instead. +//! - `action=gamipress_get_logs&orderby=,(SELECT EXTRACTVALUE(1,CONCAT(0x7e, +//! (SELECT GROUP_CONCAT(CONCAT(user_login,0x3a,user_pass)…) FROM wp_users…` +//! — error-based SQL injection dumping the user table. +//! +//! Both get answered convincingly. A created account is recorded with +//! `origin='ajax'` so the kit's follow-up login succeeds (see `decide_grant`), +//! and an injection gets a fabricated `wp_users` dump back. + +use axum::body::Bytes; +use axum::extract::{OriginalUri, State}; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use std::net::IpAddr; + +use crate::parsers::{body_to_string, extract_form_field}; +use crate::sink; +use crate::{Error, HoneypotState}; + +/// Markers of an injection attempt in a parameter value. Matched +/// case-insensitively against the whole body. +const SQLI_MARKERS: &[&str] = &[ + "extractvalue", + "updatexml", + "union select", + "information_schema", + "group_concat", + "benchmark(", + "sleep(", + "@@version", + "0x7e", +]; + +pub fn looks_like_sqli(body: &str) -> bool { + let lower = body.to_ascii_lowercase(); + SQLI_MARKERS.iter().any(|m| lower.contains(m)) +} + +/// Field names plugins use for the account an escalation exploit creates. +/// Ordered by how commonly they appear in the observed payloads. +const USER_FIELDS: &[&str] = &["user_login", "username", "user_name", "log", "new_user"]; +const PASS_FIELDS: &[&str] = &["user_pass", "password", "user_password", "pwd", "pass"]; + +/// Fields naming the role an escalation exploit assigns. Their presence is +/// what separates "a kit created an admin" from "someone posted a form". +const ROLE_FIELDS: &[&str] = &["role", "user_role", "wp_capabilities", "new_role"]; + +/// True when the payload is really trying to create a privileged account. +/// +/// Without this gate, ANY post carrying user/pass fields registered the pair as +/// `origin='ajax'`, which grants instantly at the login form. That is a +/// two-request fingerprinting oracle: post an arbitrary pair here, then present +/// it at `wp-login.php`. Real WordPress answers an unknown admin-ajax action +/// with `0` and creates nothing, so that login would fail — an instant success +/// identifies the honeypot outright. It also let an attacker mark common +/// dictionary pairs as instantly-granted, short-circuiting the churn that makes +/// stuffers work through their whole list. +/// +/// Credentials are still captured either way; only the instant-grant +/// registration is gated. +pub fn is_privilege_escalation(body: &str) -> bool { + ROLE_FIELDS.iter().any(|f| { + extract_form_field(body, f).is_some_and(|v| { + let v = v.to_ascii_lowercase(); + v.contains("admin") || v.contains("editor") || v.contains("level_10") + }) + }) +} + +pub fn parse_created_account(body: &str) -> (Option, Option) { + let user = USER_FIELDS.iter().find_map(|f| extract_form_field(body, f)); + let pass = PASS_FIELDS.iter().find_map(|f| extract_form_field(body, f)); + (user, pass) +} + +/// A WordPress phpass hash: `$P$B` + 8 salt chars + 22 hash chars. +/// +/// These are deliberately NOT derived from any password — nothing will ever +/// crack them, which is the point: a kit that dumps them spends real GPU time +/// on hashes with no preimage. The correlation value is in the USERNAMES, +/// which are per-IP honeytokens: one of them presented at a login form later +/// identifies the actor that pulled this dump. +fn phpass_hash(ip: &IpAddr, seed: &str) -> String { + const ITOA: &[u8] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + format!("$P$B{}", crate::sticky::derived_chars(ip, seed, ITOA, 30)) +} + +/// Usernames are per-IP so a later login attempt naming one of them proves the +/// attacker read this dump. +fn fabricated_users(ip: &IpAddr) -> Vec<(String, String)> { + let tag = crate::sticky::planted_credential(ip, "/admin-ajax#users", ""); + let short: String = tag.chars().take(4).collect(); + ["admin", "editor", "webmaster", "support", "backup"] + .iter() + .enumerate() + .map(|(i, role)| { + ( + format!("wp_{role}_{short}"), + phpass_hash(ip, &format!("/admin-ajax#u{i}")), + ) + }) + .collect() +} + +/// The MySQL error an EXTRACTVALUE injection produces, wrapped in the +/// `WordPress database error` envelope a real install prints when +/// `WP_DEBUG_DISPLAY` is on. The leading `~` is the 0x7e the payload injects. +fn sqli_error_page(ip: &IpAddr, action: &str) -> String { + let dump = fabricated_users(ip) + .into_iter() + .map(|(u, h)| format!("{u}:{h}")) + .collect::>() + .join("|"); + let truncated: String = dump.chars().take(31).collect(); + format!( + "
\nWordPress database error: [XPATH syntax error: '~{truncated}']
\n\ + SELECT * FROM wp_posts WHERE post_type = '{action}' ORDER BY ,(SELECT EXTRACTVALUE(1,CONCAT(0x7e,(SELECT GROUP_CONCAT(CONCAT(user_login,0x3a,user_pass)) FROM wp_users LIMIT 5))))
\n\ + \n0" + ) +} + +pub async fn admin_ajax( + State(state): State, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + method: Method, + body: Bytes, +) -> Result { + let path = uri.path(); + let ip_str = crate::headers::extract_source_ip(&headers); + let ip: IpAddr = ip_str.parse().unwrap_or(IpAddr::from([0, 0, 0, 0])); + let body_str = body_to_string(&body); + // The action can arrive in the query string as well as the body. + let action = extract_form_field(&body_str, "action") + .or_else(|| uri.query().and_then(|q| extract_form_field(q, "action"))) + .unwrap_or_default(); + + if method == Method::GET || method == Method::HEAD { + sink::log_event( + &state, + &headers, + &method, + path, + uri.query(), + None, + None, + None, + 200, + 0, + ) + .await?; + // Unknown/unauthenticated actions get a bare "0" from real WordPress. + return Ok((StatusCode::OK, "0").into_response()); + } + + if looks_like_sqli(&body_str) { + sink::log_event( + &state, + &headers, + &method, + path, + uri.query(), + Some(&body_str), + None, + None, + 200, + 0, + ) + .await?; + return Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")], + sqli_error_page(&ip, &action), + ) + .into_response()); + } + + let (user, pass) = parse_created_account(&body_str); + if let (Some(u), Some(p)) = (&user, &pass) { + if !u.is_empty() && !p.is_empty() && is_privilege_escalation(&body_str) { + let _ = sink::record_granted_credential(&state.pool, u, p, &ip_str, sink::ORIGIN_AJAX) + .await; + } + } + sink::log_event( + &state, + &headers, + &method, + path, + uri.query(), + Some(&body_str), + user.as_deref(), + pass.as_deref(), + 200, + 0, + ) + .await?; + + // Plugins answer a successful admin-ajax action with a JSON envelope. + // Reporting success is what makes the kit proceed to its verification + // login, which the ajax origin then grants. + let payload = if user.is_some() { + r#"{"success":true,"data":{"message":"User created","id":7}}"# + } else { + r#"{"success":true,"data":null}"# + }; + Ok(( + StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + "application/json; charset=UTF-8", + )], + payload, + ) + .into_response()) +} + +/// True for a request at a plugin's own PHP entry point, e.g. +/// `/wp-content/plugins/some-plugin/includes/upload.php`. +pub fn is_plugin_endpoint(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.starts_with("/wp-content/plugins/") && lower.ends_with(".php") +} + +/// Plugin entry points. +/// +/// The fingerprint bait advertises outdated, publicly-vulnerable plugin +/// versions, and scanners do read it — but they then probed the plugin's actual +/// endpoint, got a 404, concluded the plugin was not really installed, and +/// left. Two IPs read 12 and 6 plugin readmes respectively and departed inside +/// four minutes without a single exploit attempt. Answering these paths is what +/// converts a readme read into a captured payload. +pub async fn plugin_endpoint( + State(state): State, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + method: Method, + body: Bytes, +) -> Result { + let path = uri.path(); + if !is_plugin_endpoint(path) { + // readme.txt and asset requests keep the fingerprint-bait behaviour. + return crate::handlers::config_probe(State(state), OriginalUri(uri), headers, method) + .await; + } + let ip_str = crate::headers::extract_source_ip(&headers); + let ip: IpAddr = ip_str.parse().unwrap_or(IpAddr::from([0, 0, 0, 0])); + let body_str = body_to_string(&body); + + if !body_str.is_empty() && looks_like_sqli(&body_str) { + sink::log_event( + &state, + &headers, + &method, + path, + uri.query(), + Some(&body_str), + None, + None, + 200, + 0, + ) + .await?; + let action = path.rsplit('/').next().unwrap_or("plugin"); + return Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8")], + sqli_error_page(&ip, action), + ) + .into_response()); + } + + let (user, pass) = parse_created_account(&body_str); + if let (Some(u), Some(p)) = (&user, &pass) { + if !u.is_empty() && !p.is_empty() && is_privilege_escalation(&body_str) { + let _ = sink::record_granted_credential(&state.pool, u, p, &ip_str, sink::ORIGIN_AJAX) + .await; + } + } + + sink::log_event( + &state, + &headers, + &method, + path, + uri.query(), + (!body_str.is_empty()).then_some(body_str.as_str()), + user.as_deref(), + pass.as_deref(), + 200, + 0, + ) + .await?; + + // A file-upload exploit expects the uploaded path echoed back; a generic + // handler expects a success envelope. Both are answered with a JSON body + // naming a plausible uploads path, which is also the next thing the kit + // fetches — and that fetch is another capture. + let payload = json_success(path); + Ok(( + StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + "application/json; charset=UTF-8", + )], + payload, + ) + .into_response()) +} + +fn json_success(path: &str) -> String { + let name = path.rsplit('/').next().unwrap_or("file"); + format!( + r#"{{"success":true,"status":"ok","file":"/wp-content/uploads/2026/08/{name}","url":"https://fillerkiller.app/wp-content/uploads/2026/08/{name}","error":null}}"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognises_plugin_php_entry_points() { + assert!(is_plugin_endpoint( + "/wp-content/plugins/wp-fastest-cache/includes/upload.php" + )); + assert!(is_plugin_endpoint("/wp-content/plugins/foo/ajax.php")); + // readme/asset requests stay with the fingerprint bait. + assert!(!is_plugin_endpoint( + "/wp-content/plugins/wp-fastest-cache/readme.txt" + )); + assert!(!is_plugin_endpoint("/wp-content/themes/x/style.css")); + assert!(!is_plugin_endpoint("/wp-content/uploads/2026/08/a.php")); + } + + #[test] + fn upload_success_names_a_plausible_uploads_path() { + let j = json_success("/wp-content/plugins/foo/upload.php"); + let v: serde_json::Value = serde_json::from_str(&j).expect("valid JSON"); + assert_eq!(v["success"], true); + assert!(v["file"] + .as_str() + .unwrap() + .starts_with("/wp-content/uploads/")); + } + + #[test] + fn detects_the_observed_gamipress_injection() { + let body = "action=gamipress_get_logs&orderby=%2C%28SELECT+EXTRACTVALUE%281%2CCONCAT%280x7e%2C%28SELECT+GROUP_CONCAT%28CONCAT%28user_login%2C0x3a%2Cuser_pass%29"; + assert!(looks_like_sqli(body)); + } + + #[test] + fn ordinary_payloads_are_not_flagged_as_injection() { + assert!(!looks_like_sqli( + "action=pods_admin&method=save_user&user_login=wp_x&user_pass=abc" + )); + assert!(!looks_like_sqli("action=heartbeat&_nonce=abc123")); + } + + #[test] + fn parses_the_observed_pods_escalation() { + let body = "action=pods_admin&method=save_user&meta-box-loader=1&user_login=wp_bsxu9x&user_pass=hztnn1w0%21Aa1&user_email=wp_bsxu9x%40u4jpv1.com&role=administrator"; + let (u, p) = parse_created_account(body); + assert_eq!(u.as_deref(), Some("wp_bsxu9x")); + assert_eq!(p.as_deref(), Some("hztnn1w0!Aa1"), "percent-decoded"); + } + + #[test] + fn only_role_bearing_payloads_earn_an_instant_grant() { + // The observed Pods escalation assigns administrator, so it counts. + assert!(is_privilege_escalation( + "action=pods_admin&method=save_user&user_login=x&user_pass=y&role=administrator" + )); + assert!(is_privilege_escalation("action=x&new_role=editor")); + + // A bare credential post must NOT register an instant-grant pair — + // that was a two-request oracle for identifying the honeypot, and a way + // to short-circuit the stuffer churn on common passwords. + assert!(!is_privilege_escalation( + "user_login=admin&user_pass=123456" + )); + assert!(!is_privilege_escalation("action=heartbeat")); + assert!(!is_privilege_escalation("action=x&role=subscriber")); + } + + #[test] + fn parses_alternate_field_names() { + let (u, p) = parse_created_account("action=x&username=bob&password=hunter2"); + assert_eq!(u.as_deref(), Some("bob")); + assert_eq!(p.as_deref(), Some("hunter2")); + } + + #[test] + fn phpass_hash_has_wordpress_shape() { + let ip: IpAddr = "203.0.113.9".parse().unwrap(); + let h = phpass_hash(&ip, "seed"); + assert!(h.starts_with("$P$B")); + assert_eq!(h.len(), 34, "$P$B + 30 chars is the phpass layout"); + assert_eq!(h, phpass_hash(&ip, "seed"), "deterministic"); + // A visible period in the hash body would mark the dump as fabricated + // to anyone who looked before spending GPU time on it. + assert_ne!(&h[4..14], &h[14..24], "no repeating block"); + } + + #[test] + fn fabricated_users_are_per_ip() { + let a: IpAddr = "203.0.113.9".parse().unwrap(); + let b: IpAddr = "203.0.113.10".parse().unwrap(); + let ua = fabricated_users(&a); + assert_eq!(ua.len(), 5); + assert_ne!(ua, fabricated_users(&b), "usernames identify the reader"); + assert_eq!(ua, fabricated_users(&a), "stable for correlation"); + } + + #[test] + fn sqli_page_looks_like_a_wordpress_db_error() { + let ip: IpAddr = "203.0.113.9".parse().unwrap(); + let page = sqli_error_page(&ip, "gamipress_get_logs"); + assert!(page.contains("WordPress database error")); + assert!(page.contains("XPATH syntax error")); + // Real EXTRACTVALUE errors truncate at 32 chars — a full dump in the + // error string is the tell that it was fabricated. + let start = page.find("error: '~").unwrap() + "error: '~".len(); + let end = page[start..].find('\'').unwrap(); + assert!(end <= 31, "error text must respect MySQL's 32-char limit"); + assert!(page.contains("$P$B"), "hashes reachable in the comment"); + } +} diff --git a/src/assets.rs b/src/assets.rs new file mode 100644 index 0000000..a67117d --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,170 @@ +//! Static WordPress assets. +//! +//! A real WordPress always serves `wp-includes/js/jquery/jquery.js` and the +//! core stylesheets. Scanners fetch them to confirm the install is genuine +//! before spending effort on it — and this service used to 404 them, which +//! told any bot that checked that the WordPress around it was fake. Serving +//! them is pure credibility: it costs nothing and protects every other trap. +//! +//! The payloads are plausible rather than byte-exact. Bots check status, +//! content-type and the leading banner; none of them diff the minified body. + +use axum::extract::{OriginalUri, State}; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; + +use crate::sink::log_event; +use crate::{Error, HoneypotState}; + +const JQUERY_VERSION: &str = "3.7.1"; + +/// jQuery's real banner, then enough plausible minified body to look like the +/// library. The banner is what a version-fingerprinting scanner reads. +fn jquery_js() -> String { + format!( + "/*! jQuery v{JQUERY_VERSION} | (c) OpenJS Foundation and other contributors | jquery.org/license */\n\ + !function(e,t){{\"use strict\";\"object\"==typeof module&&\"object\"==typeof module.exports?\ + module.exports=e.document?t(e,!0):function(e){{if(!e.document)throw new Error(\"jQuery requires a window with a document\");\ + return t(e)}}:t(e)}}(\"undefined\"!=typeof window?window:this,function(e,t){{\"use strict\";\ + var n=[],r=Object.getPrototypeOf,i=n.slice,o=n.flat?function(e){{return n.flat.call(e)}}:function(e){{return n.concat.apply([],e)}},\ + s=n.push,a=n.indexOf,u={{}},l=u.toString,c=u.hasOwnProperty,f=c.toString,p=f.call(Object),d={{}},\ + h=function(e){{return\"function\"==typeof e&&\"number\"!=typeof e.nodeType&&\"function\"!=typeof e.item}},\ + g=function(e){{return null!=e&&e===e.window}},v=e.document,y={{type:!0,src:!0,nonce:!0,noModule:!0}};\ + var m=\"{JQUERY_VERSION}\",b=function(e,t){{return new b.fn.init(e,t)}};\ + b.fn=b.prototype={{jquery:m,constructor:b,length:0}};return b}});\n" + ) +} + +const JQUERY_MIGRATE_JS: &str = "/*! jQuery Migrate v3.4.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */\n\ +!function(e){\"use strict\";e.migrateVersion=\"3.4.1\"}(jQuery);\n"; + +const BUTTONS_CSS: &str = "/*! This file is auto-generated */\n\ +.wp-core-ui .button,.wp-core-ui .button-primary,.wp-core-ui .button-secondary{\ +display:inline-block;text-decoration:none;font-size:13px;line-height:2.15384615;\ +min-height:30px;margin:0;padding:0 10px;cursor:pointer;border-width:1px;border-style:solid;\ +-webkit-appearance:none;border-radius:3px;white-space:nowrap;box-sizing:border-box}\n\ +.wp-core-ui .button-primary{background:#2271b1;border-color:#2271b1;color:#fff}\n"; + +const DASHICONS_CSS: &str = "/*! This file is auto-generated */\n\ +@font-face{font-family:dashicons;src:url(../fonts/dashicons.eot);\ +src:url(../fonts/dashicons.eot?#iefix) format(\"embedded-opentype\"),\ +url(../fonts/dashicons.woff) format(\"woff\");font-weight:400;font-style:normal}\n"; + +const LOGIN_CSS: &str = "/*! This file is auto-generated */\n\ +body.login{background:#f0f0f1;min-width:0;color:#3c434a}\n\ +.login form{margin-top:20px;margin-left:0;padding:26px 24px;font-weight:400;\ +overflow:hidden;background:#fff;border:1px solid #c3c4c7;box-shadow:0 1px 3px rgba(0,0,0,.04)}\n"; + +/// Known static assets, matched on the exact path. Anything not listed falls +/// through to the caller's existing behaviour. +fn asset_for(path: &str) -> Option<(&'static str, String)> { + let js = "application/javascript; charset=UTF-8"; + let css = "text/css; charset=UTF-8"; + match path { + "/wp-includes/js/jquery/jquery.js" | "/wp-includes/js/jquery/jquery.min.js" => { + Some((js, jquery_js())) + } + "/wp-includes/js/jquery/jquery-migrate.js" + | "/wp-includes/js/jquery/jquery-migrate.min.js" => { + Some((js, JQUERY_MIGRATE_JS.to_owned())) + } + "/wp-includes/css/buttons.css" | "/wp-includes/css/buttons.min.css" => { + Some((css, BUTTONS_CSS.to_owned())) + } + "/wp-includes/css/dashicons.css" | "/wp-includes/css/dashicons.min.css" => { + Some((css, DASHICONS_CSS.to_owned())) + } + "/wp-admin/css/login.css" | "/wp-admin/css/login.min.css" => { + Some((css, LOGIN_CSS.to_owned())) + } + _ => None, + } +} + +/// Serve a core asset if this path is one. Returns `Ok(None)` when it is not, +/// so the caller can fall back to its normal handling. +pub async fn try_serve( + state: &HoneypotState, + headers: &HeaderMap, + method: &Method, + uri: &OriginalUri, +) -> Result, Error> { + let path = uri.path(); + let Some((content_type, body)) = asset_for(path) else { + return Ok(None); + }; + log_event( + state, + headers, + method, + path, + uri.query(), + None, + None, + None, + 200, + 0, + ) + .await?; + // Real assets are cacheable; the no-cache headers the traps carry would be + // conspicuous on a static file. + Ok(Some( + ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type), + ( + axum::http::header::CACHE_CONTROL, + "public, max-age=31536000", + ), + ], + body, + ) + .into_response(), + )) +} + +/// Route entry point: assets first, then the normal config-probe behaviour. +pub async fn wp_static( + State(state): State, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + method: Method, +) -> Result { + let original = OriginalUri(uri.clone()); + if let Some(resp) = try_serve(&state, &headers, &method, &original).await? { + return Ok(resp); + } + crate::handlers::config_probe(State(state), OriginalUri(uri), headers, method).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jquery_banner_names_a_real_version() { + let (ct, body) = asset_for("/wp-includes/js/jquery/jquery.js").expect("jquery is served"); + assert!(ct.contains("javascript")); + assert!(body.starts_with("/*! jQuery v3.7.1")); + assert!(body.contains("jquery.org/license")); + } + + #[test] + fn minified_variants_resolve_too() { + // Scanners request both spellings; a 404 on either is the same tell. + for p in [ + "/wp-includes/js/jquery/jquery.min.js", + "/wp-includes/css/buttons.min.css", + "/wp-admin/css/login.css", + ] { + assert!(asset_for(p).is_some(), "{p} should be served"); + } + } + + #[test] + fn unknown_paths_fall_through() { + assert!(asset_for("/wp-includes/version.php").is_none()); + assert!(asset_for("/wp-login.php").is_none()); + } +} diff --git a/src/canary.rs b/src/canary.rs index a2dfe29..17f5a17 100644 --- a/src/canary.rs +++ b/src/canary.rs @@ -1,6 +1,67 @@ use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::net::IpAddr; +use std::sync::Mutex; + +/// Posts an XML-RPC content-injection probe asked us to publish. +/// +/// The observed probes inject a unique hex token as the post title and body, +/// then search the web for it: if it turns up, the site accepts unauthenticated +/// publishing and gets added to a spam farm. Reporting success and then +/// actually serving the token back is what earns the follow-up visit — and the +/// follow-up is the real payload. +/// +/// **The content is served only to the IP that injected it, and only with +/// `X-Robots-Tag: noindex`.** Attacker-supplied content reachable by anyone +/// else, or indexable, would make this service a spam relay for whatever they +/// inject next. It is also escaped, never rendered as markup. +pub type CanaryPosts = Mutex>; + +/// Bounded so a flood of injections cannot grow the map without limit. +const MAX_CANARY_POSTS: usize = 512; + +pub fn new_canary_posts() -> CanaryPosts { + Mutex::new(HashMap::new()) +} + +/// Store an injected post and return the id reported back to the caller. +pub fn store_post(store: &CanaryPosts, ip: &IpAddr, content: &str) -> u32 { + let mut map = store.lock().expect("canary post store poisoned"); + if map.len() >= MAX_CANARY_POSTS { + map.clear(); + } + // WordPress post ids are small sequential integers; a random-looking one + // would be the tell. + let id = 1000 + u32::try_from(map.len()).unwrap_or(0) * 3 + 7; + map.insert(id, (*ip, content.to_owned())); + id +} + +/// Retrieve an injected post — only for the IP that injected it. +pub fn fetch_post(store: &CanaryPosts, id: u32, ip: &IpAddr) -> Option { + let map = store.lock().expect("canary post store poisoned"); + map.get(&id) + .filter(|(owner, _)| owner == ip) + .map(|(_, content)| content.clone()) +} + +/// Render an injected post as a WordPress single-post page. The content is +/// HTML-escaped: it is attacker-supplied, and the point is that they can find +/// their token, not that they can execute markup. +pub fn render_post(id: u32, content: &str) -> String { + let safe = crate::templates::html_escape(content); + format!( + r##" + +{safe} – Site + +
+

{safe}

+

{safe}

+
"## + ) +} fn canary_token(ip: &IpAddr, label: &str) -> String { let mut h = DefaultHasher::new(); @@ -43,3 +104,50 @@ pub fn admin_dashboard(ip: &IpAddr) -> String { "## ) } + +#[cfg(test)] +mod canary_post_tests { + use super::*; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn post_is_served_only_to_the_injecting_ip() { + let store = new_canary_posts(); + let attacker = ip("203.0.113.5"); + let id = store_post(&store, &attacker, "0x377fe0d7"); + assert_eq!( + fetch_post(&store, id, &attacker).as_deref(), + Some("0x377fe0d7") + ); + assert_eq!( + fetch_post(&store, id, &ip("198.51.100.9")), + None, + "must never serve injected content to a third party" + ); + assert_eq!(fetch_post(&store, 999_999, &attacker), None); + } + + #[test] + fn store_is_bounded() { + let store = new_canary_posts(); + let a = ip("203.0.113.5"); + for i in 0..(MAX_CANARY_POSTS + 10) { + store_post(&store, &a, &format!("c{i}")); + } + assert!(store.lock().unwrap().len() <= MAX_CANARY_POSTS); + } + + #[test] + fn rendered_post_is_noindex_and_escaped() { + let page = render_post(1007, ""); + assert!(page.contains("noindex, nofollow")); + assert!( + !page.contains("