all posts

Your Free Tier Is a Mining Pool: Stopping Cryptomining Abuse

Ajay Kumar··10 min read

There is a sentence every platform founder writes in a launch post and immediately regrets: "free tier, no credit card required." What that sentence says to most readers is "try it out." What it says to a specific and highly motivated audience is "unlicensed mining pool, generous terms, signup automated in an afternoon." They will read it correctly, and they will read it before your first paying customer does.

I'm Ajay; I build PandaStack, a Firecracker microVM platform where anyone can create a sandbox and get a shell in it. That is the product. It is also, viewed from the other side of the table, a machine that hands out CPU to strangers on request. This is the operator's-eye view of cryptomining abuse — which detection signals are actually load-bearing, which ones are theater, and the deeply unglamorous controls that do the real work.

Why the free tier is the attack surface

Mining is the purest possible expression of compute arbitrage. The miner's cost of goods is your electricity bill, their revenue is denominated in something they can sell, and the margin is one hundred percent because they are not paying for the input. Every other kind of abuse on a code-execution platform — scraping, spam relays, credential stuffing — needs some external thing to work against. Mining needs nothing but cores and a route to a pool.

The economics also explain the shape of the attack. A single account under a free quota is worth very little, so nobody bothers with a single account. What you get instead is a farm: scripted signups against disposable-email domains, each account creating sandboxes that sit politely just under whatever limit you published, all of them behaving identically because they are the same script. Individually every account is a well-behaved user. Collectively they are a datacenter you are donating.

  • Signup automation — the form is public, the flow is documented, and you deliberately made it frictionless. That is not a bug you can fix without breaking the funnel.
  • Disposable and throwaway email domains — cheap, plentiful, and rotated the moment you start blocking them. Domain blocklists are a speed bump, not a wall.
  • Quota-hugging — a farm that gets banned for exceeding limits will simply be reconfigured to stay under them. Your published quota becomes their config file.
  • Behavioral uniformity — same template, same region preference, same lifetime, same start command, same second-of-the-hour. Humans are messier than this.
  • Patience — mining pays by the hour, forever. The abuser is not in a hurry, which means your "burst" detection built for scrapers will never fire.

And here is the part that makes it genuinely hard: mining is not malware. It does not exploit anything. It does not escape anything. It does not touch another tenant. It is a program that does arithmetic in a loop, which is the literal purpose of the service you sell. The workload is legal-looking in every direction, right up until you notice it has pegged every core it can reach for eleven days without ever writing a file or answering a request.

You cannot solve this with your isolation boundary. Firecracker, gVisor, a separate kernel, a separate physical host — none of it matters, because the abuser is not trying to break out. They are using the box exactly as designed. Isolation protects your other tenants from them; it does not protect you from paying for them.

The detection layers, ranked honestly

Most writing on this topic leads with process detection because it demos well. In practice that is the weakest layer you have. Here is the ranking I would defend, strongest first, with what each one costs you.

  • Network egress to mining pools — Strength: the single highest-signal indicator you will ever get, because a miner that cannot reach a pool is a space heater. Stratum has a recognisable handshake, pool endpoints are enumerable, and the connection is long-lived and chatty on a fixed cadence. Weakness: encrypted pool connections on 443 look like ordinary TLS, proxies and private pools exist, and enumerating endpoints is a subscription to someone else's list.
  • CPU shape over time — Strength: a genuinely distinctive fingerprint. Sustained near-100% across every core, for hours or days, with near-zero disk I/O and near-zero inbound bytes, is a combination almost nothing legitimate produces for that long. Weakness: it is a shape, not a proof, and the honest CPU-heavy workloads in the false-positive section produce the first half of it convincingly.
  • Process and binary heuristics — Strength: cheap, immediate, and catches the entirely unbothered majority who never renamed anything. Weakness: the weakest layer by a distance. It is trivially defeated by anyone paying attention, generates false positives on any tool that happens to share a name, and requires you to inspect your customers' processes — which is both an arms race you lose and a thing that reads badly when described out loud.
  • Account-graph and signup fingerprint — Strength: catches the farm rather than the instance, which is the only way to actually end it. Same email domain, same signup path, same payment absence, same template, same behavior, N times over. Weakness: it is slow, needs history, and is the layer most likely to sweep up an innocent bystander — a bootcamp cohort signs up from one domain in one hour and looks exactly like a farm.
  • Billing and quota accounting — Strength: the only layer that works on abuse you never identified, because it caps your loss regardless of what the workload was. Weakness: it is a limit, not a detection — it tells you nothing about who or why, and a farm tuned to stay under the cap is invisible to it by construction.

