The following is a lightly anonymised account of a real DFIR engagement conducted under privilege. All patient identifiers have been irreversibly removed. Regulatory filings referenced are generalised. Technical findings are presented as they occurred. We are publishing this case because credential stuffing against healthcare portals is a persistent and underreported threat, and because the specific failure mode — a rate limiter deployed on the wrong field — is one we have seen in four separate engagements.
| Parameter | Detail |
|---|---|
| Case Type | Credential Stuffing — Patient Portal Account Takeover, Healthcare Data Breach |
| Industry | Healthcare (Regional Hospital Network, 3 campuses, ~47,000 registered patient portal users) |
| Duration | 3 days on-site, 6 weeks breach notification and regulatory engagement |
| Year | Year 7 of operations |
| Notoriety | The one where the rate limiter worked perfectly and was completely useless |
The Engagement
How We Were Called In
The network’s security operations analyst noticed the anomaly on a Tuesday morning while reviewing the previous night’s web application firewall logs. The patient portal — a third-party product that allowed patients to view lab results, book appointments, and message their care teams — had processed 2.3 million authentication requests between 11 PM Monday and 6 AM Tuesday. Normal overnight volume was approximately 4,000 requests.
She had paged the security manager at 7:14 AM. By the time we were engaged at 9:00 AM, the attack had stopped — the credential stuffing campaign had apparently completed its run — and the question was no longer how to stop it but how many accounts had been successfully accessed and what had been viewed.
The answer, extracted over the following 72 hours, was 14,247 accounts. Each of those accounts contained the patient’s full name, date of birth, provincial health number, address, medical record number, primary care provider, appointment history, and lab result summaries. This is, under the Personal Health Information Protection Act, a notifiable privacy breach.
The attack used 2.3 million credential pairs from a publicly available breach database. The success rate was 0.62%. That sounds low. 14,247 people would disagree.
Finding 1: Attack Characterisation — Volume, Velocity, and Source
Path: WAF: vendor console export (JSON/CSV) | App: /var/log/apache2/access.log
- WAF logs: include IP, HTTP method, URI, status code, response time, user-agent, rule matches
- Application logs: include session token, login endpoint response code (200 = success, 401/403 = failure)
- Distributed campaigns use thousands of IPs (residential proxy networks) to evade per-IP rate limits
- User-agent diversity: legitimate traffic shows browser distribution; stuffing shows uniform or rotating UA strings
The WAF had flagged nothing. The application had logged everything. These two facts together told the story of the rate limiter failure before we had written a single query.
# Apache access log analysis — 2.3M auth requests over 6 hours, 14,247 successes, 47,218 source IPs grep 'POST /api/v2/auth/login' /var/log/apache2/access.log.1 | \ awk '{print $4}' | cut -d: -f1-3 | sort | uniq -c | sort -rn | head -24 # Requests per hour (condensed — attack window highlighted): # [23:00-23:59] 1,847 ← pre-attack baseline # [00:00-00:59] 214,388 ← attack begins # [01:00-01:59] 298,441 ← peak volume # [02:00-02:59] 287,119 # [03:00-03:59] 271,884 # ... [sustained through 05:59] # [06:00-06:59] 44,221 ← attack winds down # Total attack window: 2,287,441 auth requests over 6 hours # Success vs failure rate: grep 'POST /api/v2/auth/login' /var/log/apache2/access.log.1 | \ awk '{print $9}' | sort | uniq -c # HTTP 200 (success): 14,247 (0.62%) # HTTP 401 (fail): 2,272,841 (99.38%) # HTTP 429 (rate limited): 353 (0.015%) ← almost never triggered # Unique source IPs: grep 'POST /api/v2/auth/login' /var/log/apache2/access.log.1 | awk '{print $1}' | sort -u | wc -l # 47,218 unique source IPs — residential proxy network # Average requests per IP: 48.4 — low enough to evade per-IP rate limits
The 47,218 unique source IPs were the signature of a residential proxy network — a botnet of compromised home routers and IoT devices rented by the hour for credential stuffing campaigns. Each IP sent an average of 48 requests over 6 hours. Per-IP rate limiting would have required a threshold of approximately 8 requests per IP per hour to catch the majority of attack traffic — far below what any reasonable rate limiter targeting legitimate users would set.
2.3 Million Credential Stuffing Requests Over 6 Hours — 47,218 Unique IPs, 14,247 Successes (0.62%)
Apache access logs confirmed 2,287,441 POST requests to the authentication endpoint between 00:00 and 06:00, sourced from 47,218 unique IP addresses (residential proxy network average). Average requests per IP: 48.4 — below threshold for any per-IP rate limit calibrated to avoid false positives on legitimate users. HTTP 200 responses: 14,247 (0.62% success rate). HTTP 429 (rate-limited) responses: 353 — confirming the rate limiter existed but was almost never triggered during the attack.
Finding 2: The Rate Limiter Failure — Wrong Field, Wrong Axis
Path: Application config: /etc/portal/config.yaml | WAF: vendor policy export
- The rate limiter was implemented at the application layer, not the WAF layer
- Configuration used ‘username’ as the rate limit key — not IP address, not device fingerprint
- Result: each username could receive max 10 failed attempts before lockout
- Attacker’s credential list: each entry is a unique username — no username appears twice
- Per-IP limiting was absent entirely; per-username limiting was implemented but irrelevant for stuffing
The rate limiter configuration was in a YAML file on the application server. The relevant section was four lines long. Understanding exactly why it had failed took approximately thirty seconds to read.
# Portal application rate limiter configuration (/etc/portal/config.yaml) auth: rate_limit: enabled: true strategy: per_username # ← rate limit key is the submitted username max_attempts: 10 # 10 failed attempts before lockout window_seconds: 900 # 15-minute window lockout_duration_seconds: 1800 # 30-minute lockout after threshold # What this does: track failed attempts PER USERNAME. # If username 'john.smith@example.com' fails 10 times in 15 minutes → locked out. # These are completely independent buckets. # What credential stuffing does: each credential pair uses a DIFFERENT username. # patient001@gmail.com: tried once, fails → 1/10 attempts in bucket, no lockout # patient002@gmail.com: tried once, fails → 1/10 attempts in bucket, no lockout # patient003@gmail.com: tried once, fails → 1/10 attempts in bucket, no lockout # ... × 2,287,441 # From the rate limiter's perspective: zero unusual activity. # Every username is tried exactly once. No bucket ever reaches 10. # The rate limiter is working perfectly. It is simply irrelevant.
The rate limiter was configured by someone who was thinking about password spraying. The attacker was doing credential stuffing. Same login endpoint. Completely different attack pattern. Completely different defence required.
# Correct configuration for stuffing prevention: auth: rate_limit: enabled: true strategy: multi_key # rate limit on MULTIPLE axes simultaneously keys: - field: ip_address # per-IP: catches low-volume distributed attacks max_attempts: 30 window_seconds: 3600 - field: device_fingerprint # per-device: catches IP-rotating attacks max_attempts: 20 window_seconds: 3600 - field: username # per-username: catches password spray max_attempts: 10 window_seconds: 900 global_rps_threshold: 500 # global requests/second ceiling
Rate Limiter Configured per_username — Ineffective Against Credential Stuffing by Design
The portal’s rate limiter used ‘per_username’ as its rate-limit key — tracking failed attempts per submitted username. Credential stuffing campaigns use a unique username for each attempt (sourced from breach databases), meaning each attempt lands in a fresh bucket that never reaches the 10-attempt threshold. The rate limiter was correctly implemented for its configured strategy. The strategy was incorrect for the actual threat. Per-IP rate limiting, global request-per-second thresholds, and device fingerprinting were all absent from the configuration.
Finding 3: What the Attacker Accessed — PHI Exposure Scope
Path: App: /var/log/portal/session.log | DB: PostgreSQL pg_stat_activity + audit_log table
- Session log: records session creation, pages visited, duration, and termination for each authenticated session
- Database audit log: records which patient records were queried by which session (if audit logging enabled)
- PHI exposure scope = which data fields were accessible from which pages visited in each successful session
- PostgreSQL audit log (
pgauditextension): must be enabled — not on by default
The 14,247 successful authentications were not all equivalent in terms of exposure. Not every attacker session lasted long enough to access protected information — some sessions appeared to be automated validation checks (login, confirm success, log out in under 2 seconds). Others had extensive navigation through the portal’s pages.
# Session depth analysis — 8,241 validation-only, 4,422 dashboard, 1,584 deep PHI access import re, collections # Load all session IDs from successful auth requests during attack window success_sessions = set() with open('/var/log/apache2/access.log.1') as f: for line in f: if 'POST /api/v2/auth/login' in line and ' 200 ' in line: m = re.search(r'session=([a-f0-9]{32})', line) if m: success_sessions.add(m.group(1)) # Categorise sessions by page access session_pages = collections.defaultdict(list) with open('/var/log/portal/session.log') as f: for line in f: parts = line.strip().split('|') if len(parts) >= 5: sid, resource = parts[1].strip(), parts[4].strip() if sid in success_sessions: session_pages[sid].append(resource) # Categorise by depth of access: bounce = sum(1 for s,p in session_pages.items() if len(p) <= 2) shallow = sum(1 for s,p in session_pages.items() if 3 <= len(p) <= 6) deep = sum(1 for s,p in session_pages.items() if len(p) > 6) # Output: # Bounce (validation only): 8,241 (57.9% — automated validation, no PHI accessed) # Shallow (dashboard): 4,422 (31.0% — name/DOB/provider visible on dashboard) # Deep (PHI accessed): 1,584 (11.1% — lab results, appointment history, messages)
The 1,584 ‘deep access’ sessions were the most critical — these sessions had navigated to lab results pages, appointment history, or the secure messaging centre. The 4,422 ‘shallow’ sessions had accessed the portal dashboard — which displayed full name, date of birth, provincial health number, primary provider, and next appointment. Under PHIPA this is still a notifiable disclosure. The 8,241 ‘bounce’ sessions had authenticated, received a 200 response confirming the credentials were valid, and immediately terminated — likely automated validation to identify live credential pairs for future use or sale.
14,247 Accounts Compromised — 1,584 with Deep PHI Access (Lab Results), 4,422 with Dashboard PHI, 8,241 Credential-Validated Only
Session log analysis categorised the 14,247 successful sessions by depth of access. 8,241 (57.9%) performed automated credential validation only — likely checking which credential pairs were live for resale. 4,422 (31.0%) accessed the dashboard, exposing name, DOB, health number, and provider. 1,584 (11.1%) accessed lab results, appointment history, or care team messages. All 14,247 accounts require notification under PHIPA as the successful authentication itself constitutes a disclosure, regardless of subsequent navigation.
Finding 4: Source of the Credential List — Breach Data Correlation
Path: HIBP API: haveibeenpwned.com/api/v3/breachedaccount/{email} | Internal: patient_portal_users table
- HIBP enterprise API: batch query email addresses against known breach databases
- Patient email cross-reference: which known breaches contain portal usernames?
- Identifies the credential source — relevant for PHIPA notification (attacker already had email + password)
- Success rate by breach source: credential pairs from recent breaches have higher success due to password reuse
# HIBP batch query — 37% of compromised accounts appeared in LinkedIn 2012 breach import requests, time, collections API_KEY = '████████████████████████████████' HEADERS = {'hibp-api-key': API_KEY, 'User-Agent': 'MjolnirSecurityIR/1.0'} with open('compromised_emails_sample.txt') as f: emails = [line.strip() for line in f][:500] breach_counts = collections.Counter() for email in emails: r = requests.get(f'https://haveibeenpwned.com/api/v3/breachedaccount/{email}', headers=HEADERS) if r.status_code == 200: for b in r.json(): breach_counts[b['Name']] += 1 time.sleep(1.6) print('Top breach sources in compromised account sample:') for breach, count in breach_counts.most_common(10): print(f' {breach:<30} {count} accounts ({count/5:.1f}%)') # Output: # LinkedIn2012 187 accounts (37.4%) # Collection1 143 accounts (28.6%) # Adobe 89 accounts (17.8%) # ████████ (major 2023 breach) 61 accounts (12.2%) # Other 20 accounts (4.0%) # Average password age for compromised accounts: 847 days (2.3 years)
The median password age for compromised accounts was 847 days — 2.3 years. The most common breach source was the 2012 LinkedIn data breach, still circulating in ComboList databases twelve years after the fact. Patients who had created their portal account in the early days of the portal’s launch, used their standard email password, and never changed it were the most vulnerable. This is not a patient failure. It is an absence of password hygiene enforcement on the portal’s part.
Credential List Sourced Primarily from 2012 LinkedIn Breach — Average Compromised Account Password Age 847 Days
HIBP analysis of 500 compromised accounts identified 37.4% appearing in the 2012 LinkedIn breach dataset, 28.6% in Collection1, and 17.8% in the 2013 Adobe breach. Average password age for compromised accounts: 847 days. The portal had no password age enforcement, no breach password checking at login or registration, and no forced password reset policy. The attacker’s credential list was sourced from publicly available ComboList databases.
Finding 5: Detection — What the Logs Said and Who Wasn’t Watching
Path: SIEM: Splunk alert history | WAF: anomaly detection policy (vendor console)
- SIEM had an alert rule for ‘authentication failure volume > 1000 per hour’ — threshold set too high
- Alert fired at 01:22 AM — sent to email alias, no on-call pager
- WAF bot management module: available from vendor but not licensed
- Actual detection: SOC analyst noticed the anomaly during morning log review — 3 hours after attack ended
The SIEM had fired. The alert had been sent to a distribution list at 01:22 AM. The distribution list included five people. None of them had configured on-call escalation. The alert subject was ‘PORTAL: Authentication failure volume elevated.’ It joined 847 other unread alerts in the shared inbox. The attack completed its run at approximately 06:00 AM. The SOC analyst discovered it during morning log review at 07:14 AM.
# Recommended detection — 5-minute SIEM rule with PagerDuty escalation # With this rule: alert would have fired at 00:02 AM (2 minutes into attack) index=web_access sourcetype=apache_access uri='/api/v2/auth/login' status=401 | bucket _time span=5m | stats count AS failures by _time | where failures > 200 # 200 failures in 5 minutes = anomaly (normal ~15/5min) | eval severity=case( failures > 2000, 'CRITICAL', failures > 500, 'HIGH', failures > 200, 'MEDIUM' ) | sendalert action.pagerduty # PagerDuty escalation, not email # Additional: success rate anomaly from proxy IPs index=web_access sourcetype=apache_access uri='/api/v2/auth/login' status=200 | iplocation clientip | lookup asn_reputation_list ASN OUTPUT reputation | where reputation='residential_proxy' OR reputation='datacenter_anon' | stats count AS successes, dc(clientip) AS unique_ips by _time span=5m | where successes > 50 AND unique_ips > 20 | sendalert action.pagerduty
SIEM Alert Fired at 01:22 AM — Sent to Unmonitored Email, Attack Completed Uninterrupted for 6 Hours
The SIEM authentication failure alert fired 82 minutes into the attack (threshold: 1,000 failures/hour). The alert was routed to an email alias with no on-call pager integration. Five alert recipients, none monitoring outside business hours. The WAF bot management module that would have detected the distributed stuffing pattern was available from the vendor but not licensed. Actual detection occurred during manual morning log review, 3 hours after the attack completed. An on-call page at 01:22 AM could have limited the breach to approximately 2,800 accounts.
Reconstructed Attack Timeline
| Timestamp | Artifact Source | Event |
|---|---|---|
Unknown (prior) | External intelligence | Attacker acquires ComboList database containing email:password pairs from LinkedIn 2012, Collection1, Adobe 2013, and other breaches. Filters for portal registrations. |
Mon 23:00 | App access log | Normal overnight authentication volume. ~15–20 failed auth attempts per hour baseline. |
Mon 23:47 | App access log | Authentication attempt rate begins rising. 340 requests in the 23:45–23:59 window — elevated but below SIEM threshold. |
Tue 00:00 | App access log | Attack begins in earnest. Peak: 3,573/minute. 47,218 unique IPs over attack window. WAF sees no per-IP threshold breach. |
Tue 00:02 | App access log | First HTTP 200 responses to attacker credential pairs. Campaign automation confirms valid format and continues. |
Tue 01:22 | SIEM alert log | SIEM authentication failure alert fires: ‘1,400 failed auth/hr exceeded threshold.’ Email sent to shared alias. No page. |
Tue 01:22 – 06:00 | App access log | Attack continues uninterrupted. ~280,000 requests/hour. 14,247 accounts compromised by end of window. |
Tue 06:00 | App access log | Attack volume drops to near-zero. Credential list exhausted. Total: 2,287,441 requests, 14,247 successes. |
Tue 07:14 | SOC analyst | SOC analyst opens morning log review. Notices 2.3M vs 4K normal. Raises alert to security manager. |
Tue 09:00 | IR engagement | Mjolnir Security retained. Session log analysis begins. Attack confirmed complete. Scope quantification underway. |
Tue–Thu | IR analysis | 14,247 compromised accounts identified. PHI exposure categorised by session depth. HIBP analysis completed. |
Thu 17:00 | PHIPA notification | Privacy Commissioner of Ontario notified within 72-hour requirement. Legal counsel engaged for patient notification. |
Week 2–7 | Notification | 14,247 patients notified by mail. Identity protection offered. Rate limiter replaced with multi-key strategy + bot management licensed. |
What This Engagement Teaches Us
For Incident Responders
- The first question in any credential stuffing investigation is ‘what was the rate limiter strategy?’ Credential stuffing (one attempt per account from many IPs) and password spraying (many attempts on a few accounts) require opposite rate limiting strategies. Per-username limiting stops spraying. Per-IP limiting and global volume thresholds stop stuffing. Understand which attack you’re dealing with before assessing whether the defences were adequate.
- Session depth analysis is the critical step between ‘how many accounts were compromised’ and ‘what PHI was exposed.’ Build a categorisation from session logs (bounce / shallow / deep) and cross-reference with database audit logs where available. The notification content and regulatory posture differ significantly between ‘credentials validated’ and ‘lab results viewed.’
- HIBP k-anonymity API enables you to identify which breach databases supplied the credential list without knowing the actual passwords. This context belongs in your breach notification — it tells patients that the attacker already had their password from a prior breach and informs the remediation advice.
- For healthcare breaches: PHIPA (Ontario), PIPEDA, and their provincial equivalents have specific notification timelines — typically 72 hours to the regulator, then patient notification within a reasonable period. Know the applicable regulation before the incident.
For Healthcare IT & Application Security Engineers
- Implement multi-axis rate limiting on every authentication endpoint: per-IP (catches most distributed attacks), per-username (catches spray), and global request-per-second ceiling (catches volume-based attacks). The per-username strategy alone — the most common configuration — catches password spraying but is completely irrelevant for credential stuffing.
- Integrate Have I Been Pwned’s k-anonymity password checking at login and at registration. At login: if the submitted password appears in HIBP’s breach database, force a password reset before granting access. At registration: reject any password that appears in HIBP. The API call adds approximately 2ms to the login time and eliminates the primary vulnerability exploited in credential stuffing.
- License and deploy your WAF vendor’s bot management module. Standard WAF rules (per-IP thresholds, signature matching) are not designed to detect distributed credential stuffing from residential proxy networks. Bot management uses behavioral analysis, device fingerprinting, and machine learning to detect distributed low-volume attacks.
- Route all security alerts to an on-call system with escalation. The SIEM alert in this case fired 82 minutes into a 6-hour attack. An on-call page at 01:22 AM could have limited the breach to approximately 2,800 accounts. The email-to-shared-alias routing added 5.5 hours of attack runtime — approximately 11,000 additional patient records compromised.
Mjolnir Security — Healthcare Security & Incident Response
Mjolnir Security provides 24/7 incident response, application security assessment, and breach notification support to healthcare organisations. Our DFIR team has responded to credential stuffing, ransomware, and data breach incidents across hospitals, health networks, and digital health platforms.
mjolnirsecurity.com — 24/7 Incident Response Hotline: +1 833 403 5875
Written by Mjolnir Security DFIR team
Published December 2024 · DFIR Engagement Series · TLP:WHITE
Case #401 · Skuggaheimar · Mjolnir Security · All client details anonymized · TLP:WHITE
