Bushwhackers关于rwctf的题目-hardened redis的Writeup

WriteUp 1年前 (2023) admin
578 0 0

This post will go through an exploit that achieves code execution in the Redis server via a memory corruption issue. It works for Redis 6.0.16, the Ubuntu 22.04 repos’ current version at the time of writing.

This post is also a write-up for the “hardened redis” CTF challenge from the 5th Real World CTF, in which I played as part of the Bushwhackers team. Usually, reading CTF write-ups is not interesting for non-participants; RWCTF is a different story: there is the “Clond-and-Pwn” category, which is basically “take an open-source program and hack it”.

In fact, I’m not sure if I can call this an exploit since the Redis security model states that a Redis server should never be accessible by someone untrusted. However, issues with similar impact are known to receive a CVE sometimes (e.g., recent CVE-2022–0543). Considering this and the fact that my exploit is based on a bug I found myself almost six years ago, I figured this post might be worth writing.

What we’re hacking

Redis is a popular key-value storage. It supports different types of data (lists, dicts, sets & sorted sets, etc.), can persist them on disk, instances can form a cluster, and much more.

One can connect to Redis via TCP or the redis-cli utility and send commands using Redis Serialization Protocol protocol or its memcache-like text version. Redis supports authentication and even different permissions for different users, but it is not enabled by default. However, the default Ubuntu installation has “protected mode” enabled, which effectively restricts allowed incoming connections to the loopback interface only.

As I mentioned, the security model of the Redis server states that it should be accessed by trusted clients inside trusted environments. For example, there was a post from a Redis creator where he described a trivial way to overwrite ~/.ssh/authorized_keys and then log in to the server just by tampering with the DB disk dump storing path.

However, it is implied that one should not be able to execute code at a Redis server in case one of the several security mechanisms is enabled. The easiest method of hardening Redis is to disable commands intended for administration. For example, disabling the CONFIG command cuts all the trivial ways to achieve arbitrary file write.

We’ll be hacking Redis server with this configuration: the default ubuntu config without the “protected mode” but with several commands disabled.

Environment setup

Setting up the environment for testing the target is easy via a docker file:

FROM ubuntu:22.04

RUN apt-get update && apt-get install redis-server -y
RUN sed -ri 's/(protected-mode|daemonize) yes/\1 no/' /etc/redis/redis.conf
RUN sed -ri 's/^(bind|logfile)/#&/;' /etc/redis/redis.conf

   echo "rename-command ${cmd} ''" >> /etc/redis/redis.conf; \

USER redis
ENTRYPOINT [ "redis-server", "/etc/redis/redis.conf"]

In the original CTF challenge, the config was slightly different but effectively the same.

After building the image and running the container, we get the Redis server available at the docker’s IP (172.17.0.X) at port 6379. Our goal is to achieve code execution by communicating with the program via this port.

The RDB format and its issues

The usual source for easily-found memory corruption bugs is parsing complicated binary formats, and Redis has such a format — RDB.

Originally, RDB was developed as the database dump file format that provides data persistence between Redis server restarts. Thus, no security checks were needed — if a hacker can access the filesystem to tamper with the database dump, they probably are not interested in Redis buffer overflows. However, as Redis evolved, it also started being used as the format for exchanging data between cluster nodes — that is, the format used for the payload of DUMP and RESTORE commands.

The ideas behind the RDB format are straightforward, but it gets pretty complicated because of the enormous variety of data structures Redis supports. Moreover, implementation got more “hacky” because of the high-performance requirements.

The first time I looked through RDB format was about six years ago — that time, I had just learned how to use AFL, and a complex binary format parsing that was easy to transform into a fuzzer harness looked like a decent target. I disabled CRC checksumming and unneeded initialization in the main() of the Redis server, leaving only the bare minimum for dump.rdbparsing.

After I started AFL, a cornucopia of crashes appeared quickly. A short investigation showed that parsing of almost every data type Redis supports was vulnerable to memory corruption. Looked like there were no checks at all! I reported the issue to the developers, developed a PoC for amusement, and left it as is.

The issue on GitHub survived for 5 years, marked as a “Critical bug” in 2016, getting the “Security” label and being placed into the “Urgent” milestone in 2017, and finally being fixed in late 2020. The strategy was to implement additional checks that are run only for RESTORE before passing the payload to actual RDB parsing. This approach (double parsing with two different implementations) has obvious drawbacks, but it is better than nothing.

However, the issue had no CVE assigned and thus was not backported to Linux distributions. Therefore, when I opened the source code for the Redis version used in the Real World CTF challenge — the one I obtained with apt source redis-server on Ubuntu 22.04 — I was surprised to find that this version still responded by a segmentation fault to the most trivial malicious RDB payloads.

The vulnerability

The particular missing check I’ve chosen is inside intset deserialization. An intset, as one can guess, is just a (sorted) set of integers. It supports adding and removing elements and accessing its values in several ways. From the user’s perspective, intset is a transparent optimization for regular sets — Redis detects if a reasonably small one consists of decimal integer representations and then stores such set as an intset internally. However, the RDB format preserves information about a set being an intset — it would require too costly conversions on load and save otherwise.

