Let me start by saying I am no security expert. It is, however, a gigantic surprise when a user with any repository is able to take over and use my subdomain that was once configured for a staging site.
What?
The Python Blog was recently moved from Blogger to a custom Astro-based blog hosted as a static site on GitHub Pages. In the process of the migration, I set up a Route53 record to point blog-stage.python.org to python.github.io so that a smaller group could verify the changes and that we were all happy with the style choices.
Once the new blog was made public, the existing Route53 record was changed for blog.python.org to point to GitHub Pages instead of Blogger's DNS.
This means that, in Route53, we have blog-stage.python.org still pointing to python.github.io and on the repo we have changed the custom domain to blog.python.org

All seems well right? Well… No :)
Why
For some reason what happens is that there is no inherent validation from GitHub pages against the organization it is pointing to. Although we say "Hey I have this subdomain, it should work with the Python organization on GitHub only since well.. I am saying ONLY python.github.io.
But I guess in reality it allows someone to takeover your domain by just yeeting some CNAME records into a repository not under the Python organization.
The Actual Why
You'd think a CNAME pointing to python.github.io would only serve content from the python organization. It doesn't. Here's what actually happens under the hood:
As I understand it, GitHub Pages uses a claim-based routing system, not a DNS-ownership-based one. When a request hits GitHub's servers for a custom domain, GitHub doesn't check which *.github.io subdomain the CNAME points to. It checks which repository has claimed that custom domain in its Pages settings (the CNAME file or the repository's custom domain config).
Any GitHub user can create a repository, drop a CNAME file containing blog-stage.python.org, enable Pages, and GitHub will very happily route traffic to it. This is no bueno.
The CNAME target (python.github.io) is only used for DNS resolution to get the request to GitHub's IP addresses. Once the request arrives at GitHub, the Host header (blog-stage.python.org) is what determines which repository serves the content.
GitHub looks up which repo has claimed that hostname, and if the original repo no longer claims it (because we switched the custom domain to blog.python.org), the hostname is up for grabs.
This is called a dangling DNS record; the DNS entry still exists and resolves, but the resource it was supposed to point to no longer claims it. It's a well-documented class of subdomain takeover that affects GitHub Pages and many other services.
In our case, the attack was straightforward:
@tnirmalz created a repo with a CNAME for blog-stage.python.org and immediately owned a python.org subdomain.
The Fix
GitHub provides a solution: just add a TXT record. You can read more about that here.
In the end it makes sense but seems a little silly.
Credits
Thanks to
@tnirmalz for the report! Here is a very nice showcase of how easy it is: blog-stage.python.org
Further Reading
This is a well-known class of vulnerability. Others have documented similar takeovers:
- GitHub Pages Domain Takeover: walkthrough of the same CNAME-based takeover pattern
- How GitHub Page Takeover Works: another breakdown with screenshots
- can-i-take-over-xyz: a community-maintained list of services vulnerable to subdomain takeover via dangling DNS