Security Advanced

Monetizing Web App Compromise — From SQLi to Wire Fraud

How attackers turn a web app breach into euros — credential resale, payment data, account takeover, fraudulent transactions. The economics that drive defenses.

KC Ramo · DjangoZen Team May 10, 2026 24 min read 365 views

Why economics matter in defense

Attackers optimize for profit per hour. Defenses that don't change attacker economics don't reduce risk — they just shuffle which doors get opened. To build defenses that actually deter attacks, you need to understand what makes a compromise profitable and what makes it not.

This tutorial walks through the main monetization patterns for web app compromise and the specific defenses that disrupt each.

Pattern 1 — Credential resale at scale

The most common monetization route, requires the least skill from the buyer.

The flow

  1. Attacker scrapes login forms from thousands of websites
  2. Runs credential-stuffing botnet against each, using stolen credential dumps
  3. Successful logins compiled into "combolists" — sold on dark forums
  4. Buyers use them for higher-value attacks: SaaS account takeover, banking fraud, identity theft

The economics

  • Generic SaaS credentials: €0.10-€1.00 per working combo
  • Streaming/media credentials: €2-€20
  • Banking credentials: €50-€500 (US/EU)
  • Corporate credentials at known companies: €200-€10,000 (resold to IABs)

A successful stuffing campaign returns 0.1%-1% of attempted credentials. The math works because compute is cheap and attempts are unlimited.

Defenses that change the economics

  • MFA — single biggest disruption. Combos without working MFA codes are nearly worthless.
  • Rate limiting per IP and per account — botnets need clean IPs, which cost money. Forcing them to rotate slows attacks dramatically.
  • Block known breached passwords on login — Pwned Passwords k-anonymity lookup, fail if hit. Defeats stuffing because the breached creds being stuffed are the exact ones being blocked.
  • Account lockout with timing-safe messaging — "username/password incorrect or too many attempts, try again later" — same response regardless of which condition, so enumeration is harder.
  • CAPTCHA on suspicious patterns — not on every login (terrible UX), only when risk signals fire (new IP, new device, fast retries).
  • Device fingerprinting — even when the password is right, an unrecognized device triggers step-up auth.
# Django example: enforce breached-password check on login
import hashlib
import requests

def is_breached(password: str) -> bool:
    sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]
    r = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=2)
    if r.status_code != 200:
        return False  # Fail open — don't lock users out on API issues
    return any(line.startswith(suffix) for line in r.text.splitlines())

def login_view(request):
    if request.method == 'POST':
        password = request.POST['password']
        if is_breached(password):
            messages.error(request, 'This password appears in known breaches. Please use a different password.')
            return redirect('password_reset')
        # ...regular auth flow

Pattern 2 — Payment data theft

The classic and still highly profitable. Attackers compromise either:

  • The site's card storage (PCI breach — disastrous, regulated, fineable)
  • The checkout flow (Magecart-style JavaScript injection that exfiltrates cards as customers type)
  • The payment processor integration (stolen Stripe API keys = fraudulent charges)

Magecart in detail

Attackers compromise either the host directly or a CDN/script you load. Inject JavaScript into the checkout page that copies form data and POSTs it to attacker-controlled infrastructure. The page works normally — customer pays, gets product. But their card is now stolen.

Famous victims: British Airways, Ticketmaster, Newegg. Each faced heavy remediation costs and regulatory action; the UK regulator fined British Airways £20m.

Defenses

  • Don't store card data — let Stripe / your PSP handle storage. Tokenization moves PCI scope off your servers.
  • Content Security Policy (CSP) — strict CSP prevents arbitrary script injection. script-src 'self' plus explicit allowlist for CDNs.
  • Subresource Integrity (SRI) on every external script — <script src="..." integrity="sha384-..."> ensures the loaded content matches the expected hash.
  • CSP report-uri — get notified when CSP violations occur, often the first sign of injection attempts.
  • Stripe API key restriction — restrict by IP where your provider supports it, use restricted keys for production not full secret keys.
  • Webhook signature verification — never trust unsigned webhook calls. Stripe-Signature header check on every event.
