Redis: from TLS reuse to RCE
How a TLS ticket crosses the Redis cluster boundary, exposes an internal secret, and opens a separate UAF chain.
Introduction
A few weeks ago I decided to take a look at Redis source code. The first thing that I immediately noticed is that Redis accepts TLS connections on both its normal data port and its cluster bus, see the docs here. The data port serves clients while the bus serves other Redis nodes. I tested those listeners and shared the same server side OpenSSL context.
A TLS session issued on the data port could resume on the cluster bus even though the client’s certificate did not satisfy the bus’s peer name requirement. The data connection did not need to authenticate to Redis first.
This is not a vulnerability by default, it can’t be considered session theft nor a break in TLS. The user can reuse a session legitimately issued to their own data port connection in a second listener with a stricter identity policy.
Build
I tested on the latest branch of the GitHub repository.
The build that I used include this:
tls-cluster yestls-expected-peer-name
TLS bypass
The first clue is that Redis keeps server side context global in src/tls.c:
1
2
SSL_CTX *redis_tls_ctx = NULL;
SSL_CTX *redis_tls_client_ctx = NULL;
With session caching enabled, the context gets a cache and the same session ID context on both listeners:
1
2
3
4
5
6
if (ctx_config->session_caching) {
SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_SERVER);
SSL_CTX_sess_set_cache_size(ctx, ctx_config->session_cache_size);
SSL_CTX_set_timeout(ctx, ctx_config->session_cache_timeout);
SSL_CTX_set_session_id_context(ctx, (void *) "redis", 5);
}
The certificate passed the data-port handshake. Resumption carried that session state to a listener whose peer-name policy it did not satisfy.
The five bytes are redis. The bus does not get its own resumption namespace.
OpenSSL uses the session ID context to decide whether a session may be reused across server contexts. It also restores the certificate verification result from a resumed session. Here the effective policy depends on the listener, but the resumption state does not:
1
2
3
4
5
6
if (ctx_config->session_caching) {
SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_SERVER);
SSL_CTX_sess_set_cache_size(ctx, ctx_config->session_cache_size);
SSL_CTX_set_timeout(ctx, ctx_config->session_cache_timeout);
SSL_CTX_set_session_id_context(ctx, (void *) "redis", 5);
}
Redis uses the same five bytes for all accepted server connections.
The connection constructor then creates each inbound SSL object from that global context:
1
2
3
4
5
6
static connection *createTLSConnection(struct aeEventLoop *el, int client_side) {
SSL_CTX *ctx = redis_tls_ctx;
if (client_side && redis_tls_client_ctx)
ctx = redis_tls_client_ctx;
tls_connection *conn = zcalloc(sizeof(tls_connection));
conn->ssl = SSL_new(ctx);
There is no data listener context and cluster listener context here. Both server side listeners draw from the same session state and ticket keys.
For this bug, I found it useful to think of the resumption domain as a tuple rather than a single cache, something like:
R = (sID context, ticket key domain, server cache, authentication state)
Both Redis listeners used the same first three components. The fourth component was created by whichever listener handled the original full handshake. That is the mismatch: the storage namespace was shared, while the meaning of a successful peer authentication was not.
This remains relevant whether OpenSSL uses a stateful or stateless ticket. A stateful ticket points back into server side session storage; a stateless ticket carries encrypted session state that the same context can decrypt. Sharing the context gives both listeners access to the machinery needed to accept either form.
The cluster-specific name check appears later in cluster_legacy
1
2
3
4
5
6
if (server.tls_ctx_config.expected_peer_name != NULL &&
connSetVerifyName(conn, server.tls_ctx_config.expected_peer_name) == C_ERR)
{
connClose(conn);
continue;
}
The ordinary TLS accept path does not apply that expected cluster name. Redis therefore creates two different effective policies on top of one OpenSSL resumption namespace.
The ordering is subtle. connSetVerifyName() configures the individual cluster connection, but the offered session is considered during the TLS handshake. The listener specific policy has to be part of the state OpenSSL uses before it accepts that session. Adding a stricter check to an SSL object is not enough if the resumed session already represents a successful authentication from another service.
OpenSSL stores certificate verification state in the established session. The SSL_get_verify_result() docs states that the result is restored when a session is reused. In my test, a ticket created after the data port handshake restored the data port verification result on the bus. The bus did not perform a fresh certificate exchange, so the new connection never evaluated ordinary client against clusterpeer.
TLS 1.3 makes one timing detail worth calling out. Session tickets are sent after the main handshake, at a time chosen by the server. Take a look at the behavior in SSL_get_session() here. Redis application authentication is a later protocol step.
No Redis password was needed to seed the ticket.
Internal secret disclosure
The cluster bus speaks a binary protocol defined in cluster_legacy.c.
PING and PONG messages can carry extensions, and one of those extensions contains the cluster internal secret.
The response is built in writePingExt():
1
2
3
4
5
6
clusterMsgPingExtInternalSecret *ext =
preparePingExt(cursor, CLUSTERMSG_EXT_TYPE_INTERNALSECRET,
getInternalSecretPingExtSize());
memcpy(ext->internal_secret,
server.cluster->internal_secret,
CLUSTER_INTERNALSECRETLEN);
CLUSTER_INTERNALSECRETLEN is 40 bytes.
The exchange needed two messages. I first sent a valid cluster PING with a synthetic sender ID. The PONG gave me the node ID expected by the receiver. I then sent another PING using that node ID and advertising extension support. The next PONG contained extension type CLUSTERMSG_EXT_TYPE_INTERNALSECRET and the 40 byte value.
The fixed cluster header and gossip entries come before the length-tagged extensions. The second PING advertises extension support; writePingExt() then adds the secret to the PONG. There is no out-of-bounds read here. The cluster parser assumes TLS has already identified a peer, and the resumed connection defeats that assumption.
Internal auth
The internal secret enters the normal Redis protocol through code in acl.c. Redis recognizes the username internal connection and compares the supplied password with the cluster secret:
1
2
3
4
5
6
7
8
9
10
if (!time_independent_strcmp((char *)internal_secret,
(char *)password, len)) {
c->flags |= CLIENT_INTERNAL;
c->authenticated = 1;
if (c->user != NULL) {
clientSetUser(c, NULL, 1);
moduleNotifyUserChanged(c);
}
addReply(c, shared.ok);
}
The two lines to watch are CLIENT_INTERNAL and the switch to the null internal user:
clientSetUser(c, NULL, 1)
This is the identity Redis uses for internal connections. ACL key patterns and command categories belong to a user object; setting the user to NULL takes a different path from giving an ordinary user +@all.
Changing an application password or ACL rule does not invalidate the cluster secret.
After recovering the secret from the bus, I opened a new TLS connection to the data port. I did not need to resume the earlier session. I authenticated with:
AUTH “internal connection” <40-byte secret>
From that connection I could read a key outside the restricted account’s key pattern, overwrite it, inspect and change configuration where the command path allowed it, and create a new ACL user with all commands, all keys and all channels.
Memory corruption is not required for that result.
UAF
The RCE chain begins with a second bug in clusterProcessPingExtensions. Redis saves a pointer to the sending node before it processes the extensions:
1
2
clusterNode *sender = link->node ? link->node :
clusterLookupNode(hdr->sender, CLUSTER_NAMELEN);
A FORGOTTEN_NODE extension may then find and delete a node:
1
2
3
4
5
6
clusterNode *n = clusterLookupNode(forgotten_node_ext->name,
CLUSTER_NAMELEN);
if (n && n != myself &&
!(nodeIsSlave(myself) && myself->slaveof == n)) {
clusterDelNode(n);
}
If n is the node referenced by sender, clusterDelNode tears down that node’s current inbound clusterLink. The link owns the receive buffer holding hdr and ext, so the message being parsed is freed from underneath the parser. The loop still advances the extension cursor:
1
ext = getNextPingExt(ext);
getNextPingExt() reads the extension length from that freed receive buffer. ASan reported the first invalid read there. There is a second stale pointer: after the extension loop, the function still uses sender:
1
2
3
4
5
updateAnnouncedHostname(sender, ext_hostname);
updateAnnouncedHumanNodename(sender, ext_humannodename);
if (ext_shardid == NULL)
ext_shardid = clusterNodeGetMaster(sender)->shard_id;
updateShardId(sender, ext_shardid);
The two lifetime violations are different. The first is an observed read through ext; the later sender accesses depend on how far execution gets after the deletion.
To demonstrate this concept better, this is what it should look like with ASan:
OS RCE
I kept the resumed bus connection for the FORGOTTEN_NODE message and a separate data connection authenticated as CLIENT_INTERNAL. I used ordinary Redis commands on the latter to prepare the heap around the bus-side trigger and to check the resulting allocations.
I found an overlap on this build and checked that reads and writes hit the intended allocation before trying to locate server state. The reclaim layout and offsets are build specific.
restartServer() already reaches execve() using the mutable server.executable and server.exec_argv fields. I changed those fields and reached the debug restart path on the tested build. That used existing control flow; I did not need a ROP chain.
Exploitation
1
2
3
4
5
6
data: TLS handshake -> ticket T, before AUTH
bus: resume(T) -> two PINGs -> internal secret
data: AUTH "internal connection" -> heap setup
bus: PING with FORGOTTEN_NODE(sender) -> sender/link lifetime bug
data: overlap checks -> verified read/write -> Redis text pointer -> redisServer
debug: CRASH-AND-RECOVER -> restartServer() -> execve()
First, let’s see the build:
As you can see, I have no special privileges:
Now, we can run the PoC:
Closing
I kept coming back to the first failed handshake. It proved the bus could reject a client without the right cluster identity. Yet a ticket minted on the data port let that same client in without a new certificate check. From there, Redis treated my packets as traffic from a node: it returned the internal secret and processed the extension that reached the UAF.
The heap work was specific to this build. The TLS mistake is easier to miss and easier to repeat: two listeners shared an SSL_CTX even though they disagreed about which peers to trust. If I were auditing another service with multiple TLS ports, I would try moving a ticket between them before looking at either protocol parser. In this case, the first boundary failed before Redis parsed a single cluster packet.