The intset’s memory layout is straightforward: first goes the encoding (which is essentially just the size of an element — 2, 4, or 8), then the number of the elements, and then the elements themselves. The deserialization procedureis also simple: it stores the intset representation into a string and then switches the object type. As one can see, no check on the stored set cardinality was performed in 6.0.16; thus, any following accesses to the object might fall out of bounds.

We will employ the following strategy:

  1. First, we use RESTORE to create an intset with the stored cardinality bigger than the actual payload length.
  2. Then, we use SREM to remove the first element from the set.

As SREM works by value, not by index, we need to place something like -2³² in the payload to be sure that the binary search will find the first element no matter what (remember, an intset is supposed to be sorted).

After the first element removal, Redis will move the rest of the set to the left. Because it doesn’t know the memory region allocated is smaller than the set itself, it will end up moving a slice of heap memory just behind the set.

Obtaining heap access

A common step in interpreter exploitation is to use the bug to get a string or other array-like object with size overwritten by some large value — that way, we achieve the ability to access heap memory behind such a string.

As keys and values in Redis can contain \0, it cannot use null-terminated char* for them; Therefore, Redis stores all strings in a structure called SDS. It has the metadata (including the length) placed in front of the actual contents, with no indirect pointers.

Let s be a pointer to data of an SDS. The memory layout of an SDS object is like this:

  1. s[-1] contains flags, and most importantly — type of the SDS. The type basically encodes the size required for storing the SDS length, which might be 1, 2, 4, or 8 bytes.
  2. Starting from s[-2] to the left, there are two fields: the number of allocated and used for the data. These fields’ actual size (and, thus, layout) depends on the SDS type.

This is, for example, how sdshdr16 looks like, which can be used to store strings up to 65535 bytes. There’s a special type for very short strings that stores SDS length in the same s[-1] byte, but it is not of our interest.

Using the technique described in the previous paragraph, one can do the following:

1. Spray the heap with SDSs containing specially crafted data, so one will be placed after the crafted intset.
2. Use SREM to move the heap to the left. The SDS content would overwrite its header, making the string large enough to access the entire heap.

If we use the normal string values for the spraying, we can later read and modify the heap using the GETRANGE / SETRANGE commands.

Arbitrary memory access

The next thing we want is to gain the ability to access memory at arbitrary addresses, as well as to defeat ASLR. The latter is simple — as we can read the heap, we can just scan it for addresses that look like ones from the binary/the heap.

Actually, the SDS structure described earlier was only half of the story of how Redis stores strings. If a string is a value assigned to some key in the database, there’s another entity: the redis object struct (or robj). Such a struct is stored for every value in the database and contains the type of the value and a pointer to its data (which is an SDS if the value is a string).

In some cases, the SDS and the robj share the same allocated memory region. This is called an embedded string. In this case, the value is stored right next to the robj struct, and the pointer to the SDS inside the robj points to the fixed offset to the right (that is, to something like obj + sizeof(robj)).

I believe there are plenty of strategies for achieving arbitrary read/write. I used the following:

  1. Spray the heap with many string objects with the SDS embedded.
  2. Scan the heap using the heap view to find one of such objects and then modify it.

The only option for creating strings with the SDS embedded available to the end-user is via the INCRBYFLOAT command. This command adds a value to a float in decimal representation and then stores the result as a decimal string as well (but the SDS is embedded there). Thus, I just created a lot of floats and incremented them by some constant for step 1.

Another thing to note is that if we want to read some pointer p, we cannot set the pointer to the SDS in the robj to p right away. That is because p[-1]must contain parsable flags for the SDS to be valid. Thus, we need to find some offset such that several bytes in front of p — offset forms a correct SDS header and its size is enough to access the memory at p. I achieve that by simply brute-forcing the offset.

Command execution

The only thing left is to get the actual RCE.

I decided not to mess with binary stuff for this and just use server restart functionality embedded into Redis. One can trigger this by running the debug crash-and-recover command.

In fact, the debug is a dangerous command by itself and should’ve been disabled at the challenge. However, even if it was, we could mess with the command table to resurrect it as we have arbitrary memory access.

The server is restarted by doing execve of the server start command. The latter is stored inside the redisServer struct, which contains both the binary path and the arguments. We can change these pointers to the representation of something like ["/bin/sh", "-c", "our-command"] to achieve command execution.

Thus, we can use the following strategy:

  1. Scan memory and find the redisServer struct. In 6.0.16, the struct is static; thus, we may scan the binary to locate it using the memory access we gained above.
  2. Overwrite the command by our payload.
  3. Use debug crash-and-recover to make the server restart itself. Our command gets executed, so we finally get a shell.


I’m always keen to play a good CTF, but playing Real World CTF is especially amusing. It allows me not only to prove myself as unable to solve specially-crafted challenges but also to confirm that my CTF skills are useless in hacking real-world programs. This task was a rare exception.

The full exploit PoC is available here (sorry for the messy code). It is not very stable and flushes the whole DB during exploitation; however, it could be made more reliable if needed.


版权声明:admin 发表于 2023年1月16日 下午4:32。
转载请注明:Bushwhackers关于rwctf的题目-hardened redis的Writeup | CTF导航