Notice that the two strongest layers — egress and accounting — are both things you control at the platform boundary, and neither requires you to know what the customer's code is doing. That is not an accident. Every control that depends on identifying the workload is a control that decays as the workload adapts.

Network: the miner has to phone home

This is the one asymmetry in your favour, and it is a large one. Mining is only worth doing if the shares reach a pool, so the workload has a hard, non-negotiable requirement to talk to the outside world on a persistent connection. Everything else about the miner is fungible — the binary, the process name, the user it runs as, the directory it lives in. The pool connection is structural. So the highest-value telemetry you can collect is not "what is running" but "where is it connecting, how often, and for how long." A long-lived outbound session exchanging small messages on a steady cadence for days, with essentially no other network activity, tells you far more than any process list will — and if you log denials at the egress boundary you get the same signal for free from the workloads you already blocked.

CPU shape: the fingerprint, and its limits

The second signal is resource shape, and the important word is shape — not "high CPU." High CPU is what a build looks like. What mining looks like is high CPU that never comes down, spread evenly across every core, with a disk that is asleep and a network interface that receives almost nothing. A compiler reads and writes constantly. A test suite finishes. A web service receives requests. An agent workload has long idle gaps between bursts of tool calls. A miner does one thing forever.

Below is roughly how I would express that as a review query. Note what it produces: a queue for a human, ordered by suspicion, joined back to the owning org. It does not ban anybody. The column carrying most of the weight is the egress denial count — resource shape narrows the field, but the network boundary is what turns a suspicion into something you would put in an email to a customer. If you are tempted to wire this straight into an enforcement action, read the false-positive section first, then come back and don't.

# DEFENSIVE: build a review QUEUE of sandboxes whose resource shape looks
# like mining, joined back to the owning org so you can see the farm and
# not just the instance. Nothing here auto-bans -- a human reads the queue.
import os
import psycopg

# The shape we care about is "pegged forever, doing nothing else":
#   - CPU high on EVERY sample in the window (a build finishes; this doesn't)
#   - disk I/O near zero        (compilers write; miners don't)
#   - inbound bytes near zero   (servers receive; miners barely do)
#   - outbound small but steady (shares go up; results don't come back)
CANDIDATES = """
WITH win AS (
  SELECT
    m.sandbox_id,
    count(*)                                     AS samples,
    avg(m.cpu_pct)                               AS cpu_avg,
    min(m.cpu_pct)                               AS cpu_floor,
    sum(m.disk_read_bytes + m.disk_write_bytes)  AS io_bytes,
    sum(m.net_rx_bytes)                          AS rx_bytes,
    sum(m.net_tx_bytes)                          AS tx_bytes,
    stddev_pop(m.net_tx_bytes)                   AS tx_jitter
  FROM sandbox_metrics m
  WHERE m.ts > now() - interval '6 hours'
  GROUP BY m.sandbox_id
  HAVING count(*) >= 60                       -- ~6h of 1-per-6-min samples
)
SELECT
  s.id            AS sandbox_id,
  s.org_id,
  o.slug          AS org_slug,
  o.plan,
  o.created_at    AS org_age,
  s.template,
  s.created_at    AS sandbox_started,
  w.cpu_avg, w.cpu_floor, w.io_bytes, w.rx_bytes, w.tx_bytes, w.tx_jitter,
  d.deny_count,
  d.distinct_dst
FROM win w
JOIN sandboxes s ON s.id = w.sandbox_id
JOIN orgs       o ON o.id = s.org_id
LEFT JOIN LATERAL (
  -- Egress denials are the strongest column in this table. A workload
  -- that keeps hammering a blocked destination is telling on itself.
  SELECT count(*) AS deny_count, count(DISTINCT dst_ip) AS distinct_dst
  FROM egress_denials e
  WHERE e.sandbox_id = s.id AND e.ts > now() - interval '6 hours'
) d ON true
WHERE w.cpu_floor > 85          -- never dipped: not a build, not a test run
  AND w.cpu_avg   > 95
  AND w.io_bytes  < 64  * 1024 * 1024
  AND w.rx_bytes  < 16  * 1024 * 1024
ORDER BY o.plan = 'free' DESC, d.deny_count DESC NULLS LAST, w.cpu_avg DESC
"""


