What automated reconnaissance looks like in your event log

By ·

I run a small subscription service that generates illustrated stories. It has almost no users. A typical month brings one signup.

Over two days it received roughly a hundred times that, from Tor exit nodes, using email addresses that (mostly) did not work.

The attack

Free tier abuse, also called trial farming or free credit farming. The target is any service that gives something valuable away on signup.

Each account on my service receives two generation credits. A credit costs me real money: model inference, image synthesis, orchestration. An account costs the attacker an email address and a few seconds of script time. The gap between those two numbers is the entire motive, and it can be very wide for anything with an AI backend.

The attacker does not need a high success rate. If most addresses are dead and most accounts never verify, the few that do are free compute at someone else’s expense.

The supply chain involves three separate roles:

Discovery. A crawler looks for signup forms alongside phrases like “free credits”, “free trial”, “get started free”, and increasingly for anything that looks like a generation frontend. It builds a list, which is the product shared on forums, sold in bulk, or traded alongside credential dumps.

Address lists. Email addresses harvested from breach dumps, scraped from public sources, or bought cheaply. Quality is poor by design, because volume matters and individual addresses cost nothing. A few real, controlled inboxes are seeded through the list to confirm the verification loop closes.

Exploitation. A script that drives a real browser through the signup flow. Browser automation frameworks are free, well documented, and built for legitimate testing, which means they handle JavaScript-heavy frontends perfectly and produce traffic indistinguishable from a real browser at the network layer.

Total cost to run: a few pounds for the address list, a few pounds a month for a server, an afternoon of work. Against an unlimited number of targets.

The probe

What arrived was the proving run, not the attack. Establish that an account can be created, verified, and funded. With the intention to then scale the exploit.

Here is what it looked like.

Bounce notifications. Verification emails failing to addresses the submitter did not own.

554 4.4.7 Message expired: unable to deliver in 840 minutes.
<450 User is receiving mail too quickly>

AOL rate-limiting. Yahoo, mailbox not found. AT&T, mailbox disabled. A dead address list behaving as a dead address list does.

A traffic spike with the wrong shape.

Bot ratio: 20.6% (HIGH), up 550% week on week
31 of 70 sessions on bot device type
Regions: New York, Brandenburg, Beijing

_discover -> _signup -> _signup -> _signup    38% of sessions
_signup -> _signup -> _signup -> _signup      23%
_create -> _discover -> _signup -> _signup    24%

The signup step repeating three and four times inside single sessions, and the signup page as the largest exit point in the window. Retry logic, not interest. This is the event data captured by Clientlog.

One user agent. Every signup session reported Chrome 142.0.0 on Mac OS X 10.15.7. Identically, across sessions from Stockholm, Bavaria, Amsterdam, Luxembourg, Frankfurt and Delaware. Real traffic from six countries shows version spread. A hardcoded spoof does not.

macOS Chrome is a deliberate choice. It is common among real users and less associated with server traffic than Linux would be. However, this is a mistake by the attacker. A more experienced person would vary the user agent string.

Tor throughout. The network operators were Artikel10 e.V, Digitalcourage e.V, BuyVM, Sabotage LLC, R0CKET-CLOUD, Keyweb AG, Unredacted Inc. The first two are German civil-liberties organisations running exit nodes as a public service. The rest are hosting and VPN providers. No residential ISP appeared in any signup session.

The operator changed mid-session, as Tor rebuilt circuits and the script ran through the change. That is the proxy layer and the browser layer operating independently, which tells you the script does not manage its own rotation.

A fixed path. Every session ran home, account, login, signup, create, discover, signup, signup, signup. Events four to eight seconds apart, evenly. Again, a fairly simple mistake by the attacker which aided finger-printing.

Error handling visible in the sequence. A UsernameExistsException followed two seconds later by a successful signup with a different generated username: a collision, caught and retried. The same 22-character username rejected twice for InvalidParameterException, seconds apart, from Stockholm and then Brandenburg: the same input, retried, with a new exit node between attempts.

Parameter enumeration. Signup page views cycling ?plan=listener, ?plan=creator, ?plan=learner inside single sessions. Mapping the surface, in the order presented on the pricing page.

Polling. The one unauthenticated endpoint was called every five seconds for minutes at a time from the same Tor ranges. Every response was 4548 bytes, identical every time, because nothing new was being created.

The attacker was watching for a change and each poll invoked a function on my side. The endpoint had no caching available to it, so the cost of their monitoring loop was mine.

