以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());
}
注意上传文件时候的
file
的name
属性与下面这段代码有关,如果与中间件的不匹配则上传不成功
中间件过滤 'fileInput' 是 HTML 表单中<input type="file">
的 name 属性值,Multer 将根据这个值来识别上传的文件。
可以用如下表单上传,注意file
的name
属性值为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引擎漏洞的漏洞及函数特性的利用。