August 20, 2026
Blind SSRF Without Callbacks: How I Used Timing to Prove Kubernetes and AWS Metadata Access
By R4yz0x

By Nizar Kadiri
5 min read
Most SSRF writeups follow the same pattern. You find a URL parameter, point it at your Burp Collaborator or interactsh listener, get a DNS ping back, and call it confirmed. That works fine when you have external callback infrastructure available and when the target does not filter outbound DNS. But what do you do when the response body tells you nothing and you cannot or do not want to rely on out-of-band callbacks?
You use timing.
This is the story of a blind SSRF I found in a cloud-native developer platform, confirmed entirely through response time differentials, and used to prove access to the Kubernetes API server, the AWS and GCP instance metadata services, and localhost services running on the same host as the application. No callbacks. No DNS pings. Just numbers.
Finding the surface
During recon on the platform's console API I came across a migration checklist endpoint. It accepted a connection_uri in the POST body, a standard database connection string, and would use it server-side to attempt a connection and validate a migration setup.
Any endpoint that takes a URI and makes a server-side network connection based on it is worth testing for SSRF. The design intent here was completely reasonable: the platform was trying to help users verify their database configuration before running a migration. But the behavior that makes it useful for that purpose, making a real outbound TCP connection to whatever host and port you specify, is exactly what makes it dangerous if the input is not validated.
The response body was useless for SSRF testing. It told me nothing about whether the connection succeeded, what it reached, or what was returned. Standard blind SSRF territory.
Why timing works here
When a server tries to connect to a host that exists and is reachable, the TCP handshake completes quickly, within milliseconds, and the application gets a response or an application-level error almost immediately. When it tries to connect to a host that does not exist or is filtered at the network layer, the connection attempt hangs until a timeout fires. Depending on the server's configuration, that timeout is usually somewhere between 30 and 120 seconds.
That gap is your signal. If an internal address returns in 0.4 seconds and an unreachable control address returns in 100+ seconds, that is not noise. That is proof of network-layer access, without a single external callback.
The technique works best when you can establish a reliable baseline first, which is exactly what I did.
Establishing the baseline
Before testing any interesting internal addresses I needed a control: an RFC-1918 address that almost certainly does not exist in the target's infrastructure. I used a private IP in a range unlikely to be allocated and measured how long the request took to complete.
curl 'https://[REDACTED]/api/v2/migration/checklist' \
-H 'Cookie: keycloak_token=[YOUR_TOKEN]' \
-H 'Content-Type: application/json' \
-d '{"connection_uri":"postgres://x:x@10.0.0.1:5432/db"}' \
-w '\nTime: %{time_total}s\n'
# HTTP 524 — timeout after ~100 secondscurl 'https://[REDACTED]/api/v2/migration/checklist' \
-H 'Cookie: keycloak_token=[YOUR_TOKEN]' \
-H 'Content-Type: application/json' \
-d '{"connection_uri":"postgres://x:x@10.0.0.1:5432/db"}' \
-w '\nTime: %{time_total}s\n'
# HTTP 524 — timeout after ~100 secondsOne hundred seconds to time out. That is my ceiling. Any internal address that responds meaningfully faster than that is reachable.
Test 1: The Kubernetes API server
kubernetes.default.svc.cluster.local is the internal DNS name that resolves to the Kubernetes API server from within any pod in the default namespace. It is a standard name that works across any Kubernetes deployment. If the application is running inside a cluster, which is likely for any modern cloud-native platform, this address will resolve and be reachable on port 443.
-d '{"connection_uri":"postgres://x:x@kubernetes.default.svc.cluster.local:443/db"}'
# HTTP 200 OK
# Time: 0.36s-d '{"connection_uri":"postgres://x:x@kubernetes.default.svc.cluster.local:443/db"}'
# HTTP 200 OK
# Time: 0.36sZero point three six seconds. Against a 100 second baseline that is a 278x timing differential. The Kubernetes API server is reachable from within the application's network context.
What that means in practice: a more sophisticated attacker could chain this SSRF with Kubernetes API access to enumerate cluster secrets, steal service account tokens from the pod's mounted credentials, or query the API for sensitive workload metadata. The SSRF is the entry point; the Kubernetes API is what makes it serious.
Test 2: AWS and GCP instance metadata services
169.254.169.254 is the link-local address of the AWS Instance Metadata Service. On any EC2 instance or EKS node it is reachable and returns IAM role credentials, instance identity documents, and other sensitive configuration. I also tested the GCP equivalent.
# AWS metadata service
-d '{"connection_uri":"postgres://x:x@169.254.169.254:80/db"}'
# Time: 0.53s — reachable
# GCP metadata service
-d '{"connection_uri":"postgres://x:x@metadata.google.internal:80/db"}'
# Time: 0.37s — reachable# AWS metadata service
-d '{"connection_uri":"postgres://x:x@169.254.169.254:80/db"}'
# Time: 0.53s — reachable
# GCP metadata service
-d '{"connection_uri":"postgres://x:x@metadata.google.internal:80/db"}'
# Time: 0.37s — reachableBoth metadata services responded in under a second. On a platform that describes itself as cloud-native and supports multiple cloud providers, getting fast responses from both AWS and GCP metadata endpoints suggests the application may span infrastructure across both clouds, or at minimum that the network configuration permits access to both.
Test 3: Localhost services
I also probed common localhost ports. Redis on 6379 and PostgreSQL on 5432 both responded in under half a second.
# Localhost Redis
-d '{"connection_uri":"postgres://x:x@127.0.0.1:6379/db"}'
# Time: 0.47s — reachable
# Localhost PostgreSQL
-d '{"connection_uri":"postgres://x:x@127.0.0.1:5432/db"}'
# Time: 0.41s — reachable# Localhost Redis
-d '{"connection_uri":"postgres://x:x@127.0.0.1:6379/db"}'
# Time: 0.47s — reachable
# Localhost PostgreSQL
-d '{"connection_uri":"postgres://x:x@127.0.0.1:5432/db"}'
# Time: 0.41s — reachableRedis and PostgreSQL running on the same host as the API server is not surprising for a database platform. But it means that SSRF to localhost is not just about reaching metadata services. It is about reaching the application's own data layer directly.
The full picture
Here is the timing summary across all tested targets:
kubernetes.default.svc.cluster.local:443 0.36s reachable
metadata.google.internal:80 0.37s reachable
127.0.0.1:5432 (PostgreSQL) 0.41s reachable
127.0.0.1:6379 (Redis) 0.47s reachable
169.254.169.254:80 (AWS IMDS) 0.53s reachable
10.0.0.1:5432 (unreachable baseline) 100s+ not reachablekubernetes.default.svc.cluster.local:443 0.36s reachable
metadata.google.internal:80 0.37s reachable
127.0.0.1:5432 (PostgreSQL) 0.41s reachable
127.0.0.1:6379 (Redis) 0.47s reachable
169.254.169.254:80 (AWS IMDS) 0.53s reachable
10.0.0.1:5432 (unreachable baseline) 100s+ not reachableFive internal targets, all sub-second. One baseline, 100 seconds. The PoC was confirmed through triage.
Why timing-based proof is underused
Most hunters reach for external callbacks first because they are easier to interpret. You see a DNS ping, you know the server made an outbound request. That is clean evidence.
But timing oracles give you something callbacks cannot always give you: proof of what is reachable inside the internal network, not just proof that outbound requests are made. The Kubernetes API server and localhost Redis are not going to make DNS requests to your collaborator instance. Timing is how you prove you can reach them.
The key is always establishing the baseline first. Without it, a 0.4 second response time means nothing. With it, it means everything. And when you have five separate internal targets all clustering in the same sub-second range against a 100 second unreachable baseline, the statistical argument is hard to dismiss. A 278x differential is not a coincidence.
What I took from this
Any endpoint that accepts a URI and makes a server-side connection is an SSRF candidate. It does not matter if the feature looks benign, checking a database connection is a completely reasonable product feature. The risk is not in the intent, it is in the behavior.
When response bodies give you nothing, think about what else the server is telling you. Timing is information. Error messages are information. Response sizes are information. Blind does not mean you are working without evidence. It just means the evidence is less obvious than a DNS ping in your collaborator dashboard.
And when you write the report, put the numbers in a table. "It responded faster for internal addresses" is easy to dismiss. "278x timing differential across five independent targets with a documented baseline" is not.
All testing was conducted against researcher-owned accounts within the program's defined scope. No production data was accessed or exfiltrated. Internal services were confirmed reachable via timing only and were not probed beyond establishing TCP reachability.