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..9708ad0 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,81 @@ 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 `x-cloud-trace-context` +on the way back, which the container cannot do itself and which otherwise +reaches the client on every response. (`server` needs no rewrite: Cloudflare +already replaces the origin's value with `server: cloudflare`.) Applied +centrally so a new trap cannot forget it. + +`PUBLIC_HOSTNAME` supplies the host for the REST `Link` header. It is never +derived from the request's `Host`: behind the edge that is the origin's own +address, so echoing it would publish the backend URL and identify the stack. +Unset, the header is simply omitted. + +## 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..2cd445e 100644 --- a/cloudflare-worker.js +++ b/cloudflare-worker.js @@ -23,25 +23,69 @@ 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 `x-cloud-trace-context` on every response and it survives + * all the way to the client — a Google Cloud tell on what is meant to look like + * a PHP host. The container cannot remove it; the header is added downstream of + * it, so the strip has to happen here. + * + * `server` is deliberately NOT rewritten. Cloudflare already replaces whatever + * the origin sent with `server: cloudflare`, which is what every + * Cloudflare-fronted site returns and therefore reveals nothing. Setting it to + * a fake nginx here would be overwritten anyway, and a value that disagreed + * with the rest of the CF response set would be more conspicuous than the + * default. + */ +function disguiseOrigin(upstream) { + const headers = new Headers(upstream.headers); + headers.delete("x-cloud-trace-context"); + headers.delete("alt-svc"); + 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("