以2024 CISCN决赛 ezjs题目为例分析

题目源码如下
app.js

// 导入必要的模块
const express = require('express'); // Express web framework
const ejs = require('ejs'); // Embedded JavaScript templates
const session = require('express-session'); // Session middleware for Express
const bodyParse = require('body-parser'); // Middleware for parsing HTTP request bodies
const multer = require('multer'); // Middleware for handling multipart/form-data
const fs = require('fs'); // File system module
const path = require("path"); // Path module

// 创建目录以确保文件路径存在

function createDirectoriesForFilePath(filePath) {
    const dirname = path.dirname(filePath);
    fs.mkdirSync(dirname, { recursive: true });
}


// 检查用户是否登录的中间件
function IfLogin(req, res, next) {
    if (req.session.user != null) {
        next();
    } else {
        res.redirect('/login');
    }
}


// 设置 Multer 存储引擎

const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        cb(null, path.join(__dirname, 'uploads')); // 设置上传文件的目标目录
    },
    filename: function (req, file, cb) {
        cb(null, file.originalname); // 使用原始文件名作为上传后的文件名
    }
});


// 配置 Multer 上传中间件
const upload = multer({
    storage: storage, // 使用自定义存储选项
    fileFilter: (req, file, cb) => {
        const fileExt = path.extname(file.originalname).toLowerCase();
        if (fileExt === '.ejs') {
            // 如果文件后缀为 .ejs,则拒绝上传该文件
            return cb(new Error('Upload of .ejs files is not allowed'), false);
        }
        cb(null, true); // 允许上传其他类型的文件
    }
});



// 定义管理员账户
const admin = {
    "username": "ADMIN",
    "password": "123456"
};
// 初始化 Express 应用
const app = express();
// 静态文件服务和中间件配置
app.use(express.static(path.join(__dirname, 'uploads')));
app.use(express.json());
app.use(bodyParse.urlencoded({ extended: false }));
app.set('view engine', 'ejs');
app.use(session({
    secret: 'Can_U_hack_me?',
    resave: false,
    saveUninitialized: true,
    cookie: { maxAge: 3600 * 1000 }
}));

// 路由定义

app.get('/', (req, res) => {
    res.redirect('/login');
});

app.get('/login', (req, res) => {
    res.render('login');
});

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username === 'admin') {
        return res.status(400).send('you can not be admin');
    }
    const new_username = username.toUpperCase();
    if (new_username === admin.username && password === admin.password) {
        req.session.user = "ADMIN";
        res.redirect('/rename');
    } else {
        // res.redirect('/login');
    }
});
app.get('/upload', (req, res) => {
    res.render('upload');
});
//中间件过滤 'fileInput' 是 HTML 表单中 <input type="file"> 的 name 属性值,Multer 将根据这个值来识别上传的文件。
app.post('/upload', upload.single('fileInput'), (req, res) => {
    if (!req.file) {
        return res.status(400).send('No file uploaded');
    }
    res.send('File uploaded successfully: ' + req.file.originalname);
});
//
app.get('/render', (req, res) => {

    const { filename } = req.query;
    if (!filename) {
        return res.status(400).send('Filename parameter is required');
    }
    const filePath = path.join(__dirname, 'uploads', filename);
    if (filePath.endsWith('.ejs')) {
        return res.status(400).send('Invalid file type.');
    }
    //漏洞点:将文件内容渲染到页面中
    res.render(filePath);
});
app.get('/rename', IfLogin, (req, res) => {
    //admin检查
    if (req.session.user !== 'ADMIN') {
        return res.status(403).send('Access forbidden');
    }
    const { oldPath, newPath } = req.query;

    if (!oldPath || !newPath) {

        return res.status(400).send('Missing oldPath or newPath');

    }

    if (newPath && /app\.js|\\|\.ejs/i.test(newPath)) {

        return res.status(400).send('Invalid file name');

    }

    if (oldPath && /\.\.|flag/i.test(oldPath)) {

        return res.status(400).send('Invalid file name');

    }

    const new_file = newPath.toLowerCase();
    const oldFilePath = path.join(__dirname, 'uploads', oldPath);
    const newFilePath = path.join(__dirname, 'uploads', new_file);
    if (newFilePath.endsWith('.ejs')) {

        return res.status(400).send('Invalid file type.');

    }

    if (!oldPath) {

        return res.status(400).send('oldPath parameter is required');

    }
    if (!fs.existsSync(oldFilePath)) {
        return res.status(404).send('Old file not found');
    }
    if (fs.existsSync(newFilePath)) {
        return res.status(409).send('New file path already exists');
    }

    createDirectoriesForFilePath(newFilePath)

    fs.rename(oldFilePath, newFilePath, (err) => {

        if (err) {
            console.error('Error renaming file:', err);

            return res.status(500).send('Error renaming file');

        }
        res.send('File renamed successfully');

    });

});
// 启动服务器
app.listen('3000', () => {
    console.log(`http://localhost:3000`);
});

