CTF - Intigriti - 0825

6 minute read

Published:

After a long break from challenges and CTFs, I felt it was time to start training again. The urge came earlier this month, following some introspection on how little I had been dedicating to such exercises, a realization that left me with a sense of.. guilt? I then visited the Intigriti Discord server, hoping to find an ongoing challenge, but nothing was happening at that time. So, I was eagerly awaiting this one, let’s dive in.

This August challenge is a CTF created by 0xblackbird with the following rules:

  • Should leverage an RCE on the server without a sandbox.
  • Shouldn’t exploit a zero-day or Chromium RCE.
  • Should include the flag in the format INTIGRITI{.*}

It’s a white-box challenge, so the source code is available. The stack includes the Next.js framework with NextAuth for authentication, a MongoDB database, and deployment via Docker Compose.

Tree structure

- docker-compose.local.yml
  - Dockerfile
  - mongodb/
    - Dockerfile
  - public
  - src/
    - app/
      - api/
        - auth/
          - [...nextauth]/
            - route.ts
          - register/
            - route.ts
      - auth/
        - signin/
          - page.tsx
        - signup/
          - page.tsx
      - challenge/
        - page.tsx
      - globals.css
      - layout.tsx
      - page.tsx
    - components/
    - lib/
      - auth.ts
      - mongodb.ts
    - middleware.ts
    - types/
      - next-auth.d.ts
  - tsconfig.json

Premises

I have a tumultuous past with Next.js middleware, and nostalgia quickly led me to take a look at the middleware.ts file. Since it isn’t normally present by default, the use of middleware being of course optional, there was a good chance something interesting was happening there, and indeed, there was. Take a look at the following piece of code:

middleware.ts

If the request is unauthenticated, the path is the root, and any of the three utm-* URL parameters are present, then the entire request headers are copied into the response headers. This opens up a range of possibilities, and it was clear that this was the key piece for our exploit.

I played around with the vector a bit, testing quite a few things, knowing full well that it wouldn’t be the expected solution, like this self-XSS being able to be transformed into SXSS via cache poisoning under certain conditions:

Here I use the internal RSC next.js header (which I discuss here) which forces the fetch of the React Server Component, overrides the framework’s intended Content-Type text/x-component, replacing it with text/html, and I also override the Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate to Cache-Control: public, max-age=99999999999 in order to make it cache-friendly. Fun, but nothing relevant to our CTF.

Give me your location - SSRF

Back to our goal, and moving forward a few tests later; it is possible to obtain an SSRF via the Location header, the latter being reflected in the response:

The legitimate question: Why is this possible? Isn’t Location basically used for client-side redirection?

Next.js checks if the response contains a Location header and retrieves its value to create a NextURL object, parsing and normalizing the absolute URL found in Location in order to prepare the redirection logic (linked to how Next.js Middleware handles rewrites and redirects):

next.js/packages/next/src/server/web/adapter.ts

If the URL parses to an absolute URL with a valid protocol, and the finished flag is true, Next.js will proxy the request to that URL via proxyRequest, allowing us to get an SSRF since in our case the value is user-controlled due to reflection done in the middleware.

next.js/packages/next/src/server/lib/router-server.ts

Wandering

Having been on the middleware side from the very beginning, I put this vector aside and took a quick tour of the owner, just to see what angle of attack to adopt for my SSRF. The docker-compose indicates that Mongodb listens on port 27017, but doesn’t speak HTTP, so it’s not exploitable:

It looks like you are trying to access MongoDB over HTTP on the native driver port.

After several failed attempts that I’ll spare you, the wandering began, and eventually I set the challenge aside to get back to work. I’m not used to CTFs and I have trouble knowing where to set the bar and knowing how deep the author wants me to go, the prohibition on using zero-days has been specified, do they intend to haunt players to the point where they would be ready to burn a zero-day for the challenge? What should I deduce from this? Being very manic about my time, the idea of ​​digging the depths of a library to find a specific behavior that would serve as a gadget exclusively for my case when it was simply a classic exploitation hurts my mind.

My lack of experience with challenges/CTFs also doesn’t help me when it comes to understanding the accuracy of the intention behind the author’s statement. When it says Solve it locally! does that mean solving it 100% locally, in which case the flag would be the value of NEXTAUTH_SECRET? Or does it mean just finding the general solution and applying it to the specific case in production? Skill issues bring a lot of trouble, may my Lord preserve us from it.

End of the existential crisis, and solution

The author, in his great generosity, left a hint in the signup/page.tsx file that allows you to significantly narrow the search scope:

{/* Hint for CTF players */}
{/* Internal services use default configurations */}

Ok so this is just an internal service with its default configuration, no descent into the abyss. After testing several things locally, I generate a small list of URLs pointing to the ports of the most popular services and launch my intruder on the production version. The result is conclusive, http://127.0.0.1:8080 points to Jenkins, 8080 being its default port.

The fog clears, a quick test on /script reveals that the Jenkins Groovy Script Console interface is accessible, no more suspense for the rest of the exploit, this is our RCE.

Switch the HTTP verb to POST and add the appropriate content-type to send our crude recon command through the script parameter provided for this purpose, in order to localize the flag :

script=def cmd = "ls -la / /app /var /home /opt /tmp /etc".execute();
println cmd.text

Then, finally, reading the flag :

script=def cmd = "cat /app/flag.txt".execute();
println cmd.text

Thanks to 0xblackbird and Intigriti for the CTF, and thank you for reading.

Al hamduliLlah;

zhero;