[D3CTF2021]记录
难到自闭,所以就只能叫做记录了。。。最简单的8-bit-pub看了两天头都给打蒙了,剩下的题基本上就不想看了
开始对着wp复现
8-bit-pub
前端好看,反手进行一个下载
给了源码,主要看一下就三点,以admin身份登录,admin拥有发邮件功能,shvl很可疑的原型链污染库
Admin登录
先看登录部分,使用了session-file-store和mysql两个库
const sql = require("../utils/db.js");
module.exports = {
signup: function (username, password, done) {
sql.query(
"SELECT * FROM test WHERE username = ?",
[username],
function (err, res) {
if (err) {
console.log("error: ", err);
return done(err, null);
}
if (!res.length) {
sql.query(
"INSERT INTO test VALUES (?, ?)",
[username, password],
function (err, res) {
if (err) {
console.log("error: ", err);
return done(err, null);
} else {
return done(null, res.insertId);
}
}
);
} else {
return done({
message: "Username already taken."
}, null);
}
});
},
signin: function (username, password, done) {
sql.query(
"SELECT * FROM test WHERE username = ? AND password = ?",
[username, password],
function (err, res) {
console.log({}.pollute)
if (err) {
console.log("error: ", err);
return done(err, null);
} else {
return done(null, res);
}
}
);
},
};
session-file-store在能控制文件并掌握密钥后能进行身份伪造,登录用户名会写入session文件中,但尝试了一下并不能通过截断之类的方法构造出一个合法session文件,特殊字符均被转义。(并且真实源码中密钥位数很大,无法爆破,虽然给的源码里就六个星号。。。所以一直想着怎么整一个session文件之后再爆破密钥)
看SQL部分,注册时会先查询用户名是否存在,所以不能注册admin账户了,考虑过用admin+超长空格+1使得查询时查不到而插入时被截断去除空格来注册一个额外的admin账户,但数据库设置了不允许重复键,失败
一开始我以为这个是用了预处理的,毕竟那个?怎么看都像是预处理占位符,但是在把数据格式换成json并瞎几把按的时候,出现了报错,我们就意识到了这个预处理存在问题
查看mysql库文档,得到如下解释
This looks similar to prepared statements in MySQL, however it really just uses the same connection.escape() method internally.
Strings are safely escaped
Nested arrays are turned into grouped lists (for bulk inserts), e.g. [[‘a’, ‘b’], [‘c’, ‘d’]] turns into (‘a’, ‘b’), (‘c’, ‘d’)
Objects are turned into key = ‘val’ pairs for each enumerable property on the object. If the property’s value is a function, it is skipped; if the property’s value is an object, toString() is called on it and the returned value is used.
真有你的,我一直以为是预处理在看file-session-store怎么打
简单来说就根本不是预处理,遇到String类型的还能好好转义一下,剩下类型的就改成诡异的形式给你拼进去
既然如此就直接登录admin账号吧,提交{"username":"admin","password": {"username": "1"}}
这样子之前的语句就变成了SELECT * FROM test WHERE username = "admin" AND password = username = "aaa"
MySQL列比较
这里还有一个很有意思的东西,就是这里的password=username这么个比较,得到的结果居然是password列和username列每一行比较的结果,相同为1不同为0,而WHERE子句后面的部分是顺序执行的,所以AND后面列比较的结果就会取username所在行的结果
这里先取到username为admin的那一行,再比较这行的password=username="aaa"
,因为username和password不同得到0,字符串”aaa”与0比较得到1,成功以admin身份登录
sendmail
这个是一个超级无敌安全库,专门去查了历史CVE和issue,但是连官方文档都说自己非常安全
Heavy focus on security, no-one likes RCE vulnerabilities
任意文件读取&SSRF
但是翻到有一个参数path,可以选择本地文件作为邮件内容,能够做到任意文件读取了,还有一个href可以简单地ssrfhref – an URL to the file (data uris are allowed as well)
当然超级安全的这个库有两个选项disableFileAccess,disableUrlAccess
能阻止文件读取和ssrf,不过这里没配置
所以做到任意文件读取,把源码翻了出来,没变化,也就是把原来打星号的密钥变成了明文长的一逼,亏给的源码就六个星号
RCE探索
这里写了一个shvl库的深度赋值和然后就是一个sendmail函数调用了,想打只能打sendmail,只能硬看这个库了,发邮件那里有可能是通过执行命令完成的
看源码
async function send(contents) {
let transporter = nodemailer.createTransport({
host: "******", // Plz use your own smtp server for testing.
port: 25,
tls: { rejectUnauthorized: false },
auth: {
user: "******",
pass: "******",
},
});
return transporter.sendMail(contents);
}
email: async function (req, res) {
let contents = {};
Object.keys(req.body).forEach((key) => {
shvl.set(contents, key, req.body[key]);
});
contents.from = '"admin" <[email protected]>';
try {
await send(contents);
return res.json({message: "Success."});
} catch (err) {
return res.status(500).json({ message: err.message });
}
}
在nodemailer库中搜索几个动态命令执行相关函数,eval没找到,但是spawn有一个,开始往回看怎么到这里nodemailer/lib/sendmail-transport/index.js
省略无关代码
const spawn = require('child_process').spawn;
.....
class SendmailTransport {
constructor(options) {
options = options || {};
// use a reference to spawn for mocking purposes
this._spawn = spawn;
this.options = options || {};
this.name = 'Sendmail';
this.version = packageData.version;
this.path = 'sendmail';
this.args = false;
this.winbreak = false;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'sendmail'
});
if (options) {
if (typeof options === 'string') {
this.path = options;
} else if (typeof options === 'object') {
if (options.path) {
this.path = options.path;
}
if (Array.isArray(options.args)) {
this.args = options.args;
}
this.winbreak = ['win', 'windows', 'dos', '\r\n'].includes((options.newline || '').toString().toLowerCase());
}
}
}
......
sendmail = this._spawn(this.path, args);
.....
这里引入了spawn,赋值给自己的_spawn属性,最后_spawn(this.path,args)进行了一个命令执行
那么现在的目标就是控制住this.path和args
可以看到constructor传进来一个option,而option如果存在且为一个object,把option的path和args赋给自己,简直完美,接下来看下option怎么给以及怎么让transporter变成这个sendmail的
再从题目源码的createTransport
进入,看我们创建出来的transporter是什么类型的,有没有可能成为sendmailTransport
module.exports.createTransport = function (transporter, defaults) {
let urlConfig;
let options;
let mailer;
if (
// provided transporter is a configuration object, not transporter plugin
(typeof transporter === 'object' && typeof transporter.send !== 'function') ||
// provided transporter looks like a connection url
(typeof transporter === 'string' && /^(smtps?|direct):/i.test(transporter))
) {
if ((urlConfig = typeof transporter === 'string' ? transporter : transporter.url)) {
// parse a configuration URL into configuration options
options = shared.parseConnectionUrl(urlConfig);
} else {
options = transporter;
}
if (options.pool) {
transporter = new SMTPPool(options);
} else if (options.sendmail) {
transporter = new SendmailTransport(options);
} else if (options.streamTransport) {
transporter = new StreamTransport(options);
} else if (options.jsonTransport) {
transporter = new JSONTransport(options);
} else if (options.SES) {
transporter = new SESTransport(options);
} else {
transporter = new SMTPTransport(options);
}
}
mailer = new Mailer(transporter, options, defaults);
return mailer;
};
这里的option就等于传进来的参数transporter,而我们的transporter没有配置这一串if else需要的属性,所以看起来我们最后的transporter是SMTPTransport,SMTPTransport看了半天并不能命令执行。
但是,没有配置的属性都能通过原型链污染来搞定,option的内容等于硬编码写入的transporter,自然没有sendmail,path,args等属性,但只要掌握一个原型链污染的点给它硬加上这个属性就能一波搞定
shvl 2.0.2 原型链污染
本身发邮件之前的这一步引入外部库的深度迭代赋值就非常的奇怪,因此,而深度赋值最容易出现的漏洞就是原型链污染啦,真是相当的容易呢。进行一个npm audit
->no vulnerablity,诶?
搜了一下,在2.0.1中存在非常直接的原型链污染,但是在2.0.2修了,而这里的package.json写的是"shvl": "^2.0.2"
。去GitHub翻了一下,修的方法也非常直接,加了一句/__proto__/.test(path)
,直接不让用__proto__
但这里允许超级深入迭代,属性套的再深也能一层层的赋值进去,而如果理解了__proto__和constructor.prototype的区别的话,就能很容易的进行原型链污染
__proto__&&constructor.prototype
一个对象的__proto__属性就像一个指针,指向自己这个类的原型,而constructor获取到对象的构造函数,构造函数的prototype就是这个类的原型
简单的例子,就是Object.prototype === {}.__proto__
进行原型链污染时,要修改prototype下的属性,而不是prototype本身的值,就像是通过指针修改指针指向对象的属性,而不是把指针指向另一个对象,一开始忘了这茬搞了半天没搞定
所以直接用constructor.prototype原型链污染加上sendmail,path,args这几个参数就能打通了
刚好题目中shvl.set是遍历复制的,写个demo
obj = {}
shvl.set(obj, "constructor.prototype.sendmail", 1)
shvl.set(obj, "constructor.prototype.path", "path")
shvl.set(obj, "constructor.prototype.args", [args....])
完成命令执行
pool_calc
其实是一个不难的题,但是上一个题做的我头昏脑涨的。。。这个题就智障的没写出来
附件就给了个docker-compose.yml,告诉我们起了4个docker,一个app和三个计算器,一个PHP一个java一个python
只有app暴露在外,剩下三个在docker的内网中,需要通过app进行交互
点开题目就有超级明显的提示/redirect?filename=index.html
,像是一个任意文件读取,估计是js写的,猜一个app.js读到app的源码
const fs = require('fs')
const express = require('express')
const {exec} = require('child_process')
const format = require("string-format")
const dotenv = require("dotenv");
dotenv.config()
const app = express()
app.use(express.static('public'));
app.get("/", (req, res) => {
return res.redirect("/redirect?filename=index.html")
})
app.get("/redirect", (req, res) => {
let filename = req.query.filename
res.sendFile(`${__dirname}/` + filename)
})
app.get('/calc', (req, res) => {
let params = req.query
var lang = params.language !== undefined ? params.language : "python"
let calc_client_path = {
"python": process.env.py_calc_tool_path,
"php": process.env.php_calc_tool_path,
"java": process.env.java_calc_tool_path
}
if (lang === 'python') {
let data = {
"action": params.action,
"a": params.a,
"b": params.b,
"ip": process.env.py_calc_address,
"port": process.env.py_calc_port
}
var cmd = format(calc_client_path.python + " " + '-action {action} -a {a} -b {b} -ip {ip} -p {port}', data)
} else if (lang === 'php') {
let data = {
"action": params.action,
"a": params.a,
"b": params.b,
"ip": process.env.php_calc_address,
"port": process.env.php_calc_port
}
var cmd = format(calc_client_path.php + " " + '-action {action} -a {a} -b {b} -ip {ip} -p {port}', data)
} else if (lang === 'java') {
let data = {
"action": params.action,
"a": params.a,
"b": params.b,
"ip": process.env.java_calc_address,
"port": process.env.java_calc_port
}
var cmd = format("java -jar" + " " + calc_client_path.java + " " + '-action {action} -a {a} -b {b} -ip {ip} -p {port}', data)
}
try {
exec(cmd, ((error, stdout, stderr) => {
res.send(stdout)
}))
} catch (e) {
res.send("Something Error")
}
})
const port = process.env.web_app_port
app.listen(port, () => {
console.log(`App listening at http://0.0.0.0:${port}`)
})
那个时候已经看傻了,简单的看了一下后面的交互过程,然后就开始疯狂的想收集信息,猜解目录,大失败,进入自闭。。。。
后来赛后看分析才发现这个题cmd那里随便执行命令。。。。经典毫无过滤直接拼接的命令执行,可以直接命令执行拿到app下的flag
然后怎么个就能获取到剩下三个环节的源码?
python
用了pickle模块,毫无过滤,pickle反序列化照抄打通
java
反编译后直接看见flag
PHP
之前一个比赛的题的非预期,有机会再看看
好像这个题就PHP那个非预期解法难一些,如果之前做过可能也还好,整体来说不是难题,但是那个时候真的头给锤歪了。。。这么明显的命令执行都没看见
shell-gen
好像是个特别复杂的题,提到了docker-socket之类的东西
学一下docker逃逸什么的另补一篇吧
non-RCE
java题,我是java废物,完全没看。据说是反序列化加条件竞争绕过检测