# CSP middleware in Django settings
# django-csp 3.x setting names; django-csp 4.x moves these into the
# CONTENT_SECURITY_POLICY = {"DIRECTIVES": {...}} dict
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "https://js.stripe.com", "https://cdn.jsdelivr.net")
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net")
CSP_FRAME_SRC = ("https://js.stripe.com",)
CSP_CONNECT_SRC = ("'self'", "https://api.stripe.com")
CSP_REPORT_URI = "/csp-report/"

Pattern 3 — Account takeover for high-value users

The economics shift when the target is a specific account, not a bulk credential. Attackers take over:

  • Influencer / executive accounts — used for blackmail, brand damage, or to send messages to their network
  • Customer support / staff accounts — to access customer data without raising flags
  • Developer accounts — to push malicious code through CI/CD, leak source, plant backdoors
  • Finance / AP accounts — to authorize fraudulent transfers

The attack: targeted phishing, SIM swap to bypass SMS-MFA, OAuth consent phishing, or session token theft.

Specific defenses

  • Phishing-resistant MFA for high-value roles (WebAuthn / FIDO2, not SMS or TOTP)
  • Approval workflows for high-impact actions even within an authenticated session
  • Session reauthentication — "this action requires re-entering your password"
  • Anomaly detection — alerts when an admin logs in from a new location/device
  • Audit logs that can be reviewed after a suspected incident
  • OAuth scope minimization — apps should never grant broader scopes than needed

Pattern 4 — Cryptominer drop and infrastructure abuse

Attacker doesn't want your data. They want your CPU.

The flow

  1. Exploit any RCE vulnerability in the web stack
  2. Drop a miner pointed at attacker's pool
  3. Sometimes drop a reverse shell as a backup persistence mechanism
  4. Stay quiet — mining is more profitable when not detected

Why it's persistent

The miner often doesn't even need root — runs as the application user, indistinguishable from a busy worker process. Easy to miss until the cloud bill arrives.

Defenses

  • Patch promptly — CVE-to-exploit is now hours, not weeks
  • Egress firewall — miners need to connect to pools. Block outbound to anywhere other than known endpoints
  • CPU/memory anomaly alerting — sustained 100% CPU outside known job windows is suspicious
  • Cloud bill alerting — set a daily threshold; sudden 5x spike means something
  • Container hardening — read-only filesystems, no shell, drop unnecessary capabilities
  • Runtime detection — Falco, Wazuh, or commercial EDR will catch most miner deployments

Pattern 5 — Business Email Compromise via the web app

Less obvious than the others but extremely lucrative.

Many web apps integrate with email — sending invoices, password resets, notifications, customer communications. Compromise of the app may give attackers:

  • The ability to send email from your domain (passes SPF/DKIM)
  • Access to email templates that look legitimate
  • Customer data to craft personalized pretexts

The attacker uses this to send convincing emails to your customers' AP departments, requesting that future invoices be paid to an attacker-controlled bank account.

Defenses

  • DMARC enforcement — p=reject with monitoring
  • Restrict who can send email from your domain — application servers via authenticated SMTP, no other source
  • Output sanitization in admin tools — preventing internal staff from accidentally including customer-controlled content in outgoing communications
  • Audit logging of all transactional emails sent — anomalies show up here
  • Vendor banking change verification — your own customers should refuse to update banking info via email alone

Pattern 6 — Supply chain via your web app

A specific and growing pattern: your web app loads JS/CSS from a third-party CDN or package manager. The third party gets compromised. Your users get malicious code.

Recent examples:

  • eslint-scope (2018) — npm package compromised, malicious code shipped to thousands of CI systems
  • CodeCov (2021) — bash uploader modified to exfiltrate env vars, including AWS keys
  • xz Utils (2024) — multi-year supply chain attack into a core OS library

Defenses

  • Pin dependency versions with integrity hashes (package-lock.json, poetry.lock with hashes)
  • SRI on every external script load
  • Mirror critical dependencies internally
  • Periodic audit of dependencies — what loaded, what version, when last updated
  • Egress filtering on build systems — supply chain malware often phones home with secrets

The defense-economics summary

Mapping defense priorities to threat types:

