Intermediate levelWith account

Account enumeration via LinkedIn + phone — exposure of millions of users

Real case: API account enumeration combined with LinkedIn scraping + reverse phone lookups to correlate real identities with private accounts. Critical privacy impact.

Gorka El BochiMay 11, 202614 min

Quick answer

Account enumeration looks like a "low severity bug" in shallow reports. But the chain — enumerating accounts in a private API + correlating with public LinkedIn + reverse phone lookup → you turn "private user" into "Juan Pérez, CTO of XYZ, mobile +34 6XX XXX XXX, wife María". Massive privacy impact. The bug class that generated the most big bounties in 2026 in dating, fintech, health, and networking apps. Reported to 5+ unicorns with combined bounties >€80K. Medium-high severity when you frame it with GDPR/CCPA/PIPL regulation.


The framing — why it matters beyond "user exists"

A typical enumeration response:

json
GET /api/user/check?email=victim@gmail.com
{"exists": true}

Shallow reports: "Severity Low, just informational". Reward: $50-$500.

The correct framing: enumeration + correlation = de-anonymization. Apps with "private" users (dating, mental health, recovery, activism) → mapping real identity to an "anonymous" profile is a Critical privacy violation.

Without chainWith chain (correlation)
"victim@gmail.com exists""victim@gmail.com is Juan Pérez, CTO at XYZ, father of 2, lives in Madrid"
Severity LowSeverity High/Critical
$50-$500$2K-$15K

Pattern 1 — enumeration via /forgot timing

Endpoint POST /forgot { email }:

graphql
victim@gmail.com    → 200 OK in 850ms ("email enviado si existe")
random@test.tld     → 200 OK in 120ms ("email enviado si existe")

Same response body, different timing. The real email runs more code (DB lookup, token generation, email send) than the fake one → a measurable difference.

Automated detection

python
import requests, time

def time_request(email):
    start = time.time()
    requests.post("https://target.com/forgot", json={"email": email})
    return time.time() - start

print(time_request("victim@gmail.com"))   # 0.85s
print(time_request("fake_user_xx@gmail.com"))  # 0.12s

After 20 control samples, distinguishing 200ms from 800ms is trivial.

The mitigation that breaks

Devs usually "fix" this by adding setTimeout(800ms) to the fake endpoint → but they forget there is a real setTimeout → the variance distinguishes 800±50ms (artificial) from 800±200ms (natural).


Pattern 2 — enumeration via different response codes

http
POST /signup
{"email": "exists@target.com", "password": "x"}
→ 400 "Email already in use"

POST /signup
{"email": "new@target.com", "password": "x"}
→ 200 "OK"

Trivial. But less obvious:

http
POST /api/2fa/send-otp
{"email": "exists@target.com"}      → 200 (otp sent)
{"email": "new@target.com"}          → 200 (otp sent, fake)

Same response — but only the real one generates an audit log, SMS cost, and per-user rate limit. Pivot to side channels: is there a counter of "X emails sent" visible in some admin/billing endpoint? Every call to the first one increments, the second doesn't.


Pattern 3 — GraphQL introspection + enumeration

GraphQL multiplies the surface. Partial search queries → enumeration friendly:

graphql
query {
  searchUsers(query: "victim", limit: 50) {
    edges {
      node {
        id
        username
        # campos sensibles según el schema
        publicProfile {
          fullName
          jobTitle
          company { name }
        }
        contactInfo {
          email
          phoneNumber
        }
      }
    }
  }
}

Test:

  1. Introspect the schema (if exposed): query { __schema { types { name fields { name } } } }.
  2. List fields on User. There are often scope-protected fields that leak by mistake.
  3. Fire searchUsers with short queries (1-2 chars) → extensive results.

Typical bug

User.email and User.phoneNumber are "protected" in the direct query user(id) (it checks ownership) but not in searchUsers (it returns the list without a per-field check) → mass leak.

<!-- PAYWALL -->

Pattern 4 — chain with LinkedIn scraping

The real severity jump. After enumerating 10K emails on the target:

Step 1 — bulk LinkedIn lookup

LinkedIn allows contact import by email. If you control a LinkedIn account with a moderate professional network, you can:

  • Upload a CSV of emails → LinkedIn returns "these are on LinkedIn, these aren't".
  • For the ones that match: full name + current company + role + location + photo.
  • Tools: Hunter.io, Apollo.io, ZoomInfo, Lusha, Snov.io — all consume an email → return an enriched profile.

Step 2 — reverse phone lookup

If the target.com API exposes phone:

  • Truecaller (web search, no public API but scraping doable)
  • WhitePages.com (US)
  • Yellow.es (Spain)
  • Showcaller / Hiya
  • Other aggregator services

Result: anonymous number → "Juan Pérez, Madrid, age ~35".

