This month, Intigriti hosted another RCE challenge, made by @0xblackbird. You can still play it at challenge-0825.intigriti.io before I spoil the solution. We'll learn some things about NextJS internals and how a powerful SSRF vulnerability can result in RCE on some specific services. The path to get there was more black-box than usual, although the tricks are still fun and serve as good practice for the real world. I ended up getting the first blood 🩸 on this challenge!
Finding vulnerabilities
The challenge provided source code that we can look through to find vulnerabilities locally before trying them on the remote instance. There are a lot of configuration files, but we're interested in the pages and handlers.
A quick look shows no sign of an automated browser bot, which is common for client-side challenges, and our end goal is Remote Code Execution, so we have to look at the server-side components of this app. Some of these are defined in src/app/api
, like auth/[...nextauth]/route.ts
which appears to be very standard. The other custom API auth/register/route.ts
is more interesting as it takes input from our JSON body and uses it to create a new user in the database:
NextRequest)
:
Having experience with testing JavaScript apps, one idea that always quickly jumps to mind when seeing anything JSON is injecting objects (eg. {a: 1}
) instead of strings. A kind of type confusion. This problem is very common in JavaScript because many functions accept either, and while a string may represent input, an object often represents configuration. Configuration that we can inject.
NoSQL Injection
Our email
parameter is passed into the db.collection("users").findOne(...)
method without validation. What would happen if we injected an object into this input? The answer is NoSQL Injection, the JSON equivalent of SQL Injection vulnerabilities. It comes as a result of Query Predicates that MongoDB supports in its queries to change the way our value is matched with one of the columns. An example is $regex
, which says that the string we input will represent a Regular Expression instead of an exact match.
The above example will match any user whose name starts with a 'j'. We can detect this in the response, and if successful, search for names starting with 'ja', 'jb', 'jc', and so on until we find another valid next character. Then simply repeat. This would let you find all emails existing in the application.
Instead of going letter by letter, a more efficient approach would be Binary Search, where you eliminate half of the possibilities for every request. This is easy in RegEx by using character ranges ([a-z]
):
=
# A function that returns True if the regex passes
=
=
return ==
# Binary Search algorithm
= 0
= 127
= // 2
= + 1
= - 1
return
# Keep searching until whole string found
+=
return
=
# admin@...
While we can leak emails with this, passwords are a different story. Not only are they never matched with NoSQL for us to inject into, they are hashed strongly and, to be honest, quite useless anyway. Accounts in this application cannot store any information, so there really is no impact to hijacking accounts. It is a bug in the application, but not one that will help us further in getting the flag.
PS. The login functionality in
src/lib/auth.ts
is vulnerable in the exact same way, but this also doesn't help us, as the password hash is still verified using bcrypt in JavaScript.
Middleware header echoing
Another curious piece of the code is in middleware.ts
:
;
For some reason, when the special utm_source
, utm_medium
, or utm_campaign
query parameters are found, request headers are parsed and reconstructed, then passed to NextResponse.next()
. Checking out the docs or experimentally, we can find out that the headers
(not request.headers
) key in this function refers to the Response headers. This means the request headers are set as response headers for such requests. We see this in our proxy:
? HTTP/2
HTTP/2
This leaks some special headers injected by the frontend proxy, which we are even able to alter, but don't seem to result in anything. Still, injecting into response headers sounds like a very interesting gadget. It would definitely result in Cache Poisoning if that were enabled anywhere, but unfortunately, I couldn't find any endpoints that were (recognized by the random X-Request-Id
).
I thought for a bit about triggering XSS from a browser request. This is a pretty interesting rabbit hole. I was thinking about using Content-Type: multipart/form-data; boundary=...
from a POST form submission to split the response into different sections with HTML, but we can't predict the boundary, and the response is downloaded instead of rendered in modern browsers. There might be some other tricks possible from the browser, but let's focus on the server-side again.
One thing I looked at for a bit was the fact that Nginx has some special response headers that influence its behavior, of the X-Accel-*
variety. The most interesting is X-Accel-Redirect
, which can rewrite the path and let Nginx handle the new URI as another request. It can bypass so-called internal
locations that aren't normally accessible from the outside, so I fuzzed for some paths like this:
? HTTP/2
While the idea worked, no internal/inaccessible paths were found, so it seems quite useless again.
SSRF via Location
I remembered seeing this vulnerable code snippet before in some CTF writeup, but forgot which one. To find it again, I went looking on GitHub Search for any similar code snippets that may lead me to the challenge I read it from, and its solution.
const =
A few queries later, I got to searching /\(\{\s*headers: requestHeaders/ NextResponse
(a RegEx with our vulnerable pattern and involving middleware with "NextResponse"). Scrolling past the 0-days, the following writeup eventually got my attention:
https://github.com/Vexcited/Vexcited/blob/6c1d16c1a3804a7d520c49509d965c58ddcdce2e/writeups/2025-04-21_Under-Nextruction_FCSC.md
In it, they show this vulnerable code pattern from FCSC - Under Nextruction. The author of this challenge created a writeup explaining the trick in more detail. This was the one I had originally read and slightly remembered:
https://mizu.re/post/fcsc-2025-writeups#under-nextruction-3rd-nextresponse-next-missconfiguration
The solution ends up being that NextJS will interpret a Location:
header set by itself as the need to proxy the request to that location! A complete full-read SSRF. We can test it out like this:
? HTTP/2
HTTP/2
It works perfectly. We can perform requests as the server. At this point, we can simply loop over a bunch of common HTTP ports on localhost to eventually find 8080
hosting a Jenkins instance:
? HTTP/2
HTTP/2
This is definitely our next target.
Jenkins RCE
Jenkins is notorious for being a sort of RCE-as-a-service application, with many ways of executing code if not protected. For fun, let's try to access the GUI of Jenkins by creating a proxy server of our own that sends requests through the vulnerable application.
Nginx is made for this, and can quite easily be configured to perform the exploit as we understand it. Request headers will be passed by Nginx, as well as the method, body, etc. Our SSRF is very powerful.
Below is the Nginx configuration you can use:
Start it in Docker with the following docker-compose.yml
and running docker compose up --build
:
services:
nginx:
image: nginx:latest
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
Then simply visit your http://localhost to interact with the hidden Jenkins application:
Clicking around, we can find numerous ways to add commands to build scripts and the like. But the cleanest is via Settings -> Script Console, where you can execute arbitrary Groovy code. We can find well-known payloads to run shell commands with this
def proc = . ;
def os = new StringBuffer();
proc. ;
println(os. );
We successfully achieved RCE! All that's left is to find the flag with a little find / -name '*flag*'
to discover /app/flag.txt
. One more command to cat
it, and we get our well-deserved flag!
INTIGRITI{1337a0b4-56bf-4c2a-9fd2-b4a6d7237ce2}
Feedback
As you may notice in this writeup, several extra vulnerabilities we found didn't end up being useful. Some intentional while others not. I think this is fine if it's not overdone. The goal of CTF for most players is to learn cool techniques without wasting too much time. Letting the player focus on that cool technique is often more fun.
What was slightly off in this challenge, and others pointed out, is that several descriptions contained wrong or misleading information. This led to players dismissing ideas that they may otherwise would have explored given a real-world scenario without "rules", for example:
- Download the challenge source code!
- Solve it locally!
- Repeat your attack against the challenge server.
Regarding the 2nd point, you could use the source code to locally find the SSRF, you wouldn't know there is a whole different Jenkins application on the remote instance as it is not given in the docker-compose.yml
of the challenge. I think it should have been clear that this challenge is not fully solvable locally. I just happened to guess that it is not.
Apart from this, I still found the solution quite fun. The SSRF technique in NextJS is very powerful and surprisingly common if the GitHub search results were any indication. Making a clean Nginx proxy to access the Jenkins instance was satisfying. GG!