Defense Disrupts
MFA (phishing-resistant) Credential stuffing, account takeover, BEC
Rate limiting on auth Credential stuffing
Breached password blocking Credential stuffing
CSP + SRI Magecart, supply chain JS injection
DMARC Email spoofing, BEC
Egress filtering Cryptominer C2, data exfiltration
Dependency pinning + scanning Supply chain
Audit logging + anomaly alerting All categories — your detection layer

The combination is multiplicative. Each defense alone deters some attackers; the stack deters the ones who'd otherwise persist.

Where SMEs typically fail

Patterns in real-world Dutch SME web app breaches:

  1. No MFA on a single forgotten staff account — the rest of the security was solid, but one admin account without MFA was enough
  2. Outdated framework with public CVE — patched four hours after disclosure, but breached three hours after disclosure
  3. Phishing → OAuth consent — sophisticated phishing led to an OAuth grant that bypassed all the password-focused defenses
  4. CMS plugin with leaked DB credentials — third-party plugin had a hardcoded API endpoint; that endpoint was misconfigured and leaked the connection string
  5. Default admin URL with weak password and brute force — the WAF didn't see /admin/ as needing special protection

Each is preventable with the basics from tutorial 1 plus the disruptions in this tutorial. None require nation-state-tier defenses — just consistent execution of fundamentals.

Following the money to the defense

Understanding how attackers turn a technical compromise into actual money is one of the most useful things a defender can learn, because it reveals what they are really after and therefore what to protect hardest. A vulnerability is only a means to an end, and the end is almost always financial — fraud, theft, ransom, or selling access and data. Tracing the path from an initial flaw to a payout shows you which assets and capabilities are the real targets, letting you concentrate defenses where the money is rather than spreading effort evenly across things that have very different value to an attacker.

Where small businesses are most exposed

Smaller organizations are frequently targeted precisely because they often lack the layered defenses and monitoring of larger ones, while still handling money and valuable data. The common failure points are predictable: weak authentication, unpatched systems, poor separation between functions, and no monitoring to catch fraud in progress. These gaps are what let a compromise translate into a financial loss before anyone notices. Recognizing where these weaknesses typically lie — and that being small is not the same as being an unattractive target — is the first step to closing the paths that turn an intrusion into a wire fraud or a drained account.

Breaking the monetization chain

Because monetization is a chain of steps from compromise to cash-out, you can defend at multiple points, not just at the initial vulnerability. Strong authentication and patching reduce initial access; segmentation limits movement; transaction controls and anomaly detection catch fraud before it completes; and monitoring shortens the time an attacker has to act. Even if one defense fails, another can break the chain before money is lost. Thinking about security as disrupting the attacker's path to profit — rather than only preventing the first flaw — gives you many opportunities to stop an attack, and it focuses your effort on protecting the financial outcomes attackers are ultimately chasing.

Mapping the SQLi-to-wire-fraud chain to controls

The title of this tutorial names the most damaging chain for a small business, and it is also a very ordinary one. An injection flaw or a stolen admin session gives someone read and sometimes write access to the database. They use it to find customers, invoices and, above all, the stored bank details for payouts, refunds or supplier payments. Then they either change those details quietly or use the harvested invoice data to send convincing "our bank has changed" messages. The money leaves by a normal, authorised transfer. From the bank's side nothing looks wrong, because the payment was initiated by the legitimate owner of the account.

That last point shapes the whole defence. By the time money moves, every technical control has already been bypassed, and the only things left are business-process controls and how fast you respond. So you need controls at every stage, and each stage should leave a signal that someone actually watches. The table maps each stage to what a defender can observe and the control that breaks it.