解题分析与调试

在upload路由我们上传文件1.aaa
文件内容为 (至于为什么下面会有详细说明)

exports.__express = function () {
    console.log(require('child_process').execSync("dir").toString());
}

注意上传文件时候的filename属性与下面这段代码有关,如果与中间件的不匹配则上传不成功


中间件过滤 'fileInput' 是 HTML 表单中<input type="file">的 name 属性值,Multer 将根据这个值来识别上传的文件。

可以用如下表单上传,注意filename属性值为fileInput

之后我们调试render路由看一下render函数的逻辑流程

/render?filename=1.aaa

发现View 类

跟进查看View类 的内容

调试发现,ext为你渲染的文件后缀名字.aaa,然后var mod = this.ext.slice(1),mod值即为aaa
之后会有require函数加载这个模块,这里便是其中的漏洞点之一:require便相当于执行这个模块的代码(赛后与noname战队的学长得知)

继续往下跟踪会报错,因为node_modules文件夹下并没有aaa模块
本地调试如下:

那么我们可以利用题目中的rename路由,进行路径穿越,payload如下,并把文件名改为index.js,方便之后漏洞利用执行js命令

可以看到node_modules下已经有了文件夹aaa和index.js文件

需要注意的是:
rename之后,uploads文件夹下1.js已经被移走,所以需要再次上传一个名为1.js的文件,不然之后render路由还会报错

建立好文件夹之后我们接着调试,此时可以requre js这个模块,不再报错

这段代码的目的是从一个模块中导入一个名为 __express 的函数或属性,并将其赋值给变量 fn

还记得之前文件的内容为:

exports.__express = function () {
    console.log(require('child_process').execSync("dir").toString());
}

那么fn便被赋值为这个恶意执行命令函数

接着走engine被赋值为这个恶意函数代码

View的创建已经调试完成,好了接着步入函数tryRender

跟进去便看到了engine执行点

顺利执行了engine的代码,结果如下,可以直接反弹shell

方法二

除了上文的rce方法,还有另一处漏洞便是
上文提到requie一处存在漏洞可以执行js文件:

注意:这处漏洞是无回显,所以需要写到可以访问的文件夹下

这样的话index.js文件内容需要如下:

访问/render?filename=1.aaa,便成功执行了恶意代码

直接路由访问文件output.txt即可

方法三

  • 观察文件上传中间件处理中的过滤,文件后缀用了extname函数取

分析extname函数,如果文件名称中没有.或者以.开头,默认取空字符串

那么我们便可以上传文件名为.ejs的文件来绕过文件名检查

文件内容为:

<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
    <div>
        <%= process.mainModule.require('child_process').execSync('calc') %>
    </div>
</body>
</html>
  • 再看render路由

虽然我们成功上传了ejs文件,但是渲染时仍有waf

接着我们分析render函数里面View类里面代码:
如果你传的文件名称this.ext = extname(name);ext后,如果ext为空字符串,那么变为默认赋值为.ejs,而ext还是extname函数取出来的值,所以可以传文件名为.或者传不含.的值这样解析处理就是空字符串

注意:但是发现如果直接传.是不行的,会发现filename值为
C:\\Users\\86150\\Desktop\\ezjs\\uploads.ejs,也就是和路径连起来了。

我们发现filename初始值为name变量,而name是View函数参数,

通过不断回溯发现这个参数就是filePath

看到这里,我们便清楚可以传一个\来隔开路径与文件名,最后得到的filename就是C:\Users\86150\Desktop\ezjs\uploads\.ejs,成功解析ejs文件!

以上便是以CISCN决赛 赛题ezjs的3种不同的解题思路与分析过程为例的对ejs引擎漏洞的漏洞及函数特性的利用。

点击收藏 | 1 关注 | 1 打赏
  • 动动手指,沙发就是你的了!
登录 后跟帖