Step 3 — correlation engine

For each enumerated user:

yaml
target_id: 184729
email: jperez@gmail.com
phone: +34 6XX XXX XXX

 LinkedIn lookup:
   name: Juan Pérez
   company: XYZ Corp
   title: Senior Engineer
   location: Madrid, ES
   
 Reverse phone:
   carrier: Movistar
   region: Madrid centro
   public listings: Juan Pérez (esposa María)

 Combined dossier:
   {full_name, employer, title, family, location, target_userId}

→ Now the "anonymous user 184729" on target.com is Juan Pérez with all his data. This is the attack that privacy regulations classify as severe — a user who thought they were using the app "anonymously" is completely identified.


Pattern 5 — phone + LinkedIn email reset chain

Documented in the report "An attacker forgets your email and LinkedIn gives him your phone number":

  1. The attacker finds that your public LinkedIn shows "+34 6XX..." in the bio (common in freelancer and consultant profiles).
  2. The target.com app allows "forgot password" via an alternative phone number:
    http
    POST /auth/forgot-by-phone
    {"phone": "+34 6XX XXX XXX"}
    
  3. If the app doesn't require other factors → SMS reset to the attacker's phone (SIM-port if you got a swap, or if the target SMS arrives at a phone made visible via LinkedIn).

Severity

When this includes recovery via the attacker's phone (not the victim's) → ATO. When it only allows discovering the victim's phone → high privacy violation.


Pattern 6 — bulk extraction without triggering rate limit

"Informational" endpoints usually have a relaxed rate limit (100 req/min) because "no sensitive leak". But if you leak phone/email/PII → lax rate limit × millions of IDs = bulk extraction.

Techniques to avoid triggering the rate limit

  • Distributed IPs: residential proxy network (legal: ProxyMesh, Bright Data; bug bounty use: NO).
  • Pagination with offset: ?limit=100&offset=N — each request is "different" in the logs.
  • Field selection (GraphQL): only request the sensitive fields, avoid "expensive" fields that trigger monitoring.
  • Off-peak time: run the attack in the target's local early morning → less manual detection.

[!warning] Bug bounty scope Do NOT run bulk extraction against real users for a PoC. Most programs explicitly forbid it — extract 5-10 records from YOUR accounts + 1-2 control ones (that you know exist), document the pattern. The report with "and here are 50K real extracted records" usually results in a ban from the program.


Reporting framework

The report has to narrate the chain:

markdown
## Summary
Combined account enumeration in /api/forgot + bulk lookup via /graphql 
searchUsers allows correlating private user accounts with full real-world
identities via LinkedIn email lookup.

## Steps
1. Enumeration: POST /api/forgot exhibits timing oracle (850ms real vs 120ms fake)
2. Bulk: GraphQL searchUsers(query) returns phone+email without ownership check
3. Correlation: LinkedIn email lookup (CSV import) yields full name + employer

## Impact
"Anonymous" user IDs 100% de-anonymizable. PII covered by GDPR Art. 6/9.
Specific cases (mental health, dating apps, recovery): severity Critical.

## Demonstration
Attached: 3 test accounts I control + 1 control account explicitly verified
with the program team. Total records: 4. Method generalizable to entire dataset.

Report the full chain and not each piece separately. Individual piece = $200; chain = $5K-$15K.


Hunting checklist

  • Endpoints where the response gives away a user's existence: forgot, signup, login error messages, 2FA send.
  • Timing oracle: measure real vs fake time (10 samples each).
  • GraphQL: introspection + searchUsers / nodes by partial filter.
  • Bulk APIs: contact import, batch lookup, suggested friends.
  • Sensitive fields in the response: email, phone, full name, address, jobs.
  • Pagination: ?offset=N for incremental extraction without a spike.
  • LinkedIn lookup chain: does the target have a public email or phone?
  • Reverse phone: if the API leaks phone, is it correlatable with a real identity?
  • Privacy framing: identify the applicable regulation (GDPR, CCPA, LGPD).
  • Report with a narrative chain + minimal PoC (3-5 records).

Practice enumeration timing, GraphQL bulk extraction and PII correlation in enumeration and PII labs.

Practice this in a lab

Mass Pii Extraction Graphql Enumeration

Solve

Keep learning · free account

Save your progress, unlock advanced payloads and rank your flags.

Create account
Premium · 1 more technique

There's an extra payload at the end

La query GraphQL exacta que extrae 100k usuarios con email + phone + employer en <2h sin trigger rate limits.

Unlock

€7.99/mo · cancel anytime

Related articles

hunters training
711

hunters training

labs from real reports
55

labs from real reports

completions
1,205

completions

in bounties practiced
$213,970

in bounties practiced

46 flags captured this week·Real reports from HackerOne · Bugcrowd · Intigriti·No commitment·Free Academy