How professional adversary simulation operates against web applications — initial access via phishing, persistence inside accounts, lateral movement through connected systems.
Pentests find specific bugs in scope. Red teams simulate full adversaries — phishing users, evading detection, establishing persistence, achieving specific business-impact objectives. The goal isn't a long bug list; it's understanding whether your security operations actually detect and respond to a sophisticated adversary.
This tutorial walks through how a web-app-focused red team operates, with defensive takeaways at each stage.
Beyond technical recon (tutorial 7), the human reconnaissance:
Effective phishing pretexts are specific. Examples successfully used:
The more specific to the target's actual context, the higher the success rate. Spear phishing at this level returns 30-70% click rates in unprepared environments.
For modern targets, the most common initial vectors:
Email sends user to a fake login page (perfect clone of real). User enters credentials and MFA code. Attacker forwards in real-time to real login page, completes auth, captures session cookie.
Modern tooling: Evilginx2, Modlishka. Transparent reverse-proxy attack frameworks. Bypass TOTP MFA almost trivially.
Email links to a real OAuth authorize URL for an app the attacker registered. User authenticates with their real provider (Google, Microsoft), grants consent. Attacker now has a long-lived access token.
Username from one breach + password from another breach matches your user. No phishing needed.
User browses to attacker-controlled page. Browser exploit or malware grabs cookies (no MFA needed since the session is already authenticated).
Once an account is compromised, attackers want to maintain access even if the password is changed or session is revoked.
Attacker adds their own TOTP enrollment to the account. Even if the user resets their password, attacker still passes MFA.
Defense: notify on MFA changes, require step-up auth for MFA modifications, periodic audit of registered MFA methods.
Attacker sets their own email as a recovery option. If user does full account reset, recovery email is the bypass.
Defense: require recent password verification to add recovery options, notify on changes.
Attacker authorizes their own OAuth app with broad scopes. Long-lived token, not affected by password change.
Defense: admin approval for OAuth apps, periodic review of authorized apps per user, scope minimization.
If the user has a developer account, attacker generates a personal access token with broad scopes. Doesn't expire with password change.
Defense: API key expiration, scope minimization, audit of issued tokens, anomaly detection on API usage.
If the app supports webhooks, integrations, third-party connections: attacker establishes a backdoor connection that delivers events to attacker infrastructure.
Defense: audit of integrations, approval workflows for new integrations, periodic verification of integration endpoints.
Once persisted as a low-privilege user, attackers escalate.
User has more permissions than their job requires (overprovisioned access). Attacker exploits the excess.
Defense: principle of least privilege, periodic access reviews, just-in-time elevation.
The compromised user can access features that elevate. "Edit my profile" forms that accept role parameters. Internal APIs without authorization checks.
Defense: server-side authorization on every endpoint, not just client-side UI hiding.
Compromised internal account asks help desk for "my password reset" or "my MFA reset." Help desk obliges. Now attacker has more permanent access.
Defense: out-of-band verification for credential/MFA changes, never reset based solely on "I'm locked out" requests.
Low-privilege OAuth token used to authorize a higher-privilege app in the same flow.
Defense: explicit re-authentication for elevated scopes, scope auditing.
Modern SaaS environments interconnect. Compromise of one often pivots to others.
Attacker has access to your SSO provider's session. Now logged into Slack, Google Workspace, GitHub, AWS console, etc., all via the SSO.
Defense: separate, stronger auth for high-stakes services (AWS console requires additional MFA even after SSO), audit logs cross-correlated.
Developer account compromised. Attacker pushes code to a branch, opens PR, exploits CI/CD permissions to deploy malicious code or steal secrets from build environment.
Defense: required code review (no bypass), production deploy approval workflows, ephemeral CI credentials, secret scanning in repos and builds.
Application compromise → instance metadata service → IAM role credentials → cloud account access. (Covered in tutorial 5.)
Defense: scoped IAM roles, IMDSv2 mandatory, network segmentation between app and metadata service.
Compromise of an account that has vendor admin access. Attacker uses your access to your vendor (a SaaS you bought, a service you use) to attack the vendor's other customers, or to harvest data passing through.
Defense: scoped permissions on vendor accounts, audit of cross-account access, vendor SOC 2 review.
Whatever the actual goal:
If you didn't detect earlier, this is your last chance. Indicators:
Centralized logging + anomaly detection finds these — if you're looking.
After a red team engagement, the failures cluster into categories:
Missing telemetry. The attack happened, but you can't see it because logging wasn't enabled for that path.
Alert fatigue, no on-call rotation, alerts going to dead email distribution lists.
Triage failed. The first responder dismissed it as noise.
No documented response, no clear authority, conflicting decisions.
Outdated procedures, missing escalation contacts, broken assumptions.
Each is fixable, but only if you've tested. Red team exercises reveal which category you fall into. Then you fix the broken layer and test again.
Stacking the controls from this tutorial against the full attack chain:
| Stage | Best defense |
|---|---|
| OSINT | Periodic external recon against self |
| Phishing | Phishing-resistant MFA + employee training |
| Initial access | MFA, conditional access, session hardening |
| Persistence | MFA change notifications, OAuth review, API key audit |
| Privilege escalation | Least privilege, application-layer auth checks |
| Lateral movement | Network segmentation, separate auth for high-stakes |
| Action | Detection at every previous stage |
No single layer stops everything. Combined, they create choke points that slow attackers and give detection time to fire.
Red teaming simulates a real adversary end to end — not just finding a vulnerability but chaining initial access, movement, and persistence into a realistic attack, the way a genuine intruder would. The value for defenders is that it tests not just whether a flaw exists but whether your detection and response actually work against an attacker who behaves like a real one. Understanding red-team tactics helps you see your environment as an adversary does: as a series of steps from a foothold to an objective, each of which is an opportunity for you to detect and stop them if your defenses are layered and monitored.
A real intrusion rarely jumps straight to the goal. It starts with initial access — often phishing a user — then moves laterally through the environment, escalating privileges and pivoting between systems, and establishes persistence so the access survives. Each stage is a chance to detect the intruder if you are watching for the right signals: unusual authentication, unexpected internal connections, new persistence mechanisms. Defending against the full attack chain, rather than only the initial vulnerability, means instrumenting and monitoring each stage so that even an attacker who gets a foothold is caught as they try to move toward what they actually want.
The lesson red teaming teaches most clearly is that prevention will sometimes fail, and detection is what limits the damage when it does. An attacker who gets in but is detected and contained quickly does far less harm than one who roams undetected for months. Investing in the ability to notice an intrusion in progress — logging the right events, alerting on anomalous behavior, having a response plan — is what turns a breach from a catastrophe into an incident. Red-team exercises are valuable precisely because they test this detection-and-response capability under realistic conditions, revealing the blind spots before a real adversary exploits them.
Stage 2 above shows why transparent reverse-proxy phishing defeats one-time-code MFA: the user types a TOTP or SMS code into a proxied page, and the proxy replays it to the real site inside the window it is valid. WebAuthn/FIDO2 breaks this attack at the protocol level. The authenticator signs a challenge that includes the origin the browser is actually talking to, and the private key never leaves the device. A key registered for https://app.example.com will not produce a valid assertion for https://app-example.com or any proxy origin, so a relayed login simply fails. This is not "stronger MFA" — it is a different trust model, and it is the single highest-leverage control against the phishing chain in this tutorial.
Two deployment shapes matter for a web app. Roaming security keys (hardware keys) are the strongest option for administrators and developers — the highest-value accounts in every stage above. Passkeys (discoverable credentials synced through a platform keychain) give ordinary users a phishing-resistant option without carrying hardware, at the cost of trusting the sync provider. Most organisations require hardware keys for privileged roles and allow passkeys for everyone else.
The webauthn package (the maintained py_webauthn library) implements the server side of the ceremony. Registration issues options tied to your relying-party ID and verifies the attestation the browser returns. Store the resulting credential ID, public key and signature counter against the user.
from webauthn import generate_registration_options, verify_registration_response
from webauthn.helpers.structs import AuthenticatorSelectionCriteria, UserVerificationRequirement
from webauthn.helpers import options_to_json
RP_ID = "app.example.com" # must match the site origin exactly
RP_ORIGIN = "https://app.example.com"
def begin_registration(request):
user = request.user
options = generate_registration_options(
rp_id=RP_ID,
rp_name="myproject",
user_name=user.get_username(),
user_id=str(user.pk).encode(),
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED,
),
)
request.session["webauthn_challenge"] = options.challenge # bind to this session
return JsonResponse(json.loads(options_to_json(options)))
def finish_registration(request):
body = json.loads(request.body)
verification = verify_registration_response(
credential=body,
expected_challenge=request.session.pop("webauthn_challenge"),
expected_rp_id=RP_ID,
expected_origin=RP_ORIGIN, # origin binding is what defeats the proxy
)
WebAuthnCredential.objects.create(
user=request.user,
credential_id=verification.credential_id,
public_key=verification.credential_public_key,
sign_count=verification.sign_count,
)
return JsonResponse({"status": "ok"})
The authentication ceremony mirrors this: generate_authentication_options issues a challenge, and verify_authentication_response checks the signature against the stored public key with the same expected_origin and expected_rp_id. Two correctness details matter in production. First, the signature counter: if a verified assertion reports a counter lower than or equal to the value you stored, treat it as a possible cloned authenticator and refuse the login. Second, keep the challenge in server-side session state (as above) rather than a hidden form field, so it cannot be replayed.
Prevention fails eventually; the sections above assume it will. Detection is what bounds the damage, and account takeover has a recognisable signature if you are logging the right events. The goal is a small number of high-fidelity signals wired to a response, not a wall of noise nobody reads (the "the alert fired but no one noticed" failure named earlier).
Log authentication and account-management events as structured records, separate from application request logs, and ship them somewhere the compromised account cannot edit. Django's built-in signals give you most of the authentication surface for free.
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.dispatch import receiver
@receiver(user_logged_in)
def log_login(sender, request, user, **kwargs):
SecurityEvent.objects.create(
kind="login.success",
user=user,
ip=client_ip(request),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
# asn/country resolved from an offline GeoIP database, no external calls
country=geo_country(client_ip(request)),
)
@receiver(user_login_failed)
def log_failure(sender, credentials, request, **kwargs):
SecurityEvent.objects.create(
kind="login.failure",
username=credentials.get("username", ""),
ip=client_ip(request) if request else "",
)
Beyond login success and failure, emit an event for every action an attacker uses to entrench: password change, MFA method added or removed, recovery email or phone changed, API token created, OAuth grant authorized, role or group membership changed, and webhook or integration endpoint modified. These are the Stage 3 and Stage 4 persistence actions — logging them is what makes them detectable.
| Signal | Why it matters | Tuning to cut false positives |
|---|---|---|
| Impossible travel | Two logins from locations too far apart for the elapsed time | Whitelist corporate VPN egress; treat mobile carrier IP ranges loosely |
| New device + sensitive action in one session | Password/MFA change from a never-seen device fingerprint is classic ATO | Score, don't block, on device newness alone; escalate on the combination |
| MFA method added shortly after login from new IP | Attacker enrolling their own authenticator for persistence | Very low false-positive rate — page a human on this one |
| Credential-stuffing shape | Many distinct usernames, few attempts each, from one IP/ASN | Aggregate by ASN over a window, not per-account lockout |
| Dormant account reactivation | Account with no activity for 90+ days suddenly authenticates and acts | Compare against the account's own baseline, not a global one |
| Admin action outside baseline hours | Bulk export or user creation at 03:00 from an admin who works 09:00–17:00 | Per-user working-hours profile; weight by sensitivity of the action |
Most of these are cheap to compute on the SecurityEvent stream. Impossible travel, for example, is a windowed self-join comparing consecutive logins per user:
-- flag logins where the implied travel speed exceeds ~900 km/h
WITH ordered AS (
SELECT user_id, created_at, country, lat, lon,
LAG(created_at) OVER w AS prev_at,
LAG(lat) OVER w AS prev_lat,
LAG(lon) OVER w AS prev_lon
FROM security_event
WHERE kind = 'login.success'
WINDOW w AS (PARTITION BY user_id ORDER BY created_at)
)
SELECT user_id, created_at, prev_at
FROM ordered
WHERE prev_at IS NOT NULL
AND haversine_km(prev_lat, prev_lon, lat, lon)
/ GREATEST(EXTRACT(EPOCH FROM created_at - prev_at) / 3600, 0.01) > 900;
Administrative accounts are the objective of most of the escalation and lateral-movement stages above, so instrument them harder than ordinary users. Django's admin emits LogEntry rows for every add, change and delete through the admin site — surface them, don't just let them accumulate. Alert when a new user is granted is_staff or is_superuser, when a permission or group is broadened, and on bulk actions (a single admin deleting or exporting thousands of rows). Compare the count of privileged accounts against a known-good baseline on a schedule; a new superuser that no change ticket explains is a Stage 3 persistence finding, not a curiosity.
Stages 4 and 5 only pay off because a foothold reaches far. The countermeasure is to make any single compromised account or service worth as little as possible. This is architectural work, done before an incident, and it is what turns a full breach into a contained one.
PermissionRequiredMixin, @permission_required) and enforce object-level ownership in the queryset itself — filter by request.user rather than trusting an ID from the request.is_superuser grants are the overprovisioning Stage 4 exploits. Prefer narrow, named permissions grouped into roles that map to real job functions.When an account is confirmed compromised, resetting the password is not containment. The persistence mechanisms in Stage 3 survive a password change by design: a rogue MFA enrollment, a recovery email, an authorized OAuth app, a personal access token, a webhook, or a scheduled task. Eviction means finding and revoking each one. Build the inventory queries now, while it is calm, so they are ready during an incident.
Django flushes a user's sessions when their password changes, but you should be able to do it explicitly, and you should be able to list what exists across the account regardless of whether a password reset happened.
from django.contrib.sessions.models import Session
from django.utils import timezone
def revoke_all_sessions(user):
# delete only sessions belonging to this user
for s in Session.objects.filter(expire_date__gte=timezone.now()):
if str(s.get_decoded().get("_auth_user_id")) == str(user.pk):
s.delete()
def account_persistence_report(user):
return {
"mfa_methods": list(user.webauthn_credentials.values("id", "created_at")),
"recovery_email": user.profile.recovery_email,
"api_tokens": list(user.api_tokens.values("id", "name", "scopes", "created_at")),
"oauth_grants": list(user.oauth_grants.values("client_id", "scopes")),
"webhooks": list(Webhook.objects.filter(created_by=user)
.values("id", "target_url", "events")),
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
}
Two account-wide sweeps catch the persistence that hides outside a single user record. New privileged accounts:
-- superusers created in the last 30 days: reconcile each against a change ticket
SELECT id, username, date_joined, last_login
FROM auth_user
WHERE is_superuser = true
AND date_joined > now() - interval '30 days'
ORDER BY date_joined DESC;
-- API tokens with broad scope and no recent use are prime persistence
SELECT t.id, t.user_id, t.name, t.scopes, t.created_at, t.last_used_at
FROM api_token t
WHERE t.scopes @> ARRAY['admin']
AND (t.last_used_at IS NULL OR t.last_used_at < now() - interval '14 days');
Server-side schedulers are a favourite persistence spot because they run without anyone logging in. Whatever runs your periodic jobs — cron on the host, systemd timers, a Celery beat schedule, or database-backed schedules — treat the full list as an asset to reconcile. Enumerate every scheduled job, diff it against a known-good manifest kept in version control, and alert on any entry that appears out of band. The same applies to OS-level footholds outside the app: unexpected authorized SSH keys for the deploy user, new entries in ~/.ssh/authorized_keys or /srv/app startup scripts, and unfamiliar systemd units. If a job or key is not in the manifest and no change record explains it, treat it as hostile until proven otherwise.
Everything above is what a red team tests. Running one safely and legally is its own discipline: without explicit written authorisation, simulated-attack activity is indistinguishable from a crime, and an out-of-scope action can cause real outage or data loss. The paperwork is not bureaucracy — it is what makes the exercise defensible and safe.
The rules of engagement (RoE) document is agreed and signed before any activity begins. At minimum it fixes:
Don't commission a full engagement before the basics are in place. As the closing note warns, a red team burning its budget on findings an internal audit should have caught tells you little. Engage when you have done the hardening and genuinely want to know whether it holds.
A pure red-team engagement is adversarial and quiet by design: the defenders don't know it's happening, and the value is the realism. That realism has a cost — a single end-to-end run tells you whether you were caught this time, but not much about why a control failed or whether a fix works. Purple teaming closes that loop by having the offensive (red) and defensive (blue) sides work together, deliberately and transparently, to improve detection.
| Question you're answering | Exercise that fits |
|---|---|
| Would our SOC actually catch a realistic adversary, end to end? | Red team (covert, objective-driven) |
| Do we have detection for this specific technique, and does it work? | Purple team (collaborative, per-behaviour) |
| Does a named flaw exist in this application, in scope? | Penetration test |
| Are our controls configured to a baseline across the estate? | Configuration/audit review |
A common maturity path is to run purple-team exercises to build and validate detections technique by technique, then commission a covert red team to confirm the whole operation holds together under realistic conditions. The purple work makes the eventual red-team engagement worth its cost, because you are no longer paying a specialist team to discover gaps you could have closed yourself.
Red team exercises are expensive (€30K-€200K+ for serious engagements) and embarrassing (they will find things). They're also the only realistic test of your operational security.
Don't engage one before you've done the basics. They'll waste their time and yours finding obvious bugs that internal audits should catch. Engage when you've done the work and want to see if it's actually enough.
Tutorial 10 covers what to do when the red team — or a real attacker — succeeds.
Found a mistake in this tutorial? Let us know and we will fix it.