The Setup
You know that feeling when you're triaging security findings and you see a bunch of mediums in the backlog? They'll get fixed eventually. Probably. After the criticals. And the highs. And that one feature the PM has been asking about for three sprints.
Here's the thing: attackers don't triage by severity. They triage by what chains together.
I want to walk through a vulnerability chain we recently documented that combines two completely unremarkable findings into something that enables authenticated phishing and persistent access to Microsoft 365 environments.
Neither finding would make anyone panic. Together, they're a full compromise.
Finding #1: The Newsletter Endpoint That Does Too Much
Every web app has endpoints that send emails. Newsletter signups. Contact forms. Password resets. Transactional notifications.
These endpoints need to be public to function. That's the point. But they also need strict input validation to prevent abuse.
Here's the vulnerable pattern:
POST /api/newsletter/subscribe
Content-Type: application/json
{
"recipient": "victim@target.com",
"subject": "Urgent: Security Alert",
"body": "<html>...phishing content...</html>"
}
No authentication. Arbitrary recipient, subject, and HTML body.
When this request gets processed, the application sends an email through the organization's legitimate mail infrastructure. The email originates from an authorized mailbox with proper authentication.
What this means in practice:
- Email passes SPF, DKIM, and DMARC checks
- Sender shows the organization's official email address
- Gmail auto-tags it as "Important" because of the legitimate origin
- Lands in primary inbox, not spam
You've just turned the target's own infrastructure into a phishing platform.
Finding these endpoints isn't hard:
site:target.com newsletter
site:target.com "sign up"
site:target.com contact
Pages that aren't linked in the main navigation are often still indexed and fully functional.
Finding #2: Error Messages That Leak Tokens
The second finding involves verbose error handling in production. You've seen this pattern before:
POST /api/newsletter/subscribe
Content-Type: application/json
{
"recipient": "test@test.com"
// missing required fields
}
Response:
{
"error": "ValidationError",
"stack": "...",
"context": {
"oauth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"service": "graph.microsoft.com",
...
}
}
Why would an error response contain OAuth tokens? In many applications, internal services authenticate to each other using tokens stored in application context. Verbose error handling dumps that context to the client. The tokens come along for the ride.
In this case, the leaked tokens were for Microsoft Graph API.
Depending on scope, that's access to:
- Mail (read and send)
- Calendar
- Teams conversations
- SharePoint and OneDrive files
- User directory and org charts
- Sometimes Azure resources and Intune
"But tokens expire in an hour"
True. But you can just trigger the error again to get a fresh one. The vulnerability becomes a token dispenser. Persistence without credentials.
The Chain
Here's how these combine in practice:
Stage 1: Token extraction
Attacker finds the verbose error condition, pulls a valid Graph token. They now have authenticated M365 access without triggering failed login alerts.
Stage 2: Reconnaissance
Using the token, they enumerate:
// Get org chart
GET https://graph.microsoft.com/v1.0/users
// Get user details
GET https://graph.microsoft.com/v1.0/users/{id}
// Get manager chain
GET https://graph.microsoft.com/v1.0/users/{id}/manager
Employee names. Titles. Projects. Reporting structure. Internal terminology. All the intelligence needed to craft convincing phishing.
Stage 3: Targeted phishing
Now they use the email endpoint to send phishing campaigns. But these aren't generic "click here to verify your account" emails. They're crafted using real project names, accurate org structure, and internal terminology.
And they come from the organization's own mail server.
Stage 4: Escalation
Harvested credentials get them deeper. Admin accounts. Azure resources. Production infrastructure.
Stage 5: Persistence
As long as the verbose error exists, they can regenerate tokens. Credential rotation doesn't help. The vulnerability itself is the persistence mechanism.
The Fix
For the email endpoint:
# Bad: accepts arbitrary input
@app.post("/api/newsletter/subscribe")
def subscribe(data: dict):
send_email(
to=data.get("recipient"),
subject=data.get("subject"),
body=data.get("body")
)
# Good: strict schema, single purpose
class SubscribeRequest(BaseModel):
email: EmailStr
@app.post("/api/newsletter/subscribe")
def subscribe(data: SubscribeRequest):
add_to_mailing_list(data.email)
send_confirmation_email(data.email) # fixed template
If it's a newsletter signup, it accepts an email address. That's it.
For error handling:
# Bad: dumps everything to client
@app.exception_handler(Exception)
def handle_error(request, exc):
return JSONResponse({
"error": str(exc),
"stack": traceback.format_exc(),
"context": app.state.__dict__ # tokens live here
})
# Good: generic client response, detailed server logs
@app.exception_handler(Exception)
def handle_error(request, exc):
logger.error(f"Error: {exc}", exc_info=True) # server-side only
return JSONResponse({
"error": "An error occurred",
"request_id": generate_request_id()
})
Production should never return stack traces or application context to clients.
The Takeaway
Two medium findings. One accepts too many parameters. One returns too much information.
Your vulnerability scanner assessed these individually and rated them appropriately. The scanner isn't wrong. But it's also not thinking like an attacker.
Attackers don't care about severity ratings. They care about paths.
Full technical writeup with the detailed attack flow: https://www.praetorian.com/blog/gone-phishing-got-a-token-when-separate-flaws-combine/
Question for the comments: What's the gnarliest vulnerability chain you've seen where individual findings looked harmless but combined into something ugly?




Top comments (0)