Skip to content

Memory leak in S3EncryptionClient.getObject() #518

Description

@KaurKadakWise

Security issue notifications

If you discover a potential security issue in the Amazon S3 Encryption Client we ask that you notify AWS Security via our vulnerability reporting page. Please do not create a public GitHub issue.

noted

Problem:

S3EncryptionClient.getObject() leaks memory on every call because the ResponseInputStream returned by the internal async pipeline is never closed.

Code in question: src/main/java/software/amazon/encryption/s3/S3EncryptionClient.java

@Override
    public <T> T getObject(GetObjectRequest getObjectRequest,
                           ResponseTransformer<GetObjectResponse, T> responseTransformer)
            throws AwsServiceException, SdkClientException {

        // GetEncryptedObjectPipeline builder

        try {
            ResponseInputStream<GetObjectResponse> joinFutureGet = pipeline.getObject(getObjectRequest, AsyncResponseTransformer.toBlockingInputStream()).join();
            return responseTransformer.transform(joinFutureGet.response(), AbortableInputStream.create(joinFutureGet));
        } catch (CompletionException e) {
            throw new S3EncryptionClientException(e.getCause().getMessage(), e.getCause());
        } catch (Exception e) {
            throw new S3EncryptionClientException("Unable to transform response.", e);
        }
    }

The joinFutureGet ResponseInputStream is never closed so in an internal service where we use this method to read client side encrypted files in batches we get these errors:

io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 2048 byte(s) of direct memory (used: 7,025,458,950, max: 7,025,459,200)
      at io.netty.buffer.UnpooledByteBufAllocator.newDirectBuffer(UnpooledByteBufAllocator.java:97)
      at io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:168)
      at io.netty.handler.ssl.SslHandler.allocate(SslHandler.java:2381)
      at io.netty.handler.ssl.SslHandler.unwrap(SslHandler.java:1488)

Reproduction test:

import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.net.URI;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;                                                                                                                            
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.encryption.s3.S3EncryptionClient;

// Requires LocalStack: docker run -d -p 4566:4566 -e SERVICES=s3 localstack/localstack:3.0.2

var creds = StaticCredentialsProvider.create(AwsBasicCredentials.create("test", "test"));
var endpoint = URI.create("http://localhost:4566");

var encryptionClient = S3EncryptionClient.builder()
    .credentialsProvider(creds)
    .region(Region.EU_CENTRAL_1)
    .endpointOverride(endpoint)
    .forcePathStyle(true)
    .kmsKeyId("alias/test-key")
    .enableLegacyWrappingAlgorithms(true)
    .enableLegacyUnauthenticatedModes(true)
    .build();

// Create bucket and upload a test file
encryptionClient.createBucket(CreateBucketRequest.builder().bucket("test").build());                                                                            
encryptionClient.putObject(                                                                                                                                                                    
    PutObjectRequest.builder().bucket("test").key("file.txt").build(),
    RequestBody.fromString("hello"));

long before = getDirectMemoryUsed();

for (int i = 0; i < 5000; i++) {
    try {
        encryptionClient.getObjectAsBytes(
            GetObjectRequest.builder().bucket("test").key("file.txt").build());
    } catch (Exception e) {
        // Decryption fails (no real KMS), but the leak happens regardless
    }
}

long after = getDirectMemoryUsed();
System.out.printf("Leaked: %,d bytes in 5000 calls (~%d bytes/call)%n", after - before, (after - before) / 5000);

static long getDirectMemoryUsed() {
    return ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class).stream()
        .filter(pool -> pool.getName().equals("direct"))
        .mapToLong(BufferPoolMXBean::getMemoryUsed)
        .sum();
}

Results:

5000 getObjectAsBytes() calls:
Direct memory before: 45,056 bytes
Direct memory after: 4,993,032 bytes
Leaked: 4,947,976 bytes

Also got a warning:

LEAK: ByteBuf.release() was not called before it's garbage-collected.
Created at:
    io.netty.buffer.AdaptiveByteBufAllocator.newDirectBuffer(AdaptiveByteBufAllocator.java:67)
    io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:168)
    ...

Solution:

Open the stream with try catch with resources block eg:

  try (ResponseInputStream<GetObjectResponse> joinFutureGet =
          pipeline.getObject(getObjectRequest, AsyncResponseTransformer.toBlockingInputStream()).join()) {
      return responseTransformer.transform(joinFutureGet.response(), AbortableInputStream.create(joinFutureGet));
  } catch (CompletionException e) {
      throw new S3EncryptionClientException(e.getCause().getMessage(), e.getCause());
  } catch (Exception e) {
      throw new S3EncryptionClientException("Unable to transform response.", e);
  }

Out of scope:

N/A

☑️ (NOTE: If you believe this might be a security issue, please email aws-security@amazon.com instead of creating a GitHub issue. For more details, see the AWS Vulnerability Reporting Guide: https://aws.amazon.com/security/vulnerability-reporting/ )

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions