Home

DOM-Based XSS at accounts.google.com by Google Voice Extension.

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



I rushed to report it to avoid dupe, without even checking what's going on, as a Stored XSS in Gmail triggered by google ads rules as the picture shows, 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 extract google voice source code to find out what is in the question. in the file contentscript.js, there was a function called Wg() which 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 in the body's elements content, grab it, create another span element with the grabbed phone number as its content so the user can click and call that number right from the web page.
Let's break it down, from line 1 to line 9, it is looping through the body's elements' contents with document.evaluate, document.evaluate is a method makes it possible to search within the HTML and XML document, returns XPathResult object that represents the result and here it is meant to evaluate and grab all body's elements' contents, technically select all the texts nodes from the current node and assign it to the variable 'a', and this was the source, note here it 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);

then executes a search (variable 'b' which is a regex for America phone number format) for a match in the returned result that is stored in variable 'a'. then if the match found assign it to variable 'f' then put it as span element's content in variable 'h'.
Line 10 and 11 was checking the tag name that HTML element from which the variable 'f' got its content, is neither one of these tags SCRIPT, STYLE, HEAD, OBJECT, TEXTAREA, INPUT, SELECT, and A, nor it has the class attribute with the name of "gc-cs-link", this checking was mainly for two things:
1) prevent the extension from messing with DOM because it doesn't want to play with the content on an element such as SCRIPT, STYLE, and HEAD and doesn't achieve what it wants to do on elements like INPUT, SELECT, etc...
2) it stops the script from looping infinitely, because it doesn't want to create span element with phone number again if it already exists.


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 in case the variable 'a' has no child elements, othewise it will excute the next statment where it sinks again in the following line:

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


The fix:

I believe the developer was going to execute variable 'f' that was holding the value of phone number for example '+12223334455' on the sinks (innerHTML, insertBefore), instead for reason I couldn't understand he executes variable 'a' which was holding the payload ex: '444-555-4455 <img src=x onerror=alert(1)>' on the sinks, this XSS could be spared if he did not do so.


Reward:

$3,133.7