Home

DOM-based XSS at accounts.google.com

This universal DOM-based XSS was discovered accidentally; it is fortunate that the Google Ads customer ID is the same format as an American phone number. I opened Gmail to check my inbox, and the following popped up:



I rushed to report it to avoid a duplicate, without even checking what was going on, as I thought the picture showed a Stored XSS in Gmail triggered by Google Ads rules; but the reality was something else.


Why did it work?

Because two things: google voice extension was installed and this text '444-555-4455 <img src=x onerror=alert(1)>' was in the inbox page.
after a couple of minutes, I realized that this XSS was triggered by Google Voice Extension, which could execute javascript anywhere and thus on accounts.google.com and facebook.com.




I extracted the Google Voice source code to find out what the issue was. In the file contentscript.js, there was a function called Wg() that was responsible for the DOM XSS.


function Wg(a) {
    for (var b = /(^|\s)((\+1\d{10})|((\+1[ \.])?\(?\d{3}\)?[ \-\.\/]{1,3}\d{3}[ \-\.]{1,2}\d{4}))(\s|$)/m, c = document.evaluate('.//text()[normalize-space(.) != ""]', a, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null), d = 0; d < c.snapshotLength; d++) {
        a = c.snapshotItem(d);
        var f = b.exec(a.textContent);
        if (f && f.length) {
            f = f[2];
            var g = "gc-number-" + Ug,
                h = '<span id="' + g + '" class="gc-cs-link"title="Call with Google Voice">' + f + "</span>",
                k;
            if (k = a.parentNode && !(a.parentNode.nodeName in Og)) k = a.parentNode.className,
                k = "string" === typeof k && k.match(/\S+/g) || [], k = !Fa(k, "gc-cs-link");
            if (k) try {
                if (!document.evaluate('ancestor-or-self::*[@googlevoice = "nolinks"]', a, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null)
                    .snapshotLength) {
                    if (0 == a.parentNode.childElementCount) {
                        var w = a.parentNode.innerHTML,
                            y = w.replace(f, h);
                        a.parentNode.innerHTML = y
                    } else {
                        w = a.data;
                        y = w.replace(f, h);
                        var u = Qc("SPAN");
                        u.innerHTML = y;
                        h = u;
                        k = a;
                        v(null != h && null != k, "goog.dom.insertSiblingAfter expects non-null arguments");
                        k.parentNode && k.parentNode.insertBefore(h,
                            k.nextSibling);
                        Vc(a)
                    }
                    var t = Ic(document, g);
                    t && (Ug++, nc(t, "click", ma(Sg, t, f)))
                }
            } catch (E) {}
        }
    }
}

The function wasn't difficult to read. The developer was looking for a phone number within the content of the body's elements, grabbing it, and then creating a new element with the grabbed phone number as its content. This feature allows the user to click and call that number directly from the webpage.
Let's break it down: from line 1 to line 9, the code uses document.evaluate to loop through the body's elements' contents. document.evaluate is a method that allows searching within an HTML or XML document, returning an XPathResult object. Here, it was meant to evaluate and grab all the body's elements' contents—specifically, to select all text nodes from the current node and assign them to the variable a. This was the source of the issue. Note here that this was a DOM XPath-injection

(var b = /(^|\s)((\+1\d{10})|((\+1[ \.])?\(?\d{3}\)?[ \-\.\/]{1,3}\d{3}[ \-\.]{1,2}\d{4}))(\s|$)/m, c = document.evaluate('.//text()[normalize-space(.) != ""]', a, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null), d = 0; d < c.snapshotLength; d++) {
        a = c.snapshotItem(d);

The code then executes a search (using variable b, which is a regex for the American phone number format) for a match within the returned result stored in variable a. If a match is found, it is assigned to variable f and then placed as the content of a <span> element in variable h. Lines 10 and 11 check the tag name of the HTML element from which variable f got its content. It ensures the tag is neither SCRIPT, STYLE, HEAD, OBJECT, TEXTAREA, INPUT, SELECT, nor A, and also ensures it doesn't have a class attribute named "gc-cs-link". This checking serves two main purposes: Preventing DOM Interference: It stops the extension from manipulating the DOM where it's not intended, such as elements like SCRIPT, STYLE, and HEAD, or elements where adding a clickable number is not relevant, like INPUT or SELECT. Preventing Infinite Looping: It prevents the script from creating the <span> element with the phone number again if it already exists, effectively stopping infinite loop scenarios. From line 12 to line 27, there is an if condition: if variable k is true (meaning no element with the class attribute "gc-cs-link" has been found), it executes a try statement. Inside the try statement, another if condition checks—again using document.evaluate—if there is no element with the attribute googlevoice and the value nolinks. A final nested if condition then checks if variable a has no child elements. This is where the sink occurs:"


From line 12 to line 27, there is an if condition, if variable k is true, means no element with a class attribute name of "gc-cs-link" has been found, it will execute a try statement, another an if condition inside the try statement check, if there is nowhere an element with a "googlevoice" attribute and "nolinks" as its value can be found, again using the document.evaluate, then nested if condition check if the variable 'a' has no child elements, and here is where the sink happens:

w = a.parentNode.innerHTML,
y = w.replace(f, h);
a.parentNode.innerHTML = y


This occurs if variable a has no child elements; otherwise, it executes the next statement, where the sink occurs again in the following line:

k.parentNode && k.parentNode.insertBefore(h, k.nextSibling);


The fix:

I believe the developer intended to execute variable f, which was holding the phone number value (for example, +12223334455), on the sinks (innerHTML, insertBefore). Instead, for reasons I couldn't understand, they executed variable a, which was holding the payload (e.g., '444-555-4455 <img src=x onerror=alert(1)>' ) on the sinks. This XSS could have been avoided if they had not done so.


Reward:

$3,133.7