Attacker stageWhat the defender can observeControl that breaks the chain
Initial database access (injection, leaked credentials, stolen admin session)Bursts of database syntax errors, new query shapes in pg_stat_statements, WAF anomaly scores, admin logins from new devicesORM or parameterised queries only, least-privilege database roles, phishing-resistant MFA on admin
Harvesting customer, invoice and bank dataUnusually large result sets, reads of tables the app normally touches rarely, honeytoken records being accessed or contactedColumn-level privileges, encrypted or tokenised bank fields, honeytokens, egress filtering
Changing payout or supplier bank detailsBank-detail changes with no matching approved change request, changes outside business hours, several accounts changed to the same beneficiaryChange-request workflow with step-up auth, out-of-band confirmation, cooling-off period, database trigger audit
Social engineering customers with lookalike invoicesSpikes in transactional email volume, template edits, DMARC aggregate reports showing unknown sendersDMARC p=reject, template change audit, a published "we never change bank details by email" policy
Cash-out via a payout or wireFirst payout to a new beneficiary, payouts just under approval thresholds, amounts well above the account's historyVelocity limits, holds on first payouts to new beneficiaries, dual approval, payee-name verification where your bank offers it
Laundering after the transferUsually nothing. It happens outside your systems.A rehearsed response playbook: bank fraud contact within minutes, recall request, evidence preserved

Treat the table as a gap analysis. For each row, ask which signal you would actually see today, who would see it, and how many hours it would take. A row with no observable signal is where to invest next.

Hardening payout and bank-detail changes

If your application stores bank details for any reason (marketplace seller payouts, affiliate commissions, customer refunds, supplier records in an internal tool), changing those details is the most valuable write operation in the system. It should never be a normal UPDATE behind a model form. Model it as a request that has to pass several independent gates before it takes effect.

The four gates

  • Step-up authentication: the user proves possession again, ideally with WebAuthn, just before submitting the change. A stolen session cookie alone is not enough.
  • Out-of-band confirmation: a notice goes to contact details that were already on file before the request: the previous email address and, for business accounts, a phone number verified earlier. Never send it to anything supplied in the same request. The notice should say plainly how to cancel.
  • Cooling-off period: the new details only become usable after a delay, for example 48 hours. That delay is what turns a silent diversion into one the real owner has a chance to spot and cancel.
  • Velocity limits: cap how many changes an account can make in a period, and how many accounts can point at the same beneficiary. A single IBAN appearing on several unrelated seller accounts is a strong fraud signal.

The trade-off is friction for legitimate users who really have switched banks. In practice, a clear message ("your new account will be used from Thursday; we have emailed your previous address") gets far fewer complaints than a diverted payout does. Let support shorten the delay manually after verifying the user through a separate channel, and log that override as its own audit event.

A Django implementation

# billing/models.py
from django.conf import settings
from django.db import models


