811 words
4 minutes
K17 CTF - Web - janus

Hello dear reader! Today, we are going to look at a web challenge involving a DNS rebinding attack.

Challenge Description:#

Someone hacked my space image viewer, but it's 100% secure now! Note: Attacking nasa's API is out of scope for this challenge

The Rundown#

Ok, so before we actually look at the code, let me tell you about the website. So this site let’s us search for images from NASA using their API.

It’s hiding a secret though… there is an internal server, serving the flag.

But, how do we get to it? Well from the code you will be able to read below, it checks if the url we send it to resolves to one of NASA’s IP addresses and then it shows us the page content if it doesn’t error out.

Reading the Code#

Here is the code we are interacting with:

main.py
from flask import Flask, request, Response, abort, render_template
import time
import socket
import requests
from urllib.parse import urlparse
app = Flask(__name__)
# Resolve NASA's IP at startup
NASA_HOST = "images-api.nasa.gov"
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api")
def api():
NASA_IPS = set(socket.gethostbyname_ex(NASA_HOST)[2]).union({'3.175.115.68', '3.175.115.60', '3.175.115.113', '3.175.115.52'})
target_url = request.args.get("url")
if not target_url:
abort(400, "Missing url parameter")
# Parse the target URL
parsed = urlparse(target_url)
if not parsed.scheme:
target_url = "https://" + target_url # assume https if missing
parsed = urlparse(target_url)
hostname = parsed.hostname
if not hostname:
abort(400, "Invalid URL")
# Prevent users brute forcing our api
time.sleep(1)
try:
resolved_ip = socket.gethostbyname(hostname)
except socket.gaierror as e:
abort(400, "Unable to resolve hostname")
# Verify that the url provided resolve's to NASA's IP address
if resolved_ip not in NASA_IPS:
abort(403, "URL does not resolve to NASA")
# Fetch and stream the content
try:
r = requests.get(target_url, stream=True, timeout=5)
if r.status_code == 429:
abort(429, f"Rate limited by NASA. (This challenge is still solvable)")
r.raise_for_status()
except requests.RequestException as e:
print("failed to fetch", target_url, e)
abort(502, "Failed to fetch data")
return Response(
r.iter_content(chunk_size=8192),
content_type=r.headers.get("Content-Type", "application/octet-stream"),
)

Here is the stuff they don’t want us to get to:

internal_app.py
from flask import Flask, request, Response, abort
app = Flask(__name__)
@app.route("/")
def root():
return "the flag will go here"

Here is the Dockerfile if you were wondering how I know what the ports are:

FROM python:3.13.7-slim-trixie
RUN pip install --no-cache-dir waitress flask requests
WORKDIR /app
COPY . .
USER nobody
CMD waitress-serve --listen "127.0.0.1:5001" internal_app:app & \
waitress-serve --listen "0.0.0.0:1337" --trusted-proxy '*' --trusted-proxy-headers 'x-forwarded-for x-forwarded-proto x-forwarded-port' --log-untrusted-proxy-headers --clear-untrusted-proxy-headers --threads 4 main:app

Those forwarded for headers don’t affect the challenge by the way, it’s because the remote instance sits behind a proxy. You can ignore them.

Alrighty, so from the code above, we can control the url parameter and if we can send it to 127.0.0.1:5001, we will get the flag back as a response. How though?

DNS Rebinding#

So, DNS rebinding is going to be the key to solving the challenge here. What this helps us do is change what our url resolves to and if we time it right, we can exploit a Time Of Check Time Of Use (TOCTOU) vulnerability, (I pronounce it as /tɔk ˈtu.ə/).

What do I mean by time it right though? Well for this attack, if we aren’t lazy, we can set up a DNS server for our domain and return DNS packets with a very small Time To Live (TTL). During this short TTL window, we need to change the DNS A record to be 127.0.0.1. Since the TTL is small, the cached entry for the previous IP address (A record) expires quickly and becomes invalid. This forces the server to requery the DNS server and it will now resolve to 127.0.0.1.

So, during the check, the url will resolve to one of NASA’s IP addresses and pass, then it will resolve to 127.0.0.1 which will grant us access to the internal server with the flag on it.

How Do We Actually Do This?#

Well, I’m lazy so I just looked for some off-the-shelf solution and found this: https://lock.cmpxchg8b.com/rebinder.html (repo for the project)

From the site:

To use this page, enter two ip addresses you would like to switch between. The hostname generated will resolve randomly to one of the addresses specified with a very low ttl.

So, we just enter one of the NASA IPs such as 3.175.115.68 and 127.0.0.1.

Once we have the link, we can visit https://janus.secso.cc/api?url=03af7344.7f000001.rbndr.us:5001/.

When trying this locally it was pretty consistent, working about every other attempt, but for some reason remote didn’t want to cooperate.

I’m guessing this is because of the random DNS resolution and the remote instance erroring for some reason leading to a bunch of 403s and 502s.

gambling 1

This is where the gambling comes into play:

gambling 2

NOTICE:

gambling 3

It took a while and felt just like this:

gif of people gambling

Afterthoughts#

Honestly, I should’ve written a script to automate this but I was too lazy to and decided clicking resend request 784 times was better.

Also this was a pretty cool CTF challenge, I finally got to put into practice an attack I’ve only heard about before.

Thanks for reading! 😊

K17 CTF - Web - janus
https://rizfol.github.io/posts/k17-ctf-2025/janus/
Author
janky
Published at
2025-09-21
License
CC BY-NC-SA 4.0