Category
Web
Chall Author
wuurrd
Stats
Total Solves: 86 Players
Final Points: 100p
Description
The flag is luckily only available from inside the server, so it's totally safe.
Solution
I was provided the files: main.py, flag.txt and a Dockerfile. Let's look at the source:
import threading
from fastapi.responses import RedirectResponse
import uvicorn
import socket
import ipaddress
import aiohttp
from fastapi import FastAPI, HTTPException
from urllib.parse import urlparse
app = FastAPI()
local_app = FastAPI()
class SSRFError(Exception):
pass
@local_app.get("/flag")
def get_flag() -> str:
return open("flag.txt").read()
async def assert_url_safe(url: str):
hostname = urlparse(url).hostname
if not hostname:
return
ip = socket.gethostbyname(hostname)
try:
i = ipaddress.ip_address(ip)
if i.is_private:
raise SSRFError(f"Host {hostname} is not safe")
except ValueError:
raise SSRFError(f"Unknown Host {hostname}")
@app.get("/")
def index():
return RedirectResponse("/docs")
@app.post("/scrape", response_model=str)
async def scrape(url: str) -> str:
try:
await assert_url_safe(url)
timeout = aiohttp.ClientTimeout(
total=5, connect=5, sock_read=5, sock_connect=5, ceil_threshold=5
)
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=timeout) as response:
try:
data = await response.text()
except:
data = ""
if not response.ok:
raise HTTPException(
status_code=response.status,
detail=f"STATUS={response.status} {data}",
)
return data
except SSRFError as e:
raise HTTPException(status_code=403, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def main():
threading.Thread(
target=uvicorn.run,
kwargs={
"app": local_app,
"port": 1337,
},
).start()
uvicorn.run(
app,
host="0.0.0.0",
port=4000,
)
if __name__ == "__main__":
main()
Its a simple backend server with two main functions. assert_url_safe & scrape. The assert_url_safe function basically just checks the url we provide to the /scrape route. It does this using i.is_private which is defined in the python3.12/ipadress native library.
def is_private(self):
"""``True`` if the address is defined as not globally reachable by
iana-ipv4-special-registry_ (for IPv4) or iana-ipv6-special-registry_
(for IPv6) with the following exceptions:
* ``is_private`` is ``False`` for ``100.64.0.0/10``
* For IPv4-mapped IPv6-addresses the ``is_private`` value is determined by the
semantics of the underlying IPv4 addresses and the following condition holds
(see :attr:`IPv6Address.ipv4_mapped`)::
address.is_private == address.ipv4_mapped.is_private
``is_private`` has value opposite to :attr:`is_global`, except for the ``100.64.0.0/10``
IPv4 range where they are both ``False``.
"""
return (
any(self in net for net in self._constants._private_networks)
and all(self not in net for net in self._constants._private_networks_exceptions)
)
Now that we understand the source we can think of an exploitation plan!
We do know that flag.txt is in the server's localhost:3000/flag.txt
. So our goal is to trick the server into scraping the flag from itself locally. Problem is that because of the is.private
check, we cannot make it go to a local IP.
My first idea was to rewrite the URL in such a way that it bypasses the checks, like for example rewriting it into IPv6, but reading the native library's source, this is out of the box.
But what we can do is make it go to, for example, https://google.com/
. We control the page's functions and thus we control what the aiohttp
client does.
This means that we can do a simple redirect. Basically, for example, making the server go to https://alanoo.dev/code.php
. The aiohttp
client will thus execute the PHP code, which we will write to redirect it to:
localhost:3000/flag.txt
Now I only needed to write some simple php code and get the flag!
Final Payload
<?php
header("Location: http://localhost:3000/flag.txt");
die();
# https://skibidi.wtf/code.php
Flag
flag{wait_redirects_are_a_thing_too???}