def score(row: dict) -> tuple[int, list[str]]:
    """Explainable points. A reviewer has to be able to argue with this."""
    pts, why = 0, []
    if row["cpu_floor"] > 85:
        pts += 3; why.append("CPU never dropped below 85% in 6h")
    if row["io_bytes"] < 8 * 1024 * 1024:
        pts += 2; why.append("almost no disk I/O for a compute-bound job")
    if row["rx_bytes"] < 4 * 1024 * 1024:
        pts += 2; why.append("almost nothing inbound: not serving anyone")
    if (row["deny_count"] or 0) > 100:
        pts += 5; why.append(f"{row['deny_count']} egress denials, "
                             f"{row['distinct_dst']} destinations")
    if row["plan"] == "free":
        pts += 1; why.append("free plan, no payment method on file")
    return pts, why


with psycopg.connect(os.environ["CONTROL_PLANE_DSN"], row_factory=psycopg.rows.dict_row) as cx:
    rows = cx.execute(CANDIDATES).fetchall()

# Collapse to ORGS, not sandboxes. One pegged VM is a customer running a
# long job. Fourteen identical pegged VMs under three-day-old orgs sharing
# an email domain is a farm, and only the org view shows you that.
by_org: dict[str, list[dict]] = {}
for r in rows:
    pts, why = score(r)
    if pts >= 7:
        by_org.setdefault(r["org_slug"], []).append({**r, "points": pts, "why": why})

for org, hits in sorted(by_org.items(), key=lambda kv: -len(kv[1])):
    print(f"{org}: {len(hits)} sandboxes flagged -> REVIEW (not auto-ban)")
    for h in hits[:3]:
        print("   ", h["sandbox_id"], "|", "; ".join(h["why"]))

Process heuristics: the layer everyone builds first and trusts too much

Yes, you can look for the well-known miner binaries, and yes, it will catch people, because a large share of abusers put in exactly zero effort. Do it — but understand what you have built. You have built a filter that removes the least sophisticated attempts and teaches everyone else which one keyword to change. Name-based detection is a control with a half-life.

It is also the layer that will embarrass you. A security researcher's toolkit, a benchmark suite, a legitimately-named binary in a temp directory, someone's hobby project with an unfortunate name — all of these produce hits. You will get the alert at 2am, kill a paying customer's job, and discover the next morning that they were benchmarking. Nobody writes a nice review after that. There is a second reason to keep this layer secondary, and it is not technical. Continuously enumerating what processes your customers are running, on a platform whose entire pitch is "your code runs in an isolated environment we can't see into," is a promise you are quietly breaking. I would rather build controls at the network and billing boundaries — which are visible, documentable, and identical for everyone — than build a product feature out of reading strangers' process tables.

Any control that requires you to correctly identify what the customer's code is will eventually be defeated by a customer who renames it. Controls on what the code can reach and how much it can consume do not have that problem.

Egress policy: the actual fix

If I could keep exactly one control, it would be this one. A miner that cannot reach a pool produces nothing, so denying the pool connection does not merely detect the abuse — it removes the reason to do it on your platform at all. That is a much better property than detection. Detection is a game you play forever; making the workload worthless is a game you finish. The right posture is default-deny outbound with an explicit allowlist, applied per sandbox. On PandaStack every sandbox gets its own Linux network namespace and TAP device — there are 16,384 pre-allocated /30 subnets per agent — which means egress rules bind to exactly one tenant and disappear when that VM does. Here is the conceptual shape, written as nftables in the sandbox's own namespace.

#!/usr/bin/env bash
# DEFENSIVE: default-deny egress for ONE sandbox, applied inside that
# sandbox's own network namespace. Nothing here inspects the guest's
# processes -- it only decides where its packets may go.
set -euo pipefail