Gmail dot aliasing. The single account that completed verification used an address with full stops inserted into the local part. Gmail ignores them, so one real inbox produces unlimited apparently distinct addresses, and every verification email arrives. This is the most deliberate technique in the whole probe. The large volume of failing email addresses were dummies to “learn the process” and the dot addresses were attacker-controlled.

One request not from Tor. The click that completed verification came from an ordinary residential broadband connection in the US. The automation ran anonymised. The step that required a person to open an email and click a link ran from wherever that person was sitting. Another slip-up by our attacker.

What it cost

In total, this attack cost our service nothing.

Unconfirmed accounts cannot authenticate. Standard identity provider behaviour, doing its job. The accounts that failed verification could reach nothing.

Free credit grant was reduced to zero while the probe was running. One configuration variable, which allows signups to continue but limits the risk.

The generation pipeline takes no input from the client. Nothing a browser sends reaches the model layer. Story parameters are constructed server-side from the account state.

That third one is the reason this attack had nowhere to go, and it was not a response to anything. It was simply how the service had been built years earlier.

Never trust the client

Not sending client input to a privileged interpreter is the oldest defensive rule on the internet. SQL injection is the version everyone learned. Command injection, XSS, template injection, deserialisation attacks: same shape, same rule. Data from the client is data, never instruction.

A language model is an interpreter. Prompt injection is that rule, restated for a new interpreter, and it is harder to defend than SQL because there is no parameterised query. There is no reliable way to separate instruction from data inside a prompt. Every mitigation is probabilistic.

Which means the only complete defence is architectural: do not let client input reach the model at all. This decision closed prompt injection, model extraction, and using the service as free general-purpose inference, all at once.

This is what experience buys!

How it was visible

Also engineering, and also decided beforehand.

Failures reach a human. Bounce notifications go to my inbox rather than a filtered folder. That is the entire reason I looked at all.

Attempts are logged, not just successes. Every signup attempt, every verification attempt, every rejected request. The UsernameExistsException and InvalidParameterException retries are the clearest evidence in the dataset, and they are all failures. A system that only records success sees an attack as silence.

Accounts are inspectable. A full user export with creation times and verification status, in one command.

Every interaction is a structured event with the action, the browser, the network operator, and the timing, aggregated into sessions. This is where Clientlog helped identify the one user agent, the Tor operators, the fixed path, and the timing.

None of it was built directly for security, it was built to support debugging and observation.

How to analyse

Four sources: bounce emails, identity provider logs, gateway access logs, session events. Thousands of lines and no single view shows it.

This is where I used a language model to parse the logs.

I did not ask it to detect an intrusion. I gave it the exports and worked through them conversationally. It held four datasets in context at once, noticed that every signup session carried the same browser build, connected the network operator names to Tor exit infrastructure, and matched the timing distribution against what human browsing looks like.

It was also wrong, usefully. Early on it read the random-looking username on the verified account as machine generated. I pulled the full user list and most accounts looked like that, because browsers offer to generate usernames now and real people accept.

The model was also confidently wrong about the mechanism twice before the data corrected it.

That is the shape of the technique: The model reads volume and proposes patterns. You test the patterns against data. It is a fast hypothesis generator attached to a slow verifier.

Encoding it

Once identified, the signature is cheap to write down as a Clientlog rule:

- rule_id: hosting_or_tor_signup
  tag: high_risk_signup
  notification_target: email
  requires_events: true
  description: Signup completed from a Tor relay or hosting provider
  logic:
    {"and": [
      {"in": ["user.signup.success", {"var": "session.actions"}]},
      {"in": [{"var": "session.geo.org"}, [
        "Artikel10 e.V", "Digitalcourage e.V", "BuyVM",
        "R0CKET-CLOUD", "Keyweb AG", "Unredacted Inc"
      ]]}
    ]}

A hand-maintained list that will go stale, but is better than nothing!

What I would take from this

Obscurity is not protection. A service with no users and no visibility was found by a crawler within its first year, because discovery is automated and the target list is a commodity.

The defences that held were decided years earlier, for other reasons. No client input reaches the interpreter. Failures are logged and reach a human. Interactions are recorded with enough structure to be searched afterwards. None of that was a security programme. It was ordinary competent engineering, and it was enough.

Our small scale also made it visible. Ten extra signups is a spike here and noise at ten thousand users. Twenty percent bot traffic is an alarm here and a Tuesday elsewhere. Hence the need for defensive engineering and a layered architecture.


Documentation: Rules