From a Profile Picture to AWS Admin: SSRF via Image Fetch at an Early-Stage SaaS
Redacted (Early-Stage HR-Tech SaaS, ~20 employees)
1 week
Critical
SeverityImage Upload
Entry PointAWS IAM Role
Credential TypeCustomer S3 Buckets
Blast RadiusOn This Page
Key outcome
SSRF chain to AWS IMDSv1 credential theft identified and stopped at proof-of-concept. Outbound URL fetch hardened, IMDSv2 enforced, IAM role tightened. No customer data left the environment.
Table of Contents
13
Executive Summary
An early-stage HR-tech SaaS company, identified throughout this document only as Redacted, engaged XHack for a focused web and cloud security review ahead of their first paid customer go-live. The product was built by a small founding team and ran on a single AWS account, with the application hosted on EC2 behind an Application Load Balancer.
XHack identified a Server-Side Request Forgery (SSRF) vulnerability in the profile-picture upload feature, where the application accepted a remote URL and fetched the image server-side. The fetch had no protocol restrictions, no IP-range filtering, and no protection against requests to AWS internal endpoints. The same instance was running with the legacy IMDSv1 enabled and an IAM role that included broad read access to S3.
Within the first three days of testing, XHack chained the SSRF into a full retrieval of the EC2 instance's IAM credentials, used those credentials to enumerate the AWS account, and demonstrated read access to an S3 bucket containing onboarding documents and payroll PDFs uploaded by the pilot customers. No data was exfiltrated. The proof-of-concept was demonstrated against a known sample bucket, with the client present, and stopped at confirmation.
The full chain was patched within the same week.
Client Background
The client is an early-stage SaaS company with around twenty employees. Their product is an HR onboarding and document-management tool aimed at small employers. At the time of the engagement they had completed a private pilot with eight customers and were preparing for a broader paid launch the following month.
The client cannot be identified beyond this description for commercial reasons.
Scope
- In scope: the production web application (frontend and API), the AWS account hosting the application, and any externally reachable assets
- Out of scope: customer integrations, third-party SaaS dependencies (auth provider, email provider), and the marketing site
- Approach: grey-box. XHack received a standard pilot-tier user account, a description of the AWS account architecture, and read-only IAM access to the AWS account for cloud-side validation.
Discovery
The web application included a profile-picture upload feature that allowed users to either upload an image directly or supply a URL. The URL flow let the user paste a link to an existing image (commonly used for pulling avatars from existing identity providers).
When a URL was supplied, the API endpoint accepted it as JSON in the request body:
POST /api/profile/picture HTTP/1.1
Host: app.redacted.example
Authorization: Bearer <pilot-user-token>
Content-Type: application/json
{ "url": "https://example.com/avatar.jpg" }
Server-side, the application fetched the URL, validated that the response Content-Type started with image/, resized the result, and stored the resized image in S3 against the user's profile.
XHack tested the URL parameter against a small set of standard SSRF probes:
- File-scheme URLs (rejected by the HTTP client used)
- Localhost references (
127.0.0.1,localhost) - Private IP ranges (
10.0.0.0/8,192.168.0.0/16) - The AWS instance metadata service (
169.254.169.254)
The first three were inconclusive on their own — the application returned the same generic error in each case. The fourth was the breakthrough.
POST /api/profile/picture HTTP/1.1
Host: app.redacted.example
Authorization: Bearer <pilot-user-token>
Content-Type: application/json
{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }
The response was a generic 400 because the returned content was not an image. But the application's error handler included a verbose backend log line that surfaced in the response body in development-style format: "Upstream returned content-type: text/plain. Body length: 17 bytes."
A 17-byte plaintext body returned from the AWS metadata endpoint is the name of the IAM role attached to the instance. That single error message confirmed two things at once: the SSRF was real, and the instance had IMDSv1 enabled.
XHack then fetched the credentials directly:
{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>" }
The verbose error path now leaked a body length consistent with a JSON document containing temporary AWS credentials. To extract the contents themselves, XHack pivoted to a different exit channel: the application stored the fetched body to S3 before validating the content type. The "image" stored to S3 was, in fact, a JSON document containing an AccessKeyId, a SecretAccessKey, and a Token.
XHack retrieved the resulting object from the user-visible profile-picture URL — the same URL the application would have used to render the user's avatar.
The credentials were valid. From a workstation outside the client's environment, XHack used them to call aws sts get-caller-identity and confirmed the assumed IAM role. Subsequent enumeration with the read-only permissions the role carried demonstrated read access to several S3 buckets, including one named redacted-customer-uploads-prod containing customer onboarding documents and payroll PDFs.
XHack stopped further enumeration at this point and disclosed the finding to the client immediately.
Vulnerability Properties
| Property | Detail |
|---|---|
| Vulnerability Class | Server-Side Request Forgery → Cloud Credential Theft |
| OWASP Top 10 | A10:2021 SSRF |
| CWE | CWE-918 (SSRF), CWE-269 (Improper Privilege Management) |
| CVSSv3 Score | 9.6 (Critical) |
| Attack Vector | Network (authenticated) |
| Privilege Required | Low (any standard pilot account) |
| User Interaction | None |
| Impact | Theft of EC2 IAM credentials, read access to customer document buckets |
Why It Happened
Two conditions had to be true for the chain to work, and both were present:
- The application performed unrestricted server-side URL fetches. No protocol allowlist (only
http://andhttps://were intended, but no enforcement), no IP-range deny-list (169.254.169.254should never be reachable from application code), no DNS rebinding protections, and no use of a hardened HTTP client configured for outbound URL fetching. - The EC2 instance had IMDSv1 enabled and a broad IAM role. IMDSv1 allows any process on the instance to retrieve credentials with a single GET request, no header required. IMDSv2 requires a session-token exchange that an SSRF vulnerability cannot easily perform. The IAM role attached to the instance had read permissions on customer document buckets that did not need to be reachable from the web tier at all — they were only intended to be reachable from a separate document-processing worker.
Either condition alone would have been a finding. Together they were a critical chain.
Triage and Confirmation
XHack confirmed the chain by:
- Reproducing the credential extraction across two separately provisioned pilot accounts
- Validating the extracted credentials with
aws sts get-caller-identityand listing buckets the role could enumerate - Reading a single non-sensitive object from
redacted-customer-uploads-prodto confirm read permissions, immediately notifying the client, and ceasing further interaction with the bucket - Preserving the exact request and response evidence so the client could reproduce the chain in a non-production environment
Impact Assessment
If the same chain had been discovered by an attacker before remediation:
- Customer data exposure. Onboarding documents and payroll PDFs in the affected bucket would have been readable. For an HR-tech SaaS, this is the worst kind of disclosure.
- Regulatory exposure. Payroll documents typically contain personal and financial data subject to GDPR and, depending on the customer's jurisdiction, additional financial-services rules. A real exploitation event would almost certainly trigger reporting obligations.
- Privilege expansion potential. Even though the role was scoped to read access, a determined attacker may have found an additional privilege escalation path inside the AWS account. The right answer is not "the role was read-only, so it was limited" — the right answer is that the web tier had no business holding those credentials at all.
- Pilot customer fallout. Eight pilot customers, several of whom were intended to be early reference accounts for the public launch, would have lost confidence in the product on disclosure.
Remediation
Immediate (Same Day)
- Patched the URL-fetch endpoint to enforce a strict allowlist of permitted hosts and to reject any request resolving to a private, link-local, loopback, or AWS-metadata IP address. The validation runs after DNS resolution and is repeated immediately before the actual fetch to prevent DNS rebinding.
- Disabled the URL-based image upload entirely as a temporary measure while the hardened version was tested and rolled out.
- Enforced IMDSv2 on the EC2 instance. This alone would have broken the credential-extraction step of the original chain.
Short-Term (Within One Week)
- Removed the broad S3 read permissions from the web-tier IAM role. The web tier no longer needs them. The document-processing worker has its own dedicated role with the minimum permissions required.
- Routed all outbound URL fetches through a dedicated egress proxy that enforces protocol, host, and IP-range policy in one place, rather than relying on each calling code path to do its own validation.
- Added structured logging for all outbound URL fetches, with a human-reviewable feed for any rejected request.
Longer-Term
- Move outbound fetching to a separate, low-privilege service. Any future "fetch a URL on the server's behalf" feature runs in a service with no IAM role beyond what it needs and no network reachability to the AWS metadata service.
- Adopt least-privilege IAM as a structural rule. Every new role is scoped, reviewed, and tested against an explicit list of permitted actions. No role inherits permissions because they are convenient.
- Periodic SSRF and cloud chain re-testing. Both classes of finding tend to reappear in growing codebases. Both should be on the audit cadence.
Outcome
The URL-fetch endpoint was hardened the same day. IMDSv2 was enforced and the IAM role was scoped down within 48 hours. The egress proxy and the broader IAM cleanup landed within the same working week. A re-test by XHack confirmed that the original chain no longer succeeded at any step, that the metadata endpoint was unreachable from application code, and that the previously over-privileged role had been removed from the web tier entirely.
No customer data left the environment. The pilot continued without interruption, and the public launch proceeded on the original timeline a few weeks later.
The interesting thing about this case is not the SSRF and not the IMDSv1 default. Both have been well documented for years. The interesting thing is that the chain existed at all in a small, well-intentioned engineering team that had moved fast on launching a product. SSRF is not a niche issue. It is one of the highest-impact web vulnerabilities in cloud-hosted applications in 2026 precisely because nearly every web application fetches some kind of external content, and nearly every cloud provider exposes some kind of metadata service. Treating outbound URL fetching as a privileged operation, and treating cloud metadata as a hostile target from inside your own application, is the only way to durably close this class of finding.
Engagement details
Redacted (Early-Stage HR-Tech SaaS, ~20 employees)
HR Technology / SaaS
Web & Cloud VAPT
1 week
2026
Start your engagement
Test Your Cloud Application
XHack delivers the same rigorous methodology behind every case study. Let us pressure-test your defences.
Test Your Cloud Application