Jax Dunfee

hxpCTF 2023 - Valentine Writeup
My write up for hard web challenge (Valentine) on hxp 2023.
March 12, 2023

This was an incredibly difficult but rewarding CTF. Thanks to hxp for hosting!

Description: Create an awesome template for your valentine and share it with the world!

We were provided the file valentine-9455b10a15fc5519.tar.xz (12.4 KiB) which had these files inside:

And two links:

Accessing a link gives us this page:

And when we enter some input, this comes out:

The first thing that comes to mind is SSTI. And it was.

In order to gain the flag, we have to first bypass the filtering by using the interface between Express and EJS to finally pass in a new delimiter.

We are provided code in app .js:

var express = require('express');
var bodyParser = require('body-parser')
const crypto = require("crypto");
var path = require('path');
const fs = require('fs');

var app = express();
viewsFolder = path.join(__dirname, 'views');

if (!fs.existsSync(viewsFolder)) {

app.set('views', viewsFolder);
app.set('view engine', 'ejs');

app.use(bodyParser.urlencoded({ extended: false }))

app.post('/template', function(req, res) {
  let tmpl = req.body.tmpl;
  let i = -1;
  while((i = tmpl.indexOf("<\%", i+1)) >= 0) {
    if (tmpl.substring(i, i+11) !== "<%= name %>") {
      res.status(400).send({message:"Only '<%= name %>' is allowed."});
  let uuid;
  do {
    uuid = crypto.randomUUID();
  } while (fs.existsSync(`views/${uuid}.ejs`))

  try {
    fs.writeFileSync(`views/${uuid}.ejs`, tmpl);
  } catch(err) {
    res.status(500).send("Failed to write Valentine's card");
  let name = req.body.name ?? '';
  return res.redirect(`/${uuid}?name=${name}`);

app.get('/:template', function(req, res) {
  let query = req.query;
  let template = req.params.template
  if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(template)) {
    res.status(400).send("Not a valid card id")
  if (!fs.existsSync(`views/${template}.ejs`)) {
    res.status(400).send('Valentine\'s card does not exist')
  if (!query['name']) {
    query['name'] = ''
  return res.render(template, query);

app.get('/', function(req, res) {
  return res.sendFile('./index.html', {root: __dirname});

app.listen(process.env.PORT || 3000);

Looking at app.js, we can see that let query = req.query passes the entire query right into res.render. This means that we can input a change in delimiter, into the query.

It can be solved with the following script:

Getting us: hxp{W1ll_u_b3_my_V4l3nt1ne}