NS="ns-${SANDBOX_ID:?}"          # per-sandbox netns (own veth + tap)

ip netns exec "$NS" nft -f - <<'EOF'
flush ruleset

table inet egress {
  # The allowlist is the whole product here. Keep it SMALL and generated
  # from a service (package registries, git hosts, the tenant's declared
  # API endpoints) -- never hand-edited, never "temporarily" widened.
  set allowed_v4 {
    type ipv4_addr
    flags interval
    elements = { 203.0.113.0/24, 198.51.100.10 }
  }

  chain output {
    type filter hook output priority filter; policy drop;

    ct state established,related accept
    oif "lo" accept

    # DNS goes to OUR resolver and nowhere else. An open port 53 is not
    # plumbing, it is an exfil channel with a friendly name -- and it is
    # also how a miner finds a pool whose IP you already blocked.
    ip daddr 10.200.0.1 udp dport 53 accept
    ip daddr 10.200.0.1 tcp dport 53 accept

    # Cloud metadata: closed on principle, forever.
    ip daddr 169.254.169.254 drop

    # RFC1918 + link-local: no lateral movement into our own estate.
    ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 } drop

    # The allowlist. Ports 80/443 only -- and note that permitting 443 to
    # an allowlisted HOST is very different from permitting 443 to the
    # internet, which is the mistake that makes port rules pointless.
    ip daddr @allowed_v4 tcp dport { 80, 443 } accept

    # Everything else hits policy drop. Log a sample of it: these denial
    # records are the highest-signal abuse telemetry you will ever have,
    # and they cost you nothing extra to collect.
    limit rate 20/minute burst 40 packets \
      log prefix "egress-deny " level info flags all
    counter comment "denied-by-default"
  }
}
EOF

# Free tier gets the narrow allowlist above. Paid tenants with a payment
# method on file can opt into a wider policy -- because at that point the
# compute is billed, and billed compute is not free compute.

Now the part that gets skipped. Blocking the traditional mining ports is theater. Pools run on 443. Pools run behind proxies. Pools run on whatever port the operator felt like, because there is no law about it. A port-based blocklist stops the same population that a process-name blocklist stops — the people who were not trying — and gives you a dashboard that says you are covered while you are not. What actually works is the direction of the rule: default-deny does not care whether a pool moved to 443, because 443 to an unlisted host is denied like everything else. Allowlisting is the only egress posture that survives contact with an adversary who can change one number.

  • Default-deny outbound per sandbox, with a small generated allowlist. Everything below this line is a supplement, not a substitute.
  • Pin DNS to a resolver you run, answer only allowlisted names, and log every query. Half of the interesting behaviour on a sandbox platform is visible in DNS before it is visible anywhere else.
  • Drop RFC1918, link-local, and the metadata endpoint explicitly, so a curious tenant cannot go shopping in your own network.
  • Log denials and keep them. They are the highest-signal abuse telemetry available and they are a byproduct of a control you wanted anyway.
  • Treat known-pool endpoint lists as a bonus detection input, not as your control. A blocklist is a list of the things somebody else already found.
Default-deny will break legitimate workloads on day one, and that is the honest cost. `pip install` reaches PyPI; `npm ci` reaches a registry; an agent calls a model API. Budget real engineering time for the allowlist service and for the support load, or you will widen the policy under pressure and end up with default-allow wearing a firewall costume.

The economic control, or: billing accuracy is a security control

Here is the uncomfortable claim. Your metering pipeline is a security system. Not a finance system that happens to be useful for security — a security system, sitting on the critical path of your abuse posture, and it should be reviewed like one.