class PayoutAccountChange(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending"
        CANCELLED = "cancelled"
        APPLIED = "applied"

    account = models.ForeignKey(
        "billing.PayoutAccount", on_delete=models.PROTECT, related_name="changes"
    )
    requested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
    new_iban = models.CharField(max_length=34)
    new_holder_name = models.CharField(max_length=140)
    status = models.CharField(max_length=10, choices=Status, default=Status.PENDING)
    requested_at = models.DateTimeField(auto_now_add=True)
    effective_at = models.DateTimeField()
    applied_at = models.DateTimeField(null=True, blank=True)
    request_ip = models.GenericIPAddressField(null=True)
# billing/services.py
import time
from datetime import timedelta

from django.db import transaction
from django.utils import timezone

from .audit import audit_event
from .models import PayoutAccount, PayoutAccountChange
from .notify import notify_previous_contacts

COOLING_OFF = timedelta(hours=48)
MAX_CHANGES_PER_30_DAYS = 1
STEP_UP_MAX_AGE_SECONDS = 300


class PayoutChangeRejected(Exception):
    pass


def request_payout_change(request, account_id, new_iban, new_holder_name):
    user = request.user
    step_up_at = request.session.get("step_up_at")  # set by your WebAuthn view
    if step_up_at is None or time.time() - step_up_at > STEP_UP_MAX_AGE_SECONDS:
        raise PayoutChangeRejected("step-up authentication required")

    with transaction.atomic():
        account = PayoutAccount.objects.select_for_update().get(pk=account_id, owner=user)
        since = timezone.now() - timedelta(days=30)
        if account.changes.filter(requested_at__gte=since).count() >= MAX_CHANGES_PER_30_DAYS:
            audit_event(request, "payout_change.velocity_blocked", account=account)
            raise PayoutChangeRejected("too many recent changes; contact support")

        shared = PayoutAccount.objects.filter(iban=new_iban).exclude(pk=account.pk).exists()
        change = PayoutAccountChange.objects.create(
            account=account,
            requested_by=user,
            new_iban=new_iban,
            new_holder_name=new_holder_name,
            effective_at=timezone.now() + COOLING_OFF,
            request_ip=request.META.get("REMOTE_ADDR"),
        )
        audit_event(request, "payout_change.requested", account=account,
                    change_id=change.pk, beneficiary_shared=shared)
        # Contacts captured BEFORE this change, never the ones in the request.
        transaction.on_commit(lambda: notify_previous_contacts(change.pk))
    return change

A scheduled job applies changes whose effective_at has passed and that are still pending. It sets applied_at and writes payout_change.applied to the audit log. The payout code reads bank details only from PayoutAccount, so a pending change can never be paid out early. Two details matter. First, the select_for_update() row lock stops two concurrent requests from both passing the velocity check. Second, a beneficiary shared with another account should not be rejected automatically, because families and small companies do share accounts, but it should route the change to manual review. Behind a proxy, REMOTE_ADDR will be the proxy's address, so take the client IP from whatever trusted header your ingress sets.

Holds on the first payout

The cooling-off period protects the details. A separate rule should protect the money: the first payout to a beneficiary that has never been paid before is held for manual review, or capped at a small amount, and any single payout well above the account's normal range needs a second approver. Store the per-account baseline (typical amount, usual frequency) and compare against it, rather than a single global threshold that attackers can learn and stay under.

Database tripwires for writes that bypass the app

An injection flaw with write access, or leaked database credentials, lets someone change a bank field directly and skip every gate described above. You cannot rely on application code to record an event the application never saw. A PostgreSQL trigger owned by a separate role closes that gap: every change to the column is recorded in a schema the application role cannot modify.

-- Run as a dedicated owner role, not as the application role.
CREATE SCHEMA audit;

CREATE TABLE audit.bank_detail_changes (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    changed_at   timestamptz NOT NULL DEFAULT now(),
    db_session   text NOT NULL,
    app_user_id  text,
    row_id       bigint NOT NULL,
    old_iban     text,
    new_iban     text
);

CREATE FUNCTION audit.log_bank_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
BEGIN
    IF NEW.iban IS DISTINCT FROM OLD.iban THEN
        INSERT INTO audit.bank_detail_changes (db_session, app_user_id, row_id, old_iban, new_iban)
        VALUES (session_user, current_setting('app.user_id', true), NEW.id, OLD.iban, NEW.iban);
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER payoutaccount_bank_audit
    AFTER UPDATE OF iban ON billing_payoutaccount
    FOR EACH ROW EXECUTE FUNCTION audit.log_bank_change();

REVOKE ALL ON SCHEMA audit FROM PUBLIC;
GRANT USAGE ON SCHEMA audit TO monitoring;
GRANT SELECT ON audit.bank_detail_changes TO monitoring;

Inside a SECURITY DEFINER function, current_user is the function owner, so the trigger records session_user to capture the role that actually connected. The app.user_id setting is optional context. Django can set it per transaction with SELECT set_config('app.user_id', %s, true), but anything with SQL access can also set it, so treat it as a hint rather than evidence. The trigger only covers updates. Add a matching AFTER INSERT trigger if new payout accounts are just as sensitive in your model.

The tripwire only works once you reconcile it against the application's own record. Any database-level change with no matching applied change request is a write that bypassed the workflow, and that should page someone:

SELECT a.*
FROM audit.bank_detail_changes AS a
LEFT JOIN billing_payoutaccountchange AS c
       ON c.account_id = a.row_id
      AND c.new_iban = a.new_iban
      AND c.status = 'applied'
      AND a.changed_at BETWEEN c.applied_at - interval '5 minutes'
                           AND c.applied_at + interval '5 minutes'
WHERE c.id IS NULL
  AND a.changed_at > now() - interval '1 day';

Run it from a monitoring job every few minutes, using the read-only monitoring role. Two cheap additions make the data-theft stage visible too. First, honeytoken records: a fake customer whose email address is a mailbox nobody else uses, and a fake supplier with a bank account that is never paid. Nothing legitimate reads them in bulk or contacts them, so a message arriving at that mailbox means your data has left the building. Second, column-level privileges: if only the payout service needs to read IBANs, grant SELECT on that column only to the payout service's role. The web role cannot then dump it, even through an injection flaw.

Audit logging that survives the compromise

Audit logs are often the first thing an intruder with admin access tidies up, and the last thing a small team thinks to protect. For the monetisation chain, a good audit log answers four questions quickly: who changed money-related data, from where, through which path, and what did it look like before?

  • Record events, not only rows. Log payout_change.requested, payout_change.cancelled, payout.approved, email_template.updated, api_key.created and mfa.removed as named events with structured fields.
  • Make it append-only. Give the application role INSERT on the audit table but not UPDATE or DELETE, and ship events off the host (a log pipeline, object storage with retention locks, or a SIEM) within seconds.
  • Include the session and device. A session identifier (hashed), the authentication method used for step-up and the client IP let you connect a suspicious change back to the login that preceded it.
  • Mind the privacy. Mask bank numbers in general-purpose logs (keep the last four characters) and keep full values only in the restricted audit schema. Audit logs are personal data too, so give them a retention period.

Detection signals worth an alert

Most small teams cannot staff a security operations centre, so each alert has to earn its place. The following signals have a direct link to money and a low legitimate rate, so they suit paging rather than a dashboard nobody opens:

  • A bank-detail change without an applied change request (the tripwire query above).
  • Two or more payout accounts changed to the same beneficiary within a day.
  • A bank-detail change followed within hours by a payout request, or by a support ticket asking to skip the waiting period.
  • An admin or finance login from a new device, immediately followed by a sensitive action: disabling MFA, creating an API key, editing an email template.
  • A sudden rise in database errors from the application role. Injection probing tends to produce syntax and type errors that normal traffic does not. Set log_min_error_statement so the failing statements are logged and alert on the rate.
  • Transactional email volume or recipient count well above the daily baseline, or DMARC aggregate reports showing a new source sending as your domain.
  • Any contact with a honeytoken mailbox, or any read of honeytoken rows by a bulk query.

Route these to a channel that reaches a human outside office hours. Fraud tends to be timed for evenings, weekends and holidays, when a transfer sits unnoticed longest.

Response playbook: suspected payment diversion

When a customer reports an unpaid invoice they say they paid, or a seller says a payout never arrived, the clock is already running. Recovering funds depends mostly on how quickly the receiving bank is asked to freeze them. Write the playbook down now, with names and phone numbers, because nobody will find them calmly during the incident.

  1. Call the bank's fraud line, not the general support queue. Give the transaction reference and request a recall. If a customer paid a fraudulent account, they must call their own bank, so tell them so immediately.
  2. Freeze outgoing money. Pause automated payouts and put pending bank-detail changes on manual hold with a feature flag that can be flipped without a deploy.
  3. Preserve evidence before cleaning up. Export audit events, web and database logs, and email provider logs for the relevant window to write-once storage. Snapshot affected hosts if you suspect code changes.
  4. Contain the access path. Revoke all sessions for affected staff accounts, rotate database credentials and API keys the attacker could have seen, and review OAuth grants and newly created keys.
  5. Scope the damage. Run the tripwire reconciliation over a wider window, list every account whose details changed, and check which customers received email from your systems in that period.
  6. Warn customers through a channel you control. Tell them your bank details have not changed and how to verify them, without asking them to click links in the notice.
  7. Meet your obligations. If personal data was accessed, GDPR requires notifying the supervisory authority within 72 hours of becoming aware of the breach, where it is likely to risk people's rights and freedoms. File a police report, which banks and insurers usually ask for.
  8. Close the root cause, then run a blameless review. Record which of the chain's stages you detected, which you missed, and how long each took.

Closing thought

Attackers will always find the cheapest path to monetization. Your job is to make every path expensive enough that they move to another target. Perfect security is impossible. Sufficient deterrent is achievable.

Build defenses that change the economic calculation. Track them. Test them. The next eight tutorials show you how at the technical level.