The reasoning is simple. Abuse is a workload that consumes resources you do not get paid for. If your accounting is exact and your enforcement is prompt, then every abusive workload either gets billed — at which point it is a customer, and a strange one, but a customer — or gets stopped when the unpaid balance hits its cap. Abuse that you fail to identify still terminates, because the meter does not need to know what the code was doing. Every hour of drift in your metering, every sandbox whose usage rows silently fail to land, is an hour of free compute you are underwriting for whoever finds the gap first. This reframes a lot of unglamorous work. Idle reaping, TTLs, hard caps on free-tier CPU-seconds, requiring a payment method before sustained compute, and reconciling metered usage against what actually ran are not billing hygiene. They are the layer that holds when detection fails, and detection will fail.

  • A hard CPU-seconds budget per free org, not just a concurrent-sandbox limit. Count-based limits cap width; miners exploit depth — one VM, running forever.
  • Mandatory TTLs and idle reaping on free-tier sandboxes. "Nothing should run unattended for a week without a card on file" is a policy, and it is enforceable in a scheduler.
  • A payment method required for sustained or high-CPU workloads. This does not stop a determined abuser with stolen cards, but it converts a free attack into one with a cost and an identity attached.
  • Verified-usage reconciliation: what the meter billed versus what the fleet actually ran. Persistent gaps are either a revenue bug or an abuse channel, and you cannot tell which from the dashboard.
  • Alert on the derivative, not the level. A free org whose consumption steps up and then stays perfectly flat is a much better signal than any absolute threshold.

The false-positive problem, stated plainly

If CPU is your only signal, you are going to hurt real customers, and here is the roll call of people who look exactly like miners to a naive detector.

  • Video transcoding — pegs every core for hours by design. The distinguishing feature is heavy sequential disk I/O, which is precisely why the I/O term belongs in the query.
  • CPU inference for LLMs and embeddings — sustained full-core utilisation with modest network and modest disk. This one is genuinely close to the mining fingerprint, and it is a workload people run on sandbox platforms constantly.
  • Rust, C++, and large monorepo builds — a fully parallel build saturates everything you give it. It reads and writes a lot, and crucially it ends, which is the tell.
  • Scientific and numerical compute — simulations, solvers, Monte Carlo runs. Long, hot, quiet, and completely legitimate. Some of these run for days, and the person running them paid you for it.
  • Agent workloads under load — a swarm of tool-calling agents can look busy and chatty in ways that defeat both the mining fingerprint and your intuition about what normal looks like.

The mitigation is not a cleverer threshold. It is refusing to act on one signal. Resource shape plus egress denials plus account-graph plus payment status, reviewed by a human before anything irreversible happens, is a defensible process. Resource shape alone, wired to an automatic kill, is how you generate an incident report about yourself.

The asymmetry matters: a missed miner costs you some compute; a false positive kills a paying customer's twelve-hour job and they tell people. Weight your thresholds accordingly, and make sure the person who gets paged has the authority to say "leave it running, this is a build."

Response: contain before you delete

The instinct when you confirm a miner is to kill it immediately, and that instinct destroys your evidence. You will want that evidence later — for the appeal, for the pattern, for the next farm that uses the same playbook, and occasionally for a conversation with someone's abuse desk. Containment first, deletion second.

  1. Cut the network before you cut anything else. Tighten the sandbox's egress policy to deny-all: the workload stops earning immediately, and you have not touched a byte of state.
  2. Snapshot the VM. A snapshot preserves memory and disk as they were, which is the only version of the evidence that includes what was actually running. Tag it with the sandbox id, the org, and the reason.
  3. Map the sandbox back to a tenant. This is a schema question you should have answered on day one: every sandbox needs the owning org on the row, not reconstructed from logs at 3am during an incident.
  4. Pivot to the org, then to the graph. Other sandboxes under the same org, other orgs under the same email domain, the same signup fingerprint, the same behavioral pattern. One instance is a symptom; the farm is the finding.
  5. Act at the org level. Suspending one sandbox achieves nothing when the script creates another in under a second — on our platform a create is a snapshot restore at roughly 179ms p50. Suspend the org, then reap its sandboxes.
  6. Write down what you saw and why you acted. Six weeks later, when the same pattern returns wearing a different domain, that note is the difference between recognising it in ten minutes and rediscovering it over a weekend.

Banning at the org level rather than the sandbox level is the single most important operational habit here. Sandboxes are cheap and disposable — that is the entire point of the product — so any enforcement targeted at a sandbox is enforcement the abuser's retry loop routes around without noticing. The account is the durable object. Ban the account. And build the appeal path before you need it, because you will eventually suspend someone who was doing scientific compute on a free trial. If your answer to them is "our automated system flagged your account" with no human attached and no ability to see what triggered it, you have chosen the failure mode where being wrong is also unrecoverable.

What I would build first, in order

If you are standing up a free tier on a code-execution platform this quarter, the ordering is not "whatever is most interesting." It is this.

  1. Default-deny egress per sandbox with a generated allowlist, plus a resolver you control. This is the fix; everything else is instrumentation around it.
  2. Denial logging joined to the owning org. Free telemetry, best signal, zero extra moving parts.
  3. Hard CPU-seconds caps and TTLs on the free tier, with metering you have actually reconciled against what ran.
  4. Resource-shape review queues for humans — the kind of thing the query above produces — reviewed daily, not wired to an automatic kill.
  5. Account-graph signals to find the farm once you have found one member of it.
  6. Process and binary heuristics, last, as a cheap supplementary input that you never let make a decision on its own.

The uncomfortable summary is that the durable controls are boring and the exciting controls are decorative. Nobody gets promoted for shipping an allowlist service, and the process-name detector demos beautifully to people who will never operate it. But mining abuse is not a puzzle to be outsmarted — it is a business with a cost structure, and you win by removing the profit, not by identifying the participants. Make the compute cost something, make the pool unreachable, and the miners will go somewhere that has not read this far.

Frequently asked questions

How do you detect cryptomining in a code sandbox or container platform?

Rank your signals rather than relying on one. The strongest is network: mining requires a persistent connection to a pool, so egress denials and long-lived outbound sessions with a steady, low-volume cadence are the highest-signal indicator available. Second is resource shape over time — CPU pinned near 100% on every core for hours with near-zero disk I/O and near-zero inbound bytes, a combination almost nothing legitimate sustains. Third, and weakest, is process or binary name matching, which catches only the careless and produces false positives. Combine those with account-level signals such as plan, account age, and shared signup characteristics, then route the result to a human review queue rather than to an automatic termination.

Is blocking mining ports like 3333 enough to stop cryptomining abuse?

No, and treating it as a control is worse than doing nothing, because it produces a dashboard that says you are protected. Mining pools run on 443, behind proxies, and on arbitrary ports chosen by whoever operates them; there is no rule requiring a traditional port. A port blocklist stops exactly the population that a process-name blocklist stops — people who were not trying — while an adversary changes one number and continues. The control that survives contact is directional: default-deny outbound with an explicit allowlist of hosts the workload provably needs. Under default-deny it does not matter which port a pool moved to, because unlisted destinations are denied regardless.

Should you inspect customer processes to catch miners?

Use it as a supplementary signal, never as your primary control. Process and binary heuristics are trivially defeated by anyone who renames a file, so you are committing to an arms race in which your detector is always behind. They also generate false positives on benchmarks, security tooling, and any project with an unlucky name. There is a trust cost too: continuously enumerating what a customer runs inside an environment you sold as isolated undercuts the promise the product is built on. Network egress policy and usage accounting are better controls precisely because they work at the platform boundary, apply identically to every tenant, and do not require you to correctly identify the workload.

What legitimate workloads look like cryptomining, and how do you avoid banning them?

Video transcoding, CPU-based LLM inference and embedding generation, large Rust or C++ builds, and scientific or numerical compute all produce sustained full-core utilisation, which is exactly what a naive CPU-only detector fires on. Some run for days and are being run by paying customers. The way to avoid the mistake is to refuse to act on a single signal: require resource shape plus egress denials plus account context before anything irreversible, and put a human between the queue and the kill switch. Disk I/O and inbound bytes are the most useful discriminators — builds and transcodes read and write heavily and eventually finish, while a miner stays flat forever.

Why is billing accuracy considered a security control on a free-tier platform?

Because abuse is defined as consuming resources you are not paid for, so accurate metering plus prompt enforcement caps your exposure even to abuse you never identify. If usage is measured correctly and limits are enforced, an unrecognised abusive workload still stops when its budget is exhausted, without anyone having to work out what it was doing. Conversely, every gap in metering — usage rows that fail to land, sandboxes that escape reaping, quotas that are advertised but not enforced — is free compute for whoever finds it first. That is why hard CPU-seconds caps, TTLs, idle reaping, and reconciliation of billed usage against what actually ran belong in your abuse posture, not just